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,577 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { PATHS, MIRROR_MARKER } from '../config.js';
5
+ import { registerCodexWorkspaceRoot } from '../codex-registry.js';
6
+ import { upsertCodexThread } from '../codex-thread-db.js';
7
+
8
+ // Generate a UUIDv7-ish string Codex would accept. Codex's session ids look like
9
+ // timestamp-millis hex + random; format is "xxxxxxxx-xxxx-7xxx-xxxx-xxxxxxxxxxxx".
10
+ function uuidv7() {
11
+ const ts = Date.now();
12
+ const tsHex = ts.toString(16).padStart(12, '0');
13
+ const rand = crypto.randomBytes(10);
14
+ // Set version (7) and variant bits.
15
+ rand[0] = (rand[0] & 0x0f) | 0x70;
16
+ rand[2] = (rand[2] & 0x3f) | 0x80;
17
+ const r = rand.toString('hex');
18
+ return `${tsHex.slice(0, 8)}-${tsHex.slice(8, 12)}-${r.slice(0, 4)}-${r.slice(4, 8)}-${r.slice(8, 20)}`;
19
+ }
20
+
21
+ // Write a Codex rollout file that mirrors a UnifiedSession from another tool.
22
+ // Codex sessions live at ~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl.
23
+ // We also append to ~/.codex/session_index.jsonl and ~/.codex/history.jsonl so the
24
+ // thread shows up in `codex resume` lists and up-arrow recall.
25
+ export function writeCodexMirror(session, { existingSessionId, existingFilePath } = {}) {
26
+ const isUpdate = !!existingSessionId;
27
+ const sessionId = existingSessionId || uuidv7();
28
+ const cwd = session.cwd && session.cwd.startsWith('/') ? session.cwd : PATHS.combobulateSynced;
29
+ fs.mkdirSync(cwd, { recursive: true });
30
+
31
+ const now = new Date();
32
+ const tsIso = now.toISOString();
33
+ let filePath = existingFilePath;
34
+ if (!filePath) {
35
+ const yyyy = String(now.getUTCFullYear());
36
+ const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
37
+ const dd = String(now.getUTCDate()).padStart(2, '0');
38
+ const dir = path.join(PATHS.codexSessions, yyyy, mm, dd);
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ const isoFs = tsIso.replace(/[:.]/g, '-').slice(0, 19);
41
+ filePath = path.join(dir, `rollout-${isoFs}-${sessionId}.jsonl`);
42
+ } else {
43
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
44
+ }
45
+
46
+ // Prepend a [<source>] tag to every mirrored thread title so you can tell at
47
+ // a glance which tool a chat came from. Keep the raw snippet for firstUserMessage.
48
+ const sourceLabel = sourceTag(session.source);
49
+ const _rawTitle = session.threadName || firstUserSnippet(session) || 'Synced';
50
+ const _threadNameForMarker = `${sourceLabel} ${_rawTitle}`.slice(0, 200);
51
+ const _fumForMarker = (firstUserSnippet(session) || '').slice(0, 2000);
52
+
53
+ // Build a rollout that *structurally* looks like one Codex's own CLI would write.
54
+ //
55
+ // The crucial detail (learned the hard way): each user turn is its own
56
+ // task_started/task_complete pair with a fresh turn_id. If we lump all messages
57
+ // under one task_started, Codex's UI renders the second+ user messages as
58
+ // "steered conversation" — it thinks they're mid-turn redirects, not new turns.
59
+ //
60
+ // The loop-prevention marker hides inside session_meta.payload.combobulate so
61
+ // Codex's parser doesn't reject the rollout (originator='combobulate' makes it
62
+ // flip source to 'unknown').
63
+ const lines = [];
64
+
65
+ // Use the source session's createdAt for session_meta so the thread shows
66
+ // up in Codex's "Recent" sort under the date the original conversation
67
+ // started, not the date we ran the mirror.
68
+ const sessionStartIso = session.createdAt ? new Date(session.createdAt).toISOString() : tsIso;
69
+
70
+ lines.push(JSON.stringify({
71
+ timestamp: sessionStartIso,
72
+ type: 'session_meta',
73
+ payload: {
74
+ id: sessionId,
75
+ timestamp: sessionStartIso,
76
+ cwd,
77
+ originator: 'Codex CLI',
78
+ cli_version: '0.1.0',
79
+ model_provider: 'openai',
80
+ combobulate: {
81
+ [MIRROR_MARKER]: true,
82
+ mirrorOf: `${session.source}/${session.sessionId}`,
83
+ mirroredAt: tsIso,
84
+ title: _threadNameForMarker,
85
+ firstUserMessage: _fumForMarker,
86
+ sourceCwd: session.cwd || null,
87
+ },
88
+ },
89
+ }));
90
+
91
+ // Translate the source's typed event stream into native Codex events. Each
92
+ // "turn" starts with a real user message and ends just before the next real
93
+ // user message (or at the end of stream). Within a turn we may have
94
+ // alternating assistant text + tool calls (each with its paired tool result),
95
+ // mirroring how Codex's own CLI writes them.
96
+ //
97
+ // Tool translation:
98
+ // - Bash (or any shell-like Claude tool) → function_call name=exec_command
99
+ // + event_msg:exec_command_end + response_item:function_call_output
100
+ // - Edit / Write / MultiEdit → custom_tool_call name=apply_patch
101
+ // + event_msg:patch_apply_end (carries the unified diff that renders the
102
+ // little file-change card under the agent message) + custom_tool_call_output
103
+ // - Read / Glob / Grep / etc. → exec_command with a simulated command line
104
+ //
105
+ // Phase rules (Codex Desktop will silently drop messages with a bad phase):
106
+ // - phase = 'commentary' when the agent_message is followed by more work in
107
+ // this turn (more text or tool calls)
108
+ // - phase = 'final_answer' on the LAST agent_message of the turn — Codex
109
+ // keys "render this as the bottom bubble + diff card" off this value.
110
+ const events = session.events || legacyEventsFromMessages(session.messages);
111
+ const turns = groupEventsIntoTurns(events);
112
+ let lastAgentMessage = '';
113
+ let lastTs = session.createdAt || now.getTime();
114
+
115
+ for (const turn of turns) {
116
+ const turnId = uuidv7();
117
+ const userTs = turn.events.find((e) => e.kind === 'user')?.ts || lastTs;
118
+ const finalAgentTs = [...turn.events].reverse().find((e) => e.kind === 'assistant')?.ts
119
+ || turn.events[turn.events.length - 1]?.ts || userTs;
120
+ const userIso = new Date(userTs).toISOString();
121
+ const finalIso = new Date(finalAgentTs).toISOString();
122
+ lastTs = finalAgentTs;
123
+
124
+ lines.push(JSON.stringify({
125
+ timestamp: userIso,
126
+ type: 'event_msg',
127
+ payload: {
128
+ type: 'task_started',
129
+ turn_id: turnId,
130
+ model_context_window: 258400,
131
+ collaboration_mode_kind: 'default',
132
+ },
133
+ }));
134
+
135
+ lines.push(JSON.stringify({
136
+ timestamp: userIso,
137
+ type: 'turn_context',
138
+ payload: {
139
+ turn_id: turnId,
140
+ cwd,
141
+ approval_policy: 'never',
142
+ sandbox_policy: { type: 'danger-full-access' },
143
+ model: 'gpt-5.5',
144
+ effort: 'medium',
145
+ summary: 'none',
146
+ },
147
+ }));
148
+
149
+ // Emit the user-side first: combine any user_text events in this turn.
150
+ const userParts = turn.events.filter((e) => e.kind === 'user').map((e) => e.text);
151
+ const userText = userParts.join('\n\n');
152
+ if (userText) {
153
+ lines.push(JSON.stringify({
154
+ timestamp: userIso,
155
+ type: 'response_item',
156
+ payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: userText }] },
157
+ }));
158
+ lines.push(JSON.stringify({
159
+ timestamp: userIso,
160
+ type: 'event_msg',
161
+ payload: { type: 'user_message', message: userText, images: [] },
162
+ }));
163
+ }
164
+
165
+ // Emit the assistant-side events in their source order: text → tool call →
166
+ // tool result → text → ... Determine which assistant text is the "final"
167
+ // one (last one of the turn) so we can label its phase correctly.
168
+ const assistantEventIdxs = turn.events
169
+ .map((e, i) => ({ e, i }))
170
+ .filter(({ e }) => e.kind === 'assistant')
171
+ .map(({ i }) => i);
172
+ const finalAssistantIdx = assistantEventIdxs[assistantEventIdxs.length - 1];
173
+
174
+ // Index tool_results by callId so we can pair them up.
175
+ const toolResults = new Map();
176
+ for (const e of turn.events) {
177
+ if (e.kind === 'tool_result' && e.callId) toolResults.set(e.callId, e);
178
+ }
179
+
180
+ for (let i = 0; i < turn.events.length; i++) {
181
+ const e = turn.events[i];
182
+ if (e.kind === 'user' || e.kind === 'tool_result') continue;
183
+ const ts = new Date(e.ts).toISOString();
184
+
185
+ if (e.kind === 'assistant') {
186
+ const isFinal = i === finalAssistantIdx;
187
+ const phase = isFinal ? 'final_answer' : 'commentary';
188
+
189
+ // Reasoning stub before every agent_message — Codex's renderer expects it.
190
+ lines.push(JSON.stringify({
191
+ timestamp: ts,
192
+ type: 'response_item',
193
+ payload: { type: 'reasoning', summary: [], content: null, encrypted_content: '' },
194
+ }));
195
+ lines.push(JSON.stringify({
196
+ timestamp: ts,
197
+ type: 'event_msg',
198
+ payload: { type: 'agent_message', message: e.text, phase, memory_citation: null },
199
+ }));
200
+ lines.push(JSON.stringify({
201
+ timestamp: ts,
202
+ type: 'response_item',
203
+ payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: e.text }], phase },
204
+ }));
205
+ lastAgentMessage = e.text;
206
+ } else if (e.kind === 'tool_call') {
207
+ const result = toolResults.get(e.callId);
208
+ emitToolCall(lines, { call: e, result, cwd, turnId, ts });
209
+ }
210
+ }
211
+
212
+ // If a turn has no assistant text at all (rare — Claude usually responds),
213
+ // still close with task_complete so Codex doesn't think the turn is open.
214
+ lines.push(JSON.stringify({
215
+ timestamp: finalIso,
216
+ type: 'event_msg',
217
+ payload: {
218
+ type: 'task_complete',
219
+ turn_id: turnId,
220
+ last_agent_message: lastAgentMessage.slice(0, 2000),
221
+ completed_at: Math.floor(finalAgentTs / 1000),
222
+ duration_ms: Math.max(0, finalAgentTs - userTs),
223
+ },
224
+ }));
225
+ }
226
+
227
+ fs.writeFileSync(filePath, lines.join('\n') + '\n');
228
+
229
+ // Only append to session_index + history on the FIRST mirror. Re-mirrors overwrite
230
+ // the rollout in place. (Codex's UI dedups by id, but appending repeatedly bloats
231
+ // history and shows duplicate entries in some views.)
232
+ // Same tagged title for the threads-table title field and session_index entry.
233
+ const threadName = `${sourceLabel} ${_rawTitle}`.slice(0, 80);
234
+ const firstUserMessage = firstUserSnippet(session) || '';
235
+ // Rough token estimate from the full transcript (~4 chars per token). Just
236
+ // needs to be nonzero so Codex Desktop doesn't treat the thread as empty.
237
+ const approxTokens = Math.max(1, Math.ceil(session.messages.reduce((n, m) => n + (m.text?.length || 0), 0) / 4));
238
+
239
+ // Upsert the threads-table row on every mirror (first AND update). This is
240
+ // what makes the chat visible in Codex Desktop's per-cwd sidebar. INSERT OR
241
+ // REPLACE refreshes title/preview/tokens as the source grows. Use source
242
+ // timestamps so the row sorts under the real chat date, not the mirror time.
243
+ try {
244
+ upsertCodexThread({
245
+ sessionId,
246
+ rolloutPath: filePath,
247
+ cwd: session.cwd && session.cwd.startsWith('/') ? session.cwd : PATHS.combobulateSynced,
248
+ title: threadName,
249
+ firstUserMessage,
250
+ createdAtMs: session.createdAt || now.getTime(),
251
+ updatedAtMs: session.updatedAt || now.getTime(),
252
+ approxTokens,
253
+ });
254
+ } catch {}
255
+
256
+ if (!isUpdate) {
257
+ // Ask Codex Desktop to register this cwd as a workspace root (via the
258
+ // `codex app <path>` CLI, which IPCs the running app). Skipped if already
259
+ // registered, so daemon-driven mirrors to known cwds don't steal focus.
260
+ // Fire-and-forget — the mirror file + SQLite row are written regardless.
261
+ if (session.cwd && session.cwd.startsWith('/')) {
262
+ registerCodexWorkspaceRoot(session.cwd).catch(() => {});
263
+ }
264
+
265
+ fs.mkdirSync(path.dirname(PATHS.codexSessionIndex), { recursive: true });
266
+ fs.appendFileSync(
267
+ PATHS.codexSessionIndex,
268
+ JSON.stringify({ id: sessionId, thread_name: threadName, updated_at: tsIso }) + '\n'
269
+ );
270
+
271
+ const lastUser = [...session.messages].reverse().find((m) => m.role === 'user');
272
+ if (lastUser) {
273
+ const label = `[from ${session.source}${session.threadName ? `: ${session.threadName}` : ''}] `;
274
+ fs.mkdirSync(path.dirname(PATHS.codexHistory), { recursive: true });
275
+ fs.appendFileSync(
276
+ PATHS.codexHistory,
277
+ JSON.stringify({
278
+ session_id: sessionId,
279
+ ts: Math.floor(now.getTime() / 1000),
280
+ text: (label + lastUser.text).slice(0, 4000),
281
+ }) + '\n'
282
+ );
283
+ }
284
+ }
285
+
286
+ return { sessionId, filePath };
287
+ }
288
+
289
+ // Human-readable label for the tool the mirror came from. Prepended to every
290
+ // title so Codex's chat list makes it obvious which tool originated the chat.
291
+ function sourceTag(source) {
292
+ switch (source) {
293
+ case 'claude': return '[Claude Code]';
294
+ case 'cursor': return '[Cursor]';
295
+ case 'codex': return '[Codex]';
296
+ default: return `[${source || 'synced'}]`;
297
+ }
298
+ }
299
+
300
+ // Fallback for sources that haven't been upgraded to emit the typed event
301
+ // stream (currently Cursor). Synthesizes a minimal user/assistant event list.
302
+ function legacyEventsFromMessages(messages) {
303
+ return (messages || []).map((m) => ({ kind: m.role, text: m.text, ts: m.ts }));
304
+ }
305
+
306
+ // Group a typed event stream into Codex-shaped turns. A turn starts at a
307
+ // `user` event (real human prompt) and runs until just before the next `user`
308
+ // event. `tool_result` events stay with the turn that produced their matching
309
+ // `tool_call`, not with whatever user message technically delivered them in
310
+ // the source format.
311
+ function groupEventsIntoTurns(events) {
312
+ const turns = [];
313
+ let cur = null;
314
+ for (const e of events) {
315
+ if (e.kind === 'user') {
316
+ if (cur) turns.push(cur);
317
+ cur = { events: [e] };
318
+ } else {
319
+ if (!cur) cur = { events: [] };
320
+ cur.events.push(e);
321
+ }
322
+ }
323
+ if (cur) turns.push(cur);
324
+ return turns;
325
+ }
326
+
327
+ // Translate a single Claude tool_use + its paired tool_result into the right
328
+ // Codex events. Bash → exec_command, Edit/Write/MultiEdit → apply_patch with a
329
+ // proper unified_diff so Codex's UI renders the per-message diff card.
330
+ //
331
+ // The call_id format MATTERS: Codex Desktop's renderer links function_call to
332
+ // function_call_output by exact-matching `^call_[A-Za-z0-9]{20,}$`. If the id
333
+ // doesn't match the expected shape, the output never associates with the call
334
+ // and the expanded details panel shows nothing. We generate ids that fit.
335
+ function emitToolCall(lines, { call, result, cwd, turnId, ts }) {
336
+ const callId = codexCallId();
337
+ const tool = call.tool || 'unknown';
338
+ const isEdit = /^(Edit|MultiEdit|Write|NotebookEdit)$/i.test(tool);
339
+
340
+ if (isEdit) {
341
+ const { patch, diffs, filePath } = renderApplyPatch(tool, call.input);
342
+ lines.push(JSON.stringify({
343
+ timestamp: ts,
344
+ type: 'response_item',
345
+ payload: { type: 'custom_tool_call', status: 'completed', call_id: callId, name: 'apply_patch', input: patch },
346
+ }));
347
+ const rawOut = result?.output || `Success. Updated the following files:\nM ${filePath || '(unknown)'}`;
348
+ lines.push(JSON.stringify({
349
+ timestamp: ts,
350
+ type: 'event_msg',
351
+ payload: {
352
+ type: 'patch_apply_end',
353
+ call_id: callId,
354
+ turn_id: turnId,
355
+ stdout: rawOut,
356
+ stderr: '',
357
+ success: !(result?.isError),
358
+ changes: diffs,
359
+ status: 'completed',
360
+ },
361
+ }));
362
+ lines.push(JSON.stringify({
363
+ timestamp: ts,
364
+ type: 'response_item',
365
+ payload: {
366
+ type: 'custom_tool_call_output',
367
+ call_id: callId,
368
+ output: JSON.stringify({ output: rawOut, metadata: { exit_code: result?.isError ? 1 : 0, duration_seconds: 0 } }),
369
+ },
370
+ }));
371
+ } else {
372
+ const cmd = renderExecCommand(tool, call.input);
373
+ const args = JSON.stringify({ cmd, workdir: cwd, yield_time_ms: 1000, max_output_tokens: 10000 });
374
+ lines.push(JSON.stringify({
375
+ timestamp: ts,
376
+ type: 'response_item',
377
+ payload: { type: 'function_call', name: 'exec_command', arguments: args, call_id: callId },
378
+ }));
379
+ const rawOut = result?.output || '';
380
+ lines.push(JSON.stringify({
381
+ timestamp: ts,
382
+ type: 'event_msg',
383
+ payload: {
384
+ type: 'exec_command_end',
385
+ call_id: callId,
386
+ process_id: '0',
387
+ turn_id: turnId,
388
+ command: ['/bin/zsh', '-lc', cmd],
389
+ cwd,
390
+ parsed_cmd: [{ type: 'unknown', cmd }],
391
+ source: 'unified_exec_startup',
392
+ stdout: '',
393
+ stderr: '',
394
+ aggregated_output: rawOut,
395
+ exit_code: result?.isError ? 1 : 0,
396
+ duration: { secs: 0, nanos: 0 },
397
+ formatted_output: '',
398
+ status: 'completed',
399
+ },
400
+ }));
401
+ // Wrap the function_call_output payload in Codex's standard envelope so
402
+ // the details panel renders the structured Chunk-ID / Process-exited /
403
+ // Output: header it expects.
404
+ lines.push(JSON.stringify({
405
+ timestamp: ts,
406
+ type: 'response_item',
407
+ payload: {
408
+ type: 'function_call_output',
409
+ call_id: callId,
410
+ output: formatCodexExecOutput(rawOut, result?.isError ? 1 : 0),
411
+ },
412
+ }));
413
+ }
414
+ }
415
+
416
+ // Build the Codex-style function_call_output text envelope that the Desktop
417
+ // renderer parses to populate the expanded tool-output panel.
418
+ function formatCodexExecOutput(text, exitCode) {
419
+ const chunkId = Math.random().toString(36).slice(2, 8);
420
+ const tokens = Math.max(1, Math.ceil((text || '').length / 4));
421
+ return `Chunk ID: ${chunkId}\nWall time: 0.0000 seconds\nProcess exited with code ${exitCode}\nOriginal token count: ${tokens}\nOutput:\n${text}`;
422
+ }
423
+
424
+ // Generate a call_id in Codex's expected shape: `call_` + 24 base62 chars.
425
+ function codexCallId() {
426
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
427
+ let out = 'call_';
428
+ for (let i = 0; i < 24; i++) out += alphabet[Math.floor(Math.random() * alphabet.length)];
429
+ return out;
430
+ }
431
+
432
+ // Compose a Codex-style apply_patch input from Claude's Edit-family tool args.
433
+ // Returns the V4A patch string + a `changes` dict for patch_apply_end.
434
+ function renderApplyPatch(tool, input) {
435
+ input = input || {};
436
+ const filePath = input.file_path || input.path || input.notebook_path || '(unknown file)';
437
+
438
+ if (/^MultiEdit$/i.test(tool) && Array.isArray(input.edits)) {
439
+ let patch = `*** Begin Patch\n*** Update File: ${filePath}\n`;
440
+ const diffHunks = [];
441
+ for (const ed of input.edits) {
442
+ patch += '@@\n';
443
+ patch += unifiedHunkFromEdit(ed.old_string || '', ed.new_string || '');
444
+ diffHunks.push(unifiedHunkFromEdit(ed.old_string || '', ed.new_string || ''));
445
+ }
446
+ patch += '*** End Patch';
447
+ return {
448
+ patch,
449
+ filePath,
450
+ diffs: { [filePath]: { type: 'update', unified_diff: diffHunks.join(''), move_path: null } },
451
+ };
452
+ }
453
+
454
+ if (/^Write$/i.test(tool)) {
455
+ const content = input.content || '';
456
+ const lines = content.split('\n');
457
+ const diff = lines.map((l) => `+${l}`).join('\n');
458
+ return {
459
+ patch: `*** Begin Patch\n*** Add File: ${filePath}\n${diff}\n*** End Patch`,
460
+ filePath,
461
+ diffs: { [filePath]: { type: 'add', unified_diff: `@@\n${diff}\n`, move_path: null } },
462
+ };
463
+ }
464
+
465
+ // Edit (single-edit). NotebookEdit uses similar shape.
466
+ const oldStr = input.old_string || '';
467
+ const newStr = input.new_string || '';
468
+ const hunk = unifiedHunkFromEdit(oldStr, newStr);
469
+ return {
470
+ patch: `*** Begin Patch\n*** Update File: ${filePath}\n@@\n${hunk}*** End Patch`,
471
+ filePath,
472
+ diffs: { [filePath]: { type: 'update', unified_diff: hunk, move_path: null } },
473
+ };
474
+ }
475
+
476
+ // Convert an Edit's (old_string, new_string) into a minimal unified-diff hunk.
477
+ // No surrounding context — Codex's renderer just needs the +/- lines to show
478
+ // the change card under the agent message.
479
+ function unifiedHunkFromEdit(oldStr, newStr) {
480
+ const oldLines = oldStr.split('\n').map((l) => `-${l}`);
481
+ const newLines = newStr.split('\n').map((l) => `+${l}`);
482
+ return [...oldLines, ...newLines].join('\n') + '\n';
483
+ }
484
+
485
+ // Synthesize a shell command for non-Bash Claude tools so the exec_command
486
+ // rendering reads like a real terminal invocation rather than "[tool: Glob]".
487
+ function renderExecCommand(tool, input) {
488
+ input = input || {};
489
+ switch (tool) {
490
+ case 'Bash': return input.command || input.cmd || '';
491
+ case 'Read': return `cat ${input.file_path || ''}`.trim();
492
+ case 'Glob': return `find . -path '${input.pattern || ''}'`;
493
+ case 'Grep': {
494
+ const pattern = JSON.stringify(input.pattern || '');
495
+ const path_ = input.path ? ` ${input.path}` : '';
496
+ return `rg ${pattern}${path_}`;
497
+ }
498
+ case 'WebFetch': return `curl -L ${input.url || ''}`;
499
+ case 'WebSearch': return `# search: ${input.query || ''}`;
500
+ default: {
501
+ // Generic fallback: tool name + JSON-ish args, kept short.
502
+ const summary = JSON.stringify(input).slice(0, 200);
503
+ return `# ${tool} ${summary}`;
504
+ }
505
+ }
506
+ }
507
+
508
+ function cryptoRandomId() {
509
+ return Math.random().toString(36).slice(2, 14);
510
+ }
511
+
512
+ // Group the normalized message stream into Codex-shaped turns. A "turn" is
513
+ // (optional user, optional assistant). Consecutive same-role messages get
514
+ // concatenated so each transitions user→assistant→user creates a new turn,
515
+ // matching how Codex thinks about the conversation.
516
+ //
517
+ // Each turn also carries the source timestamp of its first user and first
518
+ // assistant message so the rollout's per-event timestamps reflect when the
519
+ // original conversation actually happened (not when we mirrored it).
520
+ function groupIntoTurns(messages) {
521
+ const turns = [];
522
+ let cur = null;
523
+ let lastRole = null;
524
+ for (const m of messages) {
525
+ if (!m.text) continue;
526
+ if (m.role === 'user') {
527
+ if (cur && lastRole === 'assistant') {
528
+ turns.push(cur);
529
+ cur = null;
530
+ }
531
+ if (!cur) cur = { user: '', userTs: null, assistant: '', assistantTs: null };
532
+ cur.user = cur.user ? cur.user + '\n\n' + m.text : m.text;
533
+ if (!cur.userTs && m.ts) cur.userTs = m.ts;
534
+ lastRole = 'user';
535
+ } else if (m.role === 'assistant') {
536
+ if (!cur) cur = { user: '', userTs: null, assistant: '', assistantTs: null };
537
+ cur.assistant = cur.assistant ? cur.assistant + '\n\n' + m.text : m.text;
538
+ if (!cur.assistantTs && m.ts) cur.assistantTs = m.ts;
539
+ lastRole = 'assistant';
540
+ }
541
+ }
542
+ if (cur) turns.push(cur);
543
+ return turns;
544
+ }
545
+
546
+ // Pick a clean title for the synced chat. Skip Claude Code's IDE-injected
547
+ // preambles (<ide_opened_file>...), system reminders, environment dumps,
548
+ // and the like — those make terrible thread names. Use the first user
549
+ // message that looks like an actual human prompt.
550
+ function firstUserSnippet(session) {
551
+ const looksInjected = (text) =>
552
+ /^\s*<(ide_opened_file|system-reminder|command-(name|message)|environment_context|local-command-stdout|command-stderr)\b/i.test(text) ||
553
+ /^\s*\[Pasted text/.test(text);
554
+
555
+ for (const m of session.messages) {
556
+ if (m.role !== 'user') continue;
557
+ if (looksInjected(m.text)) continue;
558
+ return m.text.split('\n').find((l) => l.trim()).slice(0, 80);
559
+ }
560
+ // fallback: any user message at all
561
+ const u = session.messages.find((m) => m.role === 'user');
562
+ return u ? u.text.split('\n')[0].slice(0, 80) : null;
563
+ }
564
+
565
+ function renderTranscript(session) {
566
+ const header =
567
+ `[Synced from ${session.source}${session.threadName ? `: ${session.threadName}` : ''} via combobulate]\n` +
568
+ `Source session: ${session.sessionId}\n` +
569
+ (session.cwd ? `Source cwd: ${session.cwd}\n` : '') +
570
+ `Synced: ${new Date().toISOString()}\n\n` +
571
+ `Below is the prior conversation. Continue from where it left off.\n` +
572
+ `---\n`;
573
+ const body = session.messages
574
+ .map((m) => `**${m.role === 'user' ? 'User' : 'Assistant'}:** ${m.text}`)
575
+ .join('\n\n');
576
+ return header + body;
577
+ }