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/LICENSE +21 -0
- package/README.md +187 -0
- package/bin/combobulate.js +6 -0
- package/package.json +46 -0
- package/src/cli.js +92 -0
- package/src/codex-registry.js +73 -0
- package/src/codex-thread-db.js +72 -0
- package/src/commands/cleanup.js +104 -0
- package/src/commands/doctor.js +119 -0
- package/src/commands/fix-codex-projects.js +164 -0
- package/src/commands/install.js +87 -0
- package/src/commands/status.js +50 -0
- package/src/commands/sync.js +80 -0
- package/src/commands/uninstall.js +17 -0
- package/src/config.js +38 -0
- package/src/daemon.js +126 -0
- package/src/log.js +29 -0
- package/src/sinks/claude.js +166 -0
- package/src/sinks/codex.js +577 -0
- package/src/sources/claude.js +144 -0
- package/src/sources/codex.js +93 -0
- package/src/sources/cursor.js +102 -0
- package/src/state.js +70 -0
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
|
+
}
|