/** * The continual harness — a pi-rlm port of prime-agent's core/refinement. * * The harness is a small set of persistent notes ("memory" = reusable * facts/tactics, "prompt" = behavior policies) that get injected into the * system prompt on every turn. A refinement is a tool-less child LLM call * that analyzes the recent conversation trajectory and proposes structured * edits (create/update/delete), which are applied to the store. * * Two scopes, mirroring prime: * - global: a JSONL file under the pi agent dir (cross-session) * - local: pi session custom entries (session-scoped; free tree/branch * semantics, survives /resume, dies with the session) * * Prime's skill/subagent entry kinds and rollback history are intentionally * left out of this port. */ import { randomUUID } from "node:crypto"; import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname } from "node:path"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { runPiJson } from "./child.ts"; export type HarnessKind = "memory" | "prompt"; export type HarnessScope = "local" | "global"; export interface HarnessEntry { id: string; kind: HarnessKind; scope: HarnessScope; title: string; content: string; version: number; deleted?: boolean; created_at: string; updated_at: string; } export interface RefineEdit { action: "create" | "update" | "delete"; scope?: HarnessScope; kind?: HarnessKind; id?: string; title?: string; content?: string; reason?: string; } export interface RefineProposal { summary: string; edits: RefineEdit[]; } export const HARNESS_ENTRY_TYPE = "pi-rlm:harness"; export const REFINEMENT_ENTRY_TYPE = "pi-rlm:refinement"; const CONVERSATION_CAP = 20_000; const ENTRY_CONTENT_PROMPT_CAP = 500; const SECTION_ENTRY_LIMIT = 20; // ------------------------------------------------------------------- stores /** Fold a global JSONL harness file: latest record per id wins, tombstones dropped. */ export function loadGlobalHarness(path: string): HarnessEntry[] { if (!existsSync(path)) return []; const byId = new Map(); for (const line of readFileSync(path, "utf8").split("\n")) { if (!line.trim()) continue; try { const entry = JSON.parse(line) as HarnessEntry; if (entry && typeof entry.id === "string") byId.set(entry.id, entry); } catch { // skip malformed lines } } return foldEntries([...byId.values()]); } /** Fold local harness entries recorded in the pi session. */ export function loadLocalHarness(ctx: ExtensionContext): HarnessEntry[] { const records: HarnessEntry[] = []; for (const entry of ctx.sessionManager.getEntries()) { if (entry.type === "custom" && entry.customType === HARNESS_ENTRY_TYPE) { const data = entry.data as HarnessEntry | undefined; if (data && typeof data.id === "string") records.push(data); } } return foldEntries(records); } function foldEntries(records: HarnessEntry[]): HarnessEntry[] { const byId = new Map(); for (const r of records) byId.set(r.id, r); return [...byId.values()] .filter((e) => !e.deleted) .sort((a, b) => a.created_at.localeCompare(b.created_at)); } function appendGlobalRecords(path: string, records: HarnessEntry[]): void { mkdirSync(dirname(path), { recursive: true }); for (const r of records) appendFileSync(path, `${JSON.stringify(r)}\n`); } // ------------------------------------------------------------------- edits export interface ApplyResult { applied: HarnessEntry[]; errors: string[]; } export function applyEdits(opts: { edits: RefineEdit[]; defaultScope: HarnessScope; globalPath: string; globalEntries: HarnessEntry[]; localEntries: HarnessEntry[]; appendLocal: (entry: HarnessEntry) => void; }): ApplyResult { const now = new Date().toISOString(); const applied: HarnessEntry[] = []; const errors: string[] = []; const globalRecords: HarnessEntry[] = []; const findEntry = (id: string, scope: HarnessScope) => (scope === "global" ? opts.globalEntries : opts.localEntries).find((e) => e.id === id); const commit = (entry: HarnessEntry) => { if (entry.scope === "global") globalRecords.push(entry); else opts.appendLocal(entry); applied.push(entry); }; for (const edit of opts.edits) { const scope: HarnessScope = edit.scope === "global" || edit.scope === "local" ? edit.scope : opts.defaultScope; const kind: HarnessKind = edit.kind === "prompt" ? "prompt" : "memory"; try { if (edit.action === "create") { if (!edit.title || !edit.content) throw new Error("create needs title and content"); commit({ id: randomUUID().slice(0, 8), kind, scope, title: edit.title.slice(0, 120), content: edit.content.slice(0, 2_000), version: 1, created_at: now, updated_at: now, }); } else if (edit.action === "update") { const existing = edit.id ? findEntry(edit.id, scope) : undefined; if (!existing) throw new Error(`update: no entry with id "${edit.id ?? "?"}" (${scope})`); commit({ ...existing, title: (edit.title ?? existing.title).slice(0, 120), content: (edit.content ?? existing.content).slice(0, 2_000), version: existing.version + 1, updated_at: now, }); } else if (edit.action === "delete") { const existing = edit.id ? findEntry(edit.id, scope) : undefined; if (!existing) throw new Error(`delete: no entry with id "${edit.id ?? "?"}" (${scope})`); commit({ ...existing, deleted: true, version: existing.version + 1, updated_at: now }); } else { throw new Error(`unknown action "${String(edit.action)}"`); } } catch (err) { errors.push(err instanceof Error ? err.message : String(err)); } } if (globalRecords.length > 0) appendGlobalRecords(opts.globalPath, globalRecords); return { applied, errors }; } // -------------------------------------------------------------- child call function extractText(content: unknown): string { if (typeof content === "string") return content; if (Array.isArray(content)) { return content .map((p) => p && typeof p === "object" && (p as { type?: string }).type === "text" ? String((p as { text?: string }).text ?? "") : "", ) .filter(Boolean) .join("\n"); } return ""; } /** Recent conversation as plain text, tool results capped, tail-biased. */ export function serializeConversation(ctx: ExtensionContext): string { const lines: string[] = []; for (const entry of ctx.sessionManager.buildContextEntries()) { if (entry.type !== "message") continue; const msg = entry.message as { role?: string; content?: unknown }; if (msg.role === "user" || msg.role === "assistant") { const text = extractText(msg.content); if (text) lines.push(`${msg.role.toUpperCase()}: ${text}`); } else if (msg.role === "toolResult") { const text = extractText(msg.content); if (text) lines.push(`TOOL: ${text.slice(0, 500)}${text.length > 500 ? "…" : ""}`); } } const out = lines.join("\n\n"); return out.length > CONVERSATION_CAP ? `…[earlier conversation omitted]\n\n${out.slice(-CONVERSATION_CAP)}` : out; } function buildRefinePrompt(opts: { instructions?: string; scope: HarnessScope; entries: HarnessEntry[]; conversation: string; }): string { const entriesJson = JSON.stringify( opts.entries.map(({ id, kind, scope, title, content, version }) => ({ id, kind, scope, title, content, version, })), null, 2, ); return `You are the refinement harness of a self-improving coding agent. Analyze the conversation trajectory and propose small, evidence-backed updates to the agent's persistent harness. The harness is a set of short notes injected into the agent's system prompt on every future run: - kind "memory": a reusable fact, tactic, or failure pattern (e.g. "this repo's tests need --runInBand") - kind "prompt": a behavior policy (e.g. "prefer small diffs over rewrites") - scope "local": relevant only to the current project/session; scope "global": useful across projects Current harness entries (may be empty): ${entriesJson} Conversation trajectory (recent, possibly truncated): ${opts.conversation || "(empty)"} ${opts.instructions ? `Focus instructions: ${opts.instructions}\n` : ""} Rules: - Only propose an edit with concrete evidence in the trajectory: repeated failure, reusable tactic, repeated delegation pattern, or a behavior policy worth persisting. - Prefer updating an existing entry over creating near-duplicates. - Each entry: imperative, generalizable, under 300 characters. No session trivia. - Default scope: "${opts.scope}". Use the other scope only when clearly warranted. - If nothing is worth persisting, return an empty edits array. Respond with ONLY a JSON object (no prose, no code fence): {"summary": "one sentence", "edits": [{"action": "create|update|delete", "scope": "local|global", "kind": "memory|prompt", "id": "", "title": "short label", "content": "the note"}]}`; } function parseProposal(text: string): RefineProposal | null { let candidate = text.trim(); const fence = candidate.match(/```(?:json)?\s*([\s\S]*?)```/); if (fence) candidate = fence[1].trim(); const start = candidate.indexOf("{"); const end = candidate.lastIndexOf("}"); if (start < 0 || end <= start) return null; let parsed: unknown; try { parsed = JSON.parse(candidate.slice(start, end + 1)); } catch { return null; } if (!parsed || typeof parsed !== "object") return null; const p = parsed as { summary?: unknown; edits?: unknown }; if (!Array.isArray(p.edits)) return null; return { summary: typeof p.summary === "string" ? p.summary : "(no summary)", edits: p.edits.filter((e): e is RefineEdit => Boolean(e) && typeof e === "object"), }; } export interface RunRefinementResult { ok: boolean; proposal?: RefineProposal; error?: string; raw?: string; } export async function runRefinement(opts: { instructions?: string; scope: HarnessScope; entries: HarnessEntry[]; conversation: string; model?: string; cwd: string; signal?: AbortSignal; }): Promise { const args = [ "--mode", "json", "-p", "--no-session", "--no-extensions", "--no-builtin-tools", // pure analysis: fast and cheap ]; if (opts.model) args.push("--model", opts.model); args.push( buildRefinePrompt({ instructions: opts.instructions, scope: opts.scope, entries: opts.entries, conversation: opts.conversation, }), ); const result = await runPiJson(args, { cwd: opts.cwd, signal: opts.signal }); if (!result.ok) return { ok: false, error: result.error ?? "refinement child failed", raw: result.output }; const proposal = parseProposal(result.output); if (!proposal) { return { ok: false, error: "could not parse refinement JSON", raw: result.output.slice(0, 2_000) }; } return { ok: true, proposal }; } // ---------------------------------------------------------------- injection /** Markdown section appended to the system prompt; empty string when no entries. */ export function buildHarnessSection(globalEntries: HarnessEntry[], localEntries: HarnessEntry[]): string { const all = [...globalEntries, ...localEntries]; if (all.length === 0) return ""; const render = (entries: HarnessEntry[]) => entries .slice(-SECTION_ENTRY_LIMIT) .map( (e) => `- [${e.id}] ${e.title} — ${ e.content.length > ENTRY_CONTENT_PROMPT_CAP ? `${e.content.slice(0, ENTRY_CONTENT_PROMPT_CAP)}…` : e.content }`, ) .join("\n"); const parts: string[] = [ "## Continual harness (pi-rlm)", "Persistent notes from past refinements. Follow them; they survive this conversation.", ]; const memories = all.filter((e) => e.kind === "memory"); const prompts = all.filter((e) => e.kind === "prompt"); if (memories.length > 0) parts.push("### Memories", render(memories)); if (prompts.length > 0) parts.push("### Policies", render(prompts)); parts.push('Manage via /refine, or refine("instructions") inside the ipython kernel.'); return parts.join("\n"); }