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.
package/src/log.js ADDED
@@ -0,0 +1,29 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PATHS } from './config.js';
4
+
5
+ let stream = null;
6
+
7
+ function ensureStream() {
8
+ if (stream) return stream;
9
+ fs.mkdirSync(PATHS.combobulateDir, { recursive: true });
10
+ stream = fs.createWriteStream(PATHS.combobulateLog, { flags: 'a' });
11
+ return stream;
12
+ }
13
+
14
+ function ts() {
15
+ return new Date().toISOString();
16
+ }
17
+
18
+ export function log(level, ...args) {
19
+ const line = `[${ts()}] ${level} ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}\n`;
20
+ process.stdout.write(line);
21
+ try { ensureStream().write(line); } catch {}
22
+ }
23
+
24
+ export const info = (...a) => log('INFO', ...a);
25
+ export const warn = (...a) => log('WARN', ...a);
26
+ export const error = (...a) => log('ERROR', ...a);
27
+ export const debug = (...a) => {
28
+ if (process.env.COMBOBULATE_DEBUG) log('DEBUG', ...a);
29
+ };
@@ -0,0 +1,166 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { PATHS, MIRROR_MARKER, encodeClaudeProjectDir } from '../config.js';
5
+
6
+ // Convert a UnifiedSession from another tool (Codex / Cursor / …) into a
7
+ // Claude Code session file that resumes cleanly in Claude Code's /resume UI.
8
+ //
9
+ // What we emit:
10
+ // - line 1: the loop-prevention marker (top-level field; Claude ignores it)
11
+ // - line 2: an `ai-title` event so the synced chat shows up under a
12
+ // human-readable name like "[Codex] <thread name>" instead of the first
13
+ // user message snippet
14
+ // - one user / assistant pair per source turn, each with the original
15
+ // per-message timestamp so the conversation timeline is preserved
16
+ // - tool-call events from the source aren't replayed natively (Claude and
17
+ // Codex have incompatible tool formats); we drop them — the agent text
18
+ // either side of a tool call carries enough context to continue the chat
19
+ export function writeClaudeMirror(session, { existingSessionId, existingFilePath } = {}) {
20
+ const cwd = session.cwd && session.cwd.startsWith('/') ? session.cwd : PATHS.combobulateSynced;
21
+ fs.mkdirSync(cwd, { recursive: true });
22
+
23
+ const projDir = path.join(PATHS.claudeProjects, encodeClaudeProjectDir(cwd));
24
+ fs.mkdirSync(projDir, { recursive: true });
25
+
26
+ const isUpdate = !!existingSessionId;
27
+ const sessionId = existingSessionId || crypto.randomUUID();
28
+ // Claude's filename is deterministic from sessionId+cwd, so reusing sessionId
29
+ // alone makes the rewrite hit the same path. existingFilePath is honored if given.
30
+ const filePath = existingFilePath || path.join(projDir, `${sessionId}.jsonl`);
31
+
32
+ const sourceLabel = sourceTag(session.source);
33
+ const rawTitle = session.threadName || firstUserSnippet(session) || 'Synced chat';
34
+ const taggedTitle = `${sourceLabel} ${rawTitle}`.slice(0, 200);
35
+ const sessionStartIso = session.createdAt
36
+ ? new Date(session.createdAt).toISOString()
37
+ : new Date().toISOString();
38
+
39
+ const lines = [];
40
+
41
+ // Loop-prevention marker — readers detect this and skip the file as a source.
42
+ lines.push(JSON.stringify({
43
+ [MIRROR_MARKER]: true,
44
+ mirrorOf: `${session.source}/${session.sessionId}`,
45
+ mirroredAt: new Date().toISOString(),
46
+ sourceCwd: session.cwd || null,
47
+ title: taggedTitle,
48
+ }));
49
+
50
+ // Replay each turn from the source as native Claude user/assistant messages
51
+ // with their original timestamps. Skip tool_call / tool_result events — we
52
+ // can't faithfully translate them to Claude's tool format.
53
+ let parentUuid = null;
54
+ let lastTs = session.createdAt || Date.now();
55
+ const messages = (session.events || legacyEvents(session.messages || []))
56
+ .filter((e) => e.kind === 'user' || e.kind === 'assistant');
57
+
58
+ for (const m of messages) {
59
+ if (!m.text) continue;
60
+ const ts = m.ts || lastTs;
61
+ lastTs = ts;
62
+ const iso = new Date(ts).toISOString();
63
+ const uuid = crypto.randomUUID();
64
+
65
+ if (m.kind === 'user') {
66
+ lines.push(JSON.stringify({
67
+ parentUuid,
68
+ isSidechain: false,
69
+ type: 'user',
70
+ message: {
71
+ role: 'user',
72
+ content: [{ type: 'text', text: m.text }],
73
+ },
74
+ uuid,
75
+ timestamp: iso,
76
+ userType: 'external',
77
+ entrypoint: 'combobulate',
78
+ cwd,
79
+ sessionId,
80
+ version: '2.1.111',
81
+ }));
82
+ } else {
83
+ lines.push(JSON.stringify({
84
+ parentUuid,
85
+ isSidechain: false,
86
+ type: 'assistant',
87
+ message: {
88
+ model: `claude-mirror-from-${session.source}`,
89
+ id: `msg_${uuid.replace(/-/g, '').slice(0, 24)}`,
90
+ type: 'message',
91
+ role: 'assistant',
92
+ content: [{ type: 'text', text: m.text }],
93
+ },
94
+ uuid,
95
+ timestamp: iso,
96
+ sessionId,
97
+ }));
98
+ }
99
+ parentUuid = uuid;
100
+ }
101
+
102
+ // Emit ai-title AFTER the conversation messages — Claude Code's /resume
103
+ // picker reads the LAST ai-title event in the file (Claude itself rewrites
104
+ // ai-title throughout a session as the conversation evolves). Putting ours
105
+ // at the top makes it ignored; putting it at the end makes it authoritative.
106
+ lines.push(JSON.stringify({
107
+ type: 'ai-title',
108
+ aiTitle: taggedTitle,
109
+ sessionId,
110
+ }));
111
+
112
+ fs.writeFileSync(filePath, lines.join('\n') + '\n');
113
+
114
+ // Only append to ~/.claude/history.jsonl on the FIRST mirror — re-mirroring
115
+ // should overwrite the session file in place, not spam up-arrow recall.
116
+ if (!isUpdate) {
117
+ const lastUser = [...messages].reverse().find((m) => m.kind === 'user');
118
+ if (lastUser) {
119
+ appendClaudeHistory({ text: lastUser.text, cwd, sourceLabel });
120
+ }
121
+ }
122
+
123
+ return { sessionId, filePath };
124
+ }
125
+
126
+ function appendClaudeHistory({ text, cwd, sourceLabel }) {
127
+ fs.mkdirSync(path.dirname(PATHS.claudeHistory), { recursive: true });
128
+ const entry = {
129
+ display: `${sourceLabel} ${text}`.slice(0, 4000),
130
+ pastedContents: {},
131
+ timestamp: Date.now(),
132
+ project: cwd,
133
+ };
134
+ fs.appendFileSync(PATHS.claudeHistory, JSON.stringify(entry) + '\n');
135
+ }
136
+
137
+ // Human-readable label for the tool the mirror came from — keep this in sync
138
+ // with the codex sink's sourceTag so titles look consistent across the system.
139
+ function sourceTag(source) {
140
+ switch (source) {
141
+ case 'claude': return '[Claude Code]';
142
+ case 'cursor': return '[Cursor]';
143
+ case 'codex': return '[Codex]';
144
+ default: return `[${source || 'synced'}]`;
145
+ }
146
+ }
147
+
148
+ // Pick a clean first-user-prompt snippet for the fallback title. Skip Codex/
149
+ // Claude system-injected preambles that aren't real user input.
150
+ function firstUserSnippet(session) {
151
+ const ev = (session.events || legacyEvents(session.messages || []));
152
+ for (const e of ev) {
153
+ if (e.kind !== 'user' || !e.text) continue;
154
+ const body = e.text.trim();
155
+ if (/^<environment_context>/i.test(body)) continue;
156
+ if (/^<(ide_opened_file|system-reminder|command-(name|message))/i.test(body)) continue;
157
+ return body.split('\n').find((l) => l.trim())?.slice(0, 80) || null;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ // Sources that haven't been upgraded to the typed event stream emit just
163
+ // `messages: [{role, text, ts}]`. Synthesize an equivalent event list.
164
+ function legacyEvents(messages) {
165
+ return messages.map((m) => ({ kind: m.role, text: m.text, ts: m.ts }));
166
+ }