diff --git a/packages/amico-run/src/sessions_import/discover.ts b/packages/amico-run/src/sessions_import/discover.ts new file mode 100644 index 00000000..53d925e9 --- /dev/null +++ b/packages/amico-run/src/sessions_import/discover.ts @@ -0,0 +1,236 @@ +import { existsSync, readdirSync, statSync, readFileSync } from "node:fs"; +import { join, basename } from "node:path"; +import { homedir } from "node:os"; + +export interface SourceSession { + id: string; + title: string; + directory: string; + source: "claude" | "codex" | "opencode"; + path: string; + bytes: number; + time: number; + messageCount?: number; +} + +export interface Discovery { + claude: SourceSession[]; + codex: SourceSession[]; + opencode: SourceSession[]; + warnings: string[]; + isDevcontainer: boolean; + claudeHome: string; + codexHome: string; + opencodeDb: string; +} + +function isDevcontainer(): boolean { + if (process.env.REMOTE_CONTAINERS === "true" || process.env.CODESPACES === "true") return true; + try { + return existsSync("/.dockerenv"); + } catch { + return false; + } +} + +function resolveOpencodeDb(): string { + const env = process.env.OPENCODE_DB; + if (env && env.trim() !== "") { + if (env === ":memory:" || env.startsWith("/")) return env; + return join(homedir(), ".local", "share", "opencode", env); + } + const xdg = process.env.XDG_DATA_HOME; + const base = xdg && xdg.trim() !== "" ? xdg : join(homedir(), ".local", "share"); + return join(base, "opencode", "opencode.db"); +} + +function decodeProjectDir(encoded: string): string { + // dash-encoded path: -home-jack-repos-foo → /home/jack/repos/foo + if (encoded === "-home-jack") return "/home/jack"; + // simple: replace leading - with / and remaining - with / + // but dash encoding is "-" → "/" — e.g. "-home-jack-repos-harmoniqs-amicode" → "/home/jack/repos/harmoniqs/amicode" + return "/" + encoded.slice(1).replace(/-/g, "/"); +} + +export function discover(opts?: { homedir?: string; opencodeDb?: string }): Discovery { + const home = opts?.homedir ?? homedir(); + const claudeHome = join(home, ".claude", "projects"); + const codexSessionsRoot = join(home, ".codex", "sessions"); + const codexArchived = join(home, ".codex", "archived_sessions"); + const codexIndexPath = join(home, ".codex", "session_index.jsonl"); + const opencodeDb = opts?.opencodeDb ?? resolveOpencodeDb(); + const warnings: string[] = []; + const claude: SourceSession[] = []; + const codex: SourceSession[] = []; + const opencode: SourceSession[] = []; + + // — Claude: scan ~/.agent/projects//*.jsonl + if (existsSync(claudeHome)) { + let projectDirs: string[] = []; + try { + projectDirs = readdirSync(claudeHome); + } catch { + warnings.push(`cannot read ${claudeHome}`); + } + for (const enc of projectDirs) { + const dir = join(claudeHome, enc); + let st: ReturnType | undefined; + try { + st = statSync(dir); + } catch { + continue; + } + if (!st.isDirectory()) continue; + const decoded = decodeProjectDir(enc); + let files: string[] = []; + try { + files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")); + } catch { + continue; + } + for (const f of files) { + const full = join(dir, f); + let size = 0; + let mtime = 0; + try { + const s = statSync(full); + size = s.size; + mtime = s.mtimeMs; + } catch { + continue; + } + // id is filename without .jsonl (uuid) + const id = basename(f, ".jsonl"); + // quick title: first user message display if available — we just use id for discover, + // parse step will extract real title; here use decoded dir as hint + const title = `${decoded} — ${id.slice(0, 8)}`; + claude.push({ id, title, directory: decoded, source: "claude", path: full, bytes: size, time: mtime }); + } + } + } else { + if (isDevcontainer()) warnings.push(`claude home missing at ${claudeHome} (devcontainer — mount host ~/.claude)`); + } + + // — Codex: sessions/2026/08/*/*.jsonl + archived_sessions/*.jsonl + const codexRoots = [codexSessionsRoot, codexArchived]; + // also need to handle nested date dirs: sessions/2026/08/25/*.jsonl + for (const root of codexRoots) { + if (!existsSync(root)) continue; + const collect = (dir: string) => { + let entries: string[] = []; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const e of entries) { + const full = join(dir, e); + let st: ReturnType | undefined; + try { + st = statSync(full); + } catch { + continue; + } + if (st.isDirectory()) { + collect(full); + } else if (e.endsWith(".jsonl")) { + const id = basename(e, ".jsonl").replace(/^rollout-/, ""); + // try to get title from index if available + let size = st.size; + let mtime = st.mtimeMs; + // directory hint from session_meta if we can peek quickly + let directory = ""; + try { + const firstLine = readFileSync(full, "utf8").split("\n")[0] ?? ""; + if (firstLine) { + const obj = JSON.parse(firstLine); + if (obj?.payload?.cwd) directory = String(obj.payload.cwd); + else if (obj?.payload?.session_id) directory = ""; + } + } catch { + // ignore + } + codex.push({ id, title: id.slice(0, 24), directory, source: "codex", path: full, bytes: size, time: mtime }); + } + } + }; + collect(root); + } + // enrich codex titles from session_index.jsonl if present + if (existsSync(codexIndexPath)) { + try { + const lines = readFileSync(codexIndexPath, "utf8").split("\n").filter(Boolean); + const titleMap = new Map(); + for (const line of lines) { + try { + const obj = JSON.parse(line); + if (obj?.id && obj?.thread_name) titleMap.set(String(obj.id), String(obj.thread_name)); + } catch { + // ignore + } + } + for (const s of codex) { + // id may be like 2026-08-25T10-02-08-01a0393a-... — extract uuid suffix + const uuidMatch = s.id.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i); + const uuid = uuidMatch?.[1]; + if (uuid && titleMap.has(uuid)) s.title = titleMap.get(uuid)!; + else if (titleMap.has(s.id)) s.title = titleMap.get(s.id)!; + } + } catch { + // ignore + } + } + + // — Opencode: query DB for sessions if it exists + const dbPath = opencodeDb; + if (existsSync(dbPath)) { + try { + // Use bun:sqlite if available, else skip gracefully + // Dynamic string avoids esbuild bundling + const { Database } = require("bun:sqlite" as string); + const db = new Database(dbPath, { readonly: true }); + try { + const rows = db.query("SELECT id, title, directory, time_created, time_updated FROM session ORDER BY time_created DESC").all() as Array<{ + id: string; + title: string; + directory: string; + time_created: number; + time_updated: number; + }>; + for (const r of rows) { + opencode.push({ + id: r.id, + title: r.title || r.id, + directory: r.directory || "", + source: "opencode", + path: dbPath, + bytes: 0, + time: r.time_updated ?? r.time_created ?? Date.now(), + }); + } + } finally { + db.close(); + } + } catch (e) { + // bun:sqlite not available (node) — fall back to file existence only + warnings.push(`opencode DB found at ${dbPath} but bun:sqlite unavailable — discovery lists 0 sessions (run with bun)`); + } + } + + // sort newest first + const byTime = (a: SourceSession, b: SourceSession) => b.time - a.time; + claude.sort(byTime); + codex.sort(byTime); + opencode.sort(byTime); + + return { + claude, + codex, + opencode, + warnings, + isDevcontainer: isDevcontainer(), + claudeHome, + codexHome: join(home, ".codex"), + opencodeDb: dbPath, + }; +} diff --git a/packages/amico-run/src/sessions_import/import_opencode.ts b/packages/amico-run/src/sessions_import/import_opencode.ts new file mode 100644 index 00000000..d6f551e7 --- /dev/null +++ b/packages/amico-run/src/sessions_import/import_opencode.ts @@ -0,0 +1,66 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; +import type { ExportData } from "./to_export.js"; + +export interface ImportResult { + sessionId: string; + created: boolean; + messages: number; + parts: number; +} + +function resolveOpencodeBinary(explicit?: string): string { + const fromArg = explicit ?? process.env.OPENCODE_BINARY; + if (fromArg && fromArg.trim() !== "") return fromArg.trim(); + // Fall back to `opencode` on PATH (dev convenience). The extension always + // passes the vendored binary explicitly, so this is only a local-dev nicety. + return "opencode"; +} + +/** + * Import one session by shelling out to the vendored opencode binary's canonical + * `import` command. We write the ExportData to a temp JSON file and let opencode + * decode it against its own strict schemas (Session.Info / SessionV1.Info / Part), + * re-key projectID/directory/path to the process cwd, and insert idempotently. + * + * This deliberately replaces the old hand-rolled `bun:sqlite` writer: no bun + * dependency, no duplicated schema knowledge, and a shape bug now fails loudly + * (openCode's decode throws) instead of writing rows the UI can't render. + */ +export function importExportData(opts: { + data: ExportData; + opencode?: string; + dbPath?: string; + cwd?: string; +}): ImportResult { + const binary = resolveOpencodeBinary(opts.opencode); + + const dir = mkdtempSync(join(tmpdir(), "amico-sessions-")); + const file = join(dir, "session.json"); + try { + writeFileSync(file, JSON.stringify(opts.data)); + + const env: NodeJS.ProcessEnv = { ...process.env }; + if (opts.dbPath && opts.dbPath !== ":memory:") env.OPENCODE_DB = opts.dbPath; + + const res = spawnSync(binary, ["import", file], { + env, + cwd: opts.cwd ?? process.cwd(), + encoding: "utf8", + timeout: 60_000, + }); + + if (res.status !== 0) { + const detail = (res.stderr || res.stdout || "").trim(); + throw new Error(`opencode import failed (exit ${res.status}): ${detail}`); + } + + const messageCount = opts.data.messages.length; + const partCount = opts.data.messages.reduce((n, m) => n + m.parts.length, 0); + return { sessionId: opts.data.info.id, created: true, messages: messageCount, parts: partCount }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/packages/amico-run/src/sessions_import/parse_claude.ts b/packages/amico-run/src/sessions_import/parse_claude.ts new file mode 100644 index 00000000..c2de8d2b --- /dev/null +++ b/packages/amico-run/src/sessions_import/parse_claude.ts @@ -0,0 +1,274 @@ +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; +import { + makeSessionInfo, + makeUserMessage, + makeAssistantMessage, + makeTextPart, + makeReasoningPart, + makeFilePart, + makeToolPart, + type ExportData, + type MessageInfo, + type ToolState, +} from "./to_export.js"; + +interface ClaudeLine { + type: string; + uuid?: string; + parentUuid?: string; + timestamp?: string | number; + sessionId?: string; + session_id?: string; + cwd?: string; + message?: { role?: string; content?: unknown; model?: string; id?: string }; + toolUseResult?: unknown; + gitBranch?: string; + version?: string; +} + +function toMs(ts: unknown): number | undefined { + if (typeof ts === "number") return ts; + if (typeof ts === "string") { + const n = Date.parse(ts); + if (!Number.isNaN(n)) return n; + const asNum = Number(ts); + if (!Number.isNaN(asNum)) return asNum; + } + return undefined; +} + +function extractTextFromContent(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const texts: string[] = []; + for (const p of content) { + if (p && typeof p === "object" && "type" in p) { + const t = (p as Record).type; + if (t === "text" && typeof (p as Record).text === "string") texts.push(String((p as Record).text)); + if (t === "input_text" && typeof (p as Record).text === "string") texts.push(String((p as Record).text)); + if (t === "output_text" && typeof (p as Record).text === "string") texts.push(String((p as Record).text)); + if (t === "tool_result" && typeof (p as Record).content === "string") texts.push(String((p as Record).content)); + } + } + return texts.join("\n"); + } + if (content && typeof content === "object" && "text" in content) return String((content as Record).text); + return ""; +} + +export function parseClaudeFile(filePath: string, fallbackDirectory: string): ExportData | null { + let raw: string; + try { + raw = readFileSync(filePath, "utf8"); + } catch { + return null; + } + const lines = raw.split("\n").filter(Boolean); + if (lines.length === 0) return null; + + const entries: ClaudeLine[] = []; + for (const line of lines) { + try { + entries.push(JSON.parse(line) as ClaudeLine); + } catch { + // skip malformed + } + } + + // Session identity comes from the FILENAME (unique per file), not the `sessionId` + // field — Claude Code splits one parent session across the main file plus + // `agent-*.jsonl` sub-agent files that all share the same `sessionId`. Using the + // shared field would merge every sub-agent conversation into the parent. The + // shared `sessionId` is kept as `claude_parent_session` metadata instead. + const fileId = basename(filePath, ".jsonl"); + const parentSessionId = entries.find((e) => e.sessionId)?.sessionId ?? entries.find((e) => e.session_id)?.session_id; + const id = fileId || String(parentSessionId ?? "unknown").trim() || "unknown"; + + const directory = entries.find((e) => e.cwd)?.cwd ?? fallbackDirectory ?? ""; + const originalDirectory = directory; + + let title = id.slice(0, 8); + for (const e of entries) { + if (e.type === "user" && e.message?.content !== undefined) { + const t = extractTextFromContent(e.message.content); + if (t.trim()) { + title = t.trim().slice(0, 80); + break; + } + } + } + + let timeCreated: number | undefined; + for (const e of entries) { + const ms = toMs(e.timestamp); + if (ms !== undefined) { + timeCreated = ms; + break; + } + } + + let modelId: string | undefined; + for (const e of entries) { + if (e.type === "assistant" && e.message?.model) { + modelId = String(e.message.model); + break; + } + } + + const gitBranch = entries.find((e) => e.gitBranch)?.gitBranch; + const version = entries.find((e) => e.version)?.version; + + const info = makeSessionInfo({ + id, + title, + directory, + originalDirectory, + source: "claude", + sourcePath: filePath, + modelId, + providerID: "anthropic", + timeCreated, + extraMetadata: { + ...(gitBranch ? { gitBranch } : {}), + ...(version ? { claude_version: version } : {}), + ...(parentSessionId ? { claude_parent_session: String(parentSessionId) } : {}), + }, + }); + + const messages: ExportData["messages"] = []; + // callId → mutable tool part, so a later tool_result can complete it + const toolParts = new Map(); + let lastMessageId = ""; + + for (const e of entries) { + if (e.type !== "user" && e.type !== "assistant") continue; + const roleRaw = e.message?.role; + if (!roleRaw) continue; + + const isAssistant = roleRaw === "assistant"; + const timeMs = toMs(e.timestamp); + const msgId = e.uuid ?? `${id}-${messages.length}`; + + const msgInfo: MessageInfo = isAssistant + ? makeAssistantMessage({ + id: msgId, + sessionID: info.id, + parentID: lastMessageId || "msg_root", + timeCreated: timeMs, + modelId, + providerID: "anthropic", + cwd: originalDirectory, + }) + : makeUserMessage({ + id: msgId, + sessionID: info.id, + timeCreated: timeMs, + modelId, + providerID: "anthropic", + }); + + lastMessageId = msgInfo.id; + + const parts: ExportData["messages"][number]["parts"] = []; + const content = e.message?.content; + + if (typeof content === "string") { + if (content) parts.push(makeTextPart({ id: `${msgId}_0`, sessionID: info.id, messageID: msgInfo.id, text: content })); + } else if (Array.isArray(content)) { + let idx = 0; + for (const p of content) { + if (!p || typeof p !== "object") continue; + const t = (p as Record).type; + const pid = `${msgId}_${idx++}`; + if (t === "text" || t === "input_text" || t === "output_text") { + const text = String((p as Record).text ?? ""); + if (text) parts.push(makeTextPart({ id: pid, sessionID: info.id, messageID: msgInfo.id, text })); + } else if (t === "thinking") { + const thinking = String((p as Record).thinking ?? ""); + if (thinking) parts.push(makeReasoningPart({ id: pid, sessionID: info.id, messageID: msgInfo.id, text: thinking, time: timeMs })); + } else if (t === "tool_use") { + const callId = String((p as Record).id ?? ""); + const name = String((p as Record).name ?? "unknown"); + const input = ((p as Record).input ?? {}) as Record; + const part = makeToolPart({ + id: pid, + sessionID: info.id, + messageID: msgInfo.id, + callID: callId, + tool: name, + state: { status: "running", input, time: { start: timeMs ?? Date.now() } }, + }); + parts.push(part); + if (callId) toolParts.set(callId, part as unknown as { state: ToolState }); + } else if (t === "tool_result") { + const callId = String((p as Record).tool_use_id ?? ""); + const maybeContent = (p as Record).content; + const out = typeof maybeContent === "string" ? maybeContent : JSON.stringify(maybeContent ?? ""); + const isError = Boolean((p as Record).is_error); + const matched = callId ? toolParts.get(callId) : undefined; + if (matched) { + matched.state = isError + ? { status: "error", input: {}, error: out, time: { start: timeMs ?? Date.now(), end: timeMs ?? Date.now() } } + : { status: "completed", input: {}, output: out, title: "", metadata: {}, time: { start: timeMs ?? Date.now(), end: timeMs ?? Date.now() } }; + } else { + parts.push( + makeToolPart({ + id: pid, + sessionID: info.id, + messageID: msgInfo.id, + callID: callId, + tool: "unknown", + state: isError + ? { status: "error", input: {}, error: out, time: { start: timeMs ?? Date.now(), end: timeMs ?? Date.now() } } + : { status: "completed", input: {}, output: out, title: "", metadata: {}, time: { start: timeMs ?? Date.now(), end: timeMs ?? Date.now() } }, + }), + ); + } + } else if (t === "image") { + const src = (p as Record).source as Record | undefined; + if (src) { + const mime = String(src.media_type ?? "image/jpeg"); + parts.push( + makeFilePart({ + id: pid, + sessionID: info.id, + messageID: msgInfo.id, + mime, + filename: undefined, + url: `data:${mime};base64,${String(src.data ?? "")}`, + }), + ); + } + } + } + if ((e as ClaudeLine).toolUseResult !== undefined && parts.length === 0) { + const tr = (e as ClaudeLine).toolUseResult; + parts.push( + makeToolPart({ + id: `${msgId}_${idx}`, + sessionID: info.id, + messageID: msgInfo.id, + callID: "", + tool: "unknown", + state: { + status: "completed", + input: {}, + output: typeof tr === "string" ? tr : JSON.stringify(tr), + title: "", + metadata: {}, + time: { start: timeMs ?? Date.now(), end: timeMs ?? Date.now() }, + }, + }), + ); + } + } + + if (parts.length === 0) continue; + messages.push({ info: msgInfo, parts }); + } + + if (messages.length === 0) return null; + + return { info, messages }; +} diff --git a/packages/amico-run/src/sessions_import/parse_codex.ts b/packages/amico-run/src/sessions_import/parse_codex.ts new file mode 100644 index 00000000..dc11eb89 --- /dev/null +++ b/packages/amico-run/src/sessions_import/parse_codex.ts @@ -0,0 +1,268 @@ +import { readFileSync } from "node:fs"; +import { + makeSessionInfo, + makeUserMessage, + makeAssistantMessage, + makeTextPart, + makeReasoningPart, + makeToolPart, + type ExportData, + type MessageInfo, + type PartInfo, + type ToolState, +} from "./to_export.js"; + +interface CodexLine { + timestamp?: string; + type?: string; + payload?: Record; +} + +function toMs(ts: unknown): number | undefined { + if (typeof ts === "string") { + const n = Date.parse(ts); + if (!Number.isNaN(n)) return n; + } + if (typeof ts === "number") return ts; + return undefined; +} + +export function parseCodexFile(filePath: string, _fallbackDirectory: string): ExportData | null { + let raw: string; + try { + raw = readFileSync(filePath, "utf8"); + } catch { + return null; + } + const lines = raw.split("\n").filter(Boolean); + if (lines.length === 0) return null; + + let codexSessionId = ""; + let directory = ""; + let cliVersion: string | undefined; + let modelProvider: string | undefined; + let timeCreated: number | undefined; + + // First pass: header (session_meta) + title enrichment + for (const line of lines) { + let obj: CodexLine; + try { + obj = JSON.parse(line) as CodexLine; + } catch { + continue; + } + if (!timeCreated && obj.timestamp) timeCreated = toMs(obj.timestamp); + if (obj.type === "session_meta" && obj.payload) { + const p = obj.payload; + codexSessionId = String(p.session_id ?? p.id ?? codexSessionId); + directory = String(p.cwd ?? directory); + cliVersion = String(p.cli_version ?? cliVersion ?? ""); + modelProvider = String(p.model_provider ?? modelProvider ?? ""); + } + } + + // Session identity comes from the FILENAME UUID (unique per rollout), not the + // `session_id` field — Codex writes multiple `rollout-*.jsonl` files for one + // stable session, each with the SAME session_id. Using that field would merge + // every rollout into one session. The stable session_id is kept as + // `codex_session_id` metadata instead. + const fileUuid = filePath.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i)?.[1]; + const originalSessionId = fileUuid ?? codexSessionId ?? `codex-${Date.now()}`; + if (!directory) directory = _fallbackDirectory ?? ""; + const originalDirectory = directory; + + const messages: ExportData["messages"] = []; + const pendingToolCalls = new Map(); + let msgSeq = 0; + let lastMessageId = ""; + + const ensureMessage = (role: "user" | "assistant", ms: number | undefined, idHint?: string): { idx: number; msgId: string } => { + const msgId = idHint ?? `${originalSessionId}-msg-${msgSeq++}`; + const msgInfo: MessageInfo = + role === "assistant" + ? makeAssistantMessage({ + id: msgId, + sessionID: "", + parentID: lastMessageId || "msg_root", + timeCreated: ms, + modelId: modelProvider ? `codex/${modelProvider}` : "codex", + providerID: modelProvider ?? "openai", + cwd: originalDirectory, + }) + : makeUserMessage({ + id: msgId, + sessionID: "", + timeCreated: ms, + modelId: modelProvider ? `codex/${modelProvider}` : "codex", + providerID: modelProvider ?? "openai", + }); + lastMessageId = msgInfo.id; + messages.push({ info: msgInfo, parts: [] }); + return { idx: messages.length - 1, msgId: msgInfo.id }; + }; + + // Second pass: messages. sessionID is filled in after we know the final session id. + for (const line of lines) { + let obj: CodexLine; + try { + obj = JSON.parse(line) as CodexLine; + } catch { + continue; + } + const ms = toMs(obj.timestamp); + const payload = obj.payload ?? {}; + + if (obj.type === "response_item") { + const ptype = String(payload.type ?? ""); + if (ptype === "message") { + const roleRaw = String(payload.role ?? "assistant"); + const role: "user" | "assistant" = roleRaw === "assistant" ? "assistant" : "user"; + const content = payload.content; + const idHint = typeof payload.id === "string" ? String(payload.id) : undefined; + const { idx, msgId } = ensureMessage(role, ms, idHint); + if (Array.isArray(content)) { + for (const c of content) { + if (!c || typeof c !== "object") continue; + const ctype = String((c as Record).type ?? ""); + if (ctype === "input_text" || ctype === "output_text" || ctype === "text") { + const text = String((c as Record).text ?? ""); + if (text) messages[idx].parts.push(makeTextPart({ id: `${msgId}_${messages[idx].parts.length}`, sessionID: "", messageID: msgId, text })); + } + } + } else if (typeof content === "string" && (content as string).trim()) { + messages[idx].parts.push(makeTextPart({ id: `${msgId}_0`, sessionID: "", messageID: msgId, text: String(content) })); + } + if (messages[idx].parts.length === 0) { + messages.pop(); + msgSeq--; + } + } else if (ptype === "custom_tool_call") { + const callId = String(payload.call_id ?? payload.id ?? `call-${msgSeq}`); + const name = String(payload.name ?? "exec"); + const input = (payload.input ?? {}) as Record; + let targetIdx = messages.length - 1; + let targetMsgId = ""; + if (targetIdx >= 0 && messages[targetIdx].info.role === "assistant") { + targetMsgId = messages[targetIdx].info.id; + } else { + const { idx, msgId } = ensureMessage("assistant", ms); + targetIdx = idx; + targetMsgId = msgId; + } + const part = makeToolPart({ + id: `${targetMsgId}_tool_${callId}`, + sessionID: "", + messageID: targetMsgId, + callID: callId, + tool: name, + state: { status: "running", input, time: { start: ms ?? Date.now() } }, + }); + messages[targetIdx].parts.push(part); + pendingToolCalls.set(callId, part as unknown as { state: ToolState }); + } else if (ptype === "custom_tool_call_output") { + const callId = String(payload.call_id ?? ""); + const output = payload.output; + let outText = ""; + if (Array.isArray(output)) { + for (const o of output) { + if (o && typeof o === "object" && typeof (o as Record).text === "string") outText += String((o as Record).text) + "\n"; + else if (typeof o === "string") outText += o + "\n"; + } + } else if (typeof output === "string") outText = output; + else if (output !== undefined) outText = JSON.stringify(output); + + const pending = pendingToolCalls.get(callId); + if (pending) { + pending.state = { + status: "completed", + input: {}, + output: outText, + title: "", + metadata: {}, + time: { start: ms ?? Date.now(), end: ms ?? Date.now() }, + }; + } else { + let targetIdx = messages.length - 1; + let targetMsgId = targetIdx >= 0 ? messages[targetIdx].info.id : ""; + if (targetIdx < 0 || messages[targetIdx].info.role !== "assistant") { + const { idx, msgId } = ensureMessage("assistant", ms); + targetIdx = idx; + targetMsgId = msgId; + } + messages[targetIdx].parts.push( + makeToolPart({ + id: `${targetMsgId}_out_${callId}`, + sessionID: "", + messageID: targetMsgId, + callID: callId, + tool: "unknown", + state: { status: "completed", input: {}, output: outText, title: "", metadata: {}, time: { start: ms ?? Date.now(), end: ms ?? Date.now() } }, + }), + ); + } + } else if (ptype === "reasoning") { + const summary = payload.summary; + const enc = String(payload.encrypted_content ?? ""); + const text = Array.isArray(summary) + ? summary.map((s) => (typeof s === "object" && s !== null ? String((s as Record).text ?? "") : "")).join("\n") + : enc.slice(0, 200); + if (text.trim()) { + let targetIdx = messages.length - 1; + let targetMsgId = targetIdx >= 0 ? messages[targetIdx].info.id : ""; + if (targetIdx < 0 || messages[targetIdx].info.role !== "assistant") { + const { idx, msgId } = ensureMessage("assistant", ms); + targetIdx = idx; + targetMsgId = msgId; + } + messages[targetIdx].parts.push(makeReasoningPart({ id: `${targetMsgId}_reasoning_${messages[targetIdx].parts.length}`, sessionID: "", messageID: targetMsgId, text, time: ms })); + } + } + } else if (obj.type === "event_msg") { + const etype = String(payload.type ?? ""); + if (etype === "agent_message" || etype === "agent_reasoning") { + const text = String((payload as Record).message ?? (payload as Record).text ?? ""); + if (text.trim()) { + const { idx, msgId } = ensureMessage("assistant", ms); + messages[idx].parts.push(makeTextPart({ id: `${msgId}_0`, sessionID: "", messageID: msgId, text })); + } + } else if (etype === "user_message") { + const text = String((payload as Record).message ?? ""); + if (text.trim()) { + const { idx, msgId } = ensureMessage("user", ms); + messages[idx].parts.push(makeTextPart({ id: `${msgId}_0`, sessionID: "", messageID: msgId, text })); + } + } + } + } + + const pruned = messages.filter((m) => m.parts.length > 0); + if (pruned.length === 0) return null; + + const firstUserText = pruned.find((m) => m.info.role === "user")?.parts.find((p) => p.type === "text")?.text; + const title = firstUserText?.slice(0, 80) ?? originalSessionId.slice(0, 24); + + const info = makeSessionInfo({ + id: originalSessionId, + title, + directory, + originalDirectory, + source: "codex", + sourcePath: filePath, + modelId: modelProvider ? `codex/${modelProvider}` : "codex", + providerID: modelProvider ?? "openai", + timeCreated, + extraMetadata: { + cli_version: cliVersion, + model_provider: modelProvider, + ...(codexSessionId ? { codex_session_id: codexSessionId } : {}), + }, + }); + + // Backfill sessionID (and part sessionID) now that the final session id is known. + for (const m of pruned) { + m.info.sessionID = info.id; + for (const p of m.parts) p.sessionID = info.id; + } + + return { info, messages: pruned }; +} diff --git a/packages/amico-run/src/sessions_import/sessions_verb.ts b/packages/amico-run/src/sessions_import/sessions_verb.ts new file mode 100644 index 00000000..c9044d92 --- /dev/null +++ b/packages/amico-run/src/sessions_import/sessions_verb.ts @@ -0,0 +1,142 @@ +import { discover } from "./discover.js"; +import { parseClaudeFile } from "./parse_claude.js"; +import { parseCodexFile } from "./parse_codex.js"; +import { importExportData } from "./import_opencode.js"; + +function parseArgs(argv: string[]): { + command: string; + sources: string[]; + dryRun: boolean; + db?: string; + opencode?: string; + limit?: number; + includeArchived: boolean; + json: boolean; +} { + let command = "preview"; + const sources: string[] = []; + let dryRun = false; + let db: string | undefined; + let opencode: string | undefined; + let limit: number | undefined; + let includeArchived = false; + let json = false; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "preview") command = "preview"; + else if (a === "import" || a === "run") command = "import"; + else if (a === "--dry-run") dryRun = true; + else if (a === "--include-archived") includeArchived = true; + else if (a === "--json") json = true; + else if (a.startsWith("--db=")) db = a.slice("--db=".length); + else if (a === "--db" && argv[i + 1]) db = argv[++i]; + else if (a.startsWith("--opencode=")) opencode = a.slice("--opencode=".length); + else if (a === "--opencode" && argv[i + 1]) opencode = argv[++i]; + else if (a.startsWith("--source=")) sources.push(...a.slice("--source=".length).split(",").filter(Boolean)); + else if (a === "--source" && argv[i + 1]) sources.push(...argv[++i].split(",").filter(Boolean)); + else if (a.startsWith("--limit=")) limit = Number(a.slice("--limit=".length)); + else if (a === "--limit" && argv[i + 1]) limit = Number(argv[++i]); + else if (a === "--help" || a === "-h") command = "help"; + } + + if (sources.length === 0) sources.push("claude", "codex"); + return { command, sources: sources.map((s) => s.toLowerCase()), dryRun, db, opencode, limit, includeArchived, json }; +} + +function usage(): string { + return `usage: + amico sessions preview [--source claude,codex,opencode] [--db ] [--include-archived] [--limit N] [--json] + amico sessions import [--source claude,codex] [--opencode ] [--db ] [--include-archived] [--dry-run] [--limit N] [--json] + --source comma list (default: claude,codex) + --opencode path to the vendored opencode binary (default: OPENCODE_BINARY or \`opencode\` on PATH) + --db opencode DB path (default: OPENCODE_DB or ~/.local/share/opencode/opencode.db) + --include-archived include ~/.codex/archived_sessions + --dry-run parse + validate without writing + --limit cap sessions per source (for throwaway DB testing) +`; +} + +export async function sessionsVerb(argv: string[]): Promise<{ json: unknown; code: number }> { + const opts = parseArgs(argv); + if (opts.command === "help") return { json: { usage: usage() }, code: 0 }; + + const discovery = discover({ opencodeDb: opts.db }); + + const wantClaude = opts.sources.includes("claude") || opts.sources.includes("all"); + const wantCodex = opts.sources.includes("codex") || opts.sources.includes("all"); + const wantOpencode = opts.sources.includes("opencode") || opts.sources.includes("all"); + + let claudeSessions = wantClaude ? discovery.claude : []; + let codexSessions = wantCodex ? discovery.codex : []; + if (!opts.includeArchived) codexSessions = codexSessions.filter((s) => !s.path.includes("archived_sessions")); + + if (opts.limit !== undefined && !Number.isNaN(opts.limit)) { + claudeSessions = claudeSessions.slice(0, opts.limit); + codexSessions = codexSessions.slice(0, opts.limit); + } + + if (opts.command === "preview") { + const preview = { + warnings: discovery.warnings, + isDevcontainer: discovery.isDevcontainer, + sources: { + claude: { count: claudeSessions.length, sample: claudeSessions.slice(0, 3).map((s) => ({ id: s.id, title: s.title, path: s.path, bytes: s.bytes })) }, + codex: { count: codexSessions.length, sample: codexSessions.slice(0, 3).map((s) => ({ id: s.id, title: s.title, path: s.path, bytes: s.bytes })) }, + opencode: wantOpencode ? { count: discovery.opencode.length } : undefined, + }, + total: claudeSessions.length + codexSessions.length, + }; + return { json: preview, code: 0 }; + } + + let imported = 0; + let skipped = 0; + let failed = 0; + const details: Array<{ id: string; source: string; title: string; created: boolean; messages: number; parts: number; error?: string }> = []; + + const all = [ + ...claudeSessions.map((s) => ({ ...s, _source: "claude" as const })), + ...codexSessions.map((s) => ({ ...s, _source: "codex" as const })), + ]; + + for (const s of all) { + let data; + try { + data = s._source === "claude" ? parseClaudeFile(s.path, s.directory) : parseCodexFile(s.path, s.directory); + } catch (e) { + failed++; + details.push({ id: s.id, source: s._source, title: s.title, created: false, messages: 0, parts: 0, error: e instanceof Error ? e.message : String(e) }); + continue; + } + if (!data) { + skipped++; + details.push({ id: s.id, source: s._source, title: s.title, created: false, messages: 0, parts: 0, error: "no messages (empty or filtered)" }); + continue; + } + if (opts.dryRun) { + imported++; + details.push({ id: data.info.id, source: s._source, title: data.info.title, created: true, messages: data.messages.length, parts: data.messages.reduce((n, m) => n + m.parts.length, 0) }); + continue; + } + try { + const res = importExportData({ data, opencode: opts.opencode, dbPath: opts.db }); + imported++; + details.push({ id: res.sessionId, source: s._source, title: data.info.title, created: res.created, messages: res.messages, parts: res.parts }); + } catch (e) { + failed++; + details.push({ id: s.id, source: s._source, title: s.title, created: false, messages: 0, parts: 0, error: e instanceof Error ? (e.stack ?? e.message) : String(e) }); + } + } + + return { + json: { + rekey: "always (opencode import re-keys projectID/directory/path to cwd; original in metadata.original_directory)", + warnings: discovery.warnings, + summary: { scanned: all.length, imported, skipped, failed }, + details: opts.json ? details : details.slice(0, 20), + truncated: details.length > 20 && !opts.json ? `showing 20/${details.length} — add --json for all` : undefined, + }, + code: failed > 0 ? 1 : 0, + }; +} diff --git a/packages/amico-run/src/sessions_import/to_export.ts b/packages/amico-run/src/sessions_import/to_export.ts new file mode 100644 index 00000000..b283cdf5 --- /dev/null +++ b/packages/amico-run/src/sessions_import/to_export.ts @@ -0,0 +1,189 @@ +// ExportData types matching opencode's canonical import contract +// (`opencode/packages/opencode/src/cli/cmd/import.ts`). The importer decodes +// `info` via `Session.Info`, each message via `SessionV1.Info` (User|Assistant), +// and each part via `SessionV1.Part` — all STRICT schemas. Anything we emit that +// violates them throws at import time, which is exactly the safety net we want: +// a shape bug fails loudly instead of writing rows the UI can't render. +// +// The strict schemas force three things Claude/Codex JSONL never has: +// - branded IDs: session `ses_*`, message `msg_*`, part `prt_*` +// - assistant messages carry `parentID`, `modelID`, `providerID`, `mode`, +// `agent`, `path`, `cost`, and a full `tokens` object +// - tool parts carry a `state` discriminated union (not flat input/output) +// +// Every builder below synthesizes those from what the source format gives us. + +export interface SessionInfo { + id: string; + slug: string; + title: string; + version: string; + directory: string; + path?: string; + model?: { id: string; providerID: string; variant?: string }; + metadata?: Record; + agent?: string; + time: { created: number; updated: number }; +} + +export interface MessageInfo { + id: string; + sessionID: string; + role: "user" | "assistant"; + time: { created: number; completed?: number }; + agent: string; + model?: { providerID: string; modelID: string; variant?: string }; + // assistant-only + parentID?: string; + modelID?: string; + providerID?: string; + mode?: string; + path?: { cwd: string; root: string }; + cost?: number; + tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }; +} + +export type PartInfo = + | { id: string; sessionID: string; messageID: string; type: "text"; text: string } + | { id: string; sessionID: string; messageID: string; type: "reasoning"; text: string; time: { start: number; end: number } } + | { id: string; sessionID: string; messageID: string; type: "file"; mime: string; filename?: string; url: string } + | { id: string; sessionID: string; messageID: string; type: "tool"; callID: string; tool: string; state: ToolState }; + +export type ToolState = + | { status: "pending"; input: Record; raw: string } + | { status: "running"; input: Record; time: { start: number } } + | { status: "completed"; input: Record; output: string; title: string; metadata: Record; time: { start: number; end: number } } + | { status: "error"; input: Record; error: string; time: { start: number; end: number } }; + +export interface ExportData { + info: SessionInfo; + messages: Array<{ info: MessageInfo; parts: PartInfo[] }>; +} + +// ── ID synthesis ──────────────────────────────────────────────────────────── + +/** Brand a foreign id into a valid opencode SessionID ("ses_" prefix). */ +export function sessionId(original: string): string { + const clean = original.replace(/[^A-Za-z0-9._-]/g, ""); + return `ses_${clean || Date.now().toString(36)}`; +} + +/** Brand a foreign id into a valid opencode MessageID ("msg_" prefix). */ +export function messageId(seed: string): string { + const clean = seed.replace(/[^A-Za-z0-9._-]/g, ""); + return `msg_${clean || Date.now().toString(36)}`; +} + +/** Brand a foreign id into a valid opencode PartID ("prt_" prefix). */ +export function partId(seed: string): string { + const clean = seed.replace(/[^A-Za-z0-9._-]/g, ""); + return `prt_${clean}`; +} + +const EMPTY_TOKENS = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }; + +// ── Builders ─────────────────────────────────────────────────────────────── + +export function makeSessionInfo(opts: { + id: string; + title: string; + directory: string; + originalDirectory?: string; + source: string; + sourcePath: string; + modelId?: string; + providerID?: string; + timeCreated?: number; + extraMetadata?: Record; +}): SessionInfo { + const created = opts.timeCreated ?? Date.now(); + return { + id: sessionId(opts.id), + slug: opts.id.slice(0, 8), + title: truncateTitle(opts.title), + version: "1", + directory: opts.directory, + model: opts.modelId ? { id: opts.modelId, providerID: opts.providerID ?? "unknown" } : undefined, + metadata: { + import_source: opts.source, + import_source_path: opts.sourcePath, + original_directory: opts.originalDirectory ?? opts.directory, + ...(opts.extraMetadata ?? {}), + }, + time: { created, updated: Date.now() }, + }; +} + +export function makeUserMessage(opts: { + id: string; + sessionID: string; + timeCreated?: number; + modelId?: string; + providerID?: string; +}): MessageInfo { + return { + id: messageId(opts.id), + sessionID: opts.sessionID, + role: "user", + time: { created: opts.timeCreated ?? Date.now() }, + agent: "import", + model: { providerID: opts.providerID ?? "unknown", modelID: opts.modelId ?? "unknown" }, + }; +} + +export function makeAssistantMessage(opts: { + id: string; + sessionID: string; + parentID: string; + timeCreated?: number; + modelId?: string; + providerID?: string; + cwd?: string; +}): MessageInfo { + const created = opts.timeCreated ?? Date.now(); + const root = opts.cwd ?? "/"; + return { + id: messageId(opts.id), + sessionID: opts.sessionID, + role: "assistant", + time: { created }, + agent: "import", + parentID: opts.parentID, + modelID: opts.modelId ?? "unknown", + providerID: opts.providerID ?? "unknown", + mode: "import", + path: { cwd: root, root }, + cost: 0, + tokens: EMPTY_TOKENS, + }; +} + +export function makeTextPart(opts: { id: string; sessionID: string; messageID: string; text: string }): PartInfo { + return { id: partId(opts.id), sessionID: opts.sessionID, messageID: opts.messageID, type: "text", text: opts.text }; +} + +export function makeReasoningPart(opts: { id: string; sessionID: string; messageID: string; text: string; time?: number }): PartInfo { + const t = opts.time ?? Date.now(); + return { id: partId(opts.id), sessionID: opts.sessionID, messageID: opts.messageID, type: "reasoning", text: opts.text, time: { start: t, end: t } }; +} + +export function makeFilePart(opts: { id: string; sessionID: string; messageID: string; mime: string; filename?: string; url: string }): PartInfo { + return { id: partId(opts.id), sessionID: opts.sessionID, messageID: opts.messageID, type: "file", mime: opts.mime, filename: opts.filename, url: opts.url }; +} + +export function makeToolPart(opts: { + id: string; + sessionID: string; + messageID: string; + callID: string; + tool: string; + state: ToolState; +}): PartInfo { + return { id: partId(opts.id), sessionID: opts.sessionID, messageID: opts.messageID, type: "tool", callID: opts.callID, tool: opts.tool, state: opts.state }; +} + +export function truncateTitle(s: string, max = 80): string { + const t = s.trim().replace(/\s+/g, " "); + if (t.length <= max) return t; + return t.slice(0, max - 1) + "…"; +} diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts index 28f233cd..d20edb0d 100644 --- a/packages/amico-run/src/verbs.ts +++ b/packages/amico-run/src/verbs.ts @@ -24,6 +24,7 @@ import { fleetVerb } from "./fleet_verb.js"; import { specVerb } from "./spec_verb.js"; import { planVerb } from "./plan_verb.js"; import { handoffVerb } from "./handoff_verb.js"; +import { sessionsVerb } from "./sessions_import/sessions_verb.js"; export interface VerbResult { json: unknown; // structured result (stdout as JSON for the CLI; tool content for MCP) @@ -201,4 +202,15 @@ const papers: Verb = { run: papersVerb, }; -export const SPINE_VERBS: Verb[] = [catalog, vault, device, note, ledger, profile, fleet, spec, plan, handoff, papers]; +// sessions — import previous Claude/Codex sessions into the opencode DB by +// shelling out to the vendored binary's canonical `import` command. Discovery + +// parse live here; the write is opencode's own, so shapes are always correct. +const sessions: Verb = { + name: "sessions", + summary: "discover + import previous Claude/Codex sessions into the opencode DB (preview | import)", + generalizes: "the onboarding sessions-import checkbox (Claude/Codex → opencode)", + slice: "onboarding sessions import", + run: (args) => sessionsVerb(args), +}; + +export const SPINE_VERBS: Verb[] = [catalog, vault, device, note, ledger, profile, fleet, spec, plan, handoff, papers, sessions]; diff --git a/packages/amico-run/test/s31.test.ts b/packages/amico-run/test/s31.test.ts index d509bc15..42259888 100644 --- a/packages/amico-run/test/s31.test.ts +++ b/packages/amico-run/test/s31.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { readFileSync, readdirSync } from "node:fs"; -import { join } from "node:path"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; // S31 / spec §4: no PHYSICS flag parsing, no MCP, no HTTP in the orchestrator. // (The original /SolveSpec/ ban is lifted by spec C: amico-run is now the @@ -21,9 +21,19 @@ const EXEMPT = new Set(["cloud_client.ts"]); describe("S31 grep rule", () => { it("src/ contains no forbidden tool-layer patterns", () => { const srcDir = join(__dirname, "..", "src"); - for (const f of readdirSync(srcDir)) { + const walk = (dir: string): string[] => { + const out: string[] = []; + for (const f of readdirSync(dir)) { + const full = join(dir, f); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else out.push(full); + } + return out; + }; + for (const full of walk(srcDir)) { + const f = relative(srcDir, full); if (EXEMPT.has(f)) continue; - const text = readFileSync(join(srcDir, f), "utf8"); + const text = readFileSync(full, "utf8"); for (const re of FORBIDDEN) { expect(text, `${f} matches forbidden ${re}`).not.toMatch(re); } diff --git a/packages/amico-run/test/sessions_import.test.ts b/packages/amico-run/test/sessions_import.test.ts new file mode 100644 index 00000000..0376e236 --- /dev/null +++ b/packages/amico-run/test/sessions_import.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { parseClaudeFile } from "../src/sessions_import/parse_claude.js"; +import { parseCodexFile } from "../src/sessions_import/parse_codex.js"; + +function withTempFile(name: string, content: string, fn: (path: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), "amico-sessions-test-")); + try { + const p = join(dir, name); + writeFileSync(p, content); + fn(p); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const CLAUDE_LINES = [ + JSON.stringify({ type: "user", uuid: "u1", sessionId: "sess-1", timestamp: "2026-08-01T00:00:00Z", message: { role: "user", content: "hello" } }), + JSON.stringify({ + type: "assistant", + uuid: "a1", + parentUuid: "u1", + timestamp: "2026-08-01T00:00:01Z", + message: { + role: "assistant", + model: "claude-sonnet-5", + content: [ + { type: "text", text: "hi there" }, + { type: "tool_use", id: "call_1", name: "Bash", input: { cmd: "ls" } }, + ], + }, + }), + JSON.stringify({ + type: "user", + uuid: "u2", + parentUuid: "a1", + timestamp: "2026-08-01T00:00:02Z", + message: { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: "file1" }] }, + }), +].join("\n"); + +const CODEX_LINES = [ + JSON.stringify({ timestamp: "2026-08-01T00:00:00Z", type: "session_meta", payload: { session_id: "codex-sess-1", cwd: "/tmp", cli_version: "0.1", model_provider: "openai" } }), + JSON.stringify({ timestamp: "2026-08-01T00:00:01Z", type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] } }), + JSON.stringify({ timestamp: "2026-08-01T00:00:02Z", type: "response_item", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] } }), + JSON.stringify({ timestamp: "2026-08-01T00:00:03Z", type: "response_item", payload: { type: "custom_tool_call", call_id: "call_1", name: "Bash", input: { cmd: "ls" } } }), + JSON.stringify({ timestamp: "2026-08-01T00:00:04Z", type: "response_item", payload: { type: "custom_tool_call_output", call_id: "call_1", output: "file1" } }), +].join("\n"); + +describe("sessions_import parsers emit opencode-schema-compliant ExportData", () => { + it("claude: branded IDs, assistant required fields, tool state pairing", () => { + withTempFile("sess-1.jsonl", CLAUDE_LINES, (p) => { + const data = parseClaudeFile(p, "/tmp")!; + expect(data).not.toBeNull(); + expect(data.info.id).toMatch(/^ses_/); + expect(data.info.title).toBe("hello"); + + const ids = new Set(data.messages.map((m) => m.info.id)); + for (const m of data.messages) { + expect(m.info.id).toMatch(/^msg_/); + expect(m.info.sessionID).toBe(data.info.id); + for (const part of m.parts) { + expect(part.id).toMatch(/^prt_/); + expect(part.sessionID).toBe(data.info.id); + } + } + expect(ids.size).toBe(data.messages.length); + + const assistant = data.messages.find((m) => m.info.role === "assistant")!; + expect(assistant.info.parentID).toMatch(/^msg_/); + expect(assistant.info.modelID).toBe("claude-sonnet-5"); + expect(assistant.info.providerID).toBe("anthropic"); + expect(assistant.info.mode).toBe("import"); + expect(assistant.info.cost).toBe(0); + expect(assistant.info.tokens).toBeDefined(); + + const toolPart = data.messages.flatMap((m) => m.parts).find((p) => p.type === "tool") as + | { type: "tool"; state: { status: string; output?: string } } + | undefined; + expect(toolPart).toBeDefined(); + expect(toolPart!.state.status).toBe("completed"); + expect(toolPart!.state.output).toBe("file1"); + }); + }); + + it("codex: filename UUID wins over shared session_id, tool state completed", () => { + withTempFile("rollout-2026-08-01T00-00-00-01a015ee-4e7d-70d3-a70b-b9515eb7149e.jsonl", CODEX_LINES, (p) => { + const data = parseCodexFile(p, "/tmp")!; + expect(data).not.toBeNull(); + expect(data.info.id).toBe("ses_01a015ee-4e7d-70d3-a70b-b9515eb7149e"); + expect((data.info.metadata as Record).codex_session_id).toBe("codex-sess-1"); + + for (const m of data.messages) { + expect(m.info.id).toMatch(/^msg_/); + expect(m.info.sessionID).toBe(data.info.id); + } + const toolPart = data.messages.flatMap((m) => m.parts).find((p) => p.type === "tool") as + | { type: "tool"; state: { status: string; output?: string } } + | undefined; + expect(toolPart).toBeDefined(); + expect(toolPart!.state.status).toBe("completed"); + expect(toolPart!.state.output).toBe("file1"); + }); + }); + + it("claude: two files sharing a sessionId do NOT collide (filename wins)", () => { + let id1 = ""; + let id2 = ""; + withTempFile("sess-1.jsonl", CLAUDE_LINES, (p) => { + id1 = parseClaudeFile(p, "/tmp")!.info.id; + }); + withTempFile("agent-abc.jsonl", CLAUDE_LINES, (p) => { + id2 = parseClaudeFile(p, "/tmp")!.info.id; + }); + expect(id1).not.toBe(id2); + }); +}); diff --git a/packages/extension/src/onboarding_panel.ts b/packages/extension/src/onboarding_panel.ts index 8c4f8723..ae5b3f60 100644 --- a/packages/extension/src/onboarding_panel.ts +++ b/packages/extension/src/onboarding_panel.ts @@ -11,6 +11,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import * as vscode from "vscode"; +import { execFile, execFileSync } from "node:child_process"; import { scanCredentials, @@ -23,6 +24,13 @@ import { type DetectedCredential, } from "./credential_scanner"; import { ChatPanel } from "./chat_panel"; +import { resolveOpencodeBinary } from "./opencode_binary"; +import { resolveAmicoCli } from "./fleet_panel"; +import { + discoverExternalSkillPaths, + addSkillProvider, + friendlyProviderName, +} from "./scores/user_skill_providers"; // ─── Provider → Model data (data-driven, not hard-coded conditionals) ──────── @@ -496,7 +504,7 @@ export function releaseOnboardingPanel(): void { * background. Used as an immediate visual while the server restarts. The exact * same SVG + CSS appears in ChatPanel.renderTransitionHtml's overlay, so when * adopt() fires there's no visible flash (same pixels). */ -function splashHtml(fontUri?: vscode.Uri, cspSource?: string): string { +function splashHtml(fontUri?: vscode.Uri, cspSource?: string, subtitle?: string): string { // The face is inlined as its own @font-face rather than via brand.css so the // splash stays a single self-contained string; without it the handoff screen // renders in the editor UI font while everything around it is DM Sans. @@ -540,6 +548,11 @@ ${fontFace} color: var(--vscode-foreground, #ccc); font-family: "DM Sans", var(--vscode-font-family, system-ui); } + .splash-subtitle { + margin-top: 8px; font-size: 0.95rem; + color: var(--vscode-descriptionForeground, #999); + font-family: "DM Sans", var(--vscode-font-family, system-ui); + } @@ -564,9 +577,110 @@ ${fontFace}
Getting Amico ready...
+ ${subtitle ? `
${subtitle}
` : ""} `; } +// ─── Sessions + skills import ──────────────────────────────────────────────── + +interface SessionsSkillsScan { + claude: number; + codex: number; + skillPaths: { path: string; name: string }[]; +} + +/** Scan for importable sessions (Claude/Codex) and external skill directories. + * Sessions come from `amico sessions preview --json`; skills from the known + * engine auto-load paths (~/.claude/skills, ~/.agents/skills, ~/.config/opencode/skills). */ +function scanSessionsSkills(extensionRoot: string): SessionsSkillsScan { + const amicoCli = resolveAmicoCli(extensionRoot); + let claude = 0; + let codex = 0; + try { + const out = execFileSync(amicoCli, ["sessions", "preview", "--json"], { encoding: "utf8", timeout: 30_000 }); + const parsed = JSON.parse(out) as { sources?: { claude?: { count?: number }; codex?: { count?: number } } }; + claude = parsed.sources?.claude?.count ?? 0; + codex = parsed.sources?.codex?.count ?? 0; + } catch { + // discovery failed — report zero; the webview renders "none found" + } + const skillPaths = discoverExternalSkillPaths(os.homedir()).map((p) => ({ path: p, name: friendlyProviderName(p) })); + return { claude, codex, skillPaths }; +} + +/** Register the selected skill directories synchronously, then fire the sessions + * import in the background (fire-and-forget). Skills land immediately; sessions + * trickle in as `opencode import` completes per file. `onDone` fires when the + * sessions import finishes — for logging, never to block onboarding. */ +function runSessionsSkillsImport( + extensionRoot: string, + selection: { importClaude: boolean; importCodex: boolean; skillPaths: string[] }, + onDone: (summary: { sessionsImported: number; sessionsFailed: number; skillsImported: number }) => void, +): void { + const amicoCli = resolveAmicoCli(extensionRoot); + const sources: string[] = []; + if (selection.importClaude) sources.push("claude"); + if (selection.importCodex) sources.push("codex"); + + // Skills register synchronously — fast, just writes skill-providers.json. + const providersPath = path.join(os.homedir(), ".amico", "amicode", "skill-providers.json"); + let skillsImported = 0; + for (const p of selection.skillPaths) { + addSkillProvider(providersPath, { id: friendlyProviderName(p), type: "directory", path: p, added: new Date().toISOString() }); + skillsImported++; + } + + if (sources.length === 0) { + onDone({ sessionsImported: 0, sessionsFailed: 0, skillsImported }); + return; + } + + let opencodeBinary: string | undefined; + try { + opencodeBinary = resolveOpencodeBinary( + extensionRoot, + vscode.workspace.getConfiguration("amicode").get("opencodeBinary", "") ?? "", + ).path; + } catch { + opencodeBinary = undefined; + } + + const args = ["sessions", "import", "--source", sources.join(","), "--json"]; + if (opencodeBinary) args.push("--opencode", opencodeBinary); + + execFile(amicoCli, args, { timeout: 10 * 60_000, maxBuffer: 64 * 1024 * 1024, encoding: "utf8" }, (err, stdout) => { + let imported = 0; + let failed = 0; + if (!err && stdout) { + try { + const parsed = JSON.parse(stdout) as { summary?: { imported?: number; failed?: number } }; + imported = parsed.summary?.imported ?? 0; + failed = parsed.summary?.failed ?? 0; + } catch { + // unparseable output — report zero + } + } else if (err) { + failed = 1; + } + onDone({ sessionsImported: imported, sessionsFailed: failed, skillsImported }); + }); +} + +/** Finish onboarding: clear the stale model pin, swap to the splash, fire the + * completion listeners, and restart the server so it picks up the new config. + * `importingSessions` adds a "importing in the background" note to the splash. */ +function completeOnboarding(panel: vscode.WebviewPanel, ctx: vscode.ExtensionContext, importingSessions = false): void { + void vscode.workspace.getConfiguration("amicode").update("defaultModel", undefined, vscode.ConfigurationTarget.Global); + panel.webview.html = splashHtml( + panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, "media", "ui", "atoms", "DMSans-Variable.woff2")), + panel.webview.cspSource, + importingSessions ? "Your sessions are importing in the background" : undefined, + ); + ChatPanel.setPendingOnboardingGreeting(true); + fireOnboardingComplete(); + void vscode.commands.executeCommand("amicode.restartServer"); +} + /** Register the onboarding panel command. Call from extension.ts activate(). */ export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { ctx.subscriptions.push( @@ -605,20 +719,9 @@ export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { } else if (msg.type === "config-success") { const payload = msg.payload as OnboardingConfig; writeOnboardingConfig(payload); - // Clear stale model pin — the old provider may no longer be connected. - // The server will resolve the new provider's default on its own. - void vscode.workspace.getConfiguration("amicode").update("defaultModel", undefined, vscode.ConfigurationTarget.Global); - // Swap the panel HTML directly to the splash (same as confirm-import) - panel.webview.html = splashHtml( - panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, "media", "ui", "atoms", "DMSans-Variable.woff2")), - panel.webview.cspSource, - ); - // Signal that the next chat panel open should auto-send the onboarding greeting - ChatPanel.setPendingOnboardingGreeting(true); - fireOnboardingComplete(); - // Restart server so it picks up the new provider config. - // Chat opens via the onReady-gated listener in extension.ts. - void vscode.commands.executeCommand("amicode.restartServer"); + // Advance to the sessions+skills page — the splash (and server restart) + // happen after the user imports or skips. + panel.webview.postMessage({ type: "show-sessions-page" }); } else if (msg.type === "cancel") { // User cancelled onboarding — close panel, re-open chat panel.dispose(); @@ -708,21 +811,25 @@ export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { heldCredentials = []; testResults.clear(); validatedModels.clear(); - // Clear stale model pin — the old provider may no longer be connected. - void vscode.workspace.getConfiguration("amicode").update("defaultModel", undefined, vscode.ConfigurationTarget.Global); - // Swap the panel HTML directly to the splash — no webview-side - // DOM manipulation, so there's no flash when adopt() fires later - // (adopt's overlay uses the exact same SVG + CSS). - panel.webview.html = splashHtml( - panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, "media", "ui", "atoms", "DMSans-Variable.woff2")), - panel.webview.cspSource, - ); - // Signal that the next chat panel open should auto-send the onboarding greeting - ChatPanel.setPendingOnboardingGreeting(true); - fireOnboardingComplete(); - // Restart server so it picks up the new provider config. - // Chat opens via the onReady-gated listener in extension.ts. - void vscode.commands.executeCommand("amicode.restartServer"); + // Advance to the sessions+skills page — the splash (and server restart) + // happen after the user imports or skips. + panel.webview.postMessage({ type: "show-sessions-page" }); + } else if (msg.type === "scan-sessions-skills") { + const scan = scanSessionsSkills(ctx.extensionPath); + panel.webview.postMessage({ + type: "sessions-skills-scan-results", + payload: scan, + }); + } else if (msg.type === "confirm-sessions-skills-import") { + const payload = msg.payload as { importClaude: boolean; importCodex: boolean; skillPaths: string[] }; + // Fire the import in the background — onboarding completes immediately; + // sessions trickle in as `opencode import` finishes per file. + runSessionsSkillsImport(ctx.extensionPath, payload, () => { + // background completion — nothing to post; the webview is transitioning. + }); + completeOnboarding(panel, ctx, true); + } else if (msg.type === "skip-sessions-skills") { + completeOnboarding(panel, ctx); } else if (msg.type === "transition-complete") { // The extension signals that the chat panel is ready — dispose the // splash now. This is posted by the extension host after app-ready. @@ -800,6 +907,7 @@ function buildWebviewHtml(
+