From 81f318c0b665a782aeae685933a128e1cc17b3e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=BE=E5=B1=B1=E8=B2=B4=E8=87=B3?= Date: Sun, 31 May 2026 16:56:17 +0900 Subject: [PATCH] feat(import): add `basou import claude-code` for native transcripts Derive Basou sessions from Claude Code native transcripts (~/.claude/projects//*.jsonl) after the fact, rather than wrapping a live process. Sessions produced in any Claude Code surface (desktop, IDE, CLI) write the same transcript to disk, so they can be captured for provenance without changing how the tool is launched. - New pure transform (transcript-importer.ts) maps message-level records to Basou events, then reuses the existing importSessionFromJson pipeline (id minting, path sanitization, schema validation unchanged). - Derives session_started/ended, command_executed (Bash -> bash -c) and file_changed (Edit/Write/NotebookEdit). Events are ordered by occurred_at because transcripts are not timestamp-sorted on disk. - Adds the additive `claude-code-import` session source.kind. - Flags: --all, --session , --project , --dry-run, --json. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 16 + packages/cli/src/commands/import.test.ts | 197 ++++++++++++ packages/cli/src/commands/import.ts | 284 ++++++++++++++++++ packages/cli/src/index.ts | 2 + .../core/src/adapters/claude-code/index.ts | 5 + .../claude-code/transcript-importer.test.ts | 205 +++++++++++++ .../claude-code/transcript-importer.ts | 229 ++++++++++++++ packages/core/src/index.ts | 8 +- packages/core/src/schemas/session.schema.ts | 11 +- 9 files changed, 955 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/import.test.ts create mode 100644 packages/cli/src/commands/import.ts create mode 100644 packages/core/src/adapters/claude-code/transcript-importer.test.ts create mode 100644 packages/core/src/adapters/claude-code/transcript-importer.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc0e471..eaefd164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to **basou** are recorded here. The project follows ## Unreleased +### Added + +- `basou import claude-code` — derive Basou sessions from Claude Code native + transcripts (`~/.claude/projects//*.jsonl`) after the fact, + rather than wrapping a live process. Each transcript becomes one imported + session carrying `session_started` / `session_ended`, `command_executed` + (from `Bash` tool uses, recorded as `bash -c ""`), and `file_changed` + (from `Edit` / `Write` / `NotebookEdit` tool uses). `--all` imports every + transcript for a project, `--session ` imports one, and `--dry-run` + previews without writing. Transcripts with no observable command / file + action are skipped. Imports reuse the existing `session import` pipeline, + so path sanitization and id minting are unchanged. +- `claude-code-import` session `source.kind` (additive enum value) to + distinguish transcript-derived sessions from live `claude-code-adapter` + runs and Basou-format `import`s. + ## 0.3.1 — 2026-05-21 A small follow-up that picks two paper-cut issues out of the v0.3.0 diff --git a/packages/cli/src/commands/import.test.ts b/packages/cli/src/commands/import.test.ts new file mode 100644 index 00000000..92957318 --- /dev/null +++ b/packages/cli/src/commands/import.test.ts @@ -0,0 +1,197 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { devNull, tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { + basouPaths, + createManifest, + ensureBasouDirectory, + readYamlFile, + SessionSchema, + writeManifest, +} from "@basou/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { doRunImportClaudeCode } from "./import.js"; + +const execFileAsync = promisify(execFile); + +const ENV = { + ...process.env, + GIT_CONFIG_GLOBAL: devNull, + GIT_CONFIG_SYSTEM: devNull, +}; + +const FIXED_WS_ID = "ws_01HXABCDEF1234567890ABCDEF" as const; +const FIXED_DATE = new Date("2026-05-09T03:00:00.000Z"); + +let tmpRepo: string | undefined; +let projectsRoot: string | undefined; + +beforeEach(async () => { + tmpRepo = await mkdtemp(join(tmpdir(), "basou-import-cli-test-")); + projectsRoot = await mkdtemp(join(tmpdir(), "basou-import-projects-")); + await execFileAsync("git", ["-c", "init.defaultBranch=main", "init"], { cwd: tmpRepo, env: ENV }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { + cwd: tmpRepo, + env: ENV, + }); + await execFileAsync("git", ["config", "user.name", "test"], { cwd: tmpRepo, env: ENV }); +}); + +afterEach(async () => { + for (const dir of [tmpRepo, projectsRoot]) { + if (dir !== undefined) await rm(dir, { recursive: true, force: true }); + } + tmpRepo = undefined; + projectsRoot = undefined; + process.exitCode = 0; + vi.restoreAllMocks(); +}); + +function getProjectsRoot(): string { + if (projectsRoot === undefined) throw new Error("projectsRoot not initialized"); + return projectsRoot; +} + +async function setupInitedRepo(): Promise { + const repo = await realpath(tmpRepo as string); + const paths = await ensureBasouDirectory(repo); + const manifest = createManifest({ + workspaceName: "fixture-ws", + now: FIXED_DATE, + workspaceId: FIXED_WS_ID, + }); + await writeManifest(paths, manifest); + return repo; +} + +/** Write a synthetic Claude transcript into the encoded per-project dir. */ +async function writeTranscript( + repo: string, + sessionId: string, + records: Array>, +): Promise { + const encoded = repo.replaceAll("/", "-"); + const dir = join(getProjectsRoot(), encoded); + await mkdir(dir, { recursive: true }); + const body = records.map((r) => JSON.stringify(r)).join("\n"); + await writeFile(join(dir, `${sessionId}.jsonl`), body); +} + +function actionTranscript(repo: string): Array> { + return [ + { + type: "user", + timestamp: "2026-05-10T00:00:00.000Z", + cwd: repo, + sessionId: "sess-1", + message: { role: "user", content: [{ type: "text", text: "go" }] }, + }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:01.000Z", + cwd: repo, + message: { + content: [{ type: "tool_use", name: "Bash", input: { command: "npm test" } }], + }, + }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:02.000Z", + cwd: repo, + message: { + content: [{ type: "tool_use", name: "Edit", input: { file_path: `${repo}/a.ts` } }], + }, + }, + ]; +} + +async function listSessionDirs(repo: string): Promise { + const paths = basouPaths(repo); + try { + return await readdir(paths.sessions); + } catch { + return []; + } +} + +describe("basou import claude-code", () => { + it("--all imports a transcript with actions into a new session", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, "sess-1", actionTranscript(repo)); + + await doRunImportClaudeCode({ all: true }, { cwd: repo, claudeProjectsDir: getProjectsRoot() }); + + const dirs = await listSessionDirs(repo); + expect(dirs).toHaveLength(1); + + const sessionDir = join(basouPaths(repo).sessions, dirs[0] as string); + const session = SessionSchema.parse(await readYamlFile(join(sessionDir, "session.yaml"))); + expect(session.session.source.kind).toBe("claude-code-import"); + expect(session.session.status).toBe("imported"); + + const eventsBody = await readFile(join(sessionDir, "events.jsonl"), "utf8"); + const types = eventsBody + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => (JSON.parse(l) as { type: string }).type); + expect(types).toContain("command_executed"); + expect(types).toContain("file_changed"); + expect(types[0]).toBe("session_started"); + expect(types[types.length - 1]).toBe("session_ended"); + }); + + it("--session imports a single transcript by id", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, "sess-1", actionTranscript(repo)); + + await doRunImportClaudeCode( + { session: "sess-1" }, + { cwd: repo, claudeProjectsDir: getProjectsRoot() }, + ); + + expect(await listSessionDirs(repo)).toHaveLength(1); + }); + + it("--dry-run writes nothing to disk", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, "sess-1", actionTranscript(repo)); + + await doRunImportClaudeCode( + { all: true, dryRun: true }, + { cwd: repo, claudeProjectsDir: getProjectsRoot() }, + ); + + expect(await listSessionDirs(repo)).toHaveLength(0); + }); + + it("skips transcripts with no observable action", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, "sess-empty", [ + { + type: "user", + timestamp: "2026-05-10T00:00:00.000Z", + cwd: repo, + message: { content: [{ type: "text", text: "hi" }] }, + }, + ]); + + await doRunImportClaudeCode({ all: true }, { cwd: repo, claudeProjectsDir: getProjectsRoot() }); + expect(await listSessionDirs(repo)).toHaveLength(0); + }); + + it("requires --session or --all", async () => { + const repo = await setupInitedRepo(); + await expect( + doRunImportClaudeCode({}, { cwd: repo, claudeProjectsDir: getProjectsRoot() }), + ).rejects.toThrow("Specify --session or --all"); + }); + + it("errors when the project's transcript directory is absent", async () => { + const repo = await setupInitedRepo(); + await expect( + doRunImportClaudeCode({ all: true }, { cwd: repo, claudeProjectsDir: getProjectsRoot() }), + ).rejects.toThrow("Claude transcript directory not found for project"); + }); +}); diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts new file mode 100644 index 00000000..130066ca --- /dev/null +++ b/packages/cli/src/commands/import.ts @@ -0,0 +1,284 @@ +import { readdir, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + assertBasouRootSafe, + basouPaths, + type ClaudeTranscriptRecord, + claudeTranscriptToImportPayload, + findErrorCode, + type ImportSessionResult, + importSessionFromJson, + readManifest, + resolveRepositoryRoot, + SessionImportPayloadSchema, +} from "@basou/core"; +import type { Command } from "commander"; +import { isVerbose, renderCliError } from "../lib/error-render.js"; + +const SES_PREFIX = "ses_"; +const SHORT_ID_LEN = 6; + +export type ImportClaudeCodeOptions = { + project?: string; + session?: string; + all?: boolean; + dryRun?: boolean; + json?: boolean; + verbose?: boolean; +}; + +export type ImportContext = { + /** Defaults to `process.cwd()`. Injectable for tests. */ + cwd?: string; + /** + * Root that holds per-project transcript directories. Defaults to + * `~/.claude/projects`. Injectable for tests so no real home dir is touched. + */ + claudeProjectsDir?: string; +}; + +/** + * Wire `basou import claude-code` onto `program`. The `import` group mirrors + * the `session` group layout so future adapters (`import codex`, ...) slot in + * without changing the visible surface. + */ +export function registerImportCommand(program: Command): void { + const importCmd = program + .command("import") + .description("Import provenance from an external AI tool's native logs"); + + importCmd + .command("claude-code") + .description("Derive Basou sessions from Claude Code native transcripts (~/.claude/projects)") + .option( + "--project ", + "Source project path whose transcripts to import (defaults to the current repository root)", + ) + .option("--session ", "Import a single transcript by its Claude session id") + .option("--all", "Import every transcript found for the project") + .option("--dry-run", "Validate and preview only; do not write to disk") + .option("--json", "Output the result as JSON") + .option("-v, --verbose", "Show error causes") + .action(async (options: ImportClaudeCodeOptions) => { + await runImportClaudeCode(options); + }); +} + +/** + * Programmatic entry for `basou import claude-code`. Owns process exit state; + * tests should prefer {@link doRunImportClaudeCode}. + */ +export async function runImportClaudeCode( + options: ImportClaudeCodeOptions, + ctx: ImportContext = {}, +): Promise { + try { + await doRunImportClaudeCode(options, ctx); + } catch (error: unknown) { + renderCliError(error, { verbose: isVerbose(options) }); + process.exitCode = 1; + } +} + +export async function doRunImportClaudeCode( + options: ImportClaudeCodeOptions, + ctx: ImportContext, +): Promise { + if (options.session === undefined && options.all !== true) { + throw new Error("Specify --session or --all"); + } + + const cwd = ctx.cwd ?? process.cwd(); + const repositoryRoot = await resolveRepositoryRootForImport(cwd); + const paths = basouPaths(repositoryRoot); + await assertWorkspaceInitialized(paths.root); + const manifest = await readManifest(paths); + + const projectPath = options.project ?? repositoryRoot; + const projectsRoot = ctx.claudeProjectsDir ?? join(homedir(), ".claude", "projects"); + const transcriptDir = join(projectsRoot, encodeProjectDir(projectPath)); + + const files = await selectTranscriptFiles(transcriptDir, options); + + const results: ImportSessionResult[] = []; + let skipped = 0; + let sanitizedPaths = 0; + for (const file of files) { + const records = await readTranscript(file); + const payload = claudeTranscriptToImportPayload(records, { + workspaceId: manifest.workspace.id, + }); + if (payload === null) { + skipped++; + continue; + } + + const parsed = SessionImportPayloadSchema.safeParse(payload); + if (!parsed.success) { + throw new Error("Invalid import payload", { cause: parsed.error }); + } + if (parsed.data.schema_version !== "0.1.0") { + throw new Error(`Unsupported import schema_version: ${parsed.data.schema_version}`); + } + + const result = await importSessionFromJson(paths, manifest, parsed.data, { + dryRun: options.dryRun === true, + }); + results.push(result); + sanitizedPaths += + result.pathSanitizeReport.relatedFiles + + (result.pathSanitizeReport.workingDirectoryRewritten ? 1 : 0); + } + + if (sanitizedPaths > 0) { + console.error(`Imported sessions: ${sanitizedPaths} path(s) sanitized`); + } + + printImportResult(options, results, skipped); +} + +/** + * Encode an absolute project path into Claude Code's per-project directory + * name. Claude Code replaces path separators with `-`, so + * `/Users/x/projects/foo` becomes `-Users-x-projects-foo`. Best-effort for + * the common case; paths with characters the vendor encodes differently may + * need an explicit `--project`-derived directory in a later revision. + */ +function encodeProjectDir(projectPath: string): string { + return projectPath.replaceAll("/", "-"); +} + +async function selectTranscriptFiles( + transcriptDir: string, + options: ImportClaudeCodeOptions, +): Promise { + if (options.session !== undefined) { + return [join(transcriptDir, `${options.session}.jsonl`)]; + } + let entries: string[]; + try { + entries = await readdir(transcriptDir); + } catch (error: unknown) { + if (findErrorCode(error, "ENOENT")) { + throw new Error("Claude transcript directory not found for project", { cause: error }); + } + throw new Error("Failed to read Claude transcript directory", { cause: error }); + } + return entries + .filter((name) => name.endsWith(".jsonl")) + .sort() + .map((name) => join(transcriptDir, name)); +} + +async function readTranscript(file: string): Promise { + let body: string; + try { + body = await readFile(file, "utf8"); + } catch (error: unknown) { + if (findErrorCode(error, "ENOENT")) { + throw new Error("Transcript not found", { cause: error }); + } + if (findErrorCode(error, "EISDIR")) { + throw new Error("Transcript path is not a file", { cause: error }); + } + throw new Error("Failed to read transcript", { cause: error }); + } + + const records: ClaudeTranscriptRecord[] = []; + for (const line of body.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + records.push(parsed as ClaudeTranscriptRecord); + } + } catch { + // A malformed line is skipped rather than failing the whole transcript; + // partial native logs should still yield best-effort provenance. + } + } + return records; +} + +function printImportResult( + options: ImportClaudeCodeOptions, + results: ImportSessionResult[], + skipped: number, +): void { + const isDry = options.dryRun === true; + const eventTotal = results.reduce((sum, r) => sum + r.eventCount, 0); + + if (options.json === true) { + console.log( + JSON.stringify({ + imported: results.map((r) => ({ + session_id: r.sessionId, + event_count: r.eventCount, + status: r.finalStatus, + source: { kind: r.finalSourceKind, version: "0.1.0" }, + })), + imported_count: results.length, + skipped_count: skipped, + event_total: eventTotal, + dry_run: isDry, + }), + ); + return; + } + + if (results.length === 0) { + console.log( + skipped > 0 + ? `No sessions imported (${skipped} transcript(s) had no importable actions)` + : "No transcripts found to import", + ); + return; + } + + const skippedSuffix = skipped > 0 ? `; skipped ${skipped} with no actions` : ""; + if (isDry) { + console.log( + `Dry run: would import ${results.length} session(s) (${eventTotal} events)${skippedSuffix}`, + ); + return; + } + + const single = + results.length === 1 && results[0] !== undefined ? ` (${shortId(results[0].sessionId)})` : ""; + console.log( + `Imported ${results.length} session(s)${single} (${eventTotal} events)${skippedSuffix}`, + ); +} + +function shortId(id: string): string { + if (id.startsWith(SES_PREFIX)) { + return id.slice(SES_PREFIX.length, SES_PREFIX.length + SHORT_ID_LEN); + } + return id.slice(0, SHORT_ID_LEN); +} + +async function resolveRepositoryRootForImport(cwd: string): Promise { + try { + return await resolveRepositoryRoot(cwd); + } catch (error: unknown) { + if (error instanceof Error && error.message === "Not a git repository") { + throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou import'.", { + cause: error, + }); + } + throw error; + } +} + +async function assertWorkspaceInitialized(basouRoot: string): Promise { + try { + await assertBasouRootSafe(basouRoot); + } catch (error: unknown) { + if (findErrorCode(error, "ENOENT")) { + throw new Error("Workspace not initialized. Run 'basou init' first."); + } + throw error; + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 989be858..e3d9d630 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import { registerDecisionCommand } from "./commands/decision.js"; import { registerDecisionsCommand } from "./commands/decisions.js"; import { registerExecCommand } from "./commands/exec.js"; import { registerHandoffCommand } from "./commands/handoff.js"; +import { registerImportCommand } from "./commands/import.js"; import { registerInitCommand } from "./commands/init.js"; import { registerRunCommand } from "./commands/run.js"; import { registerSessionCommand } from "./commands/session.js"; @@ -36,6 +37,7 @@ registerStatusCommand(program); registerExecCommand(program); registerRunCommand(program); registerSessionCommand(program); +registerImportCommand(program); registerApprovalCommand(program); registerDecisionCommand(program); registerTaskCommand(program); diff --git a/packages/core/src/adapters/claude-code/index.ts b/packages/core/src/adapters/claude-code/index.ts index 3729a5ca..3206bd05 100644 --- a/packages/core/src/adapters/claude-code/index.ts +++ b/packages/core/src/adapters/claude-code/index.ts @@ -4,3 +4,8 @@ export { resolveClaudeCodeCommand, summarizeAdapterOutput, } from "./claude-code-adapter.js"; +export type { + ClaudeTranscriptRecord, + ClaudeTranscriptToPayloadOptions, +} from "./transcript-importer.js"; +export { CLAUDE_IMPORT_SOURCE, claudeTranscriptToImportPayload } from "./transcript-importer.js"; diff --git a/packages/core/src/adapters/claude-code/transcript-importer.test.ts b/packages/core/src/adapters/claude-code/transcript-importer.test.ts new file mode 100644 index 00000000..97cdf62a --- /dev/null +++ b/packages/core/src/adapters/claude-code/transcript-importer.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; +import { SessionImportPayloadSchema } from "../../schemas/session-import.schema.js"; +import { + CLAUDE_IMPORT_SOURCE, + type ClaudeTranscriptRecord, + claudeTranscriptToImportPayload, +} from "./transcript-importer.js"; + +const WS_ID = "ws_01HXABCDEF1234567890ABCDEF"; +const CWD = "/Users/x/projects/foo"; + +function transform(records: ClaudeTranscriptRecord[]) { + return claudeTranscriptToImportPayload(records, { workspaceId: WS_ID }); +} + +describe("claudeTranscriptToImportPayload", () => { + it("derives session lifecycle + command_executed + file_changed from tool uses", () => { + const records: ClaudeTranscriptRecord[] = [ + { + type: "user", + timestamp: "2026-05-10T00:00:00.000Z", + cwd: CWD, + sessionId: "abc-123", + message: { role: "user", content: [{ type: "text", text: "hi" }] }, + }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:01.000Z", + cwd: CWD, + message: { + content: [ + { type: "text", text: "running" }, + { type: "tool_use", name: "Bash", input: { command: "npm test", description: "test" } }, + ], + }, + }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:02.000Z", + cwd: CWD, + message: { + content: [ + { type: "tool_use", name: "Edit", input: { file_path: `${CWD}/a.ts` } }, + { type: "tool_use", name: "Write", input: { file_path: `${CWD}/b.ts` } }, + ], + }, + }, + // Read is not a captured action; the record must be ignored. + { + type: "assistant", + timestamp: "2026-05-10T00:00:03.000Z", + cwd: CWD, + message: { + content: [{ type: "tool_use", name: "Read", input: { file_path: `${CWD}/c.ts` } }], + }, + }, + ]; + + const payload = transform(records); + expect(payload).not.toBeNull(); + if (payload === null) return; + + // Valid against the import payload contract. + expect(SessionImportPayloadSchema.safeParse(payload).success).toBe(true); + + expect(payload.session.source.kind).toBe(CLAUDE_IMPORT_SOURCE); + expect(payload.session.started_at).toBe("2026-05-10T00:00:00.000Z"); + expect(payload.session.ended_at).toBe("2026-05-10T00:00:03.000Z"); + expect(payload.session.working_directory).toBe(CWD); + expect(payload.session.workspace_id).toBe(WS_ID); + expect(payload.session.related_files).toEqual([`${CWD}/a.ts`, `${CWD}/b.ts`]); + + const types = payload.events.map((e) => e.type); + expect(types).toEqual([ + "session_started", + "command_executed", + "file_changed", + "file_changed", + "session_ended", + ]); + + const command = payload.events[1]; + if (command?.type !== "command_executed") throw new Error("expected command_executed"); + expect(command.command).toBe("bash"); + expect(command.args).toEqual(["-c", "npm test"]); + expect(command.cwd).toBe(CWD); + expect(command.exit_code).toBeNull(); + expect(command.duration_ms).toBe(0); + + const edit = payload.events[2]; + if (edit?.type !== "file_changed") throw new Error("expected file_changed"); + expect(edit.change_type).toBe("modified"); + + const write = payload.events[3]; + if (write?.type !== "file_changed") throw new Error("expected file_changed"); + expect(write.change_type).toBe("added"); + }); + + it("emits events in non-decreasing chronological order", () => { + const records: ClaudeTranscriptRecord[] = [ + { type: "user", timestamp: "2026-05-10T00:00:00.000Z", cwd: CWD, message: { content: [] } }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:05.000Z", + cwd: CWD, + message: { content: [{ type: "tool_use", name: "Bash", input: { command: "ls" } }] }, + }, + ]; + const payload = transform(records); + expect(payload).not.toBeNull(); + if (payload === null) return; + for (let i = 1; i < payload.events.length; i++) { + const prevEvent = payload.events[i - 1]; + const currEvent = payload.events[i]; + if (prevEvent === undefined || currEvent === undefined) continue; + expect(Date.parse(currEvent.occurred_at)).toBeGreaterThanOrEqual( + Date.parse(prevEvent.occurred_at), + ); + } + }); + + it("orders output even when transcript records are not timestamp-sorted on disk", () => { + // Real transcripts interleave sidechain / async-written records, so file + // order is not chronological. The transform must sort regardless. + const records: ClaudeTranscriptRecord[] = [ + { + type: "assistant", + timestamp: "2026-05-10T00:00:05.000Z", + cwd: CWD, + message: { content: [{ type: "tool_use", name: "Bash", input: { command: "second" } }] }, + }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:01.000Z", + cwd: CWD, + message: { content: [{ type: "tool_use", name: "Bash", input: { command: "first" } }] }, + }, + ]; + const payload = transform(records); + expect(payload).not.toBeNull(); + if (payload === null) return; + expect(SessionImportPayloadSchema.safeParse(payload).success).toBe(true); + expect(payload.session.started_at).toBe("2026-05-10T00:00:01.000Z"); + expect(payload.session.ended_at).toBe("2026-05-10T00:00:05.000Z"); + for (let i = 1; i < payload.events.length; i++) { + const prevEvent = payload.events[i - 1]; + const currEvent = payload.events[i]; + if (prevEvent === undefined || currEvent === undefined) continue; + expect(Date.parse(currEvent.occurred_at)).toBeGreaterThanOrEqual( + Date.parse(prevEvent.occurred_at), + ); + } + // The earlier command must come first after sorting. + const commands = payload.events.filter((e) => e.type === "command_executed"); + expect(commands).toHaveLength(2); + if (commands[0]?.type === "command_executed") { + expect(commands[0].args).toEqual(["-c", "first"]); + } + }); + + it("returns null when no observable command / file action exists", () => { + const records: ClaudeTranscriptRecord[] = [ + { + type: "user", + timestamp: "2026-05-10T00:00:00.000Z", + cwd: CWD, + message: { content: [{ type: "text", text: "just chatting" }] }, + }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:01.000Z", + cwd: CWD, + message: { + content: [{ type: "tool_use", name: "Read", input: { file_path: `${CWD}/x` } }], + }, + }, + ]; + expect(transform(records)).toBeNull(); + }); + + it("returns null for an empty transcript", () => { + expect(transform([])).toBeNull(); + }); + + it("skips malformed-shaped records without throwing", () => { + const records: ClaudeTranscriptRecord[] = [ + { type: "queue-operation", operation: "enqueue" }, // no timestamp + { type: "assistant", timestamp: "2026-05-10T00:00:01.000Z", message: "not-an-object" }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:02.000Z", + cwd: CWD, + message: { content: [{ type: "tool_use", name: "Bash", input: { command: "echo hi" } }] }, + }, + ]; + const payload = transform(records); + expect(payload).not.toBeNull(); + if (payload === null) return; + expect(payload.events.map((e) => e.type)).toEqual([ + "session_started", + "command_executed", + "session_ended", + ]); + }); +}); diff --git a/packages/core/src/adapters/claude-code/transcript-importer.ts b/packages/core/src/adapters/claude-code/transcript-importer.ts new file mode 100644 index 00000000..80245a25 --- /dev/null +++ b/packages/core/src/adapters/claude-code/transcript-importer.ts @@ -0,0 +1,229 @@ +import { type PrefixedId, prefixedUlid } from "../../ids/ulid.js"; +import type { Event } from "../../schemas/event.schema.js"; +import type { Manifest } from "../../schemas/manifest.schema.js"; +import type { SessionImportPayload } from "../../schemas/session-import.schema.js"; + +/** + * The `source` string stamped on every event derived from a Claude Code + * native transcript, and the matching session `source.kind`. + */ +export const CLAUDE_IMPORT_SOURCE = "claude-code-import"; + +/** + * One parsed line of a Claude Code native transcript + * (`~/.claude/projects//.jsonl`). The shape is the + * vendor's internal message log, not Basou's event schema, so every field + * is read defensively — unknown record types and missing fields are skipped + * rather than rejected (transcripts are an undocumented format that may gain + * fields between Claude Code releases). + */ +export type ClaudeTranscriptRecord = Record; + +/** Options for {@link claudeTranscriptToImportPayload}. */ +export type ClaudeTranscriptToPayloadOptions = { + /** Workspace id of the target Basou workspace (from its manifest). */ + workspaceId: Manifest["workspace"]["id"]; +}; + +/** + * Transform a Claude Code native transcript into a Basou + * {@link SessionImportPayload}, ready to hand to `importSessionFromJson`. + * + * This is a pure function: no disk or environment access. It DERIVES Basou's + * provenance-level events from the transcript's message-level records, rather + * than mapping one-to-one: + * + * - `session_started` / `session_ended` from the first / last timestamped record. + * - `command_executed` from each `Bash` tool use, recorded as `bash -c ""` + * (the transcript carries the shell line, not a parsed argv). + * - `file_changed` from each `Edit` / `Write` / `NotebookEdit` tool use. + * + * Exit codes and per-command durations are not present in the transcript, so + * `command_executed.exit_code` is `null` and `duration_ms` is `0`. + * + * Returns `null` when the transcript has no timestamped records, or no + * observable command / file action — such sessions carry no provenance worth + * importing and are skipped by the caller. + * + * Event `id` / `session_id` are placeholders; `importSessionFromJson` mints + * fresh ids on the way in. They are valid-by-construction so the payload + * still passes `SessionImportPayloadSchema` validation upstream. + */ +export function claudeTranscriptToImportPayload( + records: ReadonlyArray, + options: ClaudeTranscriptToPayloadOptions, +): SessionImportPayload | null { + const placeholderSessionId = prefixedUlid("ses"); + const derived: Event[] = []; + const relatedFiles = new Set(); + // Real transcripts are NOT strictly ordered by timestamp on disk + // (sidechains and async writes interleave), so file order cannot be + // trusted. Track the earliest / latest timestamp explicitly, and order the + // derived events by occurred_at below. + let minTs: string | undefined; + let maxTs: string | undefined; + let workingDir: string | undefined; + let claudeSessionId: string | undefined; + + for (const record of records) { + const ts = readString(record.timestamp); + if (ts === undefined) continue; + if (minTs === undefined || Date.parse(ts) < Date.parse(minTs)) minTs = ts; + if (maxTs === undefined || Date.parse(ts) > Date.parse(maxTs)) maxTs = ts; + if (workingDir === undefined) workingDir = readString(record.cwd); + if (claudeSessionId === undefined) claudeSessionId = readString(record.sessionId); + + if (readString(record.type) !== "assistant") continue; + const cwd = readString(record.cwd) ?? workingDir ?? "."; + for (const item of toolUses(record)) { + const name = readString(item.name); + const input = isObject(item.input) ? item.input : undefined; + if (input === undefined) continue; + + if (name === "Bash") { + const command = readString(input.command); + if (command !== undefined) { + derived.push(commandExecutedEvent(ts, placeholderSessionId, command, cwd)); + } + continue; + } + + if (name === "Edit" || name === "Write" || name === "NotebookEdit") { + const path = readString(input.file_path) ?? readString(input.notebook_path); + if (path !== undefined) { + // Edit / NotebookEdit mutate an existing file; Write is treated as a + // creation. The transcript does not reliably distinguish a Write + // that overwrites, so "added" is the conservative default. + const changeType = name === "Write" ? "added" : "modified"; + relatedFiles.add(path); + derived.push(fileChangedEvent(ts, placeholderSessionId, path, changeType)); + } + } + } + } + + if (minTs === undefined || maxTs === undefined) return null; + if (derived.length === 0) return null; + + // Order derived events by occurred_at so the assembled stream is + // non-decreasing — importSessionFromJson rejects out-of-order events. + // Array.prototype.sort is stable, so same-timestamp events keep their + // transcript order. + derived.sort((a, b) => Date.parse(a.occurred_at) - Date.parse(b.occurred_at)); + + const events: Event[] = [ + sessionStartedEvent(minTs, placeholderSessionId), + ...derived, + sessionEndedEvent(maxTs, placeholderSessionId), + ]; + + const label = + claudeSessionId !== undefined ? `claude-code import ${claudeSessionId}` : "claude-code import"; + + const payload: SessionImportPayload = { + schema_version: "0.1.0", + session: { + label, + workspace_id: options.workspaceId, + source: { kind: CLAUDE_IMPORT_SOURCE, version: "0.1.0" }, + started_at: minTs, + ended_at: maxTs, + // Validated against the canonical enum here; importSessionFromJson + // overwrites it with the literal "imported" regardless. + status: "imported", + working_directory: workingDir ?? ".", + invocation: { command: "claude", args: [], exit_code: null }, + related_files: [...relatedFiles].sort(), + summary: null, + }, + events, + }; + return payload; +} + +// --- event builders ------------------------------------------------------- + +function baseEvent( + occurredAt: string, + sessionId: PrefixedId<"ses">, +): { + schema_version: "0.1.0"; + id: PrefixedId<"evt">; + session_id: PrefixedId<"ses">; + occurred_at: string; + source: string; +} { + return { + schema_version: "0.1.0", + id: prefixedUlid("evt"), + session_id: sessionId, + occurred_at: occurredAt, + source: CLAUDE_IMPORT_SOURCE, + }; +} + +function sessionStartedEvent(occurredAt: string, sessionId: PrefixedId<"ses">): Event { + return { ...baseEvent(occurredAt, sessionId), type: "session_started" }; +} + +function sessionEndedEvent(occurredAt: string, sessionId: PrefixedId<"ses">): Event { + return { ...baseEvent(occurredAt, sessionId), type: "session_ended" }; +} + +function commandExecutedEvent( + occurredAt: string, + sessionId: PrefixedId<"ses">, + command: string, + cwd: string, +): Event { + return { + ...baseEvent(occurredAt, sessionId), + type: "command_executed", + command: "bash", + args: ["-c", command], + cwd, + exit_code: null, + duration_ms: 0, + }; +} + +function fileChangedEvent( + occurredAt: string, + sessionId: PrefixedId<"ses">, + path: string, + changeType: "added" | "modified", +): Event { + return { + ...baseEvent(occurredAt, sessionId), + type: "file_changed", + path, + change_type: changeType, + }; +} + +// --- defensive readers ---------------------------------------------------- + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Extract the `tool_use` items from an assistant record's + * `message.content[]`. Returns an empty array for any record that does not + * match the expected nesting, so callers can iterate unconditionally. + */ +function toolUses(record: ClaudeTranscriptRecord): Array> { + const message = isObject(record.message) ? record.message : undefined; + const content = message !== undefined && Array.isArray(message.content) ? message.content : []; + const result: Array> = []; + for (const item of content) { + if (isObject(item) && readString(item.type) === "tool_use") { + result.push(item); + } + } + return result; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b11bea49..5f3aba9f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,9 +4,15 @@ */ export const BASOU_CORE_VERSION = "0.1.0"; -export type { CommandLookup } from "./adapters/claude-code/index.js"; +export type { + ClaudeTranscriptRecord, + ClaudeTranscriptToPayloadOptions, + CommandLookup, +} from "./adapters/claude-code/index.js"; export { + CLAUDE_IMPORT_SOURCE, claudeCodeAdapterMetadata, + claudeTranscriptToImportPayload, resolveClaudeCodeCommand, summarizeAdapterOutput, } from "./adapters/claude-code/index.js"; diff --git a/packages/core/src/schemas/session.schema.ts b/packages/core/src/schemas/session.schema.ts index d87283f0..94310565 100644 --- a/packages/core/src/schemas/session.schema.ts +++ b/packages/core/src/schemas/session.schema.ts @@ -21,9 +21,18 @@ export const SessionStatusSchema = z.enum([ /** Inferred runtime type for {@link SessionStatusSchema}. */ export type SessionStatus = z.infer; -/** Source kind that produced the session. */ +/** + * Source kind that produced the session. + * + * - `claude-code-adapter` — a live `basou run claude-code` process wrap. + * - `claude-code-import` — derived after the fact from a Claude Code native + * transcript (`~/.claude/projects/*.jsonl`) by `basou import claude-code`. + * - `import` — a round-trip of a Basou-format export (`basou session import`). + * - `human` / `terminal` — manually-authored / terminal-recorded sessions. + */ export const SessionSourceKindSchema = z.enum([ "claude-code-adapter", + "claude-code-import", "human", "import", "terminal",