Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion src/commands.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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<string> {
const { state, coreMessages, entries } = await runtime.stateFor(ctx);
// Measure every panel percentage against the SAME real request limit the live
Expand Down
168 changes: 168 additions & 0 deletions src/export.ts
Original file line number Diff line number Diff line change
@@ -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
// <sessionFile>.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<LoadedSession> {
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<LoadedSession[]> {
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<SessionSummary[]> {
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<string> {
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 <session-id|label> [--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 <id> --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 };
}
8 changes: 5 additions & 3 deletions src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,19 @@ 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<string>([ACP_STATUS_CUSTOM_TYPE, ACP_EXPORT_CUSTOM_TYPE]);

export function entriesToCoreMessages(entries: SessionEntry[]): CoreMessage[] {
const out: CoreMessage[] = [];
for (const entry of entries) {
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 });
Expand Down
Loading
Loading