diff --git a/src/commands.ts b/src/commands.ts index 09edfcd..6588162 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,8 @@ import type { ExtensionAPI, ExtensionCommandContext, RegisteredCommand, SessionEntry } from "@earendil-works/pi-coding-agent"; +import * as path from "node:path"; import type { AcpRuntime } from "./runtime.js"; -import { ACP_STATUS_CUSTOM_TYPE } from "./messages.js"; +import { ACP_STATUS_CUSTOM_TYPE, ACP_EXPORT_CUSTOM_TYPE } from "./messages.js"; +import { exportSession, parseExportArgs } from "./export.js"; import { defaultCountTokens, parseBlockIdArg, collectBlockContent } from "acp-kernel"; import { getSystemPromptText } from "./compat.js"; import { collectCoveredMessageIds, estimateTokens, collectImageTokens, modelSupportsImages, adjustedTokenCount } from "./tokens.js"; @@ -57,6 +59,42 @@ export function makeCommands(runtime: AcpRuntime, pi?: ExtensionAPI): Array<{ na handler: statusHandler, }, }, + { + name: "acp-export", + options: { + description: + "Export a session as a handoff markdown doc (folded view by default). " + + "Usage: /acp-export [session-id|label] [--full] [--output handoff.md]", + handler: async (args, ctx) => { + const parsed = parseExportArgs(args); + if (parsed.error) { + ctx.ui.notify(parsed.error); + return; + } + const sessionDir = resolveSessionDir(ctx.sessionManager); + if (!sessionDir) { + ctx.ui.notify("No session directory available for export."); + return; + } + let result: string; + try { + result = await exportSession(parsed.selector, { full: parsed.full, output: parsed.output }, sessionDir); + } catch (e) { + ctx.ui.notify(e instanceof Error ? e.message : String(e), "error"); + return; + } + if (parsed.output) { + ctx.ui.notify(result); + return; + } + if (typeof pi?.sendMessage === "function") { + pi.sendMessage({ customType: ACP_EXPORT_CUSTOM_TYPE, content: result, display: true }); + return; + } + ctx.ui.notify(result); + }, + }, + }, { name: "acp-decompress", options: { @@ -141,6 +179,13 @@ export function makeCommands(runtime: AcpRuntime, pi?: ExtensionAPI): Array<{ na ]; } +function resolveSessionDir(sm: ExtensionCommandContext["sessionManager"]): string | undefined { + const dir = typeof sm.getSessionDir === "function" ? sm.getSessionDir() : undefined; + if (dir) return dir; + const file = sm.getSessionFile(); + return file ? path.dirname(file) : undefined; +} + async function statusReport(runtime: AcpRuntime, ctx: ExtensionCommandContext): Promise { const { state, coreMessages, entries } = await runtime.stateFor(ctx); // Measure every panel percentage against the SAME real request limit the live diff --git a/src/export.ts b/src/export.ts new file mode 100644 index 0000000..cea7ecb --- /dev/null +++ b/src/export.ts @@ -0,0 +1,168 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { promises as fs } from "node:fs"; +import * as path from "node:path"; +import { renderHandoff, matchSession, defaultCountTokens, type CompressionState } from "acp-kernel"; +import { SessionManager, type SessionEntry } from "@earendil-works/pi-coding-agent"; +import { entriesToCoreMessages, extractText } from "./messages.js"; +import { SessionStateStore } from "./state.js"; + +// The full conversation always lives in Pi's .jsonl; ACP state in the adjacent +// .acp.json (written every turn). So a session is exportable once +// ACP has processed a turn in it — we scan for the state file to enumerate them. +const ACP_STATE_SUFFIX = ".acp.json"; + +export interface ExportOptions { + output?: string; + full?: boolean; +} + +export interface SessionSummary { + id: string; + title?: string; + label?: string; + savedAt?: number; + contextTokens?: number; + blocks: number; +} + +interface LoadedSession { + id: string; + name?: string; + title?: string; + entries: SessionEntry[]; + state: CompressionState; + contextTokens: number; +} + +function truncate(text: string, max: number): string { + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; +} + +function latestBlockTime(state: CompressionState): number { + let latest = 0; + for (const b of state.blocks) if (b.createdAt > latest) latest = b.createdAt; + return latest; +} + +function firstUserText(entries: SessionEntry[]): string | undefined { + for (const e of entries) { + if (e.type !== "message") continue; + const m = e.message as { role?: string; content?: unknown }; + if (m?.role !== "user") continue; + const text = extractText(m.content); + if (text.trim()) return text; + } + return undefined; +} + +async function loadSession(jsonlPath: string, store: SessionStateStore): Promise { + const sm = SessionManager.open(jsonlPath); + const id = sm.getSessionId(); + const entries = sm.buildContextEntries(); + const state = await store.load(jsonlPath, id); + const coreMessages = entriesToCoreMessages(entries); + const contextTokens = coreMessages.reduce((sum, m) => sum + defaultCountTokens(m.text ?? ""), 0); + return { id, name: sm.getSessionName(), title: firstUserText(entries), entries, state, contextTokens }; +} + +async function loadAllSessions(sessionDir: string): Promise { + let names: string[]; + try { + names = await fs.readdir(sessionDir); + } catch { + return []; + } + const store = new SessionStateStore(); + const sessions: LoadedSession[] = []; + for (const name of names) { + if (!name.endsWith(ACP_STATE_SUFFIX)) continue; + const jsonl = name.slice(0, -ACP_STATE_SUFFIX.length); + try { + sessions.push(await loadSession(path.join(sessionDir, jsonl), store)); + } catch { + // unreadable / corrupt session file — skip it + } + } + sessions.sort((a, b) => latestBlockTime(b.state) - latestBlockTime(a.state)); + return sessions; +} + +export async function listSessions(sessionDir: string): Promise { + const sessions = await loadAllSessions(sessionDir); + return sessions.map((s) => ({ + id: s.id, + title: s.title ? truncate(s.title, 120) : undefined, + label: s.name, + savedAt: latestBlockTime(s.state) || undefined, + contextTokens: s.contextTokens || undefined, + blocks: s.state.blocks.length, + })); +} + +export async function exportSession(selector: string | undefined, opts: ExportOptions, sessionDir: string): Promise { + const all = await loadAllSessions(sessionDir); + if (all.length === 0) { + return "No ACP-managed sessions found in this project's session directory. A session becomes exportable once billion-context-pi has processed a turn in it (its compression state is saved alongside the session file)."; + } + if (!selector) { + const rows = all.map((s) => + `${s.id}${s.name ? ` label=${s.name}` : ""} blocks=${s.state.blocks.length}${s.contextTokens ? ` ctx~${s.contextTokens}` : ""} ${s.title ? truncate(s.title, 80) : ""}` + ); + return ["ACP-managed sessions:", "", ...rows.map((r) => ` ${r}`), "", "Usage: /acp-export [--output handoff.md] [--full]"].join("\n"); + } + const matches = matchSession(all, selector, (s) => s.name); + if (matches.length === 0) { + throw new Error(`no session matches "${selector}" (run "/acp-export" to list sessions)`); + } + if (matches.length > 1) { + const ids = matches.map((s) => s.id).join(", "); + throw new Error(`selector "${selector}" matches ${matches.length} sessions (${ids}); use the full session id`); + } + const s = matches[0]!; + const markdown = renderHandoff({ + coreMessages: entriesToCoreMessages(s.entries), + state: s.state, + full: opts.full ?? false, + meta: { + title: s.title ? truncate(s.title, 200) : undefined, + label: s.name, + sessionId: s.id, + contextTokens: s.contextTokens || undefined, + extraBullets: [`- messages: ${s.entries.length}`], + }, + }); + if (opts.output) { + mkdirSync(path.dirname(path.resolve(opts.output)), { recursive: true }); + writeFileSync(opts.output, markdown, "utf8"); + return `written to ${opts.output}`; + } + return markdown; +} + +export function parseExportArgs(args: string): { selector?: string; full: boolean; output?: string; error?: string } { + const tokens = args.trim().split(/\s+/).filter(Boolean); + let selector: string | undefined; + let full = false; + let output: string | undefined; + let error: string | undefined; + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]!; + if (t === "--full") { + full = true; + } else if (t === "--output" || t === "-o") { + const value = tokens[i + 1]; + if (value === undefined) { + error = "--output requires a file path (e.g. /acp-export --output handoff.md)"; + break; + } + output = value; + i++; + } else if (t.startsWith("--output=")) { + output = t.slice("--output=".length); + } else if (!selector) { + selector = t; + } + } + return { selector, full, output, error }; +} diff --git a/src/messages.ts b/src/messages.ts index 35e9d21..2b77a23 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -18,9 +18,11 @@ const REF_TAG_SOURCE = "(?:\x3cacp\\s[^>]*\x3em\\d{5}\x3c/acp\x3e|\\[m\\d{1,5}\\ const REF_TAG = new RegExp(`^${REF_TAG_SOURCE}\\s?\\n?`); const TRAILING_REF_TAG = new RegExp(`\\n*${REF_TAG_SOURCE}\\s*$`); -// /acp panels are UI-only transcript output (issue #255): persistent in the -// session, but never projected into the sent view. +// /acp panels and /acp-export docs are UI-only transcript output (issue #255): +// persistent in the session, but never projected into the sent view. export const ACP_STATUS_CUSTOM_TYPE = "acp-status"; +export const ACP_EXPORT_CUSTOM_TYPE = "acp-export"; +const CONTEXT_EXCLUDED_CUSTOM_TYPES = new Set([ACP_STATUS_CUSTOM_TYPE, ACP_EXPORT_CUSTOM_TYPE]); export function entriesToCoreMessages(entries: SessionEntry[]): CoreMessage[] { const out: CoreMessage[] = []; @@ -28,7 +30,7 @@ export function entriesToCoreMessages(entries: SessionEntry[]): CoreMessage[] { if (entry.type !== "message") { // custom_message participates in LLM context per Pi native semantics // (session-manager.d.ts) — project it as a user message. - if (entry.type === "custom_message" && entry.customType !== ACP_STATUS_CUSTOM_TYPE) { + if (entry.type === "custom_message" && !CONTEXT_EXCLUDED_CUSTOM_TYPES.has(entry.customType)) { const text = extractText(entry.content); if (text.length > 0) { out.push({ id: entry.id, role: "user", contentType: "text", text }); diff --git a/tests/export-cmd.test.ts b/tests/export-cmd.test.ts new file mode 100644 index 0000000..7f49f7f --- /dev/null +++ b/tests/export-cmd.test.ts @@ -0,0 +1,208 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm, writeFile, readFile } from "node:fs/promises"; +import * as path from "node:path"; +import { createAcpExtension } from "../src/index.js"; +import { listSessions, exportSession, parseExportArgs } from "../src/export.js"; +import { createInitialState } from "acp-kernel"; + +function captureApi() { + const handlers = new Map any)[]>(); + const api = { + on(event: string, handler: (e: any, ctx: any) => any) { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + tools: [] as any[], + commands: new Map(), + registerTool(tool: any) { + this.tools.push(tool); + }, + registerCommand(name: string, options: any) { + this.commands.set(name, options); + }, + }; + return { api, handlers }; +} + +function userMsg(id: string, text: string) { + return { type: "message", id, parentId: null, timestamp: "", message: { role: "user", content: text, timestamp: Date.now() } }; +} + +// Valid Pi session JSONL (header + linear chain) on disk so SessionManager.open can parse it. +async function writeSessionFile(file: string, id: string, entries: any[]) { + const header = { type: "session", version: 3, id, timestamp: new Date().toISOString(), cwd: "/tmp" }; + let parent: string | null = null; + const lines = [header]; + for (const e of entries) { + lines.push({ ...e, parentId: parent }); + parent = e.id; + } + await writeFile(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n", "utf8"); +} + +function fakeCtx(entries: any[], stateFile: string, notifies: string[]) { + return { + mode: "rpc", + hasUI: false, + cwd: "/tmp", + ui: { notify: (m: string) => notifies.push(m), confirm: async () => true, select: async () => undefined, input: async () => "", setStatus: () => {} }, + model: { contextWindow: 200_000 }, + sessionManager: { + getBranch: () => entries, + getSessionId: () => "test-session", + getSessionFile: () => stateFile, + getSessionDir: () => path.dirname(stateFile), + }, + }; +} + +// Full pipeline: write session file, run context handler (assigns refs), compress m00002 → leaves .jsonl + .acp.json on disk. +// e1 is the first user message (prune always keeps it) and the last 5 messages are a protected zone, +// so only e2 (m00002) is both compressible and foldable — it carries the long text that gets folded. +async function setupSession(stateFile: string) { + const longText = "This is a detailed message that needs to be compressed. ".repeat(130); + const filler = (n: string) => `filler ${n} `.repeat(400); + const entries = [ + userMsg("e1", "Initial short prompt."), + userMsg("e2", longText), userMsg("e3", filler("three")), + userMsg("e4", filler("four")), userMsg("e5", filler("five")), + userMsg("e6", filler("six")), userMsg("e7", filler("seven")), + ]; + await writeSessionFile(stateFile, "test-session", entries); + await rm(`${stateFile}.acp.json`, { force: true }); + const { api, handlers } = captureApi(); + createAcpExtension({ modelContextLimit: 200_000 })(api); + const notifies: string[] = []; + const ctx = fakeCtx(entries, stateFile, notifies); + await handlers.get("context")![0]!({ type: "context", messages: [] }, ctx); + const compressTool = api.tools.find((t: any) => t.name === "compress")!; + const res = await compressTool.execute( + "tc1", + { content: [{ startId: "m00002", endId: "m00002", summary: "This range contained a detailed user message discussing the initial context." }] }, + undefined, + undefined, + ctx, + ); + const text = (res.content[0] as any).text as string; + assert.match(text, /1 block/, "compress created a block"); + return { api, ctx, notifies }; +} + +test("parseExportArgs parses selector, --full, and --output", () => { + assert.deepEqual(parseExportArgs(""), { selector: undefined, full: false, output: undefined, error: undefined }); + assert.deepEqual(parseExportArgs("abc"), { selector: "abc", full: false, output: undefined, error: undefined }); + assert.deepEqual(parseExportArgs("abc --full"), { selector: "abc", full: true, output: undefined, error: undefined }); + assert.deepEqual(parseExportArgs("abc --output x.md"), { selector: "abc", full: false, output: "x.md", error: undefined }); + assert.deepEqual(parseExportArgs("--output x.md abc"), { selector: "abc", full: false, output: "x.md", error: undefined }); + assert.deepEqual(parseExportArgs("abc --output=x.md --full"), { selector: "abc", full: true, output: "x.md", error: undefined }); + assert.match(parseExportArgs("abc --output").error!, /requires a file path/); +}); + +test("listSessions returns ACP-managed sessions with id, title, and block count", async () => { + const dir = "/tmp/pai-acp-export-list"; + const stateFile = `${dir}/test-session.jsonl`; + await mkdir(dir, { recursive: true }); + await setupSession(stateFile); + const summaries = await listSessions(dir); + assert.equal(summaries.length, 1); + assert.equal(summaries[0]!.id, "test-session"); + assert.equal(summaries[0]!.blocks, 1); + assert.match(summaries[0]!.title!, /Initial short prompt/); +}); + +test("exportSession with no selector lists persisted sessions", async () => { + const dir = "/tmp/pai-acp-export-noselector"; + const stateFile = `${dir}/test-session.jsonl`; + await mkdir(dir, { recursive: true }); + await setupSession(stateFile); + const text = await exportSession(undefined, {}, dir); + assert.match(text, /ACP-managed sessions:/); + assert.match(text, /test-session/); + assert.match(text, /blocks=1/); + assert.match(text, /Usage: \/acp-export/); +}); + +test("exportSession renders the folded view (summary in place of the compressed range)", async () => { + const dir = "/tmp/pai-acp-export-folded"; + const stateFile = `${dir}/test-session.jsonl`; + await mkdir(dir, { recursive: true }); + await setupSession(stateFile); + const text = await exportSession("test-session", {}, dir); + assert.match(text, /# billion-context session handoff/); + assert.match(text, /folded view as the model saw it/); + assert.match(text, /This range contained a detailed user message/, "block summary present"); + assert.ok(!text.includes("This is a detailed message that needs to be compressed"), "compressed original is folded away"); + assert.match(text, /filler seven/, "uncompressed messages retained"); +}); + +test("exportSession --full renders the original messages", async () => { + const dir = "/tmp/pai-acp-export-full"; + const stateFile = `${dir}/test-session.jsonl`; + await mkdir(dir, { recursive: true }); + await setupSession(stateFile); + const text = await exportSession("test-session", { full: true }, dir); + assert.match(text, /Full conversation/); + assert.match(text, /This is a detailed message that needs to be compressed/, "original message restored"); + assert.match(text, /filler seven/); +}); + +test("exportSession --output writes the markdown file", async () => { + const dir = "/tmp/pai-acp-export-output"; + const stateFile = `${dir}/test-session.jsonl`; + const out = `${dir}/nested/handoff.md`; + await mkdir(dir, { recursive: true }); + await setupSession(stateFile); + const result = await exportSession("test-session", { output: out }, dir); + assert.equal(result, `written to ${out}`); + const content = await readFile(out, "utf8"); + assert.match(content, /# billion-context session handoff/); + await rm(out, { force: true }); +}); + +test("exportSession throws when no session matches the selector", async () => { + const dir = "/tmp/pai-acp-export-nomatch"; + const stateFile = `${dir}/test-session.jsonl`; + await mkdir(dir, { recursive: true }); + await setupSession(stateFile); + await assert.rejects(() => exportSession("nonexistent", {}, dir), /no session matches "nonexistent"/); +}); + +test("exportSession reports an empty store", async () => { + const dir = "/tmp/pai-acp-export-empty"; + await mkdir(dir, { recursive: true }); + const text = await exportSession(undefined, {}, dir); + assert.match(text, /No ACP-managed sessions found/); +}); + +test("exportSession throws on an ambiguous selector", async () => { + const dir = "/tmp/pai-acp-export-ambig"; + await mkdir(dir, { recursive: true }); + for (const id of ["sess-a", "sess-b"]) { + await writeSessionFile(`${dir}/${id}.jsonl`, id, [userMsg("m1", "hello world")]); + await writeFile(`${dir}/${id}.jsonl.acp.json`, JSON.stringify(createInitialState())); + } + await assert.rejects(() => exportSession("sess", {}, dir), /matches 2 sessions/); +}); + +test("/acp-export command is registered and lists, folds, and expands", async () => { + const dir = "/tmp/pai-acp-export-cmd"; + const stateFile = `${dir}/test-session.jsonl`; + await mkdir(dir, { recursive: true }); + const { api, ctx, notifies } = await setupSession(stateFile); + const cmd = api.commands.get("acp-export"); + assert.ok(cmd, "acp-export command is registered"); + + notifies.length = 0; + await cmd.handler("", ctx); + assert.match(notifies[0]!, /ACP-managed sessions:/); + + notifies.length = 0; + await cmd.handler("test-session", ctx); + assert.match(notifies[0]!, /folded view as the model saw it/); + + notifies.length = 0; + await cmd.handler("test-session --full", ctx); + assert.match(notifies[0]!, /Full conversation/); +}); diff --git a/tests/integration.test.ts b/tests/integration.test.ts index a54a4dd..f8b5e65 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -59,12 +59,12 @@ function userMsg(id: string, text: string) { return { type: "message", id, parentId: null, timestamp: "", message: { role: "user", content: text, timestamp: Date.now() } }; } - test("factory registers the compress tool and 6 flat commands", () => { + test("factory registers the compress tool and 7 flat commands", () => { const { api, handlers } = captureApi(); createAcpExtension()(api as any); assert.ok(api.tools.some((t) => t.name === "compress"), "compress tool registered"); - assert.deepEqual([...api.commands.keys()].sort(), ["acp", "acp-decompress", "acp-fleet", "acp-search", "acp-status", "acp-subagents"]); + assert.deepEqual([...api.commands.keys()].sort(), ["acp", "acp-decompress", "acp-export", "acp-fleet", "acp-search", "acp-status", "acp-subagents"]); assert.ok(handlers.has("context"), "context event wired"); assert.ok(handlers.has("session_before_compact"), "compaction-disable wired"); assert.ok(handlers.has("before_agent_start"), "system-prompt wired"); diff --git a/tests/messages.test.ts b/tests/messages.test.ts index f33c91f..074fdce 100644 --- a/tests/messages.test.ts +++ b/tests/messages.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { entriesToCoreMessages, coreOutToAgentMessages, matchesStoredText, messageIdentity, ACP_STATUS_CUSTOM_TYPE } from "../src/messages.js"; +import { entriesToCoreMessages, coreOutToAgentMessages, matchesStoredText, messageIdentity, ACP_STATUS_CUSTOM_TYPE, ACP_EXPORT_CUSTOM_TYPE } from "../src/messages.js"; import type { CoreMessage } from "acp-kernel"; import type { SessionEntry, SessionMessageEntry } from "@earendil-works/pi-coding-agent"; @@ -196,16 +196,17 @@ test("entriesToCoreMessages drops custom_message with non-text-only array conten assert.equal(core.length, 0, "non-text array content yields empty text → skipped"); }); -test("entriesToCoreMessages drops acp-status panels (UI-only, never sent to model)", () => { +test("entriesToCoreMessages drops acp-status panels and acp-export docs (UI-only, never sent to model)", () => { const entries: SessionEntry[] = [ msgEntry("a", user("before")), customEntry("b", ACP_STATUS_CUSTOM_TYPE, "╭── ACP ──╮\npanel body"), + customEntry("e", ACP_EXPORT_CUSTOM_TYPE, "# billion-context session handoff\n- session id: x"), customEntry("c", "subagent_result", "other custom messages still project"), msgEntry("d", user("after")), ]; const core = entriesToCoreMessages(entries); - assert.deepEqual(core.map((m) => m.id), ["a", "c", "d"], "acp-status panel excluded, other custom messages kept"); + assert.deepEqual(core.map((m) => m.id), ["a", "c", "d"], "acp-status panel and acp-export doc excluded, other custom messages kept"); }); test("custom_message round-trip: entriesToCoreMessages → collectOriginals → coreOutToAgentMessages preserves user role", () => {