From d6c325e6929445eaa18c22248aeea5474e2ae54c Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Wed, 8 Jul 2026 11:38:21 +0800 Subject: [PATCH 1/4] fix(session): encode OutputFormat as Schema.Class instance, not plain object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OutputFormatText and OutputFormatJsonSchema are Schema.Class, so their encoders are instanceof-gated. The task tool built `format` as a plain object literal, which passed TS structural typing and decode but failed at encode when the user-message Info was serialized onto the MessageUpdated sync event — surfacing as "Expected OutputFormatJsonSchema, got {...}" at ["info"]["format"] and killing researcher/reviewer subagent runs that use output_schema. - prompt(): normalize `input.format` through decodeUnknownSync(Format) at the single choke point every caller flows through, coercing any structurally-valid format (plain object or instance) into a proper instance; idempotent, and fills retryCount. - task tool: construct `new OutputFormatJsonSchema(...)` at the source for defense-in-depth and clarity. - Add encode regression tests (the prior suite tested decode only, which is how this slipped through); both json_schema and text variants are instanceof-gated, so both are covered. Verified: structured-output suite 26/26, typecheck 23/23. The 12 session.llm.stream / native-recorded failures are pre-existing network fixture failures (confirmed identical with these changes stashed). Co-Authored-By: Claude Opus 4.8 --- packages/deepagent-code/src/session/prompt.ts | 12 ++++- packages/deepagent-code/src/tool/task.ts | 7 ++- .../test/session/structured-output.test.ts | 52 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/deepagent-code/src/session/prompt.ts b/packages/deepagent-code/src/session/prompt.ts index 5204c44c..5a39b7b8 100644 --- a/packages/deepagent-code/src/session/prompt.ts +++ b/packages/deepagent-code/src/session/prompt.ts @@ -88,6 +88,8 @@ globalThis.AI_SDK_LOG_WARNINGS = false const decodeMessageInfo = Schema.decodeUnknownExit(SessionV1.Info) const decodeMessagePart = Schema.decodeUnknownExit(SessionV1.Part) +// Coerce a structurally-valid Format value into a Format INSTANCE (see the call site in prompt()). +const decodeFormatSync = Schema.decodeUnknownSync(SessionV1.Format) const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. @@ -1042,7 +1044,15 @@ export const layer = Layer.effect( variant, }, system: input.system, - format: input.format, + // `format` must be a decoded Format INSTANCE, not a plain object literal. `Format`'s + // json_schema member is a `Schema.Class`, whose encoder is `instanceof`-gated — a plain + // `{ type: "json_schema", schema }` (e.g. from the task tool) passes TS structural typing + // but fails at encode time when this message Info is serialized onto the MessageUpdated + // sync event ("Expected OutputFormatJsonSchema, got {...}"). Normalizing here — the single + // choke point every caller flows through — makes any structurally-valid format safe, and + // is idempotent for callers that already pass an instance. `withDecodingDefault` also fills + // retryCount. `format` is validated on the way in (PromptInput), so this decode never fails. + format: input.format === undefined ? undefined : decodeFormatSync(input.format), metadata: input.metadata, } diff --git a/packages/deepagent-code/src/tool/task.ts b/packages/deepagent-code/src/tool/task.ts index 41839664..05d6650f 100644 --- a/packages/deepagent-code/src/tool/task.ts +++ b/packages/deepagent-code/src/tool/task.ts @@ -326,7 +326,12 @@ export const TaskTool = Tool.define( ...(childAgentModeOverride ? { metadata: { deepagent: { agent_mode_override: childAgentModeOverride } } } : {}), - ...(resolvedOutputSchema ? { format: { type: "json_schema" as const, schema: resolvedOutputSchema } } : {}), + // Build a Format INSTANCE (not a plain literal): OutputFormatJsonSchema is a Schema.Class + // whose encoder is instanceof-gated, so a plain object would fail when the message Info is + // serialized onto the MessageUpdated sync event. prompt() also normalizes defensively. + ...(resolvedOutputSchema + ? { format: new SessionV1.OutputFormatJsonSchema({ type: "json_schema", schema: resolvedOutputSchema }) } + : {}), tools: { ...(next.permission.some((rule) => rule.permission === "todowrite") ? {} : { todowrite: false }), ...(next.permission.some((rule) => rule.permission === id) ? {} : { task: false }), diff --git a/packages/deepagent-code/test/session/structured-output.test.ts b/packages/deepagent-code/test/session/structured-output.test.ts index d2abb6a1..0a77daef 100644 --- a/packages/deepagent-code/test/session/structured-output.test.ts +++ b/packages/deepagent-code/test/session/structured-output.test.ts @@ -129,6 +129,58 @@ describe("structured-output.UserMessage", () => { }) }) +// Regression: OutputFormatJsonSchema is a Schema.Class, so its ENCODER is instanceof-gated. A +// user message whose `format` is a plain object literal (as the task tool built it) passes TS +// structural typing and decode, but fails at encode when the message Info is serialized onto the +// MessageUpdated sync event — the exact "Expected OutputFormatJsonSchema, got {...}" failure the +// researcher subagent hit. These tests pin the encode path the old decode-only tests never touched. +describe("structured-output.encode (sync-event serialization)", () => { + const encodeInfo = Schema.encodeUnknownExit(SessionV1.Info) + const userWith = (format: unknown) => ({ + id: MessageID.ascending(), + sessionID: SessionID.descending(), + role: "user" as const, + time: { created: 1 }, + agent: "researcher", + model: { providerID: "anthropic", modelID: "claude-3" }, + format, + }) + const jsonSchemaFormat = { + type: "json_schema" as const, + schema: { type: "object", properties: { module: { type: "string" } }, required: ["module"] }, + } + + test("a plain-object json_schema format does NOT survive Info encode (the original bug)", () => { + // Guards the invariant: a plain literal is unsafe. If a future refactor makes this pass, the + // instanceof gate has changed and the normalization below may no longer be load-bearing. + const result = encodeInfo(userWith(jsonSchemaFormat)) + expect(Exit.isFailure(result)).toBe(true) + }) + + test("a decoded (normalized) json_schema format survives Info encode", () => { + const format = Schema.decodeUnknownSync(SessionV1.Format)(jsonSchemaFormat) + const result = encodeInfo(userWith(format)) + expect(Exit.isSuccess(result)).toBe(true) + if (Exit.isSuccess(result)) { + const encoded = result.value as { format?: { type?: string } } + expect(encoded.format?.type).toBe("json_schema") + } + }) + + test("a constructed OutputFormatJsonSchema instance survives Info encode", () => { + const format = new SessionV1.OutputFormatJsonSchema({ ...jsonSchemaFormat, retryCount: 2 }) + const result = encodeInfo(userWith(format)) + expect(Exit.isSuccess(result)).toBe(true) + }) + + test("text format is instanceof-gated too: plain fails, decoded survives Info encode", () => { + // OutputFormatText is ALSO a Schema.Class, so normalization is load-bearing for both variants. + expect(Exit.isFailure(encodeInfo(userWith({ type: "text" })))).toBe(true) + const decoded = Schema.decodeUnknownSync(SessionV1.Format)({ type: "text" }) + expect(Exit.isSuccess(encodeInfo(userWith(decoded)))).toBe(true) + }) +}) + describe("structured-output.AssistantMessage", () => { const baseAssistantMessage = { id: MessageID.ascending(), From a8ee6af35f28db13cfb1df1eb7e00825045b7e68 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Thu, 9 Jul 2026 00:39:54 +0800 Subject: [PATCH 2/4] fix(subagent): finish-on-complete lifecycle, panel nav, question guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects surfaced in a real multi-agent run: 1. Subagents never "closed". A subagent does one turn then the runtime drops it to in-memory `idle` (never persisted), so the panel showed every completed child as merely idle — as if still available to chat. The task tool now persists a terminal marker in the child session's metadata (`deepagent.subagent.finished`) on every terminal path (foreground + background, completed/error/cancelled). We mark rather than archive: archived sessions are filtered out of the app store, which would remove the child from the panel — but the requirement is it stays listed and its full reasoning/output remain viewable. The panel now renders a three-state status (running/finished/idle) and the composer refuses new prompts on a finished child (read-only). 2. Subagent panel click → black screen. The click navigated to a bare `/session/${id}`, missing the router's required `:dir` segment (`/:dir/session/:id`), so no route matched and the panel rendered blank. Now prefixes `params.dir`, matching every other nav call site. 3. question tool hardening. The parameter schema requires `options` to be PRESENT but an empty array still decodes, and a zero-option choice question is unanswerable — `ask` would block forever, hanging the turn. execute() now returns a recoverable "rewrite your input" tool-result (matching the plan-gate soft-feedback model) when questions is empty or any question has no options. Adds regression tests. Verified: typecheck 2/2, question + task + structured-output suites green. Co-Authored-By: Claude Opus 4.8 --- packages/app/src/components/prompt-input.tsx | 14 +++++- .../pages/session/side-panel-subagents.tsx | 37 ++++++++++---- packages/deepagent-code/src/tool/question.ts | 20 ++++++++ packages/deepagent-code/src/tool/task.ts | 48 +++++++++++++++++-- .../deepagent-code/test/tool/question.test.ts | 31 ++++++++++++ 5 files changed, 137 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index daeba752..a2d21b1d 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -330,6 +330,13 @@ export const PromptInput: Component = (props) => { }) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const working = createMemo(() => sync.data.session_working(params.id ?? "")) + // A finished subagent is read-only: it did its one turn and was marked done by the task tool + // (metadata.deepagent.subagent.finished). The panel still lists it so its history is viewable, + // but new prompts must be refused — the parent orchestrates work, you don't chat with a done child. + const subagentFinished = createMemo(() => { + const meta = info()?.metadata as { deepagent?: { subagent?: { finished?: boolean } } } | undefined + return meta?.deepagent?.subagent?.finished === true + }) const imageAttachments = createMemo(() => prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) @@ -1264,6 +1271,11 @@ export const PromptInput: Component = (props) => { }) const handlePromptSubmit = (event: Event) => { + // Read-only finished subagent: swallow the submit so no new turn is started. + if (subagentFinished()) { + event.preventDefault() + return + } if (draftPreparing() || composing()) { event.preventDefault() return @@ -1778,7 +1790,7 @@ export const PromptInput: Component = (props) => { void }> = (props) => .sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0)) }) - const statusOf = (id: string): "running" | "idle" => (sync.data.session_working(id) ? "running" : "idle") + // Three states. A subagent does one turn then finishes: the task tool persists a terminal marker + // in the child session's metadata (`deepagent.subagent.finished`) so a completed subagent reads as + // "finished" (read-only) instead of "idle" (looks available). `session_working` is the live signal + // for the brief window it's actually running; the persisted marker wins once set. + const isFinished = (child: { metadata?: Record }): boolean => { + const sub = (child.metadata?.["deepagent"] as { subagent?: { finished?: boolean } } | undefined)?.subagent + return sub?.finished === true + } + const statusOf = (child: { id: string; metadata?: Record }): "running" | "finished" | "idle" => { + if (sync.data.session_working(child.id)) return "running" + if (isFinished(child)) return "finished" + return "idle" + } + const statusLabel = (state: "running" | "finished" | "idle") => + language.t( + state === "running" + ? "session.subagents.running" + : state === "finished" + ? "session.subagents.finished" + : "session.subagents.idle", + ) return (
@@ -56,17 +76,18 @@ export const SidePanelSubagents: Component<{ onClose: () => void }> = (props) => + )} + + + +
+ + + + ) +} diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 2c076f06..c5fc27df 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -31,6 +31,7 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "../link" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" +import { ImportSection } from "./import-history" import "./settings-v2.css" import { deepAgentModeFromConfig, @@ -923,6 +924,8 @@ export const SettingsGeneralV2: Component = () => { + + ) diff --git a/packages/app/src/components/settings-v2/import-history.tsx b/packages/app/src/components/settings-v2/import-history.tsx new file mode 100644 index 00000000..918c30d8 --- /dev/null +++ b/packages/app/src/components/settings-v2/import-history.tsx @@ -0,0 +1,337 @@ +import { Component, For, Show, createMemo, createSignal } from "solid-js" +import { ButtonV2 } from "@deepagent-code/ui/v2/button-v2" +import { Icon } from "@deepagent-code/ui/icon" +import { SelectV2 } from "@deepagent-code/ui/v2/select-v2" +import { Switch } from "@deepagent-code/ui/v2/switch-v2" +import { TextInputV2 } from "@deepagent-code/ui/v2/text-input-v2" +import { useLanguage } from "@/context/language" +import { useServer } from "@/context/server" +import { SettingsListV2 } from "./parts/list" +import { SettingsRowV2 } from "./parts/row" +import "./settings-v2.css" + +type SourceFormat = "codex" | "claude" +type SourceMode = SourceFormat | "custom" +type Scope = "session" | "memory" | "skill" +type ProgressEvent = + | { phase: "discover"; source: SourceFormat; count: number } + | { phase: "write-session"; sessionId: string; turns: number; reimport: boolean } + | { phase: "write-memory"; staged: number } + | { phase: "write-skill"; written: number } + | { phase: "warn"; message: string; label?: string } + | { phase: "done"; report: any } + | { phase: "error"; message: string } + +interface HttpBase { + url: string + username?: string + password?: string + bearer?: string +} + +function authHeader(http: HttpBase | undefined): Record { + if (!http) return {} + if (http.bearer) return { Authorization: `Bearer ${http.bearer}` } + if (http.password) { + const user = http.username ?? "deepagent-code" + return { Authorization: `Basic ${btoa(`${user}:${http.password}`)}` } + } + return {} +} + +async function streamImport( + baseUrl: string, + headers: Record, + payload: Record, + onEvent: (e: ProgressEvent) => void, + signal: AbortSignal, +): Promise { + const res = await fetch(`${baseUrl}/global/import`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(payload), + signal, + }) + if (!res.ok || !res.body) { + const text = await res.text().catch(() => res.statusText) + onEvent({ phase: "error", message: `HTTP ${res.status}: ${text.slice(0, 200)}` }) + return + } + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + let idx: number + while ((idx = buffer.indexOf("\n\n")) >= 0) { + const block = buffer.slice(0, idx) + buffer = buffer.slice(idx + 2) + const dataLine = block.split("\n").find((l) => l.startsWith("data:")) + if (!dataLine) continue + try { + onEvent(JSON.parse(dataLine.slice(5).trim()) as ProgressEvent) + } catch { + /* skip malformed */ + } + } + } +} + +export const ImportSection: Component = () => { + const language = useLanguage() + const server = useServer() + + const [mode, setMode] = createSignal("codex") + const [customPath, setCustomPath] = createSignal("") + const [customFormat, setCustomFormat] = createSignal("codex") + const [scopes, setScopes] = createSignal(["session", "memory", "skill"]) + const [dryRun, setDryRun] = createSignal(false) + const [copyLiveDb, setCopyLiveDb] = createSignal(true) + const [cwdFilter, setCwdFilter] = createSignal("") + const [running, setRunning] = createSignal(false) + const [logs, setLogs] = createSignal([]) + const [summary, setSummary] = createSignal("") + + let abort: AbortController | undefined + + const http = (): HttpBase | undefined => (server.current as { http?: HttpBase } | undefined)?.http + const t = (k: string, fallback: string) => { + const v = language.t(k) + return v === k ? fallback : v + } + const push = (line: string) => setLogs((l) => [...l, line]) + const toggleScope = (s: Scope) => { + const cur = scopes() + setScopes(cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]) + } + + const format = createMemo(() => (mode() === "custom" ? customFormat() : (mode() as SourceFormat))) + const sourcePath = createMemo(() => (mode() === "custom" ? customPath().trim() : "")) + + const modeOptions = createMemo(() => [ + { value: "codex" as const, label: t("settings.import.preset.codex", "Codex") }, + { value: "claude" as const, label: t("settings.import.preset.claude", "Claude Code") }, + { value: "custom" as const, label: t("settings.import.preset.custom", "Custom") }, + ]) + const formatOptions = createMemo(() => [ + { value: "codex" as const, label: "Codex" }, + { value: "claude" as const, label: "Claude Code" }, + ]) + const scopeOptions: { id: Scope; icon: string }[] = [ + { id: "session", icon: "message" }, + { id: "memory", icon: "bookmark" }, + { id: "skill", icon: "bookmark" }, + ] + + const run = async () => { + if (running()) return + setLogs([]) + setSummary("") + setRunning(true) + abort = new AbortController() + const h = http() + const src = format() + push(`→ ${src}${mode() === "custom" ? ` (custom: ${sourcePath() || "default"})` : ` (default)`} · scopes: ${scopes().join(",")}${dryRun() ? " · dry-run" : ""}`) + try { + await streamImport( + h?.url ?? "", + authHeader(h), + { + source: src, + sourcePath: sourcePath() || undefined, + scopes: scopes(), + dryRun: dryRun(), + copyLiveDb: copyLiveDb(), + cwdFilter: cwdFilter().trim() || undefined, + }, + (e) => { + switch (e.phase) { + case "discover": + push(` discovered ${e.count} session(s)`) + break + case "write-session": + push(` [session] ${e.sessionId.slice(0, 16)}… · ${e.turns} turns${e.reimport ? " · re-imported" : ""}`) + break + case "write-memory": + push(` [memory] staged ${e.staged} candidate(s) for review`) + break + case "write-skill": + push(` [skill] wrote ${e.written}`) + break + case "warn": + push(` [warn] ${e.label ?? ""} ${e.message}`) + break + case "error": + push(` [error] ${e.message}`) + break + case "done": { + const r = e.report + setSummary( + [ + `Done in ${r.elapsedMs}ms`, + `sessions=${r.sessions?.length ?? 0}`, + r.memory ? `memories_staged=${r.memory.staged}` : "", + r.skills ? `skills=${r.skills.written}` : "", + r.warnings?.length ? `warnings=${r.warnings.length}` : "", + r.dryRun ? "(dry-run)" : "", + ] + .filter(Boolean) + .join(" · "), + ) + break + } + } + }, + abort.signal, + ) + } catch (err) { + push(` [error] ${err instanceof Error ? err.message : String(err)}`) + } finally { + setRunning(false) + } + } + + const cancel = () => { + abort?.abort() + setRunning(false) + push(" (cancelled)") + } + + return ( +
+

{t("settings.import.section", "Import history")}

+ + + + o.value === mode())} + placement="bottom-end" + gutter={6} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && setMode(option.value)} + /> + + + + +
+ setCustomPath(e.currentTarget.value)} + disabled={running()} + placeholder="/Users/you/.codex_backup" + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> + o.value === customFormat())} + placement="bottom-end" + gutter={6} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && setCustomFormat(option.value)} + /> +
+
+
+ + +
+ + {(opt) => { + const active = () => scopes().includes(opt.id) + return ( + + ) + }} + +
+
+ + +
+ setDryRun(c)} /> +
+
+ + +
+ setCopyLiveDb(c)} /> +
+
+ + +
+ setCwdFilter(e.currentTarget.value)} + disabled={running()} + placeholder="/Users/you/projects/..." + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> +
+
+ + +
+ + {running() ? t("settings.import.running", "Importing…") : t("settings.import.run.btn", "Import")} + + + + {t("settings.import.cancel", "Cancel")} + + +
+
+ + + +
{summary()}
+
+
+ + 0}> + +
+              {(line) => line + "\n"}
+            
+
+
+
+
+ ) +} diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index 87223afc..ca6d0919 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -729,3 +729,81 @@ word-break: break-word; color: var(--v2-text-text-faint); } + +.settings-v2-import-log { + margin: 0; + padding: 10px 12px; + max-height: 220px; + min-width: 240px; + width: 100%; + overflow: auto; + border: 1px solid var(--v2-border-border-muted); + border-radius: 8px; + background: var(--v2-background-bg-layer-01); + font-family: var(--font-mono, monospace); + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + color: var(--v2-text-text-faint); +} + +.settings-v2-scope-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: 100%; +} + +.settings-v2-scope-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--v2-border-border-strong, var(--v2-border-border-muted)); + background: var(--v2-background-bg-layer-01); + color: var(--v2-text-text-base); + font-size: 13px; + font-weight: 500; + cursor: pointer; + user-select: none; + transition: + background 0.12s ease, + border-color 0.12s ease, + color 0.12s ease; +} + +.settings-v2-scope-toggle:hover:not(:disabled) { + border-color: var(--v2-border-border-hover, var(--v2-border-border-strong)); + background: var(--v2-background-bg-layer-02, var(--v2-background-bg-layer-01)); +} + +.settings-v2-scope-toggle:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.settings-v2-scope-toggle--active { + border-color: var(--v2-border-border-strong, currentColor); + background: var(--v2-background-bg-strong, var(--v2-background-bg-layer-02)); + color: var(--v2-text-text-base); +} + +.settings-v2-scope-toggle-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid var(--v2-border-border-muted); + background: var(--v2-background-bg-base, transparent); + color: var(--v2-text-text-invert, currentColor); +} + +.settings-v2-scope-toggle--active .settings-v2-scope-toggle-mark { + border-color: var(--v2-accent-accent, var(--v2-border-border-strong)); + background: var(--v2-accent-accent, var(--v2-background-bg-strong)); + color: #fff; +} diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index ab9107c6..1acf716d 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -842,6 +842,30 @@ export const dict = { "terminal.connectionLost.abnormalClose": "تم إغلاق WebSocket بشكل غير طبيعي: {{code}}", "settings.section.about": "حول", "settings.tab.about": "حول", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "المنتج", "settings.about.product.description": "اسم التطبيق وإصداره.", "settings.about.attribution.title": "مبني على deepagent-code", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 95f636d3..83b330fc 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -856,6 +856,30 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket fechado anormalmente: {{code}}", "settings.section.about": "Sobre", "settings.tab.about": "Sobre", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "Produto", "settings.about.product.description": "O nome e a versão do aplicativo.", "settings.about.attribution.title": "Criado sobre deepagent-code", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 72217dfa..06994c6e 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -933,6 +933,30 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket zatvoren nenormalno: {{code}}", "settings.section.about": "O aplikaciji", "settings.tab.about": "O aplikaciji", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "Produkt", "settings.about.product.description": "Naziv i verzija aplikacije.", "settings.about.attribution.title": "Zbudowane na deepagent-code", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 49597235..404f6209 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -926,6 +926,30 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket lukkede unormalt: {{code}}", "settings.section.about": "Om", "settings.tab.about": "Om", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "Produkt", "settings.about.product.description": "Programmets navn og version.", "settings.about.attribution.title": "Bygget på deepagent-code", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 70a5fe6a..3dc2a534 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -872,6 +872,30 @@ export const dict = { "settings.general.deepagent.mode.ultra": "ultra", "settings.section.about": "Info", "settings.tab.about": "Info", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "Produkt", "settings.about.product.description": "Name und Version der Anwendung.", "settings.about.attribution.title": "Auf deepagent-code aufgebaut", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 0da53ec3..02b7b05a 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -717,6 +717,8 @@ export const dict = { "session.subagents.empty": "No subagents for this session", "session.subagents.running": "running", "session.subagents.idle": "idle", + "session.subagents.finished": "finished", + "session.subagents.readonly": "This subagent has finished. Its history is read-only.", "session.fork.derivedFrom": "Derived from {title}", "session.fork.derivedFromUnknown": "Derived from another session", "browser.title": "Browser", @@ -869,6 +871,8 @@ export const dict = { "sidebar.settings": "Settings", "sidebar.help": "Help", "sidebar.review": "Knowledge Governance", + "sidebar.history": "History projects", + "sidebar.history.description": "Projects with imported or past sessions that are not currently active. Click one to open it.", "sidebar.workspaces.enable": "Enable workspaces", "sidebar.workspaces.disable": "Disable workspaces", "sidebar.gettingStarted.title": "Getting started", @@ -912,6 +916,30 @@ export const dict = { "settings.tab.general": "General", "settings.tab.shortcuts": "Shortcuts", "settings.tab.about": "About", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "Product", "settings.about.product.description": "The application name and version.", "settings.about.attribution.title": "Built on deepagent-code", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 45453dd4..20c9d71c 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -939,6 +939,30 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket cerrado anormalmente: {{code}}", "settings.section.about": "Acerca de", "settings.tab.about": "Acerca de", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "Producto", "settings.about.product.description": "El nombre y la versión de la aplicación.", "settings.about.attribution.title": "Basado en deepagent-code", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 27c1c913..4fa45f73 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -866,6 +866,30 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket fermé anormalement : {{code}}", "settings.section.about": "À propos", "settings.tab.about": "À propos", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "Produit", "settings.about.product.description": "Le nom et la version de l’application.", "settings.about.attribution.title": "Basé sur deepagent-code", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index c5902ac7..24b6a134 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -852,6 +852,30 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket が異常終了しました: {{code}}", "settings.section.about": "情報", "settings.tab.about": "情報", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "製品", "settings.about.product.description": "アプリケーション名とバージョン。", "settings.about.attribution.title": "deepagent-codeを基盤に構築", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 9f05a929..4ba90185 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -845,6 +845,30 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket이 비정상적으로 닫힘: {{code}}", "settings.section.about": "정보", "settings.tab.about": "정보", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "제품", "settings.about.product.description": "애플리케이션 이름과 버전입니다.", "settings.about.attribution.title": "deepagent-code 기반", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index fd095d24..1d2964aa 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -938,6 +938,30 @@ export const dict = { "settings.general.deepagent.mode.ultra": "ultra", "settings.section.about": "Om", "settings.tab.about": "Om", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "Produkt", "settings.about.product.description": "Applikasjonens navn og versjon.", "settings.about.attribution.title": "Bygget på deepagent-code", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index a7c17d28..6bcde90b 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -855,6 +855,30 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket zamknięty nieprawidłowo: {{code}}", "settings.section.about": "O programie", "settings.tab.about": "O programie", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "Produkt", "settings.about.product.description": "Nazwa i wersja aplikacji.", "settings.about.attribution.title": "Zbudowane na deepagent-code", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index f226ad73..0fa459df 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -935,6 +935,30 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket закрыт аварийно: {{code}}", "settings.section.about": "О программе", "settings.tab.about": "О программе", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "Продукт", "settings.about.product.description": "Название и версия приложения.", "settings.about.attribution.title": "Создано на базе deepagent-code", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 9b480c26..58a0217a 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -922,6 +922,30 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket ปิดอย่างผิดปกติ: {{code}}", "settings.section.about": "เกี่ยวกับ", "settings.tab.about": "เกี่ยวกับ", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "ผลิตภัณฑ์", "settings.about.product.description": "ชื่อและเวอร์ชันของแอปพลิเคชัน", "settings.about.attribution.title": "สร้างบน deepagent-code", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 5494ead0..a29c1a08 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -945,6 +945,30 @@ export const dict = { "settings.general.deepagent.mode.ultra": "ultra", "settings.section.about": "Hakkında", "settings.tab.about": "Hakkında", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "Ürün", "settings.about.product.description": "Uygulama adı ve sürümü.", "settings.about.attribution.title": "deepagent-code üzerine kurulu", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index 607a0520..c1573940 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -966,6 +966,30 @@ export const dict = { "workspace.reset.note": "Це скине робочу область, щоб вона відповідала гілці за замовчуванням.", "settings.section.about": "Про застосунок", "settings.tab.about": "Про застосунок", + "settings.import.section": "Import history", + "settings.import.source": "Source", + "settings.import.source.hint": "Pick a preset, or choose Custom to type a path.", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "Custom", + "settings.import.customPath": "Path", + "settings.import.customPath.hint": "Absolute path to the agent data directory.", + "settings.import.scope": "What to import", + "settings.import.scope.hint": "Select one or more categories.", + "settings.import.scope.session": "Chat history", + "settings.import.scope.memory": "Memory", + "settings.import.scope.skill": "Skills", + "settings.import.dryRun": "Dry-run", + "settings.import.dryRun.hint": "Parse and map only; write nothing.", + "settings.import.copyLiveDb": "Snapshot live DB", + "settings.import.copyLiveDb.hint": "Write into a copy of the live DB (recommended) instead of the live DB.", + "settings.import.cwdFilter": "Directory filter", + "settings.import.cwdFilter.hint": "Only import sessions whose directory starts with this prefix.", + "settings.import.run.btn": "Import", + "settings.import.running": "Importing…", + "settings.import.cancel": "Cancel", + "settings.import.result": "Result", + "settings.import.log": "Progress", "settings.about.product.title": "Продукт", "settings.about.product.description": "Назва і версія застосунку.", "settings.about.attribution.title": "Побудовано на deepagent-code", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 4b217456..2a2f8dd8 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -629,6 +629,8 @@ export const dict = { "session.subagents.empty": "此会话没有子 Agent", "session.subagents.running": "运行中", "session.subagents.idle": "空闲", + "session.subagents.finished": "已完成", + "session.subagents.readonly": "该子 Agent 已完成,历史记录为只读。", "session.fork.derivedFrom": "从《{title}》派生", "session.fork.derivedFromUnknown": "从其他对话派生", "browser.title": "浏览器", @@ -733,6 +735,8 @@ export const dict = { "sidebar.settings": "设置", "sidebar.help": "帮助", "sidebar.review": "知识治理", + "sidebar.history": "历史项目", + "sidebar.history.description": "包含已导入或历史会话、但当前未激活的项目。点击可打开。", "sidebar.workspaces.enable": "启用工作区", "sidebar.workspaces.disable": "禁用工作区", "sidebar.gettingStarted.title": "入门", @@ -1121,6 +1125,30 @@ export const dict = { "settings.general.deepagent.mode.xhigh": "xhigh", "settings.section.about": "关于", "settings.tab.about": "关于", + "settings.import.section": "导入历史", + "settings.import.source": "数据来源", + "settings.import.source.hint": "选择预设来源,或选择“自定义”手动输入路径。", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "自定义", + "settings.import.customPath": "路径", + "settings.import.customPath.hint": "Agent 数据目录的绝对路径。", + "settings.import.scope": "导入内容", + "settings.import.scope.hint": "选择一个或多个类别。", + "settings.import.scope.session": "聊天历史", + "settings.import.scope.memory": "记忆", + "settings.import.scope.skill": "技能", + "settings.import.dryRun": "试运行", + "settings.import.dryRun.hint": "仅解析与映射,不写入任何内容。", + "settings.import.copyLiveDb": "快照主库", + "settings.import.copyLiveDb.hint": "写入主库的副本(推荐),而不是直接写主库。", + "settings.import.cwdFilter": "目录过滤", + "settings.import.cwdFilter.hint": "仅导入目录以该前缀开头的会话。", + "settings.import.run.btn": "导入", + "settings.import.running": "导入中…", + "settings.import.cancel": "取消", + "settings.import.result": "结果", + "settings.import.log": "进度", "settings.about.product.title": "产品", "settings.about.product.description": "应用名称和版本。", "settings.about.attribution.title": "上游来源", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index f8345e57..4e9e4878 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -928,6 +928,30 @@ export const dict = { "settings.general.deepagent.mode.ultra": "極限", "settings.section.about": "關於", "settings.tab.about": "關於", + "settings.import.section": "匯入歷史", + "settings.import.source": "資料來源", + "settings.import.source.hint": "選擇預設來源,或選擇「自訂」手動輸入路徑。", + "settings.import.preset.codex": "Codex", + "settings.import.preset.claude": "Claude Code", + "settings.import.preset.custom": "自訂", + "settings.import.customPath": "路徑", + "settings.import.customPath.hint": "Agent 資料目錄的絕對路徑。", + "settings.import.scope": "匯入內容", + "settings.import.scope.hint": "選擇一個或多個類別。", + "settings.import.scope.session": "聊天歷史", + "settings.import.scope.memory": "記憶", + "settings.import.scope.skill": "技能", + "settings.import.dryRun": "試執行", + "settings.import.dryRun.hint": "僅解析與映射,不寫入任何內容。", + "settings.import.copyLiveDb": "快照主庫", + "settings.import.copyLiveDb.hint": "寫入主庫的副本(推薦),而非直接寫主庫。", + "settings.import.cwdFilter": "目錄過濾", + "settings.import.cwdFilter.hint": "僅匯入目錄以該前綴開頭的工作階段。", + "settings.import.run.btn": "匯入", + "settings.import.running": "匯入中…", + "settings.import.cancel": "取消", + "settings.import.result": "結果", + "settings.import.log": "進度", "settings.about.product.title": "產品", "settings.about.product.description": "應用程式名稱與版本。", "settings.about.attribution.title": "基於 deepagent-code 建置", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c596397b..4123de0d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -63,6 +63,7 @@ import { useTheme, type ColorScheme } from "@deepagent-code/ui/theme/context" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" import { DebugBar } from "@/components/debug-bar" +import { listPending } from "@/components/review/dialog-review" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { useDirectoryPicker } from "@/components/directory-picker" import { ServerConnection, useServer } from "@/context/server" @@ -156,6 +157,22 @@ export default function Layout(props: ParentProps) { const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) const currentDir = createMemo(() => route().dir) + // Pending knowledge-candidate badge on the Review sidebar icon. Re-fetches on + // workspace change and on a slow interval so it stays fresh after imports / + // reviews without coupling to the dialog lifecycle. + const [reviewPending, { refetch: refetchReviewPending }] = createResource(currentDir, async (dir) => { + if (!dir) return false + try { + const sdk = serverSDK.createDirSdkContext(dir) + const items = await listPending(sdk.client as never) + return items.some((item) => item.approval_status === "pending") + } catch { + return false + } + }) + const reviewBadgeTimer = setInterval(() => void refetchReviewPending(), 60_000) + onCleanup(() => clearInterval(reviewBadgeTimer)) + const [state, setState] = createStore({ autoselect: !initialDirectory, busyWorkspaces: {} as Record, @@ -1233,6 +1250,23 @@ export default function Layout(props: ParentProps) { if (dialogDead || dialogRun !== run) return dialog.show(() => ) }) + // Refresh the sidebar badge once the reviewer closes. + setTimeout(() => void refetchReviewPending(), 1500) + } + + function openHistoryProjects() { + const run = ++dialogRun + const activeWorktrees = new Set(server.projects.list().map((p) => p.worktree)) + void import("@/components/history/dialog-history-projects").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ( + openProject(directory)} + /> + )) + }) } function openPacks() { @@ -2362,6 +2396,9 @@ export default function Layout(props: ParentProps) { onOpenSettings={openSettings} reviewLabel={() => language.t("sidebar.review")} onOpenReview={openReview} + reviewPending={() => !!reviewPending.latest} + historyLabel={() => language.t("sidebar.history")} + onOpenHistory={openHistoryProjects} packsLabel={() => language.t("packs.title")} onOpenPacks={openPacks} helpLabel={() => language.t("sidebar.help")} diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index 5692bbd0..1a343b8e 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -28,8 +28,11 @@ export const SidebarContent = (props: { settingsLabel: Accessor settingsKeybind: Accessor onOpenSettings: () => void + historyLabel: Accessor + onOpenHistory: () => void reviewLabel: Accessor onOpenReview: () => void + reviewPending?: Accessor packsLabel?: Accessor onOpenPacks?: () => void helpLabel: Accessor @@ -94,24 +97,38 @@ export const SidebarContent = (props: {
- + - + +
+ + + + +
+
+ - + + yargs + .option("from", { + type: "string", + choices: ["codex", "claude"] as const, + demandOption: true, + describe: "which agent's data to import", + }) + .option("path", { + type: "string", + describe: "source data root (defaults to ~/.codex or ~/.claude)", + }) + .option("scope", { + type: "array", + choices: ALL_SCOPES, + default: ALL_SCOPES, + describe: "categories to import (repeatable)", + }) + .option("dry-run", { + type: "boolean", + default: false, + describe: "parse and map only; write nothing", + }) + .option("copy-live-db", { + type: "boolean", + default: false, + describe: "snapshot the live DB and write into the copy (safe pre-validation)", + }) + .option("cwd-filter", { + type: "string", + describe: "only import sessions whose cwd starts with this prefix", + }), + handler: Effect.fn("Cli.importHistory")(function* (args) { + const source = args.from as ImportSource + const sourcePath = (args.path as string) || defaultSourcePath(source) + const scopes = (args.scope as ImportScope[]) ?? ALL_SCOPES + + process.stdout.write(`Importing from ${source} (${sourcePath}) — scopes: ${scopes.join(",")}${args.dryRun ? " [dry-run]" : ""}${EOL}`) + + const report = yield* Effect.promise(() => + runImport({ + source, + sourcePath, + scopes, + dryRun: !!args.dryRun, + copyLiveDb: !!args.copyLiveDb, + cwdFilter: args.cwdFilter as string | undefined, + onProgress: (event) => { + switch (event.phase) { + case "discover": + process.stdout.write(` discovered ${event.count} session(s)${EOL}`) + break + case "write-session": + process.stdout.write(` [session] ${event.sessionId} (${event.turns} turns${event.reimport ? ", re-imported" : ""})${EOL}`) + break + case "write-memory": + process.stdout.write(` [memory] staged ${event.staged} candidate(s) for review${EOL}`) + break + case "write-skill": + process.stdout.write(` [skill] wrote ${event.written}${EOL}`) + break + case "warn": + process.stdout.write(` [warn] ${event.label ?? ""} ${event.message}${EOL}`) + break + } + }, + }), + ) + + const imported = report.sessions.length + process.stdout.write( + [ + `Done in ${report.elapsedMs}ms.`, + `sessions=${imported}`, + report.memory ? `memories_staged=${report.memory.staged}` : "", + report.skills ? `skills=${report.skills.written}` : "", + report.warnings.length ? `warnings=${report.warnings.length}` : "", + report.dryRun ? "(dry-run, nothing written)" : "", + ] + .filter(Boolean) + .join(" ") + EOL, + ) + + if (report.warnings.length > 0 && !report.dryRun) { + for (const w of report.warnings.slice(0, 20)) process.stdout.write(` ! ${w}${EOL}`) + if (report.warnings.length > 20) process.stdout.write(` ... and ${report.warnings.length - 20} more${EOL}`) + } + + if (imported === 0 && report.warnings.length > 0) { + return yield* fail("import produced no sessions; see warnings above") + } + }), +}) + +function defaultSourcePath(source: ImportSource): string { + return join(homedir(), source === "codex" ? ".codex" : ".claude") +} diff --git a/packages/deepagent-code/src/import/index.ts b/packages/deepagent-code/src/import/index.ts new file mode 100644 index 00000000..2ccbf6b9 --- /dev/null +++ b/packages/deepagent-code/src/import/index.ts @@ -0,0 +1,161 @@ +import { Effect, Exit, Layer } from "effect" +import { Database } from "@deepagent-code/core/database/database" +import { EventV2 } from "@deepagent-code/core/event" +import { SessionProjector } from "@deepagent-code/core/session/projector" +import { ProjectV2 } from "@deepagent-code/core/project" +import { Git } from "@deepagent-code/core/git" +import { FSUtil } from "@deepagent-code/core/fs-util" +import { copyFileSync, existsSync, mkdirSync } from "node:fs" +import { homedir } from "node:os" +import { dirname, join } from "node:path" +import { parseCodex } from "./source/codex" +import { parseClaude } from "./source/claude" +import { importSession } from "./writer/session" +import { stageAndReviewMemories } from "./writer/memory" +import { writeSkills } from "./writer/skill" +import type { ImportOptions, ImportReport, ImportScope, ImportSource, SessionImportResult } from "./types" +import { ALL_SCOPES } from "./types" +import type { SourceSession } from "./ir" + +const DEFAULT_DB_PATH = join(homedir(), ".local", "share", "deepagent-code", "deepagent-code-local.db") +const DEFAULT_DATA_ROOT = process.env.DEEPAGENT_CODE_HOME || join(homedir(), ".deepagent", "code") +const DEFAULT_CONFIG_DIR = join(homedir(), ".config", "deepagent-code") + +function defaultSourcePath(source: ImportSource): string { + return join(homedir(), source === "codex" ? ".codex" : ".claude") +} + +/** + * End-to-end import entry point. Both the CLI (`import --from `) + * and the desktop settings panel funnel through here. + * + * Parsers are pure; only the session write path needs the deepagent-code + * service stack (assembled from `outputDbPath`). Memory + skill writes are + * plain filesystem operations. Each session imports in isolation so one + * malformed source session cannot abort the rest. + */ +export async function runImport(options: ImportOptions): Promise { + const started = Date.now() + const scopes = options.scopes ?? ALL_SCOPES + const onProgress = options.onProgress ?? (() => {}) + const warnings: string[] = [] + const report: ImportReport = { + source: options.source, + scopes, + dryRun: !!options.dryRun, + sessions: [], + warnings, + elapsedMs: 0, + } + + // 1. Parse + const sourcePath = options.sourcePath ?? defaultSourcePath(options.source) + const parsed = options.source === "codex" ? parseCodex(sourcePath, options) : parseClaude(sourcePath, options) + onProgress({ phase: "discover", source: options.source, count: parsed.sessions.length }) + for (const skip of parsed.skipped) warnings.push(`skipped: ${skip}`) + + if (options.dryRun) { + report.sessions = parsed.sessions.map((s) => ({ sourceId: s.sourceId, targetId: "(dry-run)", turns: s.turns.length, reimport: false })) + report.elapsedMs = Date.now() - started + return report + } + + // 2. Sessions (event-sourced, idempotent delete-then-replay) + if (scopes.includes("session") && parsed.sessions.length > 0) { + const dbPath = resolveDbPath(options) + const collected: Array = [] + await Effect.runPromise( + runSessionImports(parsed.sessions, dbPath, (r) => { + collected.push(r) + if ("error" in r) { + onProgress({ phase: "warn", message: r.error, label: r.sourceId }) + } else { + onProgress({ phase: "write-session", sessionId: r.targetId, turns: r.turns, reimport: r.reimport }) + } + }), + ) + for (const r of collected) { + if ("error" in r) warnings.push(`session ${r.sourceId}: ${r.error}`) + else report.sessions.push(r) + } + } + + // 3. Memory (knowledge candidates → AI auto-review: approve safe, leave risky/conflict pending) + if (scopes.includes("memory") && parsed.memories.length > 0) { + const dataRoot = options.outputDataRoot ?? DEFAULT_DATA_ROOT + try { + report.memory = stageAndReviewMemories(parsed.memories, dataRoot) + onProgress({ phase: "write-memory", staged: report.memory.staged }) + onProgress({ phase: "write-memory-review", approved: report.memory.approved, pending: report.memory.pending }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warnings.push(`memory: ${message}`) + onProgress({ phase: "warn", message }) + } + } + + // 4. Skills + if (scopes.includes("skill") && parsed.skills.length > 0) { + const configDir = options.outputConfigDir ?? DEFAULT_CONFIG_DIR + try { + report.skills = writeSkills(parsed.skills, configDir) + onProgress({ phase: "write-skill", written: report.skills.written }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warnings.push(`skill: ${message}`) + } + } + + report.elapsedMs = Date.now() - started + return report +} + +/** Resolve the SQLite path, optionally snapshotting the live DB first. */ +function resolveDbPath(options: ImportOptions): string { + const live = DEFAULT_DB_PATH + if (options.outputDbPath) return options.outputDbPath + if (options.copyLiveDb) { + const out = join(dirname(live), "import-snapshot.db") + if (existsSync(live)) { + mkdirSync(dirname(out), { recursive: true }) + copyFileSync(live, out) + } + return out + } + return live +} + +/** + * Run session imports against a database at `dbPath`, assembling the + * Database + EventV2 + SessionProjector layers exactly as session-create.test + * does. Each import is isolated via `Effect.either` so a failure is captured, + * not thrown. + */ +export function runSessionImports( + sessions: SourceSession[], + dbPath: string, + onResult: (r: SessionImportResult | { error: string; sourceId: string }) => void, +): Effect.Effect { + return Effect.gen(function* () { + const database = Database.layerFromPath(dbPath) + const events = EventV2.layer.pipe(Layer.provide(database)) + const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) + const projects = ProjectV2.layer.pipe( + Layer.provide(database), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Git.defaultLayer), + ) + const runtime = Layer.mergeAll(database, events, projector, projects) + + for (const session of sessions) { + const exit = yield* Effect.exit(importSession(session).pipe(Effect.provide(runtime))) + if (Exit.isSuccess(exit)) { + onResult(exit.value) + } else { + const cause = exit.cause + const message = cause instanceof Error ? cause.message : String((cause as { _tag?: string })._tag ?? cause) + onResult({ error: message, sourceId: session.sourceId }) + } + } + }) +} diff --git a/packages/deepagent-code/src/import/ir.ts b/packages/deepagent-code/src/import/ir.ts new file mode 100644 index 00000000..5c5bab20 --- /dev/null +++ b/packages/deepagent-code/src/import/ir.ts @@ -0,0 +1,106 @@ +/** + * Source-of-truth intermediate representation shared by every parser. + * + * Parsers (codex / claude) each translate their native on-disk format into + * this IR; the mapper layer then turns {@link SourceSession.turns} into a + * deepagent-code `SerializedEvent[]`. Keeping the IR source-agnostic means the + * event-mapping recipe and the idempotent writer only have to be written once. + */ + +export type ImportSource = "codex" | "claude" + +/** A model reference in deepagent-code terms: `{id, providerID, variant?}`. */ +export interface IRModel { + id: string + providerID: string + variant?: string +} + +/** A single user prompt (text only; attachments are out of scope for v1). */ +export interface UserTurn { + kind: "user" + /** Epoch milliseconds, if the source recorded one. */ + timestampMs?: number + text: string +} + +export type AssistantBlock = + | { type: "text"; text: string } + | { type: "reasoning"; text: string } + | { + type: "tool" + callID: string + name: string + /** Best-effort decoded tool input. */ + input: unknown + /** Tool output text (truncated to a safe bound by the parser). */ + output?: string + error?: string + } + +/** One assistant step: model + an ordered list of content blocks. */ +export interface AssistantTurn { + kind: "assistant" + timestampMs?: number + completedMs?: number + model?: IRModel + blocks: AssistantBlock[] + finish?: string + cost?: number + tokens?: { + input?: number + output?: number + reasoning?: number + cacheRead?: number + cacheWrite?: number + } +} + +export type Turn = UserTurn | AssistantTurn + +export interface SourceSession { + source: ImportSource + /** Original session id from the source (uuid / thread id). */ + sourceId: string + /** Working directory the session ran in. */ + cwd: string + title: string + /** Epoch ms. */ + startedMs: number + updatedMs?: number + model?: IRModel + /** Ordered turns; user/assistant interleaved. */ + turns: Turn[] +} + +/** A memory/knowledge item extracted from the source's memory files. */ +export interface MemoryItem { + source: ImportSource + /** Stable id within the source (filename slug / heading slug). */ + slug: string + title: string + description?: string + body: string + /** Origin cwd for per-project scoping; empty = global. */ + cwd?: string + originSessionId?: string +} + +/** A skill discovered in the source tree. */ +export interface SkillItem { + source: ImportSource + name: string + description?: string + /** Full SKILL.md body (frontmatter may be missing/patched by the writer). */ + body: string + /** Absolute path to the source skill folder (for copying assets later). */ + sourceDir?: string +} + +export interface ParsedSource { + sessions: SourceSession[] + memories: MemoryItem[] + skills: SkillItem[] + /** Files intentionally skipped (secrets, binary, etc.). */ + skipped: string[] +} diff --git a/packages/deepagent-code/src/import/map/__tests__/events.test.ts b/packages/deepagent-code/src/import/map/__tests__/events.test.ts new file mode 100644 index 00000000..1662e627 --- /dev/null +++ b/packages/deepagent-code/src/import/map/__tests__/events.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "bun:test" +import { Schema } from "effect" +import { SessionV1 } from "@deepagent-code/core/v1/session" +import { SessionEvent } from "@deepagent-code/core/session/event" +import { mapSession } from "../events" +import type { SourceSession } from "../../ir" + +/** + * Decode a produced event's `data` against the canonical definition schema. + * The projector replays events via the same decode path, so a green decode + * here is the strongest cheap proof that replayAll won't `Effect.die` on shape. + */ +function decode(baseType: string, data: unknown): unknown { + const schema = DEFINITIONS[baseType] + if (!schema) throw new Error(`no definition registered for ${baseType}`) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Schema.decodeUnknownSync(schema as any)(data) +} + +const DEFINITIONS: Record = { + "session.created": SessionV1.Event.Created.data, + "session.next.prompted": SessionEvent.Prompted.data, + "session.next.step.started": SessionEvent.Step.Started.data, + "session.next.step.ended": SessionEvent.Step.Ended.data, + "session.next.text.ended": SessionEvent.Text.Ended.data, + "session.next.reasoning.ended": SessionEvent.Reasoning.Ended.data, + "session.next.tool.called": SessionEvent.Tool.Called.data, + "session.next.tool.success": SessionEvent.Tool.Success.data, + "session.next.tool.failed": SessionEvent.Tool.Failed.data, +} + +const baseType = (versioned: string) => versioned.replace(/\.\d+$/, "") + +function sampleSession(): SourceSession { + return { + source: "codex", + sourceId: "019f1b59-fb91-7853-8251-8acf45ef5afb", + cwd: "/tmp/proj", + title: "测试会话", + startedMs: 1_782_870_400_000, + updatedMs: 1_782_870_500_000, + model: { id: "gpt-5", providerID: "openai" }, + turns: [ + { kind: "user", text: "你好", timestampMs: 1_782_870_401_000 }, + { + kind: "assistant", + timestampMs: 1_782_870_402_000, + completedMs: 1_782_870_410_000, + model: { id: "gpt-5", providerID: "openai" }, + finish: "stop", + cost: 0.001, + tokens: { input: 100, output: 50 }, + blocks: [ + { type: "reasoning", text: "思考中" }, + { type: "text", text: "你好!" }, + { type: "tool", callID: "call_1", name: "bash", input: { command: "ls" }, output: "a.txt" }, + { type: "tool", callID: "call_2", name: "bash", input: { command: "err" }, error: "boom" }, + ], + }, + ], + } +} + +describe("import map/events", () => { + it("produces a contiguous seq stream starting at 0", () => { + const events = mapSession(sampleSession(), { projectID: "proj_1" }) + expect(events.map((e) => e.seq)).toEqual(events.map((_, i) => i)) + expect(events[0].seq).toBe(0) + }) + + it("uses one stable aggregate id and deterministic event ids", () => { + const a = mapSession(sampleSession(), { projectID: "proj_1" }) + const b = mapSession(sampleSession(), { projectID: "proj_1" }) + const agg = a[0].aggregateID + expect(a.every((e) => e.aggregateID === agg)).toBe(true) + expect(a.map((e) => e.id)).toEqual(b.map((e) => e.id)) + expect(agg.startsWith("ses_")).toBe(true) + }) + + it("versioned types match sync.version on each definition", () => { + const events = mapSession(sampleSession(), { projectID: "proj_1" }) + const types = new Set(events.map((e) => e.type)) + expect(types.has("session.created.1")).toBe(true) + expect(types.has("session.next.prompted.1")).toBe(true) + expect(types.has("session.next.step.started.1")).toBe(true) + expect(types.has("session.next.text.ended.1")).toBe(true) + expect(types.has("session.next.reasoning.ended.1")).toBe(true) + expect(types.has("session.next.tool.called.1")).toBe(true) + expect(types.has("session.next.tool.success.1")).toBe(true) + expect(types.has("session.next.tool.failed.1")).toBe(true) + // step.ended is sync.version 2 + expect(types.has("session.next.step.ended.2")).toBe(true) + expect(types.has("session.next.step.ended.1")).toBe(false) + }) + + it("every event data decodes against its canonical definition schema", () => { + const events = mapSession(sampleSession(), { projectID: "proj_1" }) + for (const e of events) { + const bt = baseType(e.type) + expect(() => decode(bt, e.data)).not.toThrow() + } + }) + + it("decodes the full replayable union when re-tagged by type", () => { + // Build a combined {type, ...data} object per event and decode against the + // SessionEvent.All union for step/text/tool/reasoning events; this mirrors + // how projectors re-dispatch by type. + const events = mapSession(sampleSession(), { projectID: "proj_1" }) + for (const e of events) { + if (e.type === "session.created.1") continue + expect(() => decode(baseType(e.type), e.data)).not.toThrow() + } + }) + + it("redacts nothing here but maps an empty assistant turn safely", () => { + const s: SourceSession = { + source: "claude", + sourceId: "abc", + cwd: "/tmp", + title: "empty", + startedMs: 1_000, + turns: [{ kind: "assistant", blocks: [] }], + } + const events = mapSession(s, { projectID: "p" }) + expect(events.map((e) => e.seq)).toEqual([0, 1, 2]) + expect(events[2].type).toBe("session.next.step.ended.2") + for (const e of events) { + expect(() => decode(baseType(e.type), e.data)).not.toThrow() + } + }) +}) diff --git a/packages/deepagent-code/src/import/map/events.ts b/packages/deepagent-code/src/import/map/events.ts new file mode 100644 index 00000000..3c74b8e0 --- /dev/null +++ b/packages/deepagent-code/src/import/map/events.ts @@ -0,0 +1,188 @@ +import type { SerializedEvent } from "@deepagent-code/core/event" +import type { AssistantTurn, SourceSession, Turn } from "../ir" +import { blockID, eventID, messageID, sessionID, slugify } from "../util/ids" + +/** + * Map a parsed {@link SourceSession} into a deepagent-code `SerializedEvent[]` + * ready for `events.replayAll`. + * + * `seq` starts at 0 and strictly increases by exactly 1 per event (replayAll + * rejects gaps). Each event id is a stable hash of (sourceId, seq, type) so + * re-importing converges on identical events — the foundation of the writer's + * delete-then-replay idempotency. + * + * Versioned type strings (`session.created.1`, `session.next.step.ended.2`, …) + * mirror `EventV2.versionedType` / the `sync.version` on each definition in + * `core/session/event.ts`. The unit test asserts every produced event decodes + * against the canonical definition schema, so a drift here fails CI. + */ + +export interface MapContext { + /** Resolved deepagent-code project id (from `ProjectV2.resolve(cwd)`). */ + projectID: string +} + +const DEFAULT_MODEL = { id: "imported", providerID: "imported" } as const +const DEFAULT_AGENT = "build" + +/** Build events with a monotonically increasing seq and matching stable id. */ +class Builder { + private seq = -1 + constructor( + private readonly sourceId: string, + private readonly aggregateID: string, + private readonly out: SerializedEvent[], + ) {} + push(type: string, data: Record): void { + const seq = (this.seq += 1) + this.out.push({ + id: eventID(this.sourceId, seq, type), + aggregateID: this.aggregateID, + seq, + type, + data, + } as SerializedEvent) + } +} + +export function mapSession(session: SourceSession, ctx: MapContext): SerializedEvent[] { + const aggregateID = sessionID(session.source, session.sourceId) + const events: SerializedEvent[] = [] + const b = new Builder(session.sourceId, aggregateID, events) + + const started = session.startedMs + const updated = session.updatedMs ?? started + const model = session.model ?? DEFAULT_MODEL + + b.push("session.created.1", { + sessionID: aggregateID, + info: { + id: aggregateID, + slug: slugify(session.title) || "imported", + projectID: ctx.projectID, + directory: session.cwd, + title: session.title || "Imported session", + version: "imported", + time: { created: started, updated }, + agent: DEFAULT_AGENT, + model, + metadata: { importedFrom: session.source, sourceSessionId: session.sourceId }, + }, + }) + + for (let i = 0; i < session.turns.length; i++) { + const turn = session.turns[i] + if (turn.kind === "user") { + appendUser(b, turn, i, session, aggregateID, started) + } else { + appendAssistant(b, turn, i, session, aggregateID, model) + } + } + + return events +} + +function appendUser( + b: Builder, + turn: Extract, + turnIndex: number, + session: SourceSession, + aggregateID: string, + started: number, +) { + b.push("session.next.prompted.1", { + timestamp: turn.timestampMs ?? started, + sessionID: aggregateID, + messageID: messageID(session.sourceId, turnIndex, "user"), + prompt: { text: turn.text }, + delivery: "steer", + }) +} + +function appendAssistant( + b: Builder, + turn: AssistantTurn, + turnIndex: number, + session: SourceSession, + aggregateID: string, + fallbackModel: { id: string; providerID: string; variant?: string }, +) { + const assistantMessageID = messageID(session.sourceId, turnIndex, "assistant") + const model = turn.model ?? fallbackModel + const ts = turn.timestampMs ?? session.startedMs + + b.push("session.next.step.started.1", { + timestamp: ts, + sessionID: aggregateID, + assistantMessageID, + agent: DEFAULT_AGENT, + model, + }) + + for (let blockIndex = 0; blockIndex < turn.blocks.length; blockIndex++) { + const block = turn.blocks[blockIndex] + const bid = blockID(session.sourceId, turnIndex, blockIndex) + if (block.type === "text") { + b.push("session.next.text.ended.1", { + timestamp: ts, + sessionID: aggregateID, + assistantMessageID, + textID: bid, + text: block.text, + }) + } else if (block.type === "reasoning") { + b.push("session.next.reasoning.ended.1", { + timestamp: ts, + sessionID: aggregateID, + assistantMessageID, + reasoningID: bid, + text: block.text, + }) + } else { + b.push("session.next.tool.called.1", { + timestamp: ts, + sessionID: aggregateID, + assistantMessageID, + callID: block.callID, + tool: block.name, + input: (block.input as Record) ?? {}, + provider: { executed: true }, + }) + if (block.error) { + b.push("session.next.tool.failed.1", { + timestamp: ts, + sessionID: aggregateID, + assistantMessageID, + callID: block.callID, + error: { type: "unknown", message: block.error }, + provider: { executed: true }, + }) + } else { + b.push("session.next.tool.success.1", { + timestamp: ts, + sessionID: aggregateID, + assistantMessageID, + callID: block.callID, + structured: { output: block.output ?? "" }, + content: block.output ? [{ type: "text", text: block.output }] : [], + provider: { executed: true }, + }) + } + } + } + + const t = turn.tokens ?? {} + b.push("session.next.step.ended.2", { + timestamp: turn.completedMs ?? ts, + sessionID: aggregateID, + assistantMessageID, + finish: turn.finish ?? "stop", + cost: turn.cost ?? 0, + tokens: { + input: t.input ?? 0, + output: t.output ?? 0, + reasoning: t.reasoning ?? 0, + cache: { read: t.cacheRead ?? 0, write: t.cacheWrite ?? 0 }, + }, + }) +} diff --git a/packages/deepagent-code/src/import/map/memory.ts b/packages/deepagent-code/src/import/map/memory.ts new file mode 100644 index 00000000..e4acd912 --- /dev/null +++ b/packages/deepagent-code/src/import/map/memory.ts @@ -0,0 +1,85 @@ +import { readFileSync } from "node:fs" +import { basename, join, relative } from "node:path" +import { readdirSyncStat } from "../util/fs" +import { isExcludedFile, redactSecrets } from "../util/secrets" +import type { ImportSource, MemoryItem } from "../ir" + +/** + * Memory parsing + knowledge-doc mapping. + * + * Both Codex (`~/.codex/memories/*.md`) and Claude Code + * (`~/.claude/projects//memory/*.md`) store memory as Markdown, optionally + * with YAML frontmatter (`name` / `description` / `metadata`). We parse each + * file into a {@link MemoryItem}; the writer decides whether to stage it as a + * knowledge candidate or append it to AGENTS.md. + */ + +/** Parse `---\nkey: value\n---` frontmatter; returns {front, body}. */ +export function parseFrontmatter(input: string): { front: Record; body: string } { + const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(input) + if (!match) return { front: {}, body: input } + const front: Record = {} + for (const raw of match[1].split(/\r?\n/)) { + const idx = raw.indexOf(":") + if (idx <= 0) continue + const key = raw.slice(0, idx).trim() + let value = raw.slice(idx + 1).trim() + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1) + } + if (key) front[key] = value + } + return { front, body: match[2] } +} + +function slugFromFile(file: string): string { + return basename(file) + .replace(/\.md$/i, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +function titleFromBody(body: string): string { + const heading = /^\s*#\s+(.+)$/m.exec(body) + return heading ? heading[1].trim().slice(0, 120) : body.split(/\n/)[0]?.slice(0, 120) ?? "memory" +} + +/** Walk a directory for `.md` memory files; `.git` and excluded files are skipped. */ +export function fromMarkdownTree(source: ImportSource, root: string): MemoryItem[] { + const items: MemoryItem[] = [] + const walk = (dir: string) => { + for (const entry of readdirSyncStat(dir)) { + const rel = relative(root, entry.path) + if (entry.isDirectory) { + if (basename(entry.path) === ".git") continue + walk(entry.path) + continue + } + if (!entry.isFile || !entry.path.endsWith(".md")) continue + if (isExcludedFile(rel)) continue + let text: string + try { + text = readFileSync(entry.path, "utf8") + } catch { + continue + } + const { front, body } = parseFrontmatter(text) + const clean = redactSecrets(body).text + items.push({ + source, + slug: front.name || slugFromFile(entry.path), + title: front.description ? titleFromBody(clean) : titleFromBody(clean), + description: front.description, + body: clean, + originSessionId: front.originSessionId, + }) + } + } + try { + walk(root) + } catch { + /* root missing */ + } + return items +} diff --git a/packages/deepagent-code/src/import/source/claude.ts b/packages/deepagent-code/src/import/source/claude.ts new file mode 100644 index 00000000..8222eef6 --- /dev/null +++ b/packages/deepagent-code/src/import/source/claude.ts @@ -0,0 +1,233 @@ +import { readFileSync } from "node:fs" +import { basename, join } from "node:path" +import { readdirSyncStat } from "../util/fs" +import type { AssistantBlock, IRModel, ParsedSource, SourceSession, Turn } from "../ir" +import { isExcludedFile, redactSecrets } from "../util/secrets" +import { fromMarkdownTree, parseFrontmatter } from "../map/memory" + +/** + * Parse a Claude Code (`~/.claude`) tree into the shared IR. + * + * Source layout (verified on a real install): + * - `projects//.jsonl` — one JSON object per line: + * { type: "user" | "assistant", uuid, parentUuid, timestamp(ISO), + * sessionId, cwd, message: { role, content: [...] } } + * content blocks: + * assistant → { type:"thinking", thinking } | { type:"text", text } + * | { type:"tool_use", id, name, input } + * user → string | [{ type:"tool_result", tool_use_id, content, is_error }] + * - `projects//memory/*.md` — per-topic memory (YAML frontmatter) + * - `skills//SKILL.md` — skills (auto-loaded by deepagent-code anyway) + * + * Turns are derived in file order: assistant lines open/extend an assistant + * turn; a user line that is plain text starts a new user turn (and closes the + * preceding assistant turn). tool_result blocks attach their output to the + * matching tool_use by id without starting a new turn. + */ + +const MAX_OUTPUT = 20_000 + +export function parseClaude(root: string, opts?: { cwdFilter?: string }): ParsedSource { + const projectsDir = join(root, "projects") + const sessions: SourceSession[] = [] + const skipped: string[] = [] + const memories = [] + const skills = parseClaudeSkills(join(root, "skills")) + + for (const projectEntry of readdirSyncStat(projectsDir)) { + if (!projectEntry.isDirectory) continue + // memories live under /memory/ + memories.push(...fromMarkdownTree("claude", join(projectEntry.path, "memory"))) + for (const entry of readdirSyncStat(projectEntry.path)) { + if (!entry.isFile || !entry.path.endsWith(".jsonl")) continue + if (isExcludedFile(basename(entry.path))) { + skipped.push(entry.path) + continue + } + const parsed = parseSessionFile(entry.path) + if (!parsed) { + skipped.push(entry.path) + continue + } + if (opts?.cwdFilter && !parsed.cwd.startsWith(opts.cwdFilter)) continue + sessions.push(parsed) + } + } + + return { sessions, memories, skills, skipped } +} + +interface ClaudeLine { + type?: string + uuid?: string + parentUuid?: string | null + timestamp?: string + sessionId?: string + cwd?: string + message?: { role?: string; content?: unknown } + isMeta?: boolean + isSidechain?: boolean + version?: string +} + +function parseSessionFile(file: string): SourceSession | undefined { + let text: string + try { + text = readFileSync(file, "utf8") + } catch { + return undefined + } + + let sessionId = basename(file).replace(/\.jsonl$/, "") + let cwd = "" + let startedMs = 0 + let updatedMs = 0 + const turns: Turn[] = [] + let currentAssistant: AssistantBlock[] | null = null + let currentAssistantStartMs: number | undefined + let assistantModel: IRModel | undefined + + const flushAssistant = (timestampMs?: number) => { + if (!currentAssistant || currentAssistant.length === 0) { + currentAssistant = null + return + } + const turn: Extract = { + kind: "assistant", + timestampMs, + blocks: currentAssistant, + } + if (assistantModel) turn.model = assistantModel + turns.push(turn) + currentAssistant = null + } + + for (const raw of text.split(/\r?\n/)) { + const trimmed = raw.trim() + if (!trimmed) continue + let line: ClaudeLine + try { + line = JSON.parse(trimmed) + } catch { + continue + } + if (line.isSidechain) continue // sidechain sub-tasks are not top-level turns + const ts = line.timestamp ? Date.parse(line.timestamp) : 0 + if (ts > 0) { + if (startedMs === 0) startedMs = ts + if (ts > updatedMs) updatedMs = ts + } + if (line.sessionId) sessionId = line.sessionId + if (line.cwd) cwd = line.cwd + if (line.message?.role === "assistant" && line.message.content) { + // Each assistant JSONL message is its own deepagent-code step (one + // step.started → blocks → step.ended). Flush the previous assistant turn + // first so tool_results still attach to the right (just-closed) turn. + flushAssistant(currentAssistantStartMs) + currentAssistant = [] + currentAssistantStartMs = ts + const blocks = line.message.content as Array> + if (line.message && typeof line.message === "object" && "model" in line.message) { + const m = (line.message as { model?: unknown }).model + if (typeof m === "string") assistantModel = { id: m, providerID: "anthropic" } + } + for (const block of blocks) { + const type = block.type as string + if (type === "thinking" && block.thinking) { + currentAssistant.push({ type: "reasoning", text: redactSecrets(String(block.thinking)).text }) + } else if (type === "text" && block.text) { + currentAssistant.push({ type: "text", text: redactSecrets(String(block.text)).text }) + } else if (type === "tool_use") { + currentAssistant.push({ + type: "tool", + callID: String(block.id ?? ""), + name: String(block.name ?? "tool"), + input: (block.input as Record) ?? {}, + }) + } + } + } else if (line.type === "user") { + const content = line.message?.content + if (typeof content === "string") { + if (line.isMeta) continue // system-injected user placeholders + flushAssistant(ts) + const t = redactSecrets(content).text.trim() + if (t) turns.push({ kind: "user", text: t, timestampMs: ts || undefined }) + } else if (Array.isArray(content)) { + // tool_result blocks attach to the in-flight assistant turn by tool_use_id + for (const block of content) { + if (block && typeof block === "object" && block.type === "tool_result") { + const toolUseId = String((block as { tool_use_id?: unknown }).tool_use_id ?? "") + const isError = (block as { is_error?: unknown }).is_error + const outText = extractToolResultText(block) + const target = currentAssistant?.find((b) => b.type === "tool" && b.callID === toolUseId) + if (target && target.type === "tool") { + if (isError) target.error = outText || "error" + else target.output = clampOutput(outText) + } + } + } + } + } + } + flushAssistant(updatedMs || startedMs) + + return { + source: "claude", + sourceId: sessionId, + cwd: cwd || process.cwd(), + title: firstUserText(turns) || sessionId.slice(-12), + startedMs: startedMs || updatedMs || Date.now(), + updatedMs: updatedMs || undefined, + model: assistantModel, + turns, + } +} + +function extractToolResultText(block: unknown): string { + const content = (block as { content?: unknown }).content + if (typeof content === "string") return content + if (Array.isArray(content)) { + return content + .map((c) => { + if (typeof c === "string") return c + if (c && typeof c === "object" && "text" in c) return String((c as { text: unknown }).text ?? "") + return "" + }) + .join("\n") + } + return "" +} + +function clampOutput(output: string): string { + const { text } = redactSecrets(output) + return text.length > MAX_OUTPUT ? text.slice(0, MAX_OUTPUT) + "\n…[truncated]" : text +} + +function firstUserText(turns: Turn[]): string { + for (const t of turns) if (t.kind === "user") return t.text.slice(0, 60) + return "" +} + +function parseClaudeSkills(skillsDir: string): ParsedSource["skills"] { + const out: ParsedSource["skills"] = [] + for (const entry of readdirSyncStat(skillsDir)) { + if (!entry.isDirectory) continue + const skillFile = join(entry.path, "SKILL.md") + let text: string + try { + text = readFileSync(skillFile, "utf8") + } catch { + continue + } + const { front, body } = parseFrontmatter(text) + out.push({ + source: "claude", + name: front.name || basename(entry.path), + description: front.description, + body: redactSecrets(body).text, + sourceDir: entry.path, + }) + } + return out +} diff --git a/packages/deepagent-code/src/import/source/codex.ts b/packages/deepagent-code/src/import/source/codex.ts new file mode 100644 index 00000000..d8ac1a39 --- /dev/null +++ b/packages/deepagent-code/src/import/source/codex.ts @@ -0,0 +1,274 @@ +import { readFileSync } from "node:fs" +import { basename, join } from "node:path" +import { readdirSyncStat } from "../util/fs" +import type { AssistantBlock, IRModel, ParsedSource, SourceSession, Turn } from "../ir" +import { isExcludedFile, redactSecrets } from "../util/secrets" +import { fromMarkdownTree } from "../map/memory" + +/** + * Parse a Codex (`~/.codex` / `~/.codex_backup`) tree into the shared IR. + * + * Source layout (verified against a real backup): + * - `session_index.jsonl` — `{id, thread_name, updated_at}` per session (titles) + * - `sessions/YYYY/MM/DD/rollout-*.jsonl` — one JSON object per line: + * * `session_meta` payload: `{id/session_id, cwd, model_provider, …}` + * * `response_item` payload.type ∈ {message, reasoning, function_call, + * function_call_output} + * * `event_msg` / `turn_context` / `compacted` — higher-level, not needed + * + * A Codex exchange is flattened to deepagent-code turns: each user message + * starts a new boundary; everything until the next user message (reasoning, + * tool calls + outputs, final assistant text) becomes one assistant turn. + */ + +const MAX_OUTPUT = 20_000 + +export function parseCodex(root: string, opts?: { cwdFilter?: string }): ParsedSource { + const titles = readTitleIndex(join(root, "session_index.jsonl")) + const rolloutFiles = discoverRollouts(join(root, "sessions")) + const sessions: SourceSession[] = [] + const skipped: string[] = [] + + for (const file of rolloutFiles) { + if (isExcludedFile(file)) { + skipped.push(file) + continue + } + const parsed = parseRollout(file, titles) + if (!parsed) { + skipped.push(file) + continue + } + if (opts?.cwdFilter && !parsed.cwd.startsWith(opts.cwdFilter)) continue + sessions.push(parsed) + } + + const memories = fromMarkdownTree("codex", join(root, "memories")) + return { sessions, memories, skills: [], skipped } +} + +function readTitleIndex(path: string): Map { + const map = new Map() + let text: string + try { + text = readFileSync(path, "utf8") + } catch { + return map + } + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed) continue + try { + const o = JSON.parse(trimmed) as { id?: string; thread_name?: string } + if (o.id && o.thread_name) map.set(o.id, o.thread_name) + } catch { + /* ignore malformed index lines */ + } + } + return map +} + +function discoverRollouts(sessionsDir: string): string[] { + const out: string[] = [] + try { + for (const entry of readdirSyncStat(sessionsDir)) { + if (entry.isDirectory) out.push(...discoverRollouts(entry.path)) + else if (entry.path.endsWith(".jsonl") && basename(entry.path).startsWith("rollout-")) out.push(entry.path) + } + } catch { + /* sessions dir may not exist */ + } + return out +} + +interface RolloutLine { + type: string + payload?: unknown + timestamp?: string +} + +function parseRollout(file: string, titles: Map): SourceSession | undefined { + let text: string + try { + text = readFileSync(file, "utf8") + } catch { + return undefined + } + + let sessionId = "" + let cwd = "" + let model: IRModel | undefined + let startedMs = 0 + let updatedMs = 0 + const turns: Turn[] = [] + let currentAssistant: { turn: AssistantTurnBlocks } | null = null + + const flushAssistant = () => { + if (!currentAssistant) return + const built = currentAssistant.turn.build() + if (built) turns.push(built) + currentAssistant = null + } + + for (const raw of text.split(/\r?\n/)) { + const trimmed = raw.trim() + if (!trimmed) continue + let line: RolloutLine + try { + line = JSON.parse(trimmed) + } catch { + continue + } + const ts = line.timestamp ? Date.parse(line.timestamp) : 0 + if (ts > 0) { + if (startedMs === 0) startedMs = ts + if (ts > updatedMs) updatedMs = ts + } + + if (line.type === "session_meta") { + const p = line.payload as Record | undefined + sessionId = String(p?.session_id ?? p?.id ?? "") + cwd = String(p?.cwd ?? "") + const provider = String(p?.model_provider ?? "openai").toLowerCase() + const modelId = p?.model ? String(p.model) : undefined + if (modelId) model = { id: modelId, providerID: provider } + continue + } + + if (line.type !== "response_item") continue + const p = line.payload as Record | undefined + if (!p) continue + const kind = p.type as string + + if (kind === "message") { + const role = p.role as string + const contentText = joinMessageContent(p.content) + if (role === "user") { + flushAssistant() + if (contentText.trim()) { + const { text } = redactSecrets(contentText) + turns.push({ kind: "user", text, timestampMs: ts || undefined }) + } + } else if (role === "assistant") { + if (!currentAssistant) currentAssistant = { turn: new AssistantTurnBlocks(ts) } + if (contentText.trim()) currentAssistant.turn.text(contentText) + } + // role === "developer" / "system" are Codex internals — skipped. + } else if (kind === "reasoning") { + if (!currentAssistant) currentAssistant = { turn: new AssistantTurnBlocks(ts) } + const summary = p.summary as Array<{ text?: string }> | undefined + const reasoningText = (summary ?? []).map((s) => s.text ?? "").join("\n").trim() + if (reasoningText) currentAssistant.turn.reasoning(reasoningText) + } else if (kind === "function_call") { + if (!currentAssistant) currentAssistant = { turn: new AssistantTurnBlocks(ts) } + const callID = String(p.call_id ?? p.id ?? "") + const name = String(p.name ?? "tool") + const input = safeParseArgs(p.arguments) + currentAssistant.turn.tool(callID, name, input) + } else if (kind === "function_call_output") { + const callID = String(p.call_id ?? "") + const output = clampOutput(String(p.output ?? "")) + // attach to the most recent matching tool block across the current turn + currentAssistant?.turn.attachOutput(callID, output) + } + } + + flushAssistant() + + if (!sessionId) { + // fall back to the filename so the session is still importable & dedupable + sessionId = basename(file).replace(/\.jsonl$/, "") + } + + return { + source: "codex", + sourceId: sessionId, + cwd: cwd || process.cwd(), + title: titles.get(sessionId) || firstUserText(turns) || sessionId.slice(-12), + startedMs: startedMs || updatedMs || Date.now(), + updatedMs: updatedMs || undefined, + model, + turns, + } +} + +function joinMessageContent(content: unknown): string { + if (typeof content === "string") return content + if (!Array.isArray(content)) return "" + return content + .map((c) => { + if (typeof c === "string") return c + if (c && typeof c === "object" && "text" in c) return String((c as { text: unknown }).text ?? "") + return "" + }) + .join("\n") + .trim() +} + +function safeParseArgs(argumentsJson: unknown): Record { + if (typeof argumentsJson !== "string") return {} + try { + const parsed = JSON.parse(argumentsJson) + return parsed && typeof parsed === "object" ? (parsed as Record) : { value: parsed } + } catch { + return { raw: argumentsJson } + } +} + +function clampOutput(output: string): string { + const { text } = redactSecrets(output) + return text.length > MAX_OUTPUT ? text.slice(0, MAX_OUTPUT) + "\n…[truncated]" : text +} + +function firstUserText(turns: Turn[]): string { + for (const t of turns) if (t.kind === "user") return t.text.slice(0, 60) + return "" +} + +/** + * Mutable builder for one assistant turn so blocks append in source order and + * tool outputs can be attached after their call by `call_id`. + */ +class AssistantTurnBlocks { + private blocks: AssistantBlock[] = [] + private model: IRModel | undefined + private finish: string | undefined + private cost: number | undefined + private tokens: { input?: number; output?: number; reasoning?: number; cacheRead?: number; cacheWrite?: number } | undefined + constructor(private readonly timestampMs: number) {} + + text(t: string) { + this.blocks.push({ type: "text", text: redactSecrets(t).text }) + } + reasoning(t: string) { + this.blocks.push({ type: "reasoning", text: redactSecrets(t).text }) + } + tool(callID: string, name: string, input: unknown) { + this.blocks.push({ type: "tool", callID, name, input }) + } + attachOutput(callID: string, output: string) { + const block = this.blocks.find((b) => b.type === "tool" && b.callID === callID) + if (block && block.type === "tool") block.output = output + } + + setModel(m: IRModel) { + this.model = m + } + setUsage(u: { input?: number; output?: number; reasoning?: number; cacheRead?: number; cacheWrite?: number }) { + this.tokens = u + } + + build(): Turn | null { + if (this.blocks.length === 0) return null + const turn: Extract = { + kind: "assistant", + timestampMs: this.timestampMs || undefined, + blocks: this.blocks, + } + if (this.model) turn.model = this.model + if (this.tokens) turn.tokens = this.tokens + if (this.finish) turn.finish = this.finish + if (this.cost !== undefined) turn.cost = this.cost + return turn + } +} diff --git a/packages/deepagent-code/src/import/types.ts b/packages/deepagent-code/src/import/types.ts new file mode 100644 index 00000000..a27bd9ef --- /dev/null +++ b/packages/deepagent-code/src/import/types.ts @@ -0,0 +1,95 @@ +import type { ImportSource } from "./ir" +export type { ImportSource } from "./ir" + +/** + * Which categories of data to import from the external agent. + * Default in the CLI/UI is all three; each can be toggled independently. + */ +export type ImportScope = "session" | "memory" | "skill" + +/** + * Progress events emitted during an import run, for the CLI logger and the + * desktop UI to surface to the user. + */ +export type ImportProgress = + | { phase: "discover"; source: ImportSource; count: number } + | { phase: "parse"; source: ImportSource; current: number; total: number; label?: string } + | { phase: "write-session"; sessionId: string; turns: number; reimport: boolean } + | { phase: "write-memory"; staged: number } + | { phase: "write-memory-review"; approved: number; pending: number } + | { phase: "write-skill"; written: number } + | { phase: "skip"; reason: string; label?: string } + | { phase: "warn"; message: string; label?: string } + +export type ProgressFn = (event: ImportProgress) => void + +/** + * Top-level options for a single import run. Both the CLI and the desktop UI + * build one of these and hand it to {@link runImport}. + */ +export interface ImportOptions { + /** Which agent produced the source data. */ + source: ImportSource + /** + * Root of the source data. + * - codex: the `~/.codex` (or `~/.codex_backup`) directory + * - claude: the `~/.claude` directory + * Defaults to the agent's home directory when omitted. + */ + sourcePath?: string + /** Which categories to import. Defaults to all. */ + scopes?: ImportScope[] + /** When true, parse + map only; do not write anything. */ + dryRun?: boolean + /** + * When true, copy the live deepagent-code DB to `outputDbPath` and write + * into the copy (safe pre-validation before swapping). When false (default), + * write into the live DB. + */ + copyLiveDb?: boolean + /** Override the target SQLite path. Defaults to the live deepagent-code DB. */ + outputDbPath?: string + /** Override the deepagent-code data root (where knowledge/skills land). */ + outputDataRoot?: string + /** Override the deepagent-code config dir (where global skills land). */ + outputConfigDir?: string + /** Optional sink for progress events. */ + onProgress?: ProgressFn + /** Restrict import to sessions whose cwd matches this prefix (optional). */ + cwdFilter?: string +} + +export interface SessionImportResult { + sourceId: string + targetId: string + turns: number + reimport: boolean +} + +export interface MemoryImportResult { + staged: number + writtenToInstructions: boolean + /** Candidates the auto-review pass promoted to active. */ + approved: number + /** Candidates left pending (sensitive / secret / blocked / empty / conflict). */ + pending: number +} + +export interface SkillImportResult { + written: number + skipped: number +} + +export interface ImportReport { + source: ImportSource + scopes: ImportScope[] + dryRun: boolean + sessions: SessionImportResult[] + memory?: MemoryImportResult + skills?: SkillImportResult + warnings: string[] + /** Elapsed milliseconds. */ + elapsedMs: number +} + +export const ALL_SCOPES: ImportScope[] = ["session", "memory", "skill"] diff --git a/packages/deepagent-code/src/import/util/fs.ts b/packages/deepagent-code/src/import/util/fs.ts new file mode 100644 index 00000000..5fe4111a --- /dev/null +++ b/packages/deepagent-code/src/import/util/fs.ts @@ -0,0 +1,37 @@ +import { readdirSync, statSync } from "node:fs" +import { join } from "node:path" + +export interface DirEntry { + path: string + isDirectory: boolean + isFile: boolean +} + +/** Non-recursive directory listing with stat info (one level deep). */ +export function readdirSyncStat(dir: string): DirEntry[] { + let names: string[] + try { + names = readdirSync(dir) + } catch { + return [] + } + const out: DirEntry[] = [] + for (const name of names) { + const p = join(dir, name) + try { + const s = statSync(p) + out.push({ path: p, isDirectory: s.isDirectory(), isFile: s.isFile() }) + } catch { + /* ignore entries we can't stat */ + } + } + return out +} + +export function isFile(path: string): boolean { + try { + return statSync(path).isFile() + } catch { + return false + } +} diff --git a/packages/deepagent-code/src/import/util/ids.ts b/packages/deepagent-code/src/import/util/ids.ts new file mode 100644 index 00000000..8d875a90 --- /dev/null +++ b/packages/deepagent-code/src/import/util/ids.ts @@ -0,0 +1,42 @@ +import { Hash } from "@deepagent-code/core/util/hash" + +/** + * Deterministic id derivation for imported entities. + * + * All ids are stable functions of the source data so that re-running an + * import converges on the same deepagent-code ids. This is what makes the + * writer's "delete aggregate, then replay" strategy safe to re-run: the same + * source always maps to the same target session/message ids. + */ + +const hex = (input: string): string => Hash.sha256(input) + +/** deepagent-code SessionID: must start with `ses`. */ +export function sessionID(namespace: string, sourceId: string): string { + return `ses_${hex(`${namespace}:${sourceId}`)}` +} + +/** deepagent-code SessionMessage id: must start with `msg_`. */ +export function messageID(sourceId: string, turnIndex: number, role: "user" | "assistant"): string { + return `msg_${hex(`${sourceId}:${turnIndex}:${role}`)}` +} + +/** Per-block stable ids (text/reasoning/tool) so events reference them consistently. */ +export function blockID(sourceId: string, turnIndex: number, blockIndex: number): string { + return `blk_${hex(`${sourceId}:${turnIndex}:${blockIndex}`)}` +} + +/** Stable event id: `evt_`. replayAll dedupes by event id. */ +export function eventID(sourceId: string, seq: number, type: string): string { + return `evt_${hex(`${sourceId}:${seq}:${type}`).slice(0, 24)}` +} + +/** A short slug suitable for session.slug / title fallback. */ +export function slugify(input: string, maxLen = 40): string { + const cleaned = input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, maxLen) + return cleaned || "imported" +} diff --git a/packages/deepagent-code/src/import/util/secrets.ts b/packages/deepagent-code/src/import/util/secrets.ts new file mode 100644 index 00000000..c6375f19 --- /dev/null +++ b/packages/deepagent-code/src/import/util/secrets.ts @@ -0,0 +1,69 @@ +/** + * Secret detection and file exclusion for imports. + * + * Source trees (especially `~/.claude`, `~/.codex`) routinely contain live + * credentials: `settings.json` with an `ANTHROPIC_AUTH_TOKEN`, `auth.json`, + * `.env` files, etc. Importing these would leak secrets into the deepagent-code + * database / knowledge store. This module centralises every exclusion rule so + * both parsers and the memory writer consult the same policy. + */ + +/** Basenames / globs that are never read from a source tree. */ +export const EXCLUDED_FILES = new Set([ + "settings.json", + "auth.json", + ".env", + "credentials.json", + "oauth-credentials.json", + "installation_id", +]) + +/** Basename patterns that imply a credential / state file. */ +const EXCLUDED_PATTERNS = [ + /\.env(\..*)?$/i, + /(^|[/_])token/i, + /(^|[/_])secret/i, + /(^|[/_])credential/i, + /(^|[/_])\.git\/./, + /\.sqlite(-\w+)?$/i, + /\.db$/i, +] + +/** Heuristic secret patterns to scrub out of imported text bodies. */ +const SECRET_PATTERNS: Array<{ re: RegExp; label: string }> = [ + { re: /sk-[A-Za-z0-9_\-]{16,}/g, label: "OpenAI-style key" }, + { re: /sk-ant-[A-Za-z0-9_\-]{16,}/g, label: "Anthropic key" }, + { re: /Bearer\s+[A-Za-z0-9_\-\.]{16,}/gi, label: "Bearer token" }, + { re: /(?:ANTHROPIC|OPENAI|DEEPSEEK)[A-Z_]*TOKEN\s*[:=]\s*["']?[A-Za-z0-9_\-]{8,}/gi, label: "env token" }, + { re: /gh[pousr]_[A-Za-z0-9]{16,}/g, label: "GitHub token" }, + { re: /AIza[0-9A-Za-z_\-]{20,}/g, label: "Google API key" }, +] + +export function isExcludedFile(relPath: string): boolean { + const base = relPath.split("/").pop() ?? relPath + if (EXCLUDED_FILES.has(base)) return true + return EXCLUDED_PATTERNS.some((re) => re.test(relPath)) +} + +export interface RedactionResult { + text: string + hits: string[] +} + +/** Replace likely-secret substrings with `[REDACTED: