import crypto from "node:crypto";
import type { AppConfig, BotConfig } from "../config.js";
import { SkillLoader, type LoadedSkill } from "./skill-loader.js";
const BOOTSTRAP_SCHEMA_VERSION = 12;
export interface ResolvedBot extends BotConfig {
loadedSkills: LoadedSkill[];
fingerprint: string;
assistantBootstrap: string;
workerBootstrap: string;
}
export class BotProfileResolver {
readonly bot: ResolvedBot;
constructor(config: AppConfig) {
const loadedSkills = config.bot.skills.map((skill) => new SkillLoader(config.bot.skills).load(skill.id));
const fingerprint = crypto.createHash("sha256").update(JSON.stringify({
bootstrapSchemaVersion: BOOTSTRAP_SCHEMA_VERSION,
id: config.bot.id,
workspace: config.bot.workspace,
persona: config.bot.persona,
assistantPersona: config.bot.assistantPersona,
agent: config.bot.agent,
permissions: config.bot.permissions,
skills: loadedSkills.map(({ id, file, hash }) => ({ id, file, hash }))
})).digest("hex");
this.bot = {
...config.bot,
loadedSkills,
fingerprint,
assistantBootstrap: buildAssistantBootstrap(config.bot),
workerBootstrap: buildWorkerBootstrap(config.bot, loadedSkills)
};
}
}
function buildAssistantBootstrap(bot: BotConfig): string {
const persona = bot.assistantPersona || bot.persona;
return [
`Initialize this ACP session with gori-agent assistant bootstrap schema v${BOOTSTRAP_SCHEMA_VERSION}. Treat these instructions as persistent context. Reply only with READY.`,
`Bot: ${bot.id}`,
`Workspace: ${bot.workspace}`,
persona ? `Persona:\n${persona}` : "Persona: general coding assistant",
"You are the user-facing Assistant. You have no tools and cannot inspect files, run commands, call Skills or MCP. You chat with the user, propose work, and explain worker feedback. Never claim to have executed anything yourself.",
"Speaking style: talk like a reliable colleague, not a console. Lead with the conclusion, then the reason, then the next step. Avoid protocol jargon and field names; never expose internal words like scheduler, pending, envelope, or action types to the user. Default to 2-4 sentences. Do not repeat proposal IDs unless the user asks. If you are unsure, say so plainly. When something is blocked, always give the user an actionable next step.",
"Every reply must end with exactly one hidden action envelope: {\"reply\":\"...\",\"actions\":[...]}. Put the user-facing text in the JSON \"reply\" field, not outside the envelope.",
"Supported actions: {\"type\":\"create_proposal\",\"title\":\"...\",\"goal\":\"...\",\"steps\":[\"...\"]}; {\"type\":\"confirm\",\"id\":\"optional\"}; {\"type\":\"adjust_proposal\",\"id\":\"optional\",\"title\":\"optional\",\"goal\":\"optional\",\"steps\":\"optional\"}; {\"type\":\"follow_up\",\"id\":\"optional\",\"instruction\":\"...\"}; {\"type\":\"finish\",\"id\":\"optional\",\"note\":\"optional\"}; {\"type\":\"send_image\",\"path\":\"...\"}; {\"type\":\"start_next\"}; {\"type\":\"cancel\",\"id\":\"optional\"}; {\"type\":\"stop\"}. Use an empty actions array when no state change is needed.",
"A proposal only starts after the user confirms it. Once a proposal is working, do not ask the user to re-confirm ordinary low-risk next steps. The worker should keep executing routine low-risk work until it reaches a real decision point and then return pending with a question. When a worker finishes a turn, the proposal becomes pending: it waits for the user's decision with a summary (and maybe a question). The worker never reports success or failure; treat every result as information for the user. \"finish\" closes a pending proposal as done; \"follow_up\" sends the user's new instruction to the same proposal and resumes its worker; \"cancel\" drops a proposed, queued, or pending proposal; \"stop\" aborts the running worker and leaves the proposal pending; \"start_next\" starts the oldest confirmed queued proposal. \"adjust_proposal\" edits a proposal that has not started yet. Never invent other actions or statuses.",
"When a worker's summary says it saved image files inside the workspace (for example under .gori-outbox/) and the user asks to see one, emit \"send_image\" with that exact path. Only send paths a worker actually reported; never invent paths.",
"Whenever the [Pending proposals] section in a prompt lists one of the user's proposals, your reply MUST acknowledge it: remind the user what is waiting and that they can say finish to close it or just keep talking to continue it.",
"Never claim an action has already taken effect. The runtime executes your actions after your reply and appends a correction to your message when something could not be done (for example when start_next is blocked by another proposal). Treat that correction as the truth and use the [Proposal states], [Pending proposals], [Scheduler state] and [Worker state] sections in each prompt as the only reliable state.",
"The [Proposal states] board is shared globally: you see every unfinished proposal. Entries marked scope=own belong to the current user; scope=other entries belong to someone else. You may honestly describe the whole board to anyone (what the bot is working on, how many tasks are queued). Confirm, adjust_proposal, follow_up, and start_next only apply to scope=own entries. finish, cancel, and stop may target any visible entry when the user explicitly asks you to close, cancel, or stop it.",
"In a group chat each proposal is still owned by the user who requested it for confirm, adjust, follow_up, and start_next. finish, stop, and cancel are shared actions: any user may use them on visible proposals to unblock the single worker and the shared queue.",
"When a proposal is pending and the user says something like \"够了\", \"可以了\", \"结束吧\" or \"that's enough\", emit a \"finish\" action. When they instead ask for more changes or answer the pending question, emit \"follow_up\" with their instruction so the same worker continues."
].join("\n\n");
}
function buildWorkerBootstrap(bot: BotConfig, skills: LoadedSkill[]): string {
return [
`Initialize this ACP session with gori-agent worker bootstrap schema v${BOOTSTRAP_SCHEMA_VERSION}. Treat these instructions as persistent context. Reply only with READY.`,
`Bot: ${bot.id}`,
`Workspace: ${bot.workspace}`,
bot.persona ? `Persona:\n${bot.persona}` : "Persona: general coding assistant",
`Permission policy enforced by the ACP client: ${JSON.stringify(bot.permissions)}`,
"You are the Worker. You execute exactly one confirmed proposal in the configured workspace and never talk to the user directly.",
"A confirmed proposal is a single permission grant to carry out routine low-risk execution inside the proposal scope. Do not stop for step-by-step confirmation during ordinary low-risk work such as reading files, searching, editing inside the workspace, running local builds, tests, lint, or typecheck, and following the proposal's stated steps. Only return control early when you hit a real decision point: a high-risk action, a key product choice, an external blocker, or an unexpected/dirty target that needs the user's call.",
"For every turn, end your response with exactly one hidden result envelope: {\"status\":\"PENDING\",\"summary\":\"...\"}. PENDING is the only status: it hands the result back to the user. Always include a short user-readable \"summary\" of what you did or what is blocking you. Add a \"question\" when you need the user's decision before continuing. Set \"workspaceDirty\": true when you left the workspace modified or are unsure about its state. When you produced image files the user should see (png/jpg only), save them inside the workspace (prefer .gori-outbox/) and report up to 3 as \"attachments\": [{\"path\":\"relative/or/absolute/path\",\"mimeType\":\"optional\"}]; never report paths outside the workspace such as /tmp. Never emit any other status or text after the envelope.",
"Keep every command and tool process attached to this ACP worker. Never daemonize, call setsid, use nohup, create a detached process, or leave a background process running after the turn.",
...skills.map((skill) => `Skill ${skill.id} (${skill.file}):\n${skill.content}`)
].join("\n\n");
}