combobulator 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import { PATHS, MIRROR_MARKER } from '../config.js';
5
+
6
+ // Walk ~/.claude/projects/*/{sessionId}.jsonl. Return [{path, mtime}] sorted newest first.
7
+ export function listClaudeSessions() {
8
+ if (!fs.existsSync(PATHS.claudeProjects)) return [];
9
+ const out = [];
10
+ for (const projDir of fs.readdirSync(PATHS.claudeProjects)) {
11
+ const full = path.join(PATHS.claudeProjects, projDir);
12
+ let stat;
13
+ try { stat = fs.statSync(full); } catch { continue; }
14
+ if (!stat.isDirectory()) continue;
15
+ for (const f of fs.readdirSync(full)) {
16
+ if (!f.endsWith('.jsonl')) continue;
17
+ const fp = path.join(full, f);
18
+ try {
19
+ const s = fs.statSync(fp);
20
+ out.push({ path: fp, mtime: s.mtimeMs, projDir });
21
+ } catch {}
22
+ }
23
+ }
24
+ return out.sort((a, b) => b.mtime - a.mtime);
25
+ }
26
+
27
+ // Read a Claude session and emit a typed event stream that the codex sink can
28
+ // translate into native function_call / custom_tool_call events.
29
+ //
30
+ // Event kinds:
31
+ // {kind: 'user', text, ts} — real human prompt
32
+ // {kind: 'assistant', text, ts} — assistant natural-language reply
33
+ // {kind: 'tool_call', tool, input, callId, ts} — assistant tool invocation
34
+ // {kind: 'tool_result', callId, output, isError, ts} — paired tool response
35
+ //
36
+ // Tool calls and tool results are matched by Claude's tool_use_id, so the sink
37
+ // can render them as the proper Codex events with matching call_id.
38
+ export async function readClaudeSession(filePath) {
39
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
40
+ const rl = readline.createInterface({ input: stream });
41
+
42
+ const events = [];
43
+ let sessionId = path.basename(filePath, '.jsonl');
44
+ let cwd = null;
45
+ let createdAt = null;
46
+ let updatedAt = null;
47
+ let isMirror = false;
48
+ let threadName = null;
49
+
50
+ for await (const line of rl) {
51
+ if (!line.trim()) continue;
52
+ let d;
53
+ try { d = JSON.parse(line); } catch { continue; }
54
+
55
+ if (d[MIRROR_MARKER]) { isMirror = true; continue; }
56
+
57
+ if (d.sessionId && !sessionId) sessionId = d.sessionId;
58
+ if (d.cwd && !cwd) cwd = d.cwd;
59
+ if (d.timestamp) {
60
+ const t = Date.parse(d.timestamp);
61
+ if (Number.isFinite(t)) {
62
+ if (createdAt === null) createdAt = t;
63
+ updatedAt = t;
64
+ }
65
+ }
66
+
67
+ if (d.type === 'ai-title' && d.aiTitle) {
68
+ threadName = d.aiTitle;
69
+ continue;
70
+ }
71
+
72
+ const ts = Date.parse(d.timestamp) || Date.now();
73
+
74
+ if (d.type === 'user' && d.message?.role === 'user') {
75
+ // Walk content blocks. A Claude user message can be a mix of plain text
76
+ // (real prompt) and tool_result blocks (responses to prior tool calls).
77
+ // Emit each as its own event so the sink can interleave them correctly.
78
+ const content = d.message.content;
79
+ if (typeof content === 'string') {
80
+ const t = content.trim();
81
+ if (t) events.push({ kind: 'user', text: t, ts });
82
+ } else if (Array.isArray(content)) {
83
+ for (const c of content) {
84
+ if (c?.type === 'text' && c.text) {
85
+ events.push({ kind: 'user', text: c.text, ts });
86
+ } else if (c?.type === 'tool_result') {
87
+ const output = typeof c.content === 'string'
88
+ ? c.content
89
+ : Array.isArray(c.content)
90
+ ? c.content.map((x) => (typeof x === 'string' ? x : x?.text || '')).join('\n')
91
+ : '';
92
+ events.push({
93
+ kind: 'tool_result',
94
+ callId: c.tool_use_id,
95
+ output,
96
+ isError: !!c.is_error,
97
+ ts,
98
+ });
99
+ }
100
+ }
101
+ }
102
+ } else if (d.type === 'assistant' && d.message?.role === 'assistant') {
103
+ const content = d.message.content;
104
+ if (typeof content === 'string') {
105
+ const t = content.trim();
106
+ if (t) events.push({ kind: 'assistant', text: t, ts });
107
+ } else if (Array.isArray(content)) {
108
+ for (const c of content) {
109
+ if (c?.type === 'text' && c.text) {
110
+ events.push({ kind: 'assistant', text: c.text, ts });
111
+ } else if (c?.type === 'tool_use') {
112
+ events.push({
113
+ kind: 'tool_call',
114
+ tool: c.name,
115
+ input: c.input,
116
+ callId: c.id,
117
+ ts,
118
+ });
119
+ }
120
+ // Skip 'thinking' blocks — internal monologue, not for rendering.
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ // Back-compat: also derive a flat "messages" list (user text + assistant text)
127
+ // for any code that still expects the old shape (claude sink, title extraction).
128
+ const messages = events
129
+ .filter((e) => e.kind === 'user' || e.kind === 'assistant')
130
+ .map((e) => ({ role: e.kind, text: e.text, ts: e.ts }));
131
+
132
+ return {
133
+ source: 'claude',
134
+ sessionId,
135
+ cwd,
136
+ threadName,
137
+ createdAt: createdAt ?? Date.now(),
138
+ updatedAt: updatedAt ?? Date.now(),
139
+ events,
140
+ messages,
141
+ isMirror,
142
+ sourcePath: filePath,
143
+ };
144
+ }
@@ -0,0 +1,93 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import { PATHS, MIRROR_MARKER } from '../config.js';
5
+
6
+ // Walk ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl recursively.
7
+ export function listCodexSessions() {
8
+ const root = PATHS.codexSessions;
9
+ if (!fs.existsSync(root)) return [];
10
+ const out = [];
11
+ walk(root, out);
12
+ return out.sort((a, b) => b.mtime - a.mtime);
13
+ }
14
+
15
+ function walk(dir, out) {
16
+ let entries;
17
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
18
+ for (const e of entries) {
19
+ const full = path.join(dir, e.name);
20
+ if (e.isDirectory()) walk(full, out);
21
+ else if (e.isFile() && e.name.endsWith('.jsonl') && e.name.startsWith('rollout-')) {
22
+ try {
23
+ const s = fs.statSync(full);
24
+ out.push({ path: full, mtime: s.mtimeMs });
25
+ } catch {}
26
+ }
27
+ }
28
+ }
29
+
30
+ export async function readCodexSession(filePath) {
31
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
32
+ const rl = readline.createInterface({ input: stream });
33
+
34
+ const messages = [];
35
+ let sessionId = null;
36
+ let cwd = null;
37
+ let createdAt = null;
38
+ let updatedAt = null;
39
+ let isMirror = false;
40
+ let threadName = null;
41
+
42
+ for await (const line of rl) {
43
+ if (!line.trim()) continue;
44
+ let d;
45
+ try { d = JSON.parse(line); } catch { continue; }
46
+
47
+ // Old top-level marker (legacy rollout format) — still detect for cleanup
48
+ if (d[MIRROR_MARKER]) { isMirror = true; continue; }
49
+
50
+ if (d.timestamp) {
51
+ const t = Date.parse(d.timestamp);
52
+ if (Number.isFinite(t)) {
53
+ if (createdAt === null) createdAt = t;
54
+ updatedAt = t;
55
+ }
56
+ }
57
+
58
+ if (d.type === 'session_meta') {
59
+ if (d.payload?.id) sessionId = d.payload.id;
60
+ if (d.payload?.cwd) cwd = d.payload.cwd;
61
+ // Detect new and legacy marker locations.
62
+ if (d.payload?.originator === 'combobulate') isMirror = true;
63
+ if (d.payload?.combobulate?.[MIRROR_MARKER]) isMirror = true;
64
+ } else if (d.type === 'event_msg') {
65
+ const p = d.payload;
66
+ if (p?.type === 'user_message' && typeof p.message === 'string') {
67
+ messages.push({ role: 'user', text: p.message.trim(), ts: Date.parse(d.timestamp) || Date.now() });
68
+ } else if (p?.type === 'agent_message' && typeof p.message === 'string') {
69
+ messages.push({ role: 'assistant', text: p.message.trim(), ts: Date.parse(d.timestamp) || Date.now() });
70
+ } else if (p?.type === 'thread_name_updated' && p.thread_name) {
71
+ threadName = p.thread_name;
72
+ }
73
+ }
74
+ }
75
+
76
+ // Filter out the auto-injected <environment_context> prelude — first user message
77
+ // is almost always the env block. We keep only "real" user text.
78
+ const filtered = messages.filter(
79
+ (m, i) => !(i === 0 && m.role === 'user' && m.text.startsWith('<environment_context>'))
80
+ );
81
+
82
+ return {
83
+ source: 'codex',
84
+ sessionId: sessionId || path.basename(filePath, '.jsonl'),
85
+ cwd,
86
+ threadName,
87
+ createdAt: createdAt ?? Date.now(),
88
+ updatedAt: updatedAt ?? Date.now(),
89
+ messages: filtered,
90
+ isMirror,
91
+ sourcePath: filePath,
92
+ };
93
+ }
@@ -0,0 +1,102 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import fs from 'node:fs';
4
+ import { PATHS } from '../config.js';
5
+
6
+ const execFileP = promisify(execFile);
7
+
8
+ // Query Cursor's sqlite db read-only. We use the system `sqlite3` CLI with `mode=ro`
9
+ // so we never hold a write lock against Cursor's running process.
10
+ async function sqlite(query) {
11
+ if (!fs.existsSync(PATHS.cursorDb)) return [];
12
+ // Use file: URI with mode=ro for read-only opening; immutable=1 avoids touching the journal.
13
+ const uri = `file:${PATHS.cursorDb}?mode=ro&immutable=1`;
14
+ const { stdout } = await execFileP('sqlite3', ['-json', uri, query], {
15
+ maxBuffer: 256 * 1024 * 1024,
16
+ });
17
+ if (!stdout.trim()) return [];
18
+ try { return JSON.parse(stdout); } catch { return []; }
19
+ }
20
+
21
+ // List Cursor composers (chats) sorted newest-first by lastUpdatedAt.
22
+ export async function listCursorComposers() {
23
+ const rows = await sqlite(
24
+ `SELECT key,
25
+ json_extract(value, '$.composerId') as id,
26
+ json_extract(value, '$.name') as name,
27
+ json_extract(value, '$.lastUpdatedAt') as updatedAt,
28
+ json_extract(value, '$.createdAt') as createdAt
29
+ FROM cursorDiskKV
30
+ WHERE key LIKE 'composerData:%'
31
+ ORDER BY updatedAt DESC NULLS LAST
32
+ LIMIT 200;`
33
+ );
34
+ return rows.map((r) => ({
35
+ id: r.id,
36
+ name: r.name,
37
+ createdAt: Number(r.createdAt) || 0,
38
+ updatedAt: Number(r.updatedAt) || 0,
39
+ }));
40
+ }
41
+
42
+ // Read one composer + all its bubbles, return UnifiedSession.
43
+ export async function readCursorComposer(composerId) {
44
+ const headerRows = await sqlite(
45
+ `SELECT value FROM cursorDiskKV WHERE key = 'composerData:${composerId}' LIMIT 1;`
46
+ );
47
+ if (!headerRows.length) return null;
48
+
49
+ let header;
50
+ try { header = JSON.parse(headerRows[0].value); } catch { return null; }
51
+
52
+ const bubbleOrder = (header.fullConversationHeadersOnly || []).map((h) => h.bubbleId);
53
+ if (!bubbleOrder.length) {
54
+ return {
55
+ source: 'cursor',
56
+ sessionId: composerId,
57
+ cwd: null,
58
+ threadName: header.name || null,
59
+ createdAt: Number(header.createdAt) || Date.now(),
60
+ updatedAt: Number(header.lastUpdatedAt) || Date.now(),
61
+ messages: [],
62
+ isMirror: false,
63
+ sourcePath: `cursor:${composerId}`,
64
+ };
65
+ }
66
+
67
+ // Pull all bubbles for this composer in one query.
68
+ const bubbleRows = await sqlite(
69
+ `SELECT substr(key, ${('bubbleId:' + composerId + ':').length + 1}) as bid,
70
+ json_extract(value, '$.type') as bt,
71
+ json_extract(value, '$.text') as text,
72
+ json_extract(value, '$.richText') as richText
73
+ FROM cursorDiskKV
74
+ WHERE key LIKE 'bubbleId:${composerId}:%';`
75
+ );
76
+
77
+ const byId = new Map();
78
+ for (const r of bubbleRows) byId.set(r.bid, r);
79
+
80
+ const messages = [];
81
+ for (const bid of bubbleOrder) {
82
+ const b = byId.get(bid);
83
+ if (!b) continue;
84
+ const role = b.bt === 1 ? 'user' : b.bt === 2 ? 'assistant' : null;
85
+ if (!role) continue;
86
+ const text = (b.text || '').trim();
87
+ if (!text) continue;
88
+ messages.push({ role, text, ts: Number(header.lastUpdatedAt) || Date.now() });
89
+ }
90
+
91
+ return {
92
+ source: 'cursor',
93
+ sessionId: composerId,
94
+ cwd: null,
95
+ threadName: header.name || null,
96
+ createdAt: Number(header.createdAt) || Date.now(),
97
+ updatedAt: Number(header.lastUpdatedAt) || Date.now(),
98
+ messages,
99
+ isMirror: false,
100
+ sourcePath: `cursor:${composerId}`,
101
+ };
102
+ }
package/src/state.js ADDED
@@ -0,0 +1,70 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { PATHS } from './config.js';
5
+
6
+ // Persistent state file shape:
7
+ // {
8
+ // epoch: number, // unix ms; sessions older than this are "pre-existing" and ignored
9
+ // mirrors: {
10
+ // "<source>/<sessionId>": {
11
+ // sourceFingerprint: "<hash of msg count + last ts>",
12
+ // targets: {
13
+ // claude?: { sessionId, filePath },
14
+ // codex?: { sessionId, filePath }
15
+ // }
16
+ // }
17
+ // }
18
+ // }
19
+
20
+ let cache = null;
21
+
22
+ function emptyState() {
23
+ return { epoch: Date.now(), mirrors: {} };
24
+ }
25
+
26
+ export function loadState() {
27
+ if (cache) return cache;
28
+ try {
29
+ const raw = fs.readFileSync(PATHS.combobulateState, 'utf8');
30
+ cache = JSON.parse(raw);
31
+ if (!cache.mirrors) cache.mirrors = {};
32
+ if (!cache.epoch) cache.epoch = Date.now();
33
+ } catch {
34
+ cache = emptyState();
35
+ }
36
+ return cache;
37
+ }
38
+
39
+ export function saveState() {
40
+ if (!cache) return;
41
+ fs.mkdirSync(PATHS.combobulateDir, { recursive: true });
42
+ const tmp = PATHS.combobulateState + '.tmp';
43
+ fs.writeFileSync(tmp, JSON.stringify(cache, null, 2));
44
+ fs.renameSync(tmp, PATHS.combobulateState);
45
+ }
46
+
47
+ export function resetEpoch() {
48
+ loadState();
49
+ cache.epoch = Date.now();
50
+ saveState();
51
+ }
52
+
53
+ export function getMirror(sourceKey) {
54
+ loadState();
55
+ return cache.mirrors[sourceKey];
56
+ }
57
+
58
+ export function setMirror(sourceKey, value) {
59
+ loadState();
60
+ cache.mirrors[sourceKey] = value;
61
+ saveState();
62
+ }
63
+
64
+ export function fingerprintMessages(messages) {
65
+ const h = crypto.createHash('sha1');
66
+ for (const m of messages) {
67
+ h.update(`${m.role}|${m.ts || 0}|${(m.text || '').slice(0, 256)}\n`);
68
+ }
69
+ return `${messages.length}:${h.digest('hex').slice(0, 16)}`;
70
+ }