// Clean-workspace compaction: asks the model to recap what it knows so far (as if briefing a // newcomer), then uses the full recap output as the orientation document that replaces the // planning conversation. import { complete, type Api, type Model } from "@earendil-works/pi-ai"; import type { ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent"; import { CLEAN_COMPACTION_MARKER } from "./identity.ts"; import type { PlanStep } from "./types.ts"; const ORIENTATION_MAX_TOKENS = 6_000; const MAX_HISTORY_CHARS = 80_000; interface SelectedModel { model: Model; apiKey?: string; headers?: Record; env?: Record; } function notify(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "info"): void { if (ctx.hasUI) ctx.ui.notify(message, type); } async function resolveActiveModel(ctx: ExtensionContext): Promise { if (!ctx.model) return undefined; const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model); if (auth.ok === false) return undefined; return { model: ctx.model, apiKey: auth.apiKey, headers: auth.headers, env: auth.env }; } function textFromContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .map((block) => { const b = block as { type?: string; text?: unknown; name?: unknown; input?: unknown }; if (b.type === "text" && typeof b.text === "string") return b.text; if ((b.type === "tool_use" || b.type === "tool-call") && typeof b.name === "string") return `[tool:${b.name}] ${JSON.stringify(b.input ?? {})}`; return ""; }) .filter(Boolean) .join("\n"); } // ── History serialization ──────────────────────────────── function serializeEntry(entry: SessionEntry): string | undefined { const e = entry as any; if (e.type === "message" && e.message) { const role = e.message.role ?? "message"; const body = textFromContent(e.message.content); if (role === "toolResult") { return `\n${body || JSON.stringify(e.message.details ?? {})}\n`; } return body ? `\n${body}\n` : undefined; } if (e.type === "custom_message" && e.display !== false) { const body = textFromContent(e.content); return body ? `\n${body}\n` : undefined; } if (e.type === "branch_summary" && e.summary) { return `\n${e.summary}\n`; } return undefined; } function bounded(text: string, maxChars = MAX_HISTORY_CHARS): string { if (text.length <= maxChars) return text; const head = Math.floor(maxChars * 0.35); const tail = maxChars - head; return `${text.slice(0, head)}\n\n[... ${text.length - maxChars} characters omitted from middle of planning history ...]\n\n${text.slice(text.length - tail)}`; } function serializePlanningHistory(entries: SessionEntry[]): string { const serialized = entries.map(serializeEntry).filter((value): value is string => Boolean(value)).join("\n\n"); return bounded(serialized || "No planning conversation captured."); } // ── Marker detection ───────────────────────────────────── export function isPrepareCleanCompaction(customInstructions?: string): boolean { return customInstructions?.includes(CLEAN_COMPACTION_MARKER) === true; } export function cleanCompactionInstructions(): string { // Leading planmode marker is an interoperability guard for older local compaction extensions // that know to step aside for pre-implementation handoffs. return `[planmode pre-implementation] ${CLEAN_COMPACTION_MARKER} Generate an orientation recap from the prepare-mode planning conversation: ask the model to PAUSE and recap what it knows so far as if briefing a newcomer, and use the full output as the orientation document. This marker is consumed by @taylorsatula/pi-prepare.`; } // ── File collection ────────────────────────────────────── function collectReadFiles(entries: SessionEntry[]): string[] { const files = new Set(); function visit(value: unknown) { if (!value || typeof value !== "object") return; if (Array.isArray(value)) { for (const item of value) visit(item); return; } const obj = value as Record; const toolName = obj.toolName ?? obj.name; const input = obj.input ?? obj.args ?? obj.parameters; if (toolName === "read" && input && typeof input === "object") { const path = (input as Record).path; if (typeof path === "string" && path.trim()) files.add(path.trim().replace(/^@/, "")); } for (const child of Object.values(obj)) visit(child); } for (const entry of entries) visit(entry); return [...files].sort(); } // ── Orientation prompts ───────────────────────────────── function buildOrientationSystemPrompt(): string { return `You produce a plain-text orientation recap that brings a newcomer up to speed on the project so far, following a strict standardized format. Strict role: - You are not the implementing agent. You are not writing a continuation summary or restating the plan. - Recap what is known about the project from the planning conversation: the goal, the relevant code and how it fits, constraints, and non-obvious gotchas a newcomer would need. - Preserve exact file paths and symbol names seen during planning. - Do not invent facts. Do not invent files or symbols you did not see. - Output plain text only, no wrapper tags, no code fences. Use exactly this section rubric. Headings are verbatim; where the rubric shows a parenthetical subtitle, write a context-appropriate one. Omit a section only if it genuinely does not apply (say so explicitly rather than silently dropping it). # Recap: One-line title naming the project/initiative. The system What the system is, the relevant subsystem, and how it works at the level a newcomer needs to understand before contributing. The problem (<context-appropriate subtitle>) The diagnosis of what's wrong — who identified it, why it matters, and what it costs. Name the mechanism that fails. What's there today (<context-appropriate subtitle>) Current state: the existing mechanisms/paths/jobs and how they actually behave. Name concrete files, classes, functions. What's good and should survive What should be preserved in v2 and why. Name concrete artifacts (models, formulas, handlers, tools, columns). The journey of our discussion Numbered narrative of how the discussion evolved: initial positions, pushback, forks, updates, and resolutions. Show the reasoning trail, not just the outcome. The ideal scenario (<context-appropriate subtitle>) The target/end-state vision — what "done" looks like when the new design is working. Decisions locked Bulleted list of decisions that are now settled and not subject to further debate. The delineation (final shape) The final delineation between concerns, broken into clearly labeled sub-sections (e.g. Programmatic / Agentic / Deleted — use whatever categories fit this project). Each bullet names concrete files/symbols so an implementer can find them. What I verified in code Concrete facts verified against the codebase, each with a file path, symbol, or line number. Distinguish what was verified from what was only assumed. Open before plan-writing Open items or blockers. If none, say so explicitly (e.g. "Nothing blocking. All forks resolved.").`; } function buildApprovedPlanText(steps: PlanStep[]): string { return steps.map((s) => { let line = `### Step ${s.number} — ${s.title}\n`; if (s.what) line += `- What — ${s.what}\n`; if (s.files?.length) line += `- Files — ${s.files.join(", ")}\n`; if (s.details) line += `- Details — ${s.details}\n`; if (s.acceptanceCriteria) line += `- Acceptance Criteria — ${s.acceptanceCriteria}\n`; if (s.dependencies?.length) line += `- Dependencies — ${s.dependencies.join(", ")}\n`; return line; }).join("\n"); } function buildOrientationUserPrompt(input: { steps?: PlanStep[]; readFiles: string[]; serializedConversation: string; focus?: string; }): string { const focusLine = input.focus?.trim() ? `\nFocus the recap on: ${input.focus.trim()}\n` : ""; const planSection = input.steps?.length ? `\nApproved plan (for reference — do not just restate it, use it to ground the recap):\n${buildApprovedPlanText(input.steps)}` : ""; return `Before you charge headlong into writing a plan I'd like you to PAUSE and give me a recap of what you know so far. Imagine as if I'm a 3rd person who comes in and needs to be brought up to speed on the project before we continue.${focusLine}${planSection} Files read during planning: ${input.readFiles.length ? input.readFiles.map((f) => `- ${f}`).join("\n") : "None detected."} Planning conversation: ${input.serializedConversation}`; } // ── Orientation generation ───────────────────────────── async function generateOrientation(input: { selected: SelectedModel; steps?: PlanStep[]; readFiles: string[]; serializedConversation: string; focus?: string; signal: AbortSignal; }): Promise<string> { const response = await complete( input.selected.model, { systemPrompt: buildOrientationSystemPrompt(), messages: [ { role: "user", content: [{ type: "text", text: buildOrientationUserPrompt(input) }], timestamp: Date.now(), }, ], }, { apiKey: input.selected.apiKey, headers: input.selected.headers, env: input.selected.env, maxTokens: Math.min(ORIENTATION_MAX_TOKENS, input.selected.model.maxTokens || ORIENTATION_MAX_TOKENS), signal: input.signal, }, ); if (response.stopReason === "error") throw new Error(response.errorMessage || "provider error"); return response.content .filter((block): block is { type: "text"; text: string } => block.type === "text") .map((block) => block.text) .join("\n") .trim(); } function wrapOrientation(input: { firstKeptEntryId: string; tokensBefore: number; steps: PlanStep[]; readFiles: string[]; orientationBody: string; }): string { return `PI PREPARE ORIENTATION HANDOFF firstKeptEntryId: ${input.firstKeptEntryId} tokensBefore: ${input.tokensBefore} Filesystem state remains authoritative. Reread files before editing; do not rely on this recap as a substitute for current file contents. APPROVED PLAN: ${buildApprovedPlanText(input.steps)} ORIENTATION DOCUMENT: ${input.orientationBody} FILES READ DURING PLANNING: ${input.readFiles.length ? input.readFiles.map((f) => `- ${f}`).join("\n") : "None detected."}`; } // ── Public API ────────────────────────────────────────── export interface OrientationRecapResult { body: string; readFiles: string[]; model: string; } /** * Generate the standardized orientation recap body from a planning conversation. * Works with or without an approved plan, so it can be invoked on-demand (/recap) * mid-discussion as well as from the clean-workspace compaction path. * Returns undefined if no model/auth is available or the model returns empty text. */ export async function buildOrientationRecap(input: { ctx: ExtensionContext; branchEntries: SessionEntry[]; steps?: PlanStep[]; focus?: string; signal: AbortSignal; }): Promise<OrientationRecapResult | undefined> { const selected = await resolveActiveModel(input.ctx); if (!selected) { notify(input.ctx, "prepare: no model/auth for orientation recap", "warning"); return undefined; } const readFiles = collectReadFiles(input.branchEntries); const serializedConversation = serializePlanningHistory(input.branchEntries); const body = await generateOrientation({ selected, steps: input.steps, readFiles, serializedConversation, focus: input.focus, signal: input.signal, }); if (!body.trim()) { notify(input.ctx, "prepare: orientation model returned empty text", "warning"); return undefined; } return { body, readFiles, model: `${selected.model.provider}/${selected.model.id}` }; } export async function buildPrepareCompaction(input: { ctx: ExtensionContext; branchEntries: SessionEntry[]; firstKeptEntryId: string; tokensBefore: number; todos: PlanStep[]; signal: AbortSignal; }): Promise<{ summary: string; details: Record<string, unknown> } | undefined> { if (input.todos.length === 0) return undefined; const recap = await buildOrientationRecap({ ctx: input.ctx, branchEntries: input.branchEntries, steps: input.todos, signal: input.signal, }); if (!recap) { notify(input.ctx, "prepare: using Pi default compaction", "warning"); return undefined; } return { summary: wrapOrientation({ firstKeptEntryId: input.firstKeptEntryId, tokensBefore: input.tokensBefore, steps: input.todos, readFiles: recap.readFiles, orientationBody: recap.body, }), details: { kind: "pi-prepare-orientation", generatedAt: new Date().toISOString(), model: recap.model, readFiles: recap.readFiles, planSteps: input.todos, }, }; }