@mmmjk/context-bridge 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/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/README.zh-CN.md +275 -0
- package/dist/src/adapters/claude-code/index.js +2 -0
- package/dist/src/adapters/claude-code/ingest.js +142 -0
- package/dist/src/adapters/claude-code/render.js +95 -0
- package/dist/src/adapters/claude-code/tool-map.js +52 -0
- package/dist/src/adapters/codex/apply-patch-parser.js +61 -0
- package/dist/src/adapters/codex/apply-patch.js +163 -0
- package/dist/src/adapters/codex/index.js +3 -0
- package/dist/src/adapters/codex/ingest.js +167 -0
- package/dist/src/adapters/codex/manifest.js +21 -0
- package/dist/src/adapters/codex/paths.js +15 -0
- package/dist/src/adapters/codex/render.js +90 -0
- package/dist/src/adapters/codex/tool-map.js +16 -0
- package/dist/src/canonical/ids.js +44 -0
- package/dist/src/canonical/schema.js +28 -0
- package/dist/src/canonical/tools.js +21 -0
- package/dist/src/cli.js +442 -0
- package/dist/src/mcp-server.js +75 -0
- package/dist/src/pair-map.js +20 -0
- package/dist/src/session-index.js +369 -0
- package/dist/src/sync-state.js +34 -0
- package/dist/src/sync.js +174 -0
- package/dist/src/title-sync.js +67 -0
- package/dist/src/translator.js +24 -0
- package/dist/src/utils/jsonl.js +30 -0
- package/dist/src/utils/path.js +12 -0
- package/package.json +52 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { CC_PROJECTS, encodeCwd } from "./adapters/claude-code/render.js";
|
|
5
|
+
import { CODEX_HOME } from "./adapters/codex/paths.js";
|
|
6
|
+
import { isAgentBridgeCc, isAgentBridgeCodex } from "./sync.js";
|
|
7
|
+
export function listSessions(opts = {}) {
|
|
8
|
+
const harness = opts.harness ?? "claude-code";
|
|
9
|
+
const cutoff = Date.now() - (opts.days ?? 365) * 86400_000;
|
|
10
|
+
let out = [];
|
|
11
|
+
if (harness === "claude-code" || harness === "both")
|
|
12
|
+
out.push(...listClaudeCode(cutoff));
|
|
13
|
+
if (harness === "codex" || harness === "both")
|
|
14
|
+
out.push(...listCodex(cutoff));
|
|
15
|
+
const source = opts.source ?? "all";
|
|
16
|
+
if (source !== "all")
|
|
17
|
+
out = out.filter((s) => sourceMatches(s.source_harness, source));
|
|
18
|
+
out.sort((a, b) => Date.parse(b.mtime_iso) - Date.parse(a.mtime_iso));
|
|
19
|
+
return out.slice(0, opts.limit ?? 20);
|
|
20
|
+
}
|
|
21
|
+
export function findSession(sessionId) {
|
|
22
|
+
return listSessions({ harness: "both", include_translated: true, limit: Number.MAX_SAFE_INTEGER }).find((s) => s.session_id === sessionId) ?? null;
|
|
23
|
+
}
|
|
24
|
+
export function cleanGenerated(opts = {}) {
|
|
25
|
+
const removed = [];
|
|
26
|
+
const skipped = [];
|
|
27
|
+
for (const s of listSessions({ harness: "both", include_translated: true, limit: Number.MAX_SAFE_INTEGER })) {
|
|
28
|
+
if (!s.translated)
|
|
29
|
+
continue;
|
|
30
|
+
try {
|
|
31
|
+
if (!opts.dry_run)
|
|
32
|
+
unlinkSync(s.path);
|
|
33
|
+
removed.push(s.path);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
skipped.push(s.path);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { removed, skipped };
|
|
40
|
+
}
|
|
41
|
+
export function dedupeGenerated(opts = {}) {
|
|
42
|
+
const seen = new Map();
|
|
43
|
+
const removed = [];
|
|
44
|
+
const skipped = [];
|
|
45
|
+
for (const s of listSessions({ harness: "both", include_translated: true, limit: Number.MAX_SAFE_INTEGER })) {
|
|
46
|
+
if (!s.translated)
|
|
47
|
+
continue;
|
|
48
|
+
const key = `${s.harness}|${s.cwd}|${displayPrompt(s) ?? ""}`;
|
|
49
|
+
const prev = seen.get(key);
|
|
50
|
+
if (!prev) {
|
|
51
|
+
seen.set(key, s);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const loser = Date.parse(s.mtime_iso) >= Date.parse(prev.mtime_iso) ? prev : s;
|
|
55
|
+
if (loser === prev)
|
|
56
|
+
seen.set(key, s);
|
|
57
|
+
try {
|
|
58
|
+
if (!opts.dry_run)
|
|
59
|
+
unlinkSync(loser.path);
|
|
60
|
+
removed.push(loser.path);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
skipped.push(loser.path);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { removed, skipped };
|
|
67
|
+
}
|
|
68
|
+
function listClaudeCode(cutoffMs) {
|
|
69
|
+
const out = [];
|
|
70
|
+
if (!existsSync(CC_PROJECTS))
|
|
71
|
+
return out;
|
|
72
|
+
for (const projectDir of childDirs(CC_PROJECTS)) {
|
|
73
|
+
for (const file of childJsonl(projectDir)) {
|
|
74
|
+
const st = statSync(file);
|
|
75
|
+
if (st.mtimeMs < cutoffMs)
|
|
76
|
+
continue;
|
|
77
|
+
const rows = readRows(file);
|
|
78
|
+
const first = rows.find((r) => r.cwd) ?? rows.find((r) => r.sessionId) ?? rows[0] ?? {};
|
|
79
|
+
const sessionId = String(first.sessionId ?? path.basename(file, ".jsonl"));
|
|
80
|
+
const cwd = String(first.cwd ?? decodeCwd(path.basename(projectDir)));
|
|
81
|
+
const sourceHarness = bridgeSourceFromClaudeMeta(rows) ?? bridgeSourceFromTitle(rows) ?? null;
|
|
82
|
+
out.push({
|
|
83
|
+
harness: "claude-code",
|
|
84
|
+
session_id: sessionId,
|
|
85
|
+
path: file,
|
|
86
|
+
cwd,
|
|
87
|
+
source_harness: sourceHarness,
|
|
88
|
+
first_prompt: firstClaudeUserPrompt(rows),
|
|
89
|
+
display_prompt: firstMeaningfulClaudeUserPrompt(rows),
|
|
90
|
+
session_title: claudeSessionTitle(rows),
|
|
91
|
+
start_command: startCommand("claude-code", cwd),
|
|
92
|
+
resume_command: resumeCommand("claude-code", sessionId, cwd),
|
|
93
|
+
launch_context: claudeLaunchContext(rows),
|
|
94
|
+
size_bytes: st.size,
|
|
95
|
+
mtime_iso: new Date(st.mtimeMs).toISOString(),
|
|
96
|
+
translated: isAgentBridgeCc(file),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
function listCodex(cutoffMs) {
|
|
103
|
+
const out = [];
|
|
104
|
+
const root = path.join(CODEX_HOME, "sessions");
|
|
105
|
+
for (const file of walkJsonl(root)) {
|
|
106
|
+
const st = statSync(file);
|
|
107
|
+
if (st.mtimeMs < cutoffMs || !path.basename(file).startsWith("rollout-"))
|
|
108
|
+
continue;
|
|
109
|
+
const rows = readRows(file);
|
|
110
|
+
const meta = rows.find((r) => r.type === "session_meta")?.payload;
|
|
111
|
+
const turn = rows.find((r) => r.type === "turn_context")?.payload;
|
|
112
|
+
const sessionId = String(meta?.id ?? codexIdFromFilename(file));
|
|
113
|
+
const cwd = String(meta?.cwd ?? turn?.cwd ?? homedir());
|
|
114
|
+
const rawTitle = readCodexThreadName(sessionId);
|
|
115
|
+
const sourceHarness = bridgeSourceFromTitleString(rawTitle) ?? bridgeSourceFromMeta(meta) ?? null;
|
|
116
|
+
const title = rawTitle ? stripBridgeTitlePrefix(rawTitle) : null;
|
|
117
|
+
out.push({
|
|
118
|
+
harness: "codex",
|
|
119
|
+
session_id: sessionId,
|
|
120
|
+
path: file,
|
|
121
|
+
cwd,
|
|
122
|
+
source_harness: sourceHarness,
|
|
123
|
+
first_prompt: firstCodexUserPrompt(rows),
|
|
124
|
+
display_prompt: firstMeaningfulCodexUserPrompt(rows),
|
|
125
|
+
session_title: title,
|
|
126
|
+
start_command: startCommand("codex", cwd),
|
|
127
|
+
resume_command: resumeCommand("codex", sessionId, cwd),
|
|
128
|
+
launch_context: codexLaunchContext(meta, turn),
|
|
129
|
+
size_bytes: st.size,
|
|
130
|
+
mtime_iso: new Date(st.mtimeMs).toISOString(),
|
|
131
|
+
translated: isAgentBridgeCodex(file),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
export function displayPrompt(session) {
|
|
137
|
+
return meaningfulText(session.session_title) ?? session.display_prompt ?? meaningfulText(session.first_prompt);
|
|
138
|
+
}
|
|
139
|
+
function claudeSessionTitle(rows) {
|
|
140
|
+
const title = rows.find((row) => row.type === "custom-title" && typeof row.customTitle === "string")?.customTitle;
|
|
141
|
+
return typeof title === "string" && title.trim() ? stripBridgeTitlePrefix(title) : null;
|
|
142
|
+
}
|
|
143
|
+
function bridgeSourceFromTitle(rows) {
|
|
144
|
+
const title = rows.find((row) => row.type === "custom-title" && typeof row.customTitle === "string")?.customTitle;
|
|
145
|
+
return typeof title === "string" ? bridgeSourceFromTitleString(title) : null;
|
|
146
|
+
}
|
|
147
|
+
function bridgeSourceFromClaudeMeta(rows) {
|
|
148
|
+
const meta = rows.find((row) => row.type === "context-bridge-meta");
|
|
149
|
+
const source = meta?.source_harness ?? meta?.sourceHarness;
|
|
150
|
+
return source === "claude-code" || source === "codex" ? source : null;
|
|
151
|
+
}
|
|
152
|
+
function bridgeSourceFromTitleString(value) {
|
|
153
|
+
const match = value?.match(/^\[from (claude-code|codex)\]\s*/);
|
|
154
|
+
return match?.[1] ?? null;
|
|
155
|
+
}
|
|
156
|
+
function bridgeSourceFromMeta(meta) {
|
|
157
|
+
if (meta?.originator !== "context-bridge" && meta?.originator !== "agent-session-transfer")
|
|
158
|
+
return null;
|
|
159
|
+
const source = meta.source_harness ?? meta.sourceHarness;
|
|
160
|
+
return source === "claude-code" || source === "codex" ? source : null;
|
|
161
|
+
}
|
|
162
|
+
export function sourceDisplay(source) {
|
|
163
|
+
if (source === "claude-code")
|
|
164
|
+
return "claude";
|
|
165
|
+
if (source === "codex")
|
|
166
|
+
return "codex";
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
function sourceMatches(source, filter) {
|
|
170
|
+
if (filter === "native" || filter === "local")
|
|
171
|
+
return source == null;
|
|
172
|
+
if (filter === "claude" || filter === "claude-code")
|
|
173
|
+
return source === "claude-code";
|
|
174
|
+
if (filter === "codex")
|
|
175
|
+
return source === "codex";
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
function firstClaudeUserPrompt(rows) {
|
|
179
|
+
for (const row of rows) {
|
|
180
|
+
if (row.type !== "user")
|
|
181
|
+
continue;
|
|
182
|
+
const prompt = claudeUserPrompt(row);
|
|
183
|
+
if (prompt)
|
|
184
|
+
return prompt;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
function firstMeaningfulClaudeUserPrompt(rows) {
|
|
189
|
+
for (const row of rows) {
|
|
190
|
+
if (row.type !== "user")
|
|
191
|
+
continue;
|
|
192
|
+
const prompt = claudeUserPrompt(row);
|
|
193
|
+
if (prompt && !isBootstrapPrompt(prompt))
|
|
194
|
+
return prompt;
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
function claudeUserPrompt(row) {
|
|
199
|
+
const msg = row.message;
|
|
200
|
+
if (typeof msg?.content === "string" && msg.content.trim())
|
|
201
|
+
return msg.content;
|
|
202
|
+
if (Array.isArray(msg?.content)) {
|
|
203
|
+
const text = msg.content.find((b) => typeof b === "object" && b && b.type === "text");
|
|
204
|
+
if (typeof text?.text === "string" && text.text.trim())
|
|
205
|
+
return text.text;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
function claudeLaunchContext(rows) {
|
|
210
|
+
const first = rows.find((r) => r.cwd || r.entrypoint || r.version || r.userType) ?? {};
|
|
211
|
+
const model = rows.map((r) => r.message?.model).find((v) => typeof v === "string");
|
|
212
|
+
return compactObject({
|
|
213
|
+
executable: "claude",
|
|
214
|
+
entrypoint: first.entrypoint,
|
|
215
|
+
version: first.version,
|
|
216
|
+
user_type: first.userType,
|
|
217
|
+
model,
|
|
218
|
+
git_branch: first.gitBranch,
|
|
219
|
+
inferred: true,
|
|
220
|
+
note: "Session files do not preserve the exact original shell argv; commands are reconstructed from session metadata.",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
function codexLaunchContext(meta, turn) {
|
|
224
|
+
const sandbox = turn?.sandbox_policy;
|
|
225
|
+
return compactObject({
|
|
226
|
+
executable: "codex",
|
|
227
|
+
originator: meta?.originator,
|
|
228
|
+
source: meta?.source,
|
|
229
|
+
cli_version: meta?.cli_version,
|
|
230
|
+
model_provider: meta?.model_provider,
|
|
231
|
+
model: turn?.model,
|
|
232
|
+
reasoning_effort: turn?.effort,
|
|
233
|
+
approval_policy: turn?.approval_policy,
|
|
234
|
+
sandbox: sandbox?.type,
|
|
235
|
+
timezone: turn?.timezone,
|
|
236
|
+
inferred: true,
|
|
237
|
+
note: "Session files do not preserve the exact original shell argv; commands are reconstructed from session metadata.",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
export function startCommand(harness, cwd) {
|
|
241
|
+
const executable = harness === "codex" ? "codex" : "claude";
|
|
242
|
+
return `cd ${shellQuote(cwd)} && ${executable}`;
|
|
243
|
+
}
|
|
244
|
+
export function resumeCommand(harness, sessionId, cwd) {
|
|
245
|
+
const command = harness === "codex" ? `codex exec resume ${shellQuote(sessionId)} "<your prompt>"` : `claude --resume ${shellQuote(sessionId)} -p "<your prompt>"`;
|
|
246
|
+
return cwd ? `cd ${shellQuote(cwd)} && ${command}` : command;
|
|
247
|
+
}
|
|
248
|
+
function compactObject(obj) {
|
|
249
|
+
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value != null && value !== ""));
|
|
250
|
+
}
|
|
251
|
+
function shellQuote(value) {
|
|
252
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
253
|
+
}
|
|
254
|
+
function firstCodexUserPrompt(rows) {
|
|
255
|
+
for (const row of rows) {
|
|
256
|
+
const prompt = codexUserPrompt(row);
|
|
257
|
+
if (prompt)
|
|
258
|
+
return prompt;
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
function firstMeaningfulCodexUserPrompt(rows) {
|
|
263
|
+
for (const row of rows) {
|
|
264
|
+
const prompt = codexUserPrompt(row);
|
|
265
|
+
if (prompt && !isBootstrapPrompt(prompt))
|
|
266
|
+
return prompt;
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
function codexUserPrompt(row) {
|
|
271
|
+
const payload = row.payload;
|
|
272
|
+
if (row.type === "response_item" && payload?.type === "message" && payload.role === "user") {
|
|
273
|
+
const content = payload.content;
|
|
274
|
+
const text = content?.find((c) => c.type === "input_text");
|
|
275
|
+
if (typeof text?.text === "string" && text.text.trim())
|
|
276
|
+
return text.text;
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
function isBootstrapPrompt(value) {
|
|
281
|
+
const text = value.trim();
|
|
282
|
+
return text.startsWith("# AGENTS.md instructions for ") ||
|
|
283
|
+
text.startsWith("<INSTRUCTIONS>") ||
|
|
284
|
+
text.startsWith("<environment_context>") ||
|
|
285
|
+
text.startsWith("<permissions instructions>") ||
|
|
286
|
+
/^<(?:command-message|command-name|command-args)>[\s\S]*?<\/(?:command-message|command-name|command-args)>/.test(text) ||
|
|
287
|
+
/^<local-command-[a-z-]+>/.test(text);
|
|
288
|
+
}
|
|
289
|
+
function meaningfulText(value) {
|
|
290
|
+
if (!value)
|
|
291
|
+
return null;
|
|
292
|
+
return isBootstrapPrompt(value) ? null : value;
|
|
293
|
+
}
|
|
294
|
+
function readCodexThreadName(sessionId) {
|
|
295
|
+
const idx = path.join(CODEX_HOME, "session_index.jsonl");
|
|
296
|
+
if (!existsSync(idx))
|
|
297
|
+
return null;
|
|
298
|
+
let found = null;
|
|
299
|
+
for (const line of readFileSync(idx, "utf8").split(/\r?\n/)) {
|
|
300
|
+
if (!line.trim())
|
|
301
|
+
continue;
|
|
302
|
+
try {
|
|
303
|
+
const row = JSON.parse(line);
|
|
304
|
+
const name = String(row.thread_name ?? "").trim();
|
|
305
|
+
if (row.id === sessionId && name)
|
|
306
|
+
found = name;
|
|
307
|
+
}
|
|
308
|
+
catch { }
|
|
309
|
+
}
|
|
310
|
+
return found;
|
|
311
|
+
}
|
|
312
|
+
function stripBridgeTitlePrefix(value) {
|
|
313
|
+
return value.replace(/^\[from [^\]]+\]\s*/, "").trim();
|
|
314
|
+
}
|
|
315
|
+
function childDirs(dir) {
|
|
316
|
+
try {
|
|
317
|
+
return readdirSync(dir).map((f) => path.join(dir, f)).filter((p) => statSync(p).isDirectory());
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function childJsonl(dir) {
|
|
324
|
+
try {
|
|
325
|
+
return readdirSync(dir).map((f) => path.join(dir, f)).filter((p) => p.endsWith(".jsonl"));
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function walkJsonl(dir) {
|
|
332
|
+
if (!existsSync(dir))
|
|
333
|
+
return [];
|
|
334
|
+
const out = [];
|
|
335
|
+
for (const item of readdirSync(dir)) {
|
|
336
|
+
const full = path.join(dir, item);
|
|
337
|
+
const st = statSync(full);
|
|
338
|
+
if (st.isDirectory())
|
|
339
|
+
out.push(...walkJsonl(full));
|
|
340
|
+
else if (full.endsWith(".jsonl"))
|
|
341
|
+
out.push(full);
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
function readRows(file) {
|
|
346
|
+
try {
|
|
347
|
+
return readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean).map((line) => {
|
|
348
|
+
try {
|
|
349
|
+
return JSON.parse(line);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return {};
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function codexIdFromFilename(file) {
|
|
361
|
+
const name = path.basename(file, ".jsonl");
|
|
362
|
+
return name.replace(/^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-/, "");
|
|
363
|
+
}
|
|
364
|
+
function decodeCwd(encoded) {
|
|
365
|
+
return encoded.startsWith("-") ? encoded.replace(/-/g, "/") : encoded;
|
|
366
|
+
}
|
|
367
|
+
export function expectedClaudeCodePath(cwd, sessionId) {
|
|
368
|
+
return path.join(CC_PROJECTS, encodeCwd(cwd), `${sessionId}.jsonl`);
|
|
369
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { existsSync, statSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { readJsonFile, writeJsonFile } from "./utils/jsonl.js";
|
|
5
|
+
export let STATE_PATH = path.join(homedir(), ".cache", "context-bridge", "sync-state.json");
|
|
6
|
+
export function setStatePath(p) { STATE_PATH = p; }
|
|
7
|
+
export function isUnchanged(source, directionKey, fingerprint = "") {
|
|
8
|
+
const rec = readJsonFile(STATE_PATH, {})[`${directionKey}|${source}`];
|
|
9
|
+
if (!rec || !existsSync(source))
|
|
10
|
+
return false;
|
|
11
|
+
const st = statSync(source);
|
|
12
|
+
return st.size === rec.size && Math.abs(st.mtimeMs / 1000 - Number(rec.mtime ?? 0)) < 1 && (rec.fingerprint ?? "") === fingerprint;
|
|
13
|
+
}
|
|
14
|
+
export function targetChanged(source, directionKey, targetPath, targetFingerprint = "") {
|
|
15
|
+
const rec = readJsonFile(STATE_PATH, {})[`${directionKey}|${source}`];
|
|
16
|
+
if (!rec || !existsSync(targetPath))
|
|
17
|
+
return false;
|
|
18
|
+
return Boolean(rec.target_path) &&
|
|
19
|
+
rec.target_path === targetPath &&
|
|
20
|
+
Boolean(rec.target_fingerprint) &&
|
|
21
|
+
rec.target_fingerprint !== targetFingerprint;
|
|
22
|
+
}
|
|
23
|
+
export function markTranslated(source, directionKey, fingerprint = "", targetPath, targetFingerprint = "") {
|
|
24
|
+
if (!existsSync(source))
|
|
25
|
+
return;
|
|
26
|
+
const st = statSync(source);
|
|
27
|
+
const data = readJsonFile(STATE_PATH, {});
|
|
28
|
+
data[`${directionKey}|${source}`] = { size: st.size, mtime: st.mtimeMs / 1000, fingerprint, target_path: targetPath, target_fingerprint: targetFingerprint };
|
|
29
|
+
writeJsonFile(STATE_PATH, data);
|
|
30
|
+
}
|
|
31
|
+
export function clear() {
|
|
32
|
+
if (existsSync(STATE_PATH))
|
|
33
|
+
unlinkSync(STATE_PATH);
|
|
34
|
+
}
|
package/dist/src/sync.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { deterministicUuid4, deterministicUuid7 } from "./canonical/ids.js";
|
|
6
|
+
import { translate } from "./translator.js";
|
|
7
|
+
import { encodeCwd, setCcProjects, CC_PROJECTS } from "./adapters/claude-code/render.js";
|
|
8
|
+
import { CODEX_HOME, codexPath, setCodexHome } from "./adapters/codex/paths.js";
|
|
9
|
+
import * as syncState from "./sync-state.js";
|
|
10
|
+
import * as pairMap from "./pair-map.js";
|
|
11
|
+
import * as claudeCode from "./adapters/claude-code/index.js";
|
|
12
|
+
import * as codex from "./adapters/codex/index.js";
|
|
13
|
+
export function makeStats() {
|
|
14
|
+
return { translated: 0, skipped_existing: 0, skipped_active: 0, skipped_too_big: 0, skipped_empty: 0, skipped_conflict: 0, failed: 0, failures: [] };
|
|
15
|
+
}
|
|
16
|
+
export function syncOnce(opts = {}) {
|
|
17
|
+
const direction = opts.direction ?? "both";
|
|
18
|
+
const cutoff = Date.now() - (opts.days ?? 365) * 86400_000;
|
|
19
|
+
const stats = makeStats();
|
|
20
|
+
if (direction === "cc-to-codex" || direction === "both")
|
|
21
|
+
for (const p of listCcSessions(cutoff))
|
|
22
|
+
tryTranslateOne(p, "claude-code", "codex", opts.max_bytes ?? 100 * 1024 * 1024, stats, opts.force ?? false, opts.log ?? (() => { }));
|
|
23
|
+
if (direction === "codex-to-cc" || direction === "both")
|
|
24
|
+
for (const p of listCodexSessions(cutoff))
|
|
25
|
+
tryTranslateOne(p, "codex", "claude-code", opts.max_bytes ?? 100 * 1024 * 1024, stats, opts.force ?? false, opts.log ?? (() => { }));
|
|
26
|
+
return stats;
|
|
27
|
+
}
|
|
28
|
+
function tryTranslateOne(source, src, tgt, maxBytes, stats, force, log) {
|
|
29
|
+
try {
|
|
30
|
+
const st = statSync(source);
|
|
31
|
+
if (st.size > maxBytes) {
|
|
32
|
+
stats.skipped_too_big++;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const [targetPath, targetId] = expectedTarget(source, src, tgt);
|
|
36
|
+
const key = `${src}-to-${tgt}`;
|
|
37
|
+
const fingerprint = fileFingerprint(source);
|
|
38
|
+
const targetFingerprint = existsSync(targetPath) ? fileFingerprint(targetPath) : "";
|
|
39
|
+
if (!force && existsSync(targetPath) && syncState.isUnchanged(source, key, fingerprint)) {
|
|
40
|
+
stats.skipped_existing++;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!force && existsSync(targetPath) && syncState.targetChanged(source, key, targetPath, targetFingerprint)) {
|
|
44
|
+
stats.skipped_conflict++;
|
|
45
|
+
log(`! ${src}->${tgt}: target modified, skipped ${targetPath}`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const session = src === "claude-code" ? claudeCode.ingest(source) : codex.ingest(source);
|
|
49
|
+
if (!session.moments.length) {
|
|
50
|
+
stats.skipped_empty++;
|
|
51
|
+
if (existsSync(targetPath) && (tgt === "claude-code" ? isAgentBridgeCc(targetPath) : isAgentBridgeCodex(targetPath)))
|
|
52
|
+
unlinkSync(targetPath);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
translate({ source_path: source, source_harness: src, target_harness: tgt, session_id: targetId, target_dir: tgt === "claude-code" ? CC_PROJECTS : CODEX_HOME, title_prefix: tgt === "claude-code" ? `[from ${src}] ` : undefined });
|
|
56
|
+
if (src === "claude-code" && tgt === "codex")
|
|
57
|
+
pairMap.record({ cc_id: path.basename(source, ".jsonl"), codex_id: targetId });
|
|
58
|
+
if (src === "codex" && tgt === "claude-code")
|
|
59
|
+
pairMap.record({ cc_id: targetId, codex_id: codexSourceId(readHead(source), source) });
|
|
60
|
+
syncState.markTranslated(source, key, fingerprint, targetPath, fileFingerprint(targetPath));
|
|
61
|
+
stats.translated++;
|
|
62
|
+
log(`✓ ${src}->${tgt}: ${path.basename(source)} -> ${targetId}`);
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
stats.failed++;
|
|
66
|
+
stats.failures.push([source, e instanceof Error ? e.message : String(e)]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function expectedTarget(source, src, tgt) {
|
|
70
|
+
const head = readHead(source);
|
|
71
|
+
if (src === "claude-code" && tgt === "codex") {
|
|
72
|
+
const started = firstTimestamp(head);
|
|
73
|
+
const id = deterministicUuid7(`cc:${path.basename(source, ".jsonl")}`, started);
|
|
74
|
+
return [codexPath(id, started), id];
|
|
75
|
+
}
|
|
76
|
+
if (src === "codex" && tgt === "claude-code") {
|
|
77
|
+
const cwd = firstCwdCodex(head) ?? homedir();
|
|
78
|
+
const id = deterministicUuid4(`codex:${codexSourceId(head, source)}`);
|
|
79
|
+
return [path.join(CC_PROJECTS, encodeCwd(cwd), `${id}.jsonl`), id];
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`${src}->${tgt} not supported`);
|
|
82
|
+
}
|
|
83
|
+
function readHead(p) {
|
|
84
|
+
return readFileSync(p, "utf8").split(/\r?\n/).filter(Boolean).slice(0, 10).map((l) => { try {
|
|
85
|
+
return JSON.parse(l);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return {};
|
|
89
|
+
} });
|
|
90
|
+
}
|
|
91
|
+
function firstTimestamp(rows) {
|
|
92
|
+
return String(rows.find((r) => r.timestamp)?.timestamp ?? "2026-01-01T00:00:00.000Z");
|
|
93
|
+
}
|
|
94
|
+
function firstCwdCodex(rows) {
|
|
95
|
+
for (const r of rows)
|
|
96
|
+
if (r.type === "session_meta")
|
|
97
|
+
return String((r.payload?.cwd) ?? "");
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
function codexSourceId(rows, p) {
|
|
101
|
+
for (const r of rows)
|
|
102
|
+
if (r.type === "session_meta" && r.payload?.id)
|
|
103
|
+
return String(r.payload.id);
|
|
104
|
+
return path.basename(p, ".jsonl").split("-").slice(-5).join("-");
|
|
105
|
+
}
|
|
106
|
+
function listCcSessions(cutoffMs) {
|
|
107
|
+
const out = [];
|
|
108
|
+
if (!existsSync(CC_PROJECTS))
|
|
109
|
+
return out;
|
|
110
|
+
for (const proj of childDirs(CC_PROJECTS))
|
|
111
|
+
for (const full of childJsonl(proj)) {
|
|
112
|
+
if (statSync(full).mtimeMs >= cutoffMs && !isAgentBridgeCc(full))
|
|
113
|
+
out.push(full);
|
|
114
|
+
}
|
|
115
|
+
return out.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
116
|
+
}
|
|
117
|
+
function listCodexSessions(cutoffMs) {
|
|
118
|
+
const root = path.join(CODEX_HOME, "sessions");
|
|
119
|
+
const out = [];
|
|
120
|
+
walk(root, out);
|
|
121
|
+
return out.filter((p) => path.basename(p).startsWith("rollout-") && p.endsWith(".jsonl") && statSync(p).mtimeMs >= cutoffMs && !isAgentBridgeCodex(p));
|
|
122
|
+
}
|
|
123
|
+
function childDirs(dir) {
|
|
124
|
+
try {
|
|
125
|
+
return readdirSync(dir).map((f) => path.join(dir, f)).filter((p) => statSync(p).isDirectory());
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function childJsonl(dir) {
|
|
132
|
+
try {
|
|
133
|
+
return readdirSync(dir).map((f) => path.join(dir, f)).filter((p) => p.endsWith(".jsonl"));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function walk(dir, out) {
|
|
140
|
+
if (!existsSync(dir))
|
|
141
|
+
return;
|
|
142
|
+
for (const item of readdirSync(dir)) {
|
|
143
|
+
const full = path.join(dir, item);
|
|
144
|
+
const st = statSync(full);
|
|
145
|
+
if (st.isDirectory())
|
|
146
|
+
walk(full, out);
|
|
147
|
+
else
|
|
148
|
+
out.push(full);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export function isAgentBridgeCc(p) {
|
|
152
|
+
try {
|
|
153
|
+
const head = readFileSync(p, "utf8").slice(0, 4096);
|
|
154
|
+
return head.includes('"type":"context-bridge-meta"') ||
|
|
155
|
+
head.includes('"type": "context-bridge-meta"') ||
|
|
156
|
+
head.includes("[from ");
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export function isAgentBridgeCodex(p) {
|
|
163
|
+
try {
|
|
164
|
+
const first = JSON.parse(readFileSync(p, "utf8").split(/\r?\n/)[0]);
|
|
165
|
+
return first.type === "session_meta" && (first.payload?.originator === "context-bridge" || first.payload?.originator === "agent-session-transfer");
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export { setCcProjects, setCodexHome };
|
|
172
|
+
function fileFingerprint(source) {
|
|
173
|
+
return createHash("sha256").update(readFileSync(source)).digest("hex");
|
|
174
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CODEX_HOME } from "./adapters/codex/paths.js";
|
|
4
|
+
import { read as readPairMap } from "./pair-map.js";
|
|
5
|
+
import { findSession } from "./session-index.js";
|
|
6
|
+
export function syncTitles() {
|
|
7
|
+
let updated_codex = 0;
|
|
8
|
+
let updated_claude_code = 0;
|
|
9
|
+
const pairs = readPairMap();
|
|
10
|
+
for (const [ccId, codexId] of Object.entries(pairs.cc_to_codex)) {
|
|
11
|
+
const cc = findSession(ccId);
|
|
12
|
+
const codex = findSession(codexId);
|
|
13
|
+
if (!cc && !codex)
|
|
14
|
+
continue;
|
|
15
|
+
const ccTitle = cc ? readClaudeTitle(cc.path) ?? cc.session_title ?? cc.first_prompt : null;
|
|
16
|
+
const codexTitle = codex ? readCodexTitle(codexId) ?? codex.session_title ?? codex.first_prompt : null;
|
|
17
|
+
if (ccTitle && codex && codexTitle !== ccTitle) {
|
|
18
|
+
appendCodexTitle(codexId, ccTitle);
|
|
19
|
+
updated_codex++;
|
|
20
|
+
}
|
|
21
|
+
if (codexTitle && cc && ccTitle !== codexTitle && cc.translated) {
|
|
22
|
+
appendClaudeTitle(cc.path, ccId, codexTitle);
|
|
23
|
+
updated_claude_code++;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { updated_codex, updated_claude_code };
|
|
27
|
+
}
|
|
28
|
+
function readClaudeTitle(file) {
|
|
29
|
+
try {
|
|
30
|
+
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
31
|
+
if (!line.trim())
|
|
32
|
+
continue;
|
|
33
|
+
const row = JSON.parse(line);
|
|
34
|
+
if (row.type === "custom-title" && typeof row.customTitle === "string")
|
|
35
|
+
return row.customTitle.replace(/^\[from [^\]]+\]\s*/, "");
|
|
36
|
+
if (row.type !== "custom-title")
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
function appendClaudeTitle(file, sessionId, title) {
|
|
44
|
+
appendFileSync(file, JSON.stringify({ type: "custom-title", customTitle: title, sessionId }) + "\n", "utf8");
|
|
45
|
+
}
|
|
46
|
+
function readCodexTitle(id) {
|
|
47
|
+
const p = path.join(CODEX_HOME, "session_index.jsonl");
|
|
48
|
+
if (!existsSync(p))
|
|
49
|
+
return null;
|
|
50
|
+
let found = null;
|
|
51
|
+
for (const line of readFileSync(p, "utf8").split(/\r?\n/)) {
|
|
52
|
+
if (!line.trim())
|
|
53
|
+
continue;
|
|
54
|
+
try {
|
|
55
|
+
const row = JSON.parse(line);
|
|
56
|
+
if (row.id === id && typeof row.thread_name === "string")
|
|
57
|
+
found = row.thread_name.replace(/^\[from [^\]]+\]\s*/, "");
|
|
58
|
+
}
|
|
59
|
+
catch { }
|
|
60
|
+
}
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
function appendCodexTitle(id, title) {
|
|
64
|
+
const p = path.join(CODEX_HOME, "session_index.jsonl");
|
|
65
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
66
|
+
appendFileSync(p, JSON.stringify({ id, thread_name: title, updated_at: new Date().toISOString() }) + "\n", "utf8");
|
|
67
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as claudeCode from "./adapters/claude-code/index.js";
|
|
2
|
+
import * as codex from "./adapters/codex/index.js";
|
|
3
|
+
export function translate(opts) {
|
|
4
|
+
if (opts.source_harness === "claude-code" && opts.target_harness === "codex") {
|
|
5
|
+
const session = claudeCode.ingest(opts.source_path, { follow_subagents: opts.follow_subagents });
|
|
6
|
+
return codex.render(session, { target_dir: opts.target_dir, session_id: opts.session_id });
|
|
7
|
+
}
|
|
8
|
+
if (opts.source_harness === "codex" && opts.target_harness === "claude-code") {
|
|
9
|
+
const session = codex.ingest(opts.source_path);
|
|
10
|
+
return claudeCode.render(session, { target_dir: opts.target_dir, session_id: opts.session_id, title_prefix: opts.title_prefix ?? `[from ${opts.source_harness}] ` });
|
|
11
|
+
}
|
|
12
|
+
throw new Error(`Direction not implemented: ${opts.source_harness} -> ${opts.target_harness}`);
|
|
13
|
+
}
|
|
14
|
+
export function copySession(opts) {
|
|
15
|
+
if (opts.source_harness === "claude-code" && opts.target_harness === "codex") {
|
|
16
|
+
const session = claudeCode.ingest(opts.source_path, { follow_subagents: opts.follow_subagents });
|
|
17
|
+
return codex.render(session, { target_dir: opts.target_dir, copy_mode: true });
|
|
18
|
+
}
|
|
19
|
+
if (opts.source_harness === "codex" && opts.target_harness === "claude-code") {
|
|
20
|
+
const session = codex.ingest(opts.source_path);
|
|
21
|
+
return claudeCode.render(session, { target_dir: opts.target_dir, copy_mode: true });
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`Direction not implemented: ${opts.source_harness} -> ${opts.target_harness}`);
|
|
24
|
+
}
|