import { readdir, readFile } from 'node:fs/promises' import { join } from 'node:path' /** * System prompt construction (spec AC15/AC20). * pi's default prompt is fully replaced by will's personality. Tool schemas * remain authoritative through pi's native tool channel; wakes add only * current facts and event payloads. */ export async function readPersona(personaDir: string): Promise { const files = (await readdir(personaDir)).filter((f) => f.endsWith('.md')).sort() const parts: string[] = [] for (const f of files) parts.push(await readFile(join(personaDir, f), 'utf8')) return parts.join('\n\n') } export async function buildSystemPrompt(personaDir: string): Promise { return (await readPersona(personaDir)).trim() } /** Live facts rendered into every wake prompt, never frozen into the system prompt. */ export interface WakeFacts { revision?: string | undefined agentDigest?: string | undefined serviceStorageState?: string | undefined queueDepth?: number | undefined budgetUsed?: number | undefined budgetCap?: number | undefined worktreeHead?: string | undefined worktreeDirty?: boolean | undefined timeUtc?: string | undefined } export function renderFacts(facts: WakeFacts): string { const lines: string[] = [] if (facts.timeUtc !== undefined) lines.push(`time_utc: ${facts.timeUtc}`) if (facts.revision !== undefined) lines.push(`deployment_revision: ${facts.revision}`) if (facts.agentDigest !== undefined) lines.push(`agent_image: ${facts.agentDigest}`) if (facts.worktreeHead !== undefined) lines.push( `worktree_head: ${facts.worktreeHead}${facts.worktreeDirty === true ? ' (dirty)' : ''}`, ) if (facts.serviceStorageState !== undefined) lines.push(`service_storage: ${facts.serviceStorageState}`) if (facts.queueDepth !== undefined) lines.push(`pending_wakes: ${facts.queueDepth}`) if (facts.budgetUsed !== undefined && facts.budgetCap !== undefined) lines.push(`model_budget_used: ${facts.budgetUsed}/${facts.budgetCap}`) return `\n${lines.join('\n')}\n` } export interface ChatWakePayloadLike { messageId: number senderName?: string | undefined text?: string | undefined triggerReason: string media?: { digest: string; type: string }[] | undefined replyToMessageId?: number | undefined } /** Boot context merged into the first pending wake (spec AC16: no separate boot call). */ export function renderBootContext(): string { return '' } export function renderWakePrompt( wake: { kind: string; payload: unknown }, facts: WakeFacts, opts: { boot?: boolean } = {}, ): string { const head = opts.boot === true ? `${renderBootContext()}\n\n` : '' const factsBlock = renderFacts(facts) switch (wake.kind) { case 'chat': { const p = wake.payload as ChatWakePayloadLike const media = (p.media?.length ?? 0) > 0 ? `\n\n${(p.media ?? []) .map((m) => `- ${m.digest} (${m.type})`) .join('\n')}\n` : '' const reply = p.replyToMessageId !== undefined ? `\n(reply to message ${p.replyToMessageId})` : '' return `${head}${factsBlock} from: ${p.senderName ?? 'unknown'} ${p.text ?? '(no text)'}${reply}${media} ` } case 'heartbeat': return `${head}${factsBlock}\n\n` case 'deploy_failure': { const p = wake.payload as { revision?: string; kind?: string; detail?: unknown } return `${head}${factsBlock} Deployment failed and was rolled back (or stopped at v0). revision: ${p.revision ?? 'unknown'} kind: ${p.kind ?? 'unknown'} detail: ${JSON.stringify(p.detail ?? {})} ` } case 'ci_failure': { const p = wake.payload as { sha?: string; job?: string; url?: string } return `${head}${factsBlock} A pushed commit failed its CI gates before deployment. commit: ${p.sha ?? 'unknown'} job: ${p.job ?? 'unknown'} logs: ${p.url ?? 'unknown'} ` } case 'recovery': { const p = wake.payload as { interruptedWakeId?: string; note?: string } return `${head}${factsBlock} A previous turn was interrupted (timeout or crash) and may have left partial side effects. interrupted wake: ${p.interruptedWakeId ?? 'unknown'} ${p.note ?? ''} ` } default: return `${head}${factsBlock}\n\n\n${JSON.stringify(wake.payload ?? null)}\n` } }