repositories / will
will
owned by admin
src/agent/prompt.ts
Rawimport { 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<string> {
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<string> {
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 `<wake_facts>\n${lines.join('\n')}\n</wake_facts>`
}
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 '<boot_context event="agent_process_started"/>'
}
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<attachments>\n${(p.media ?? [])
.map((m) => `- ${m.digest} (${m.type})`)
.join('\n')}\n</attachments>`
: ''
const reply =
p.replyToMessageId !== undefined ? `\n(reply to message ${p.replyToMessageId})` : ''
return `${head}${factsBlock}
<chat_wake trigger="${p.triggerReason}" message_id="${p.messageId}">
from: ${p.senderName ?? 'unknown'}
${p.text ?? '(no text)'}${reply}${media}
</chat_wake>`
}
case 'heartbeat':
return `${head}${factsBlock}\n\n<heartbeat_wake/>`
case 'deploy_failure': {
const p = wake.payload as { revision?: string; kind?: string; detail?: unknown }
return `${head}${factsBlock}
<deploy_failure_wake>
Deployment failed and was rolled back (or stopped at v0).
revision: ${p.revision ?? 'unknown'}
kind: ${p.kind ?? 'unknown'}
detail: ${JSON.stringify(p.detail ?? {})}
</deploy_failure_wake>`
}
case 'ci_failure': {
const p = wake.payload as { sha?: string; job?: string; url?: string }
return `${head}${factsBlock}
<ci_failure_wake>
A pushed commit failed its CI gates before deployment.
commit: ${p.sha ?? 'unknown'}
job: ${p.job ?? 'unknown'}
logs: ${p.url ?? 'unknown'}
</ci_failure_wake>`
}
case 'recovery': {
const p = wake.payload as { interruptedWakeId?: string; note?: string }
return `${head}${factsBlock}
<recovery_wake>
A previous turn was interrupted (timeout or crash) and may have left partial side effects.
interrupted wake: ${p.interruptedWakeId ?? 'unknown'}
${p.note ?? ''}
</recovery_wake>`
}
default:
return `${head}${factsBlock}\n\n<wake kind="${wake.kind}">\n${JSON.stringify(wake.payload ?? null)}\n</wake>`
}
}