import { readdir, readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, join } from "node:path"; import { type ChatMessage, type ChatRole, type ChatSession, clampText, isInScanRoots, stableMessageId, toDateOrUndefined, } from "./chat-types"; /** * Amp (ampcode.com by Sourcegraph) adapter. * * Amp stores threads server-side at ampcode.com. It does NOT write full * conversation files to local disk by default. When running with * `amp --execute "..." --stream-json`, Amp emits Claude-compatible JSONL to * stdout. This adapter reads JSONL files from ~/.config/amp/threads/ — * users must redirect Amp's stream-json output there, or use an export tool. * * Streaming JSON format (one JSON object per line): * {"type":"system","subtype":"init","cwd":"/path","session_id":"T-"} * {"type":"user","message":{"role":"user","content":[{"type":"text","text":"..."}]},"session_id":"T-"} * {"type":"assistant","message":{"role":"assistant","content":[...],"usage":{...}},"session_id":"T-"} * {"type":"result","subtype":"success","session_id":"T-"} */ interface AmpContentBlock { type?: string; text?: string; } interface AmpMessageInner { role?: string; content?: string | AmpContentBlock[]; stop_reason?: string; usage?: { input_tokens?: number; output_tokens?: number; }; } interface AmpRecord { type?: string; subtype?: string; session_id?: string; cwd?: string; message?: AmpMessageInner; timestamp?: string; uuid?: string; } export function getAmpDefaultScanRoots(): string[] { return [join(homedir(), ".config", "amp", "threads")]; } async function existsDir(path: string): Promise { try { const s = await stat(path); return s.isDirectory(); } catch { return false; } } function extractAmpText(message: AmpMessageInner): string { const { content } = message; if (typeof content === "string") return clampText(content.trim(), 32000); if (Array.isArray(content)) { const parts = content .filter((c) => c.type === "text" && typeof c.text === "string") .map((c) => (c.text as string).trim()) .filter(Boolean); return clampText(parts.join("\n\n"), 32000); } return ""; } function ampRole( recordType: string | undefined, messageRole: string | undefined, ): ChatRole { if (recordType === "user") return "user"; if (recordType === "assistant") return "assistant"; if (messageRole === "user") return "user"; if (messageRole === "assistant") return "assistant"; return "system"; } export async function parseAmpThreadFile( filePath: string, ): Promise { let raw: string; try { raw = await readFile(filePath, "utf-8"); } catch (err) { // Warn and skip-and-continue rather than throwing: a single unreadable // thread file must not abort the whole scan (mirrors the codex convention). console.warn(`[amp] failed to read thread file ${filePath}: ${err}`); return null; } const threadIdFromFile = basename(filePath).replace(/\.jsonl$/, ""); const lines = raw.split("\n").filter((l) => l.trim()); if (lines.length === 0) return null; let sessionId: string | undefined; let cwd: string | undefined; let startedAt: Date | undefined; let endedAt: Date | undefined; const messages: ChatMessage[] = []; // Surface silently-skipped malformed lines instead of losing them with zero // diagnostic (a thread file corrupted mid-write must not look like "no // activity"). Mirrors the codex parser's convention. let skippedLines = 0; let firstSkippedSample: string | undefined; for (let i = 0; i < lines.length; i++) { let record: AmpRecord; try { record = JSON.parse(lines[i]) as AmpRecord; } catch { skippedLines++; if (firstSkippedSample === undefined) { firstSkippedSample = lines[i].slice(0, 120); } continue; } sessionId = record.session_id ?? sessionId; if (record.type === "system" && record.subtype === "init") { cwd = record.cwd ?? cwd; const ts = toDateOrUndefined(record.timestamp); if (ts && !startedAt) startedAt = ts; continue; } if (record.type === "result") { const ts = toDateOrUndefined(record.timestamp); if (ts) endedAt = ts; continue; } if (record.type !== "user" && record.type !== "assistant") continue; if (!record.message) continue; const text = extractAmpText(record.message); if (!text) continue; const ts = toDateOrUndefined(record.timestamp) ?? new Date(); if (!startedAt) startedAt = ts; endedAt = ts; const externalMessageId = (typeof record.uuid === "string" && record.uuid) || stableMessageId(sessionId ?? threadIdFromFile, i, `${record.type}-${i}`); messages.push({ externalMessageId, role: ampRole(record.type, record.message.role), timestamp: ts, text, tokenIn: record.message.usage?.input_tokens, tokenOut: record.message.usage?.output_tokens, rawJson: { type: record.type, stop_reason: record.message.stop_reason }, }); } if (skippedLines > 0) { console.warn( `[amp] skipped ${skippedLines} malformed line(s) in ${filePath}` + (firstSkippedSample ? ` (first: ${firstSkippedSample})` : ""), ); } if (messages.length === 0) return null; messages.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); const resolvedId = sessionId ?? threadIdFromFile; return { providerId: "amp", externalThreadId: resolvedId, projectPath: cwd, conversationFile: filePath, startedAt: startedAt ?? messages[0].timestamp, endedAt: endedAt ?? messages[messages.length - 1].timestamp, metadata: { threadId: resolvedId }, messages, }; } export async function* getAmpSessionsStreaming( scanRoots: string[], since?: Date, options?: { dataRoots?: string[] }, ): AsyncGenerator { const roots = options?.dataRoots ?? getAmpDefaultScanRoots(); for (const root of roots) { // Amp threads directory only exists if the user has exported sessions locally. // Skip silently rather than erroring — most users won't have this yet. if (!(await existsDir(root))) continue; let files: string[] = []; try { const entries = await readdir(root, { withFileTypes: true }); files = entries .filter((e) => e.isFile() && e.name.endsWith(".jsonl")) .map((e) => join(root, e.name)); } catch { continue; } for (const file of files) { if (since) { try { const s = await stat(file); if (s.mtime < since) continue; } catch { continue; } } const session = await parseAmpThreadFile(file); if (!session) continue; if (!isInScanRoots(session.projectPath, scanRoots)) continue; if (since) { session.messages = session.messages.filter((m) => m.timestamp >= since); if (session.messages.length === 0) continue; } yield session; } } }