Update pi
This commit is contained in:
196
modules/pi/agent/extensions-common/plan-mode/handover.ts
Normal file
196
modules/pi/agent/extensions-common/plan-mode/handover.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user