Created
June 17, 2026 13:11
-
-
Save rblaine95/50320fa9937109346e9a2316cb7b5d80 to your computer and use it in GitHub Desktop.
Claude Code -> Oh-My-Pi session importer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bun | |
| /** | |
| * claude-to-omp.ts: Convert Claude Code session transcripts into oh-my-pi (omp) v3 sessions. | |
| * | |
| * WHY THIS EXISTS | |
| * --------------- | |
| * Claude Code stores one JSONL transcript per session under: | |
| * ~/.claude/projects/<abs-path-encoded>/<uuid>.jsonl | |
| * Every line is a flat, `uuid`/`parentUuid`-linked event using Anthropic message | |
| * shapes (`{type:"text"|"thinking"|"tool_use"|"tool_result"|"image", ...}`). | |
| * | |
| * omp stores sessions under: | |
| * ~/.omp/agent/sessions/<home-relative-encoded-cwd>/<timestamp>_<sessionId>.jsonl | |
| * Line 1 is a `{"type":"session","version":3,...}` header; the remaining lines are | |
| * tree entries (`{"type":"message","id","parentId","message":{...}}`) plus state | |
| * changes. omp's loader returns an empty session for any file whose first entry is | |
| * not a session header, which is why Claude transcripts cannot simply be dropped | |
| * into omp's store; they must be converted. | |
| * | |
| * This script performs that conversion, preserving the conversation tree, thinking | |
| * blocks, tool calls, tool results, and inline base64 images. Tool names and inputs | |
| * are kept verbatim (NOT remapped onto omp's native tool surface) so the imported | |
| * history stays faithful to what actually happened. | |
| * | |
| * USAGE | |
| * ----- | |
| * bun claude-to-omp.ts [options] [paths...] | |
| * | |
| * Options: | |
| * --claude-dir <dir> Claude projects root (default: ~/.claude/projects) | |
| * --out <dir> omp sessions root (default: ~/.omp/agent/sessions) | |
| * --dry-run Parse and report only; write nothing | |
| * --include-sidechains Include subagent (Task) sidechain messages (default: skip) | |
| * --overwrite Re-import even if a converted copy already exists | |
| * --limit <n> Convert at most n transcripts | |
| * -h, --help Show this help and exit | |
| * | |
| * Positional paths (optional): specific `.jsonl` files or directories to convert. | |
| * When supplied, they replace the default scan of --claude-dir. | |
| * | |
| * IDEMPOTENCY | |
| * ----------- | |
| * Each imported session records `parentSession: "claude-import:<sourceSessionId>"` | |
| * in its header. Before writing, the script scans the destination project directory | |
| * for an existing import of the same source session and skips it unless --overwrite | |
| * is set, so re-running never duplicates sessions. | |
| * | |
| * Requires Bun (uses `Bun.file`, `Bun.write`, `Bun.Glob`, and `Bun.randomUUIDv7`). | |
| */ | |
| import { randomBytes } from "node:crypto"; | |
| import { existsSync, readdirSync, rmSync, statSync } from "node:fs"; | |
| import { homedir, tmpdir } from "node:os"; | |
| import { basename, join, resolve, sep } from "node:path"; | |
| import { Glob, randomUUIDv7 } from "bun"; | |
| // --------------------------------------------------------------------------- | |
| // Source schema (Claude Code transcript lines) | |
| // --------------------------------------------------------------------------- | |
| interface ClaudeImageSource { | |
| type?: string; | |
| media_type?: string; | |
| data?: string; | |
| url?: string; | |
| file_id?: string; | |
| } | |
| interface ClaudeBlock { | |
| type?: string; | |
| // text | |
| text?: string; | |
| // thinking | |
| thinking?: string; | |
| signature?: string; | |
| // tool_use | |
| id?: string; | |
| name?: string; | |
| input?: unknown; | |
| // tool_result | |
| tool_use_id?: string; | |
| content?: unknown; | |
| is_error?: boolean; | |
| // image | |
| source?: ClaudeImageSource; | |
| } | |
| interface ClaudeUsage { | |
| input_tokens?: number; | |
| output_tokens?: number; | |
| cache_creation_input_tokens?: number; | |
| cache_read_input_tokens?: number; | |
| } | |
| interface ClaudeMessage { | |
| role?: string; | |
| model?: string; | |
| content?: string | ClaudeBlock[]; | |
| usage?: ClaudeUsage; | |
| stop_reason?: string; | |
| } | |
| interface ClaudeEntry { | |
| type?: string; | |
| uuid?: string; | |
| parentUuid?: string | null; | |
| timestamp?: string; | |
| message?: ClaudeMessage; | |
| isSidechain?: boolean; | |
| isMeta?: boolean; | |
| cwd?: string; | |
| sessionId?: string; | |
| aiTitle?: string; | |
| summary?: string; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Target schema (omp v3 session) | |
| // --------------------------------------------------------------------------- | |
| interface OmpTextBlock { | |
| type: "text"; | |
| text: string; | |
| } | |
| interface OmpThinkingBlock { | |
| type: "thinking"; | |
| thinking: string; | |
| thinkingSignature: string; | |
| } | |
| interface OmpToolCallBlock { | |
| type: "toolCall"; | |
| id: string; | |
| name: string; | |
| arguments: unknown; | |
| } | |
| interface OmpImageBlock { | |
| type: "image"; | |
| data: string; | |
| mimeType: string; | |
| } | |
| type OmpContentBlock = OmpTextBlock | OmpImageBlock; | |
| type OmpRichBlock = | |
| | OmpTextBlock | |
| | OmpThinkingBlock | |
| | OmpToolCallBlock | |
| | OmpImageBlock; | |
| interface OmpUsageCost { | |
| input: number; | |
| output: number; | |
| cacheRead: number; | |
| cacheWrite: number; | |
| total: number; | |
| } | |
| interface OmpUsage { | |
| input: number; | |
| output: number; | |
| cacheRead: number; | |
| cacheWrite: number; | |
| totalTokens: number; | |
| cost: OmpUsageCost; | |
| } | |
| interface OmpUserMessage { | |
| role: "user"; | |
| content: OmpRichBlock[]; | |
| attribution: "user"; | |
| timestamp: number; | |
| } | |
| interface OmpAssistantMessage { | |
| role: "assistant"; | |
| content: OmpRichBlock[]; | |
| api: string; | |
| provider: string; | |
| model: string; | |
| usage: OmpUsage; | |
| stopReason?: string; | |
| timestamp: number; | |
| } | |
| interface OmpToolResultMessage { | |
| role: "toolResult"; | |
| toolCallId: string; | |
| toolName: string; | |
| content: OmpContentBlock[]; | |
| isError: boolean; | |
| timestamp: number; | |
| } | |
| type OmpMessage = OmpUserMessage | OmpAssistantMessage | OmpToolResultMessage; | |
| interface OmpMessageEntry { | |
| type: "message"; | |
| id: string; | |
| parentId: string | null; | |
| timestamp: string; | |
| message: OmpMessage; | |
| } | |
| interface OmpModelChangeEntry { | |
| type: "model_change"; | |
| id: string; | |
| parentId: string | null; | |
| timestamp: string; | |
| model: string; | |
| } | |
| type OmpEntry = OmpMessageEntry | OmpModelChangeEntry; | |
| interface OmpHeader { | |
| type: "session"; | |
| version: 3; | |
| id: string; | |
| timestamp: string; | |
| cwd: string; | |
| title?: string; | |
| titleSource: "auto"; | |
| parentSession: string; | |
| } | |
| interface ConvertedSession { | |
| header: OmpHeader; | |
| entries: OmpEntry[]; | |
| messageCount: number; | |
| malformed: number; | |
| claudeSessionId: string; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // CLI options | |
| // --------------------------------------------------------------------------- | |
| interface Options { | |
| claudeDir: string; | |
| outDir: string; | |
| dryRun: boolean; | |
| includeSidechains: boolean; | |
| overwrite: boolean; | |
| revert: boolean; | |
| limit: number; | |
| paths: string[]; | |
| help: boolean; | |
| } | |
| const HELP = `claude-to-omp: import Claude Code transcripts into oh-my-pi sessions | |
| Usage: | |
| bun claude-to-omp.ts [options] [paths...] | |
| Options: | |
| --write Perform the import (default is a dry run that writes nothing) | |
| --claude-dir <dir> Claude projects root (default: ~/.claude/projects) | |
| --out <dir> omp sessions root (default: ~/.omp/agent/sessions) | |
| --include-sidechains Include subagent (Task) sidechain messages (default: skip) | |
| --overwrite Re-import even if a converted copy already exists | |
| --revert Remove imported sessions under --out (with --write, deletes them) | |
| --limit <n> Convert at most n transcripts | |
| --dry-run Force a dry run (already the default) | |
| -h, --help Show this help and exit | |
| By default nothing is written; review the report, then re-run with --write. | |
| Positional paths replace the default scan of --claude-dir; each may be a .jsonl | |
| file or a directory to walk for *.jsonl transcripts.`; | |
| function defaultOptions(): Options { | |
| return { | |
| claudeDir: join(homedir(), ".claude", "projects"), | |
| outDir: join(homedir(), ".omp", "agent", "sessions"), | |
| dryRun: true, | |
| includeSidechains: false, | |
| overwrite: false, | |
| revert: false, | |
| limit: Infinity, | |
| paths: [], | |
| help: false, | |
| }; | |
| } | |
| function parseArgs(argv: string[]): Options { | |
| const opts = defaultOptions(); | |
| for (let i = 0; i < argv.length; i++) { | |
| const arg = argv[i]; | |
| switch (arg) { | |
| case "-h": | |
| case "--help": | |
| opts.help = true; | |
| break; | |
| case "--write": | |
| opts.dryRun = false; | |
| break; | |
| case "--dry-run": | |
| opts.dryRun = true; | |
| break; | |
| case "--include-sidechains": | |
| opts.includeSidechains = true; | |
| break; | |
| case "--overwrite": | |
| opts.overwrite = true; | |
| break; | |
| case "--revert": | |
| opts.revert = true; | |
| break; | |
| case "--claude-dir": | |
| opts.claudeDir = resolve(expandHome(requireValue(argv, ++i, arg))); | |
| break; | |
| case "--out": | |
| opts.outDir = resolve(expandHome(requireValue(argv, ++i, arg))); | |
| break; | |
| case "--limit": | |
| opts.limit = parseLimit(requireValue(argv, ++i, arg)); | |
| break; | |
| default: | |
| if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`); | |
| opts.paths.push(resolve(expandHome(arg))); | |
| } | |
| } | |
| return opts; | |
| } | |
| function parseLimit(raw: string): number { | |
| const n = Number.parseInt(raw, 10); | |
| if (!Number.isFinite(n) || n <= 0) | |
| throw new Error(`--limit expects a positive integer, got "${raw}"`); | |
| return n; | |
| } | |
| function requireValue(argv: string[], index: number, flag: string): string { | |
| const value = argv[index]; | |
| if (value === undefined) throw new Error(`${flag} expects a value`); | |
| return value; | |
| } | |
| function expandHome(p: string): string { | |
| if (p === "~") return homedir(); | |
| if (p.startsWith(`~${sep}`) || p.startsWith("~/")) | |
| return join(homedir(), p.slice(2)); | |
| return p; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Path / encoding helpers | |
| // --------------------------------------------------------------------------- | |
| /** Replace path separators and colons with `-`, matching omp's directory encoding. */ | |
| function replaceSeps(s: string): string { | |
| return s.replace(/[/\\:]/g, "-"); | |
| } | |
| function stripTrailingSep(p: string): string { | |
| return p.replace(/[/\\]+$/, ""); | |
| } | |
| /** | |
| * Reproduce omp's session directory encoding for a given cwd: | |
| * - the home directory itself -> "-" | |
| * - inside home -> "-<relative>" (seps -> "-") | |
| * - inside the OS temp root -> "-tmp-<relative>" | |
| * - anywhere else (legacy absolute path) -> "--<abs-without-leading-slash>--" | |
| */ | |
| function encodeCwd(cwd: string): string { | |
| const home = stripTrailingSep(homedir()); | |
| const tmp = stripTrailingSep(tmpdir()); | |
| const dir = stripTrailingSep(cwd); | |
| if (dir === home) return "-"; | |
| if (dir.startsWith(home + sep)) | |
| return `-${replaceSeps(dir.slice(home.length + 1))}`; | |
| if (dir === tmp) return "-tmp-"; | |
| if (dir.startsWith(tmp + sep)) | |
| return `-tmp-${replaceSeps(dir.slice(tmp.length + 1))}`; | |
| return `--${replaceSeps(dir.replace(/^[/\\]/, ""))}--`; | |
| } | |
| /** ISO timestamp with `:` and `.` replaced by `-`, as omp uses in filenames. */ | |
| function timestampForFilename(iso: string): string { | |
| return new Date(iso).toISOString().replace(/:/g, "-").replace(/\./g, "-"); | |
| } | |
| function isoToMs(iso: string | undefined): number { | |
| if (!iso) return Date.now(); | |
| const ms = Date.parse(iso); | |
| return Number.isFinite(ms) ? ms : Date.now(); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Block conversion | |
| // --------------------------------------------------------------------------- | |
| function claudeImageToOmp( | |
| source: ClaudeImageSource | undefined, | |
| ): OmpContentBlock { | |
| if (source && source.type === "base64" && typeof source.data === "string") { | |
| return { | |
| type: "image", | |
| data: source.data, | |
| mimeType: source.media_type ?? "image/png", | |
| }; | |
| } | |
| const ref = source?.url ?? source?.file_id ?? ""; | |
| return { type: "text", text: ref ? `[image: ${ref}]` : "[image]" }; | |
| } | |
| /** Convert assistant/user content blocks (everything except tool_result). */ | |
| function convertRichBlocks( | |
| blocks: ClaudeBlock[], | |
| toolNameById: Map<string, string>, | |
| ): OmpRichBlock[] { | |
| const out: OmpRichBlock[] = []; | |
| for (const block of blocks) { | |
| switch (block.type) { | |
| case "text": | |
| out.push({ type: "text", text: block.text ?? "" }); | |
| break; | |
| case "thinking": | |
| out.push({ | |
| type: "thinking", | |
| thinking: block.thinking ?? "", | |
| thinkingSignature: block.signature ?? "", | |
| }); | |
| break; | |
| case "tool_use": | |
| if (typeof block.id === "string" && typeof block.name === "string") { | |
| toolNameById.set(block.id, block.name); | |
| out.push({ | |
| type: "toolCall", | |
| id: block.id, | |
| name: block.name, | |
| arguments: block.input ?? {}, | |
| }); | |
| } | |
| break; | |
| case "image": | |
| out.push(claudeImageToOmp(block.source)); | |
| break; | |
| // tool_result is handled by the caller; unknown block types are dropped. | |
| } | |
| } | |
| return out; | |
| } | |
| /** Normalize a tool_result block's `content` into omp toolResult content blocks. */ | |
| function normalizeToolResultContent(content: unknown): OmpContentBlock[] { | |
| if (typeof content === "string") return [{ type: "text", text: content }]; | |
| if (Array.isArray(content)) { | |
| const out: OmpContentBlock[] = []; | |
| for (const item of content as ClaudeBlock[]) { | |
| if (item?.type === "text") | |
| out.push({ type: "text", text: item.text ?? "" }); | |
| else if (item?.type === "image") out.push(claudeImageToOmp(item.source)); | |
| else | |
| out.push({ | |
| type: "text", | |
| text: typeof item === "string" ? item : JSON.stringify(item), | |
| }); | |
| } | |
| return out.length > 0 ? out : [{ type: "text", text: "" }]; | |
| } | |
| if (content == null) return [{ type: "text", text: "" }]; | |
| return [{ type: "text", text: JSON.stringify(content) }]; | |
| } | |
| function mapStopReason(reason: string | undefined): string | undefined { | |
| switch (reason) { | |
| case undefined: | |
| return undefined; | |
| case "tool_use": | |
| return "toolUse"; | |
| case "end_turn": | |
| return "endTurn"; | |
| case "max_tokens": | |
| return "maxTokens"; | |
| case "stop_sequence": | |
| return "stopSequence"; | |
| default: | |
| return reason; | |
| } | |
| } | |
| function mapUsage(usage: ClaudeUsage | undefined): OmpUsage { | |
| const input = usage?.input_tokens ?? 0; | |
| const output = usage?.output_tokens ?? 0; | |
| const cacheRead = usage?.cache_read_input_tokens ?? 0; | |
| const cacheWrite = usage?.cache_creation_input_tokens ?? 0; | |
| return { | |
| input, | |
| output, | |
| cacheRead, | |
| cacheWrite, | |
| totalTokens: input + output + cacheRead + cacheWrite, | |
| // Pricing is not available offline; cost is left at zero rather than guessed. | |
| cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, | |
| }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Transcript conversion | |
| // --------------------------------------------------------------------------- | |
| interface ParsedTranscript { | |
| entries: ClaudeEntry[]; | |
| malformed: number; | |
| } | |
| function parseTranscript(raw: string): ParsedTranscript { | |
| const entries: ClaudeEntry[] = []; | |
| let malformed = 0; | |
| for (const line of raw.split(/\r?\n/)) { | |
| const trimmed = line.trim(); | |
| if (!trimmed) continue; | |
| try { | |
| entries.push(JSON.parse(trimmed) as ClaudeEntry); | |
| } catch { | |
| malformed++; | |
| } | |
| } | |
| return { entries, malformed }; | |
| } | |
| interface TranscriptMeta { | |
| parentByUuid: Map<string, string | null>; | |
| cwd: string; | |
| sessionId: string; | |
| firstTimestamp: string; | |
| aiTitle: string; | |
| firstUserText: string; | |
| firstModel: string; | |
| } | |
| // Map every uuid -> its parentUuid (including skipped lines) so children of a | |
| // dropped entry can attach to the nearest surviving ancestor; also gather the | |
| // session-level fields used to build the header. | |
| function collectMeta( | |
| entries: ClaudeEntry[], | |
| fallbackCwd: string, | |
| ): TranscriptMeta { | |
| const parentByUuid = new Map<string, string | null>(); | |
| let cwd = ""; | |
| let sessionId = ""; | |
| let firstTimestamp = ""; | |
| let aiTitle = ""; | |
| let firstUserText = ""; | |
| let firstModel = ""; | |
| for (const entry of entries) { | |
| if (typeof entry.uuid === "string") | |
| parentByUuid.set( | |
| entry.uuid, | |
| typeof entry.parentUuid === "string" ? entry.parentUuid : null, | |
| ); | |
| if (!cwd && entry.cwd) cwd = entry.cwd; | |
| if (!sessionId && entry.sessionId) sessionId = entry.sessionId; | |
| if (!firstTimestamp && entry.timestamp) firstTimestamp = entry.timestamp; | |
| if ( | |
| entry.type === "ai-title" && | |
| typeof entry.aiTitle === "string" && | |
| entry.aiTitle | |
| ) | |
| aiTitle = entry.aiTitle; | |
| if ( | |
| !firstModel && | |
| entry.message?.role === "assistant" && | |
| entry.message.model | |
| ) | |
| firstModel = entry.message.model; | |
| if (!firstUserText && entry.message?.role === "user" && !entry.isMeta) | |
| firstUserText = firstTextOf(entry.message.content); | |
| } | |
| return { | |
| parentByUuid, | |
| cwd: cwd || fallbackCwd, | |
| sessionId, | |
| firstTimestamp: firstTimestamp || new Date().toISOString(), | |
| aiTitle, | |
| firstUserText, | |
| firstModel, | |
| }; | |
| } | |
| /** Mint unique 8-hex entry ids (only required to be unique within one file). */ | |
| function createIdFactory(): () => string { | |
| const used = new Set<string>(); | |
| return () => { | |
| let id = randomBytes(4).toString("hex"); | |
| while (used.has(id)) id = randomBytes(4).toString("hex"); | |
| used.add(id); | |
| return id; | |
| }; | |
| } | |
| /** Resolve a Claude parentUuid to an emitted omp id, walking past skipped ancestors. */ | |
| function resolveParentId( | |
| parentUuid: string | null | undefined, | |
| parentByUuid: Map<string, string | null>, | |
| lastIdByUuid: Map<string, string>, | |
| rootId: string | null, | |
| ): string | null { | |
| let cursor: string | null = | |
| typeof parentUuid === "string" ? parentUuid : null; | |
| while (cursor !== null && !lastIdByUuid.has(cursor)) | |
| cursor = parentByUuid.get(cursor) ?? null; | |
| if (cursor === null) return rootId; | |
| return lastIdByUuid.get(cursor) ?? rootId; | |
| } | |
| function buildAssistantMessages( | |
| message: ClaudeMessage, | |
| toolNameById: Map<string, string>, | |
| firstModel: string, | |
| ms: number, | |
| ): OmpMessage[] { | |
| const blocks = Array.isArray(message.content) | |
| ? convertRichBlocks(message.content, toolNameById) | |
| : typeof message.content === "string" | |
| ? [{ type: "text", text: message.content } as OmpRichBlock] | |
| : []; | |
| if (blocks.length === 0) return []; | |
| return [ | |
| { | |
| role: "assistant", | |
| content: blocks, | |
| api: "anthropic-messages", | |
| provider: "anthropic", | |
| model: message.model ?? firstModel ?? "unknown", | |
| usage: mapUsage(message.usage), | |
| stopReason: mapStopReason(message.stop_reason), | |
| timestamp: ms, | |
| }, | |
| ]; | |
| } | |
| // tool_result blocks each become their own omp toolResult message; remaining | |
| // text/image blocks collapse into one user message. | |
| function buildUserMessages( | |
| message: ClaudeMessage, | |
| toolNameById: Map<string, string>, | |
| ms: number, | |
| ): OmpMessage[] { | |
| if (typeof message.content === "string") { | |
| return message.content.length > 0 | |
| ? [ | |
| { | |
| role: "user", | |
| content: [{ type: "text", text: message.content }], | |
| attribution: "user", | |
| timestamp: ms, | |
| }, | |
| ] | |
| : []; | |
| } | |
| if (!Array.isArray(message.content)) return []; | |
| const built: OmpMessage[] = []; | |
| const others: ClaudeBlock[] = []; | |
| for (const block of message.content) { | |
| if (block?.type === "tool_result") { | |
| const toolCallId = block.tool_use_id ?? ""; | |
| built.push({ | |
| role: "toolResult", | |
| toolCallId, | |
| toolName: toolNameById.get(toolCallId) ?? "tool", | |
| content: normalizeToolResultContent(block.content), | |
| isError: block.is_error === true, | |
| timestamp: ms, | |
| }); | |
| } else { | |
| others.push(block); | |
| } | |
| } | |
| const otherBlocks = convertRichBlocks(others, toolNameById); | |
| if (otherBlocks.length > 0) | |
| built.push({ | |
| role: "user", | |
| content: otherBlocks, | |
| attribution: "user", | |
| timestamp: ms, | |
| }); | |
| return built; | |
| } | |
| function buildMessages( | |
| entry: ClaudeEntry, | |
| toolNameById: Map<string, string>, | |
| firstModel: string, | |
| ): OmpMessage[] { | |
| const message = entry.message; | |
| if (!message) return []; | |
| const ms = isoToMs(entry.timestamp); | |
| if (message.role === "assistant") | |
| return buildAssistantMessages(message, toolNameById, firstModel, ms); | |
| if (message.role === "user") | |
| return buildUserMessages(message, toolNameById, ms); | |
| const text = firstTextOf(message.content); | |
| return text | |
| ? [ | |
| { | |
| role: "user", | |
| content: [{ type: "text", text }], | |
| attribution: "user", | |
| timestamp: ms, | |
| }, | |
| ] | |
| : []; | |
| } | |
| function buildHeader(meta: TranscriptMeta, headerTimestamp: string): OmpHeader { | |
| const title = ( | |
| meta.aiTitle || | |
| meta.firstUserText || | |
| "Imported Claude session" | |
| ).slice(0, 120); | |
| return { | |
| type: "session", | |
| version: 3, | |
| id: randomUUIDv7(isoToMs(meta.firstTimestamp)), | |
| timestamp: headerTimestamp, | |
| cwd: meta.cwd, | |
| title, | |
| titleSource: "auto", | |
| parentSession: `claude-import:${meta.sessionId || "unknown"}`, | |
| }; | |
| } | |
| function convertTranscript( | |
| raw: string, | |
| fallbackCwd: string, | |
| includeSidechains: boolean, | |
| ): ConvertedSession | null { | |
| const { entries: claudeEntries, malformed } = parseTranscript(raw); | |
| if (claudeEntries.length === 0) return null; | |
| const meta = collectMeta(claudeEntries, fallbackCwd); | |
| const newId = createIdFactory(); | |
| const entries: OmpEntry[] = []; | |
| const lastIdByUuid = new Map<string, string>(); | |
| const toolNameById = new Map<string, string>(); | |
| const headerTimestamp = new Date(isoToMs(meta.firstTimestamp)).toISOString(); | |
| // A leading model_change makes the imported model visible and acts as the tree root. | |
| let rootId: string | null = null; | |
| if (meta.firstModel) { | |
| rootId = newId(); | |
| entries.push({ | |
| type: "model_change", | |
| id: rootId, | |
| parentId: null, | |
| timestamp: headerTimestamp, | |
| model: `anthropic/${meta.firstModel}`, | |
| }); | |
| } | |
| let messageCount = 0; | |
| for (const entry of claudeEntries) { | |
| if (!entry.message || entry.isMeta) continue; | |
| if (entry.isSidechain && !includeSidechains) continue; | |
| const built = buildMessages(entry, toolNameById, meta.firstModel); | |
| if (built.length === 0) continue; | |
| const iso = new Date(isoToMs(entry.timestamp)).toISOString(); | |
| let parentId = resolveParentId( | |
| entry.parentUuid, | |
| meta.parentByUuid, | |
| lastIdByUuid, | |
| rootId, | |
| ); | |
| for (const message of built) { | |
| const id = newId(); | |
| entries.push({ type: "message", id, parentId, timestamp: iso, message }); | |
| parentId = id; | |
| messageCount++; | |
| } | |
| if (typeof entry.uuid === "string") lastIdByUuid.set(entry.uuid, parentId); | |
| } | |
| if (messageCount === 0) return null; | |
| return { | |
| header: buildHeader(meta, headerTimestamp), | |
| entries, | |
| messageCount, | |
| malformed, | |
| claudeSessionId: meta.sessionId || "unknown", | |
| }; | |
| } | |
| function firstTextOf(content: string | ClaudeBlock[] | undefined): string { | |
| if (typeof content === "string") return content.trim(); | |
| if (Array.isArray(content)) { | |
| for (const block of content) { | |
| if (block?.type === "text" && block.text) return block.text.trim(); | |
| } | |
| } | |
| return ""; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Filesystem | |
| // --------------------------------------------------------------------------- | |
| function discoverTranscripts(opts: Options): string[] { | |
| const found: string[] = []; | |
| const roots = opts.paths.length > 0 ? opts.paths : [opts.claudeDir]; | |
| for (const root of roots) { | |
| if (!existsSync(root)) { | |
| console.warn(`! skip (not found): ${root}`); | |
| continue; | |
| } | |
| if (statSync(root).isDirectory()) { | |
| const glob = new Glob("**/*.jsonl"); | |
| for (const rel of glob.scanSync(root)) found.push(join(root, rel)); | |
| } else if (root.endsWith(".jsonl")) { | |
| found.push(root); | |
| } | |
| } | |
| // Stable, oldest first by mtime so the omp picker lists imports chronologically. | |
| return [...new Set(found)].sort( | |
| (a, b) => statSync(a).mtimeMs - statSync(b).mtimeMs, | |
| ); | |
| } | |
| function sessionFilePath(opts: Options, header: OmpHeader): string { | |
| const dir = join(opts.outDir, encodeCwd(header.cwd)); | |
| return join( | |
| dir, | |
| `${timestampForFilename(header.timestamp)}_${header.id}.jsonl`, | |
| ); | |
| } | |
| /** Read and parse a session file's header line; null if it is not a session header. */ | |
| async function readSessionHeader( | |
| path: string, | |
| ): Promise<Partial<OmpHeader> | null> { | |
| try { | |
| const head = await Bun.file(path).slice(0, 8192).text(); | |
| const firstLine = head.split("\n", 1)[0]; | |
| if (!firstLine) return null; | |
| const parsed = JSON.parse(firstLine) as Partial<OmpHeader>; | |
| return parsed.type === "session" ? parsed : null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| /** Find a prior import of the same Claude session, if any (for idempotency). */ | |
| async function findExistingImport( | |
| opts: Options, | |
| header: OmpHeader, | |
| ): Promise<string | null> { | |
| const dir = join(opts.outDir, encodeCwd(header.cwd)); | |
| if (!existsSync(dir)) return null; | |
| for (const name of readdirSync(dir)) { | |
| if (!name.endsWith(".jsonl")) continue; | |
| const path = join(dir, name); | |
| const parsed = await readSessionHeader(path); | |
| if (parsed?.parentSession === header.parentSession) return path; | |
| } | |
| return null; | |
| } | |
| function listSessionFiles(root: string): string[] { | |
| if (!existsSync(root)) return []; | |
| const glob = new Glob("**/*.jsonl"); | |
| return Array.from(glob.scanSync(root), (rel) => join(root, rel)); | |
| } | |
| /** Remove every session previously written by this importer (header marked claude-import:). */ | |
| async function revertImports(opts: Options): Promise<void> { | |
| const imported: string[] = []; | |
| for (const path of listSessionFiles(opts.outDir)) { | |
| const header = await readSessionHeader(path); | |
| if (header?.parentSession?.startsWith("claude-import:")) | |
| imported.push(path); | |
| } | |
| console.log( | |
| `${opts.dryRun ? "[dry-run] " : ""}Found ${imported.length} imported session(s) under ${opts.outDir}`, | |
| ); | |
| let removed = 0; | |
| for (const path of imported) { | |
| if (opts.dryRun) { | |
| console.log(` ~ would remove ${path}`); | |
| continue; | |
| } | |
| rmSync(path, { force: true }); | |
| rmSync(path.replace(/\.jsonl$/, ""), { recursive: true, force: true }); | |
| removed++; | |
| console.log(` - removed ${path}`); | |
| } | |
| if (opts.dryRun) | |
| console.log( | |
| `\nDry run only - nothing removed. Re-run with --revert --write to delete ${imported.length} file(s).`, | |
| ); | |
| else console.log(`\nDone. removed=${removed} of ${imported.length}`); | |
| } | |
| function serializeSession(session: ConvertedSession): string { | |
| const lines = [ | |
| JSON.stringify(session.header), | |
| ...session.entries.map((e) => JSON.stringify(e)), | |
| ]; | |
| return `${lines.join("\n")}\n`; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Main | |
| // --------------------------------------------------------------------------- | |
| interface Tally { | |
| converted: number; | |
| skippedExisting: number; | |
| skippedEmpty: number; | |
| errors: number; | |
| messages: number; | |
| malformed: number; | |
| } | |
| async function processTranscript( | |
| source: string, | |
| opts: Options, | |
| tally: Tally, | |
| ): Promise<void> { | |
| const label = basename(source); | |
| try { | |
| const raw = await Bun.file(source).text(); | |
| const session = convertTranscript( | |
| raw, | |
| process.cwd(), | |
| opts.includeSidechains, | |
| ); | |
| if (!session) { | |
| tally.skippedEmpty++; | |
| console.log(` - ${label}: no convertible messages`); | |
| return; | |
| } | |
| tally.malformed += session.malformed; | |
| const existing = opts.overwrite | |
| ? null | |
| : await findExistingImport(opts, session.header); | |
| if (existing) { | |
| tally.skippedExisting++; | |
| console.log(` = ${label}: already imported -> ${existing}`); | |
| return; | |
| } | |
| const outPath = sessionFilePath(opts, session.header); | |
| if (!opts.dryRun) await Bun.write(outPath, serializeSession(session)); | |
| tally.converted++; | |
| tally.messages += session.messageCount; | |
| console.log( | |
| ` ${opts.dryRun ? "~" : "+"} ${label}: ${session.messageCount} message(s) -> ${outPath}`, | |
| ); | |
| } catch (err) { | |
| tally.errors++; | |
| console.error( | |
| ` x ${label}: ${err instanceof Error ? err.message : String(err)}`, | |
| ); | |
| } | |
| } | |
| async function runImport(opts: Options): Promise<void> { | |
| const transcripts = discoverTranscripts(opts); | |
| const sourceLabel = | |
| opts.paths.length > 0 ? opts.paths.join(", ") : opts.claudeDir; | |
| if (transcripts.length === 0) { | |
| console.log(`No transcripts found under ${sourceLabel}`); | |
| return; | |
| } | |
| console.log( | |
| `${opts.dryRun ? "[dry-run] " : ""}Converting up to ${Math.min(transcripts.length, opts.limit)} of ${transcripts.length} transcript(s)`, | |
| ); | |
| console.log(` from: ${sourceLabel}`); | |
| console.log( | |
| ` to: ${opts.outDir}${opts.dryRun ? " (dry run: nothing written; pass --write to apply)" : ""}\n`, | |
| ); | |
| const tally: Tally = { | |
| converted: 0, | |
| skippedExisting: 0, | |
| skippedEmpty: 0, | |
| errors: 0, | |
| messages: 0, | |
| malformed: 0, | |
| }; | |
| let processed = 0; | |
| for (const source of transcripts) { | |
| if (processed >= opts.limit) break; | |
| processed++; | |
| await processTranscript(source, opts, tally); | |
| } | |
| console.log( | |
| `\nDone. converted=${tally.converted} skipped(existing)=${tally.skippedExisting} skipped(empty)=${tally.skippedEmpty} errors=${tally.errors} messages=${tally.messages} malformed-lines=${tally.malformed}`, | |
| ); | |
| if (opts.dryRun) | |
| console.log( | |
| "\nDry run only - no files written. Re-run with --write to import.", | |
| ); | |
| else if (tally.converted > 0) | |
| console.log( | |
| "\nResume in omp from the matching project directory, for example: omp --resume (Tab -> all projects)", | |
| ); | |
| } | |
| async function main(): Promise<void> { | |
| const opts = parseArgs(process.argv.slice(2)); | |
| if (opts.help) { | |
| console.log(HELP); | |
| return; | |
| } | |
| if (opts.revert) { | |
| await revertImports(opts); | |
| return; | |
| } | |
| await runImport(opts); | |
| } | |
| await main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment