197 lines
5.9 KiB
TypeScript
197 lines
5.9 KiB
TypeScript
/**
|
|
* Handover document generation for plan mode.
|
|
*
|
|
* Runs a side LLM call over the current branch (does NOT add a turn to the
|
|
* conversation), lets the user edit the result, then writes it to
|
|
* `.pi/plans/<slug>-<timestamp>.md`.
|
|
*/
|
|
|
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
import { type Message, uuidv7 } from "@earendil-works/pi-ai";
|
|
import {
|
|
BorderedLoader,
|
|
CONFIG_DIR_NAME,
|
|
convertToLlm,
|
|
type ExtensionCommandContext,
|
|
type SessionEntry,
|
|
serializeConversation,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
export const PLANS_DIR = "plans";
|
|
|
|
const SYSTEM_PROMPT = `You write handover documents. Another engineer (or agent) will pick up
|
|
this work with NO access to the conversation you are summarizing. The document must stand alone.
|
|
|
|
Output GitHub-flavoured markdown with exactly these sections:
|
|
|
|
# <short imperative title, max 8 words>
|
|
|
|
## Goal
|
|
One paragraph: what we are trying to achieve and why.
|
|
|
|
## Findings
|
|
What was learned while investigating. Cite concrete file paths and, where useful, line numbers
|
|
or symbol names. Include things that were ruled out and why.
|
|
|
|
## Plan
|
|
A numbered list of concrete, actionable steps in execution order. Each step names the files it
|
|
touches. No vague steps like "improve error handling".
|
|
|
|
## Risks & open questions
|
|
Bullets. Anything unverified, any decision the user still needs to make. Omit the section if
|
|
genuinely empty.
|
|
|
|
## Files
|
|
Bullet list of \`path\` — one-line reason it matters.
|
|
|
|
Rules:
|
|
- No preamble, no "Here is the handover". Start with the \`#\` heading.
|
|
- Be specific over complete. Facts from the conversation only; never invent file paths.
|
|
- Do not describe the conversation itself ("the user asked..."). Describe the work.`;
|
|
|
|
function entryToMessage(entry: SessionEntry): AgentMessage | undefined {
|
|
if (entry.type === "message") return entry.message;
|
|
if (entry.type === "compaction") {
|
|
return {
|
|
role: "compactionSummary",
|
|
summary: entry.summary,
|
|
tokensBefore: entry.tokensBefore,
|
|
timestamp: new Date(entry.timestamp).getTime(),
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Branch messages, respecting the most recent compaction boundary. */
|
|
function collectMessages(branch: SessionEntry[]): AgentMessage[] {
|
|
let compactionIndex = -1;
|
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
if (branch[i]?.type === "compaction") {
|
|
compactionIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
if (compactionIndex < 0) {
|
|
return branch.map(entryToMessage).filter((m): m is AgentMessage => m !== undefined);
|
|
}
|
|
|
|
const compaction = branch[compactionIndex];
|
|
const firstKept =
|
|
compaction?.type === "compaction" ? branch.findIndex((e) => e.id === compaction.firstKeptEntryId) : -1;
|
|
return [
|
|
compaction,
|
|
...(firstKept >= 0 ? branch.slice(firstKept, compactionIndex) : []),
|
|
...branch.slice(compactionIndex + 1),
|
|
]
|
|
.map((e) => (e ? entryToMessage(e) : undefined))
|
|
.filter((m): m is AgentMessage => m !== undefined);
|
|
}
|
|
|
|
function slugify(text: string): string {
|
|
return (
|
|
text
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 50)
|
|
.replace(/-+$/g, "") || "handover"
|
|
);
|
|
}
|
|
|
|
function timestamp(): string {
|
|
const d = new Date();
|
|
const p = (n: number) => String(n).padStart(2, "0");
|
|
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
|
}
|
|
|
|
/** Derive a filename slug from the document's first `# heading`. */
|
|
function slugFromDocument(markdown: string): string {
|
|
const heading = markdown.match(/^#\s+(.+)$/m);
|
|
return slugify(heading?.[1] ?? "handover");
|
|
}
|
|
|
|
/**
|
|
* Generate a handover document and write it to `.pi/plans/`.
|
|
* Returns the written path, or undefined if cancelled or unavailable.
|
|
*/
|
|
export async function writeHandover(ctx: ExtensionCommandContext, focus: string): Promise<string | undefined> {
|
|
if (ctx.mode !== "tui") {
|
|
ctx.ui.notify("/handover requires interactive mode", "error");
|
|
return;
|
|
}
|
|
if (!ctx.model) {
|
|
ctx.ui.notify("No model selected", "error");
|
|
return;
|
|
}
|
|
|
|
const messages = collectMessages(ctx.sessionManager.getBranch());
|
|
if (messages.length === 0) {
|
|
ctx.ui.notify("Nothing to hand over yet", "warning");
|
|
return;
|
|
}
|
|
|
|
const conversation = serializeConversation(convertToLlm(messages));
|
|
const model = ctx.model;
|
|
|
|
const generated = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
const loader = new BorderedLoader(tui, theme, "Writing handover document...");
|
|
loader.onAbort = () => done(null);
|
|
|
|
void (async () => {
|
|
const prompt = focus
|
|
? `## Conversation\n\n${conversation}\n\n## Focus\n\nThe handover should centre on: ${focus}`
|
|
: `## Conversation\n\n${conversation}`;
|
|
|
|
const userMessage: Message = {
|
|
role: "user",
|
|
content: [{ type: "text", text: prompt }],
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const response = await ctx.modelRegistry.complete(
|
|
model,
|
|
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
|
{ signal: loader.signal, cacheRetention: "none", sessionId: uuidv7() },
|
|
);
|
|
|
|
if (response.stopReason === "aborted") return null;
|
|
return response.content
|
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
.map((c) => c.text)
|
|
.join("\n")
|
|
.trim();
|
|
})()
|
|
.then(done)
|
|
.catch(() => done(null));
|
|
|
|
return loader;
|
|
});
|
|
|
|
if (!generated) {
|
|
ctx.ui.notify("Handover cancelled", "info");
|
|
return;
|
|
}
|
|
|
|
const edited = await ctx.ui.editor("Review handover (save to write, empty to cancel)", generated);
|
|
if (edited === undefined || !edited.trim()) {
|
|
ctx.ui.notify("Handover cancelled", "info");
|
|
return;
|
|
}
|
|
|
|
const dir = join(ctx.cwd, CONFIG_DIR_NAME, PLANS_DIR);
|
|
const path = join(dir, `${timestamp()}-${slugFromDocument(edited)}.md`);
|
|
|
|
try {
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(path, `${edited.trimEnd()}\n`, "utf8");
|
|
} catch (err) {
|
|
ctx.ui.notify(`Failed to write handover: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
return;
|
|
}
|
|
|
|
ctx.ui.notify(`Handover written to ${path}`, "info");
|
|
return path;
|
|
}
|