codex-claude-relay 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,401 @@
1
+ import { readdir, stat, readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, sep } from 'node:path';
5
+ import { parseJsonl, clip } from '../parse/jsonl.js';
6
+ export const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
7
+ /**
8
+ * Claude Code encodes the project directory by replacing path separators with
9
+ * dashes. e.g. /Users/alice/work/foo -> -Users-alice-work-foo
10
+ *
11
+ * We can't reverse this perfectly (a `-` in a real directory name is ambiguous)
12
+ * but we can compute the most likely encoded name and use it as a fast-path.
13
+ */
14
+ export function encodeProjectDir(absPath) {
15
+ // Strip leading sep so the encoded form starts with a dash, matching observed format.
16
+ // Replace any sequence of `sep` with `-`, and inside-segment '-' are kept.
17
+ const normalized = absPath.replace(/\\/g, '/');
18
+ // Replace '/' with '-' and also encode '.' as '-' (Claude's behavior).
19
+ return normalized.replace(/[/\\.]/g, '-');
20
+ }
21
+ /** Recursively collect `*.jsonl` files under `dir`. */
22
+ async function collectJsonl(dir, depth = 0) {
23
+ const out = [];
24
+ if (depth > 4)
25
+ return out; // safety
26
+ let entries;
27
+ try {
28
+ entries = (await readdir(dir, { withFileTypes: true }));
29
+ }
30
+ catch {
31
+ return out;
32
+ }
33
+ for (const e of entries) {
34
+ const full = join(dir, e.name);
35
+ if (e.isDirectory()) {
36
+ // Skip noisy subtrees like tool-results within session dirs.
37
+ if (e.name === 'tool-results' || e.name === 'memory')
38
+ continue;
39
+ const nested = await collectJsonl(full, depth + 1);
40
+ out.push(...nested);
41
+ }
42
+ else if (e.isFile() && e.name.endsWith('.jsonl')) {
43
+ out.push(full);
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+ /** Sample lines of a Claude transcript to detect its recorded cwd. */
49
+ async function detectClaudeCwd(path) {
50
+ // We can't easily peek mid-file; just sample first lines.
51
+ const { records } = await parseJsonl(path, (obj) => {
52
+ if (!obj || typeof obj !== 'object')
53
+ return null;
54
+ const rec = obj;
55
+ if (typeof rec.cwd === 'string')
56
+ return rec.cwd;
57
+ return null;
58
+ });
59
+ return records[0] ?? null;
60
+ }
61
+ /**
62
+ * Discover Claude session JSONL files and rank them.
63
+ *
64
+ * Strategy:
65
+ * 1. Fast path: try `~/.claude/projects/<encoded(root)>/*.jsonl` first; those
66
+ * get a big score boost.
67
+ * 2. Fallback: scan the full `projects/` tree (one level deep is typical),
68
+ * detect cwd from each transcript, and rank.
69
+ */
70
+ export async function discoverClaudeSessions(git) {
71
+ if (!existsSync(CLAUDE_PROJECTS_DIR))
72
+ return [];
73
+ const encoded = encodeProjectDir(git.root);
74
+ const fastPath = join(CLAUDE_PROJECTS_DIR, encoded);
75
+ let paths = [];
76
+ const fastMatch = existsSync(fastPath);
77
+ if (fastMatch) {
78
+ paths = await collectJsonl(fastPath, 0);
79
+ }
80
+ // Always also do a broad scan so the user can still find sessions even if
81
+ // the project directory encoding has changed.
82
+ const broad = await collectJsonl(CLAUDE_PROJECTS_DIR, 0);
83
+ for (const p of broad) {
84
+ if (!paths.includes(p))
85
+ paths.push(p);
86
+ }
87
+ const candidates = [];
88
+ for (const p of paths) {
89
+ let mtimeMs = 0;
90
+ try {
91
+ const st = await stat(p);
92
+ mtimeMs = st.mtimeMs;
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ let recordedCwd = await detectClaudeCwd(p);
98
+ const reasons = [];
99
+ let score = 0;
100
+ if (fastMatch && p.startsWith(fastPath + sep)) {
101
+ score += 40;
102
+ reasons.push('inside encoded project dir');
103
+ }
104
+ if (recordedCwd) {
105
+ if (recordedCwd === git.root) {
106
+ score += 60;
107
+ reasons.push('cwd matches git root exactly');
108
+ }
109
+ else if (git.inRepo && recordedCwd.startsWith(git.root + sep)) {
110
+ score += 50;
111
+ reasons.push('cwd inside git root');
112
+ }
113
+ else if (recordedCwd.includes(git.repoName)) {
114
+ score += 20;
115
+ reasons.push(`cwd path mentions repo name "${git.repoName}"`);
116
+ }
117
+ }
118
+ const ageDays = (Date.now() - mtimeMs) / (24 * 3600 * 1000);
119
+ const recency = Math.max(0, 30 * (1 - ageDays / 14));
120
+ score += recency;
121
+ reasons.push(`recency +${recency.toFixed(1)} (age ${ageDays.toFixed(1)}d)`);
122
+ candidates.push({
123
+ path: p,
124
+ mtimeMs,
125
+ recordedCwd,
126
+ score,
127
+ reasons,
128
+ });
129
+ }
130
+ candidates.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs);
131
+ return candidates;
132
+ }
133
+ export async function pickClaudeSession(git, forceLast) {
134
+ const all = await discoverClaudeSessions(git);
135
+ if (all.length === 0)
136
+ return null;
137
+ if (forceLast) {
138
+ return [...all].sort((a, b) => b.mtimeMs - a.mtimeMs)[0];
139
+ }
140
+ return all[0];
141
+ }
142
+ /**
143
+ * Read the Claude Code auto-memory directory for the current project.
144
+ *
145
+ * Layout: `~/.claude/projects/<encoded>/memory/MEMORY.md` plus arbitrarily-named
146
+ * `.md` files referenced by it.
147
+ */
148
+ export async function readClaudeMemory(git) {
149
+ const encoded = encodeProjectDir(git.root);
150
+ const dir = join(CLAUDE_PROJECTS_DIR, encoded, 'memory');
151
+ const result = { exists: false, dir, index: null, summary: '' };
152
+ if (!existsSync(dir))
153
+ return result;
154
+ result.exists = true;
155
+ const indexPath = join(dir, 'MEMORY.md');
156
+ if (existsSync(indexPath)) {
157
+ try {
158
+ result.index = await readFile(indexPath, 'utf8');
159
+ }
160
+ catch {
161
+ result.index = null;
162
+ }
163
+ }
164
+ // Concatenate index + every linked file, capped to keep things compact.
165
+ const parts = [];
166
+ if (result.index)
167
+ parts.push(`# MEMORY.md\n${result.index.trim()}`);
168
+ try {
169
+ const entries = await readdir(dir);
170
+ for (const name of entries) {
171
+ if (!name.endsWith('.md') || name === 'MEMORY.md')
172
+ continue;
173
+ try {
174
+ const content = await readFile(join(dir, name), 'utf8');
175
+ parts.push(`# ${name}\n${content.trim()}`);
176
+ }
177
+ catch {
178
+ // ignore
179
+ }
180
+ if (parts.join('\n\n').length > 8000)
181
+ break;
182
+ }
183
+ }
184
+ catch {
185
+ // ignore
186
+ }
187
+ result.summary = parts.join('\n\n').slice(0, 8000);
188
+ return result;
189
+ }
190
+ /* ---------------------------- parsing ---------------------------------- */
191
+ /**
192
+ * Detect Claude Code framework-injected "user" messages that aren't really the
193
+ * user typing — task-completion notifications, system reminders, image attachment
194
+ * markers, slash-command boilerplate, etc. These pollute the handoff and should
195
+ * be filtered out so only real user instructions remain.
196
+ */
197
+ function isClaudeSystemNoise(text) {
198
+ const t = text.trim();
199
+ if (t.length === 0)
200
+ return true;
201
+ // Tag-shaped framework messages
202
+ if (/^<(?:task-notification|system-reminder|command-name|command-message|command-args|local-command-stdout|user-prompt-submit-hook|bash-input|bash-stdout|bash-stderr)[\s>]/i.test(t)) {
203
+ return true;
204
+ }
205
+ // Image-attachment markers (one or more, possibly separated by whitespace)
206
+ if (/^(?:\[Image[: ][^\]]+\]\s*)+$/.test(t))
207
+ return true;
208
+ if (/^\[Request interrupted by user\]/i.test(t))
209
+ return true;
210
+ // Pure tag wrappers like "<...>...</...>" with no other content
211
+ if (/^<[\w-]+>[\s\S]*<\/[\w-]+>\s*$/.test(t) && t.length < 600)
212
+ return true;
213
+ return false;
214
+ }
215
+ function asString(v) {
216
+ if (typeof v === 'string')
217
+ return v;
218
+ if (v == null)
219
+ return '';
220
+ try {
221
+ return JSON.stringify(v);
222
+ }
223
+ catch {
224
+ return String(v);
225
+ }
226
+ }
227
+ /** Extract human-readable text from a Claude `message.content` value. */
228
+ function extractClaudeText(content) {
229
+ const toolUses = [];
230
+ const toolResults = [];
231
+ if (typeof content === 'string')
232
+ return { text: content, toolUses, toolResults };
233
+ if (!Array.isArray(content))
234
+ return { text: '', toolUses, toolResults };
235
+ const parts = [];
236
+ for (const c of content) {
237
+ if (!c || typeof c !== 'object')
238
+ continue;
239
+ const obj = c;
240
+ const t = obj.type;
241
+ if (t === 'text' && typeof obj.text === 'string') {
242
+ parts.push(obj.text);
243
+ }
244
+ else if (t === 'tool_use') {
245
+ const name = typeof obj.name === 'string' ? obj.name : 'tool';
246
+ const input = (obj.input && typeof obj.input === 'object') ? obj.input : {};
247
+ toolUses.push({ name, input });
248
+ }
249
+ else if (t === 'tool_result') {
250
+ const inner = obj.content;
251
+ let textOut = '';
252
+ if (typeof inner === 'string')
253
+ textOut = inner;
254
+ else if (Array.isArray(inner)) {
255
+ for (const it of inner) {
256
+ if (it && typeof it === 'object' && it.type === 'text') {
257
+ const tx = it.text;
258
+ if (typeof tx === 'string')
259
+ textOut += tx + '\n';
260
+ }
261
+ }
262
+ }
263
+ const isError = obj.is_error === true;
264
+ toolResults.push({ content: textOut.trim(), isError });
265
+ }
266
+ // skip thinking
267
+ }
268
+ return { text: parts.join('\n').trim(), toolUses, toolResults };
269
+ }
270
+ /** Convert a Claude tool_use input into a compact summary. */
271
+ function summarizeToolUse(name, input) {
272
+ let command;
273
+ const files = [];
274
+ if (typeof input.command === 'string')
275
+ command = input.command;
276
+ for (const key of ['file_path', 'path', 'notebook_path']) {
277
+ const v = input[key];
278
+ if (typeof v === 'string')
279
+ files.push(v);
280
+ }
281
+ if (Array.isArray(input.files)) {
282
+ for (const v of input.files ?? []) {
283
+ if (typeof v === 'string')
284
+ files.push(v);
285
+ }
286
+ }
287
+ // Edit/Write/MultiEdit tools have file_path; Read has file_path; Bash has command.
288
+ // Grep/Glob have `pattern` — include it as part of the summary.
289
+ if (name === 'Grep' || name === 'Glob') {
290
+ const pattern = input.pattern;
291
+ if (typeof pattern === 'string') {
292
+ return { text: `${name} pattern=${clip(pattern, 80)}` };
293
+ }
294
+ }
295
+ let text = name;
296
+ if (command)
297
+ text += ` $ ${clip(command, 200)}`;
298
+ if (files.length)
299
+ text += ` [${files.slice(0, 6).join(', ')}]`;
300
+ return { text, command, files: files.length ? files : undefined };
301
+ }
302
+ export async function parseClaudeSession(path) {
303
+ const events = [];
304
+ let recordedCwd = null;
305
+ let recordedBranch = null;
306
+ let sessionId = null;
307
+ let startedAtMs = null;
308
+ let endedAtMs = null;
309
+ const { skipped, records } = await parseJsonl(path, (obj, lineNo) => {
310
+ if (!obj || typeof obj !== 'object')
311
+ return null;
312
+ const rec = obj;
313
+ if (typeof rec.cwd === 'string' && !recordedCwd)
314
+ recordedCwd = rec.cwd;
315
+ if (typeof rec.gitBranch === 'string' && !recordedBranch)
316
+ recordedBranch = rec.gitBranch;
317
+ if (typeof rec.sessionId === 'string' && !sessionId)
318
+ sessionId = rec.sessionId;
319
+ const tsStr = typeof rec.timestamp === 'string' ? rec.timestamp : null;
320
+ const tsMs = tsStr ? Date.parse(tsStr) : NaN;
321
+ const ts = Number.isFinite(tsMs) ? tsMs : null;
322
+ if (ts !== null) {
323
+ if (startedAtMs === null || ts < startedAtMs)
324
+ startedAtMs = ts;
325
+ if (endedAtMs === null || ts > endedAtMs)
326
+ endedAtMs = ts;
327
+ }
328
+ const type = rec.type;
329
+ if (type !== 'user' && type !== 'assistant')
330
+ return null;
331
+ const message = (rec.message ?? {});
332
+ const role = message.role;
333
+ const { text, toolUses, toolResults } = extractClaudeText(message.content);
334
+ const out = [];
335
+ if (role === 'user' && type === 'user') {
336
+ // Skip side-chains and internal system reminders.
337
+ if (rec.isSidechain === true)
338
+ return null;
339
+ if (text && !isClaudeSystemNoise(text)) {
340
+ // Skip pure tool_result echoes (those are emitted from `tool_result` parts).
341
+ out.push({
342
+ lineNo,
343
+ timestampMs: ts,
344
+ kind: 'user_message',
345
+ text,
346
+ });
347
+ }
348
+ for (const r of toolResults) {
349
+ if (!r.content)
350
+ continue;
351
+ out.push({
352
+ lineNo,
353
+ timestampMs: ts,
354
+ kind: 'tool_result',
355
+ text: clip(r.content, 400),
356
+ isError: r.isError,
357
+ });
358
+ }
359
+ }
360
+ else if (role === 'assistant' && type === 'assistant') {
361
+ if (text) {
362
+ out.push({
363
+ lineNo,
364
+ timestampMs: ts,
365
+ kind: 'assistant_message',
366
+ text,
367
+ });
368
+ }
369
+ for (const tu of toolUses) {
370
+ const d = summarizeToolUse(tu.name, tu.input);
371
+ out.push({
372
+ lineNo,
373
+ timestampMs: ts,
374
+ kind: 'tool_call',
375
+ text: d.text,
376
+ toolName: tu.name,
377
+ command: d.command,
378
+ files: d.files,
379
+ });
380
+ }
381
+ }
382
+ return out.length > 0 ? out : null;
383
+ });
384
+ for (const r of records) {
385
+ if (Array.isArray(r))
386
+ events.push(...r);
387
+ else
388
+ events.push(r);
389
+ }
390
+ return {
391
+ path,
392
+ recordedCwd,
393
+ recordedBranch,
394
+ sessionId,
395
+ startedAtMs,
396
+ endedAtMs,
397
+ parsedLines: events.length,
398
+ skippedLines: skipped,
399
+ events,
400
+ };
401
+ }
@@ -0,0 +1,11 @@
1
+ import type { GitContext, ParsedSession, SessionCandidate } from '../types.js';
2
+ export declare const CODEX_SESSIONS_DIR: string;
3
+ /**
4
+ * Discover Codex rollout files and rank them by relevance to the current git
5
+ * context. Returns the candidates sorted best-first.
6
+ */
7
+ export declare function discoverCodexSessions(git: GitContext): Promise<SessionCandidate[]>;
8
+ /** Pick the best Codex session. With `forceLast`, always pick the most recent by mtime. */
9
+ export declare function pickCodexSession(git: GitContext, forceLast: boolean): Promise<SessionCandidate | null>;
10
+ /** Parse a full Codex rollout JSONL into normalized events. */
11
+ export declare function parseCodexSession(path: string): Promise<ParsedSession>;