Luigit
repositories / will

will

owned by admin

src/agent/prompt.ts

Raw
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<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>`
  }
}