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.
- package/LICENSE +21 -0
- package/README.md +534 -0
- package/README.zh.md +522 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +304 -0
- package/dist/git.d.ts +11 -0
- package/dist/git.js +58 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +10 -0
- package/dist/launch.d.ts +29 -0
- package/dist/launch.js +66 -0
- package/dist/parse/jsonl.d.ts +21 -0
- package/dist/parse/jsonl.js +75 -0
- package/dist/providers/claude.d.ts +36 -0
- package/dist/providers/claude.js +401 -0
- package/dist/providers/codex.d.ts +11 -0
- package/dist/providers/codex.js +310 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +84 -0
- package/dist/summarize.d.ts +13 -0
- package/dist/summarize.js +241 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.js +9 -0
- package/package.json +56 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { readdir, stat } 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, peekJsonl, clip } from '../parse/jsonl.js';
|
|
6
|
+
export const CODEX_SESSIONS_DIR = join(homedir(), '.codex', 'sessions');
|
|
7
|
+
/** Recursively collect `rollout-*.jsonl` files under `dir`. */
|
|
8
|
+
async function collectRollouts(dir) {
|
|
9
|
+
const out = [];
|
|
10
|
+
let entries;
|
|
11
|
+
try {
|
|
12
|
+
entries = (await readdir(dir, { withFileTypes: true }));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
for (const e of entries) {
|
|
18
|
+
const full = join(dir, e.name);
|
|
19
|
+
if (e.isDirectory()) {
|
|
20
|
+
const nested = await collectRollouts(full);
|
|
21
|
+
out.push(...nested);
|
|
22
|
+
}
|
|
23
|
+
else if (e.isFile() && e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) {
|
|
24
|
+
out.push(full);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/** Read the first object of a rollout JSONL to extract the recorded cwd. */
|
|
30
|
+
async function readCodexMeta(path) {
|
|
31
|
+
try {
|
|
32
|
+
const head = await peekJsonl(path, 1);
|
|
33
|
+
const first = head[0];
|
|
34
|
+
if (!first || first.type !== 'session_meta')
|
|
35
|
+
return { cwd: null, ts: null };
|
|
36
|
+
const payload = first.payload;
|
|
37
|
+
const cwd = typeof payload?.cwd === 'string' ? payload.cwd : null;
|
|
38
|
+
const ts = typeof payload?.timestamp === 'string' ? payload.timestamp : null;
|
|
39
|
+
return { cwd, ts };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { cwd: null, ts: null };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Discover Codex rollout files and rank them by relevance to the current git
|
|
47
|
+
* context. Returns the candidates sorted best-first.
|
|
48
|
+
*/
|
|
49
|
+
export async function discoverCodexSessions(git) {
|
|
50
|
+
if (!existsSync(CODEX_SESSIONS_DIR))
|
|
51
|
+
return [];
|
|
52
|
+
const paths = await collectRollouts(CODEX_SESSIONS_DIR);
|
|
53
|
+
const candidates = [];
|
|
54
|
+
for (const p of paths) {
|
|
55
|
+
let mtimeMs = 0;
|
|
56
|
+
try {
|
|
57
|
+
const st = await stat(p);
|
|
58
|
+
mtimeMs = st.mtimeMs;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const meta = await readCodexMeta(p);
|
|
64
|
+
const reasons = [];
|
|
65
|
+
let score = 0;
|
|
66
|
+
if (meta.cwd) {
|
|
67
|
+
if (meta.cwd === git.root) {
|
|
68
|
+
score += 60;
|
|
69
|
+
reasons.push('cwd matches git root exactly');
|
|
70
|
+
}
|
|
71
|
+
else if (git.inRepo && meta.cwd.startsWith(git.root + sep)) {
|
|
72
|
+
score += 50;
|
|
73
|
+
reasons.push('cwd inside git root');
|
|
74
|
+
}
|
|
75
|
+
else if (meta.cwd.includes(git.repoName)) {
|
|
76
|
+
score += 25;
|
|
77
|
+
reasons.push(`cwd path mentions repo name "${git.repoName}"`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Recency weight: linear decay over 14 days, max 30 points.
|
|
81
|
+
const ageDays = (Date.now() - mtimeMs) / (24 * 3600 * 1000);
|
|
82
|
+
const recency = Math.max(0, 30 * (1 - ageDays / 14));
|
|
83
|
+
score += recency;
|
|
84
|
+
reasons.push(`recency +${recency.toFixed(1)} (age ${ageDays.toFixed(1)}d)`);
|
|
85
|
+
candidates.push({
|
|
86
|
+
path: p,
|
|
87
|
+
mtimeMs,
|
|
88
|
+
recordedCwd: meta.cwd,
|
|
89
|
+
score,
|
|
90
|
+
reasons,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
candidates.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs);
|
|
94
|
+
return candidates;
|
|
95
|
+
}
|
|
96
|
+
/** Pick the best Codex session. With `forceLast`, always pick the most recent by mtime. */
|
|
97
|
+
export async function pickCodexSession(git, forceLast) {
|
|
98
|
+
const all = await discoverCodexSessions(git);
|
|
99
|
+
if (all.length === 0)
|
|
100
|
+
return null;
|
|
101
|
+
if (forceLast) {
|
|
102
|
+
return [...all].sort((a, b) => b.mtimeMs - a.mtimeMs)[0];
|
|
103
|
+
}
|
|
104
|
+
return all[0];
|
|
105
|
+
}
|
|
106
|
+
/* ----------------------------- parsing --------------------------------- */
|
|
107
|
+
function asString(v) {
|
|
108
|
+
if (typeof v === 'string')
|
|
109
|
+
return v;
|
|
110
|
+
if (v == null)
|
|
111
|
+
return '';
|
|
112
|
+
try {
|
|
113
|
+
return JSON.stringify(v);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return String(v);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Extract text out of Codex's `content` array on message payloads. */
|
|
120
|
+
function extractMessageText(content) {
|
|
121
|
+
if (typeof content === 'string')
|
|
122
|
+
return content;
|
|
123
|
+
if (!Array.isArray(content))
|
|
124
|
+
return '';
|
|
125
|
+
const parts = [];
|
|
126
|
+
for (const c of content) {
|
|
127
|
+
if (!c || typeof c !== 'object')
|
|
128
|
+
continue;
|
|
129
|
+
const obj = c;
|
|
130
|
+
if (typeof obj.text === 'string')
|
|
131
|
+
parts.push(obj.text);
|
|
132
|
+
else if (typeof obj.content === 'string')
|
|
133
|
+
parts.push(obj.content);
|
|
134
|
+
}
|
|
135
|
+
return parts.join('\n').trim();
|
|
136
|
+
}
|
|
137
|
+
/** Heuristically pull file paths and a shell command out of a Codex function_call. */
|
|
138
|
+
function describeFunctionCall(payload) {
|
|
139
|
+
const name = asString(payload.name || 'tool');
|
|
140
|
+
const rawArgs = payload.arguments;
|
|
141
|
+
let args = {};
|
|
142
|
+
if (typeof rawArgs === 'string') {
|
|
143
|
+
try {
|
|
144
|
+
args = JSON.parse(rawArgs);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Some function calls embed plain text — that's fine.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (rawArgs && typeof rawArgs === 'object') {
|
|
151
|
+
args = rawArgs;
|
|
152
|
+
}
|
|
153
|
+
let command;
|
|
154
|
+
const files = [];
|
|
155
|
+
// Common Codex tools.
|
|
156
|
+
if (typeof args.cmd === 'string')
|
|
157
|
+
command = args.cmd;
|
|
158
|
+
else if (typeof args.command === 'string')
|
|
159
|
+
command = args.command;
|
|
160
|
+
else if (Array.isArray(args.command))
|
|
161
|
+
command = args.command.join(' ');
|
|
162
|
+
// File-shaped args
|
|
163
|
+
for (const key of ['path', 'file', 'filename', 'target']) {
|
|
164
|
+
const v = args[key];
|
|
165
|
+
if (typeof v === 'string')
|
|
166
|
+
files.push(v);
|
|
167
|
+
}
|
|
168
|
+
if (Array.isArray(args.paths)) {
|
|
169
|
+
for (const v of args.paths) {
|
|
170
|
+
if (typeof v === 'string')
|
|
171
|
+
files.push(v);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// apply_patch — try to extract touched paths from a unified-diff-ish input.
|
|
175
|
+
if (name === 'apply_patch' && typeof args.input === 'string') {
|
|
176
|
+
const re = /^(?:\*\*\* (?:Update|Add|Delete) File: |\+\+\+ b\/|--- a\/)(.+)$/gm;
|
|
177
|
+
let m;
|
|
178
|
+
while ((m = re.exec(args.input)) !== null) {
|
|
179
|
+
const p = m[1]?.trim();
|
|
180
|
+
if (p)
|
|
181
|
+
files.push(p);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
let text = `${name}`;
|
|
185
|
+
if (command)
|
|
186
|
+
text += ` $ ${clip(command, 200)}`;
|
|
187
|
+
if (files.length > 0)
|
|
188
|
+
text += ` [${files.slice(0, 6).join(', ')}]`;
|
|
189
|
+
return { text, command, files: files.length ? files : undefined, toolName: name };
|
|
190
|
+
}
|
|
191
|
+
/** Compact a function_call_output payload for the summary. */
|
|
192
|
+
function describeFunctionCallOutput(payload) {
|
|
193
|
+
const raw = asString(payload.output ?? payload.content ?? '');
|
|
194
|
+
// Strip Codex's wrapper prefix lines like "Chunk ID:", "Wall time:", etc.
|
|
195
|
+
const stripped = raw
|
|
196
|
+
.replace(/^Chunk ID:.*$/gm, '')
|
|
197
|
+
.replace(/^Wall time:.*$/gm, '')
|
|
198
|
+
.replace(/^Original token count:.*$/gm, '')
|
|
199
|
+
.replace(/^Output:\s*/m, '')
|
|
200
|
+
.trim();
|
|
201
|
+
const isError = /Process exited with code (?!0)\d+/.test(raw) ||
|
|
202
|
+
/\bError\b/i.test(stripped.slice(0, 200));
|
|
203
|
+
return { text: clip(stripped, 400), isError };
|
|
204
|
+
}
|
|
205
|
+
/** Parse a full Codex rollout JSONL into normalized events. */
|
|
206
|
+
export async function parseCodexSession(path) {
|
|
207
|
+
const events = [];
|
|
208
|
+
let recordedCwd = null;
|
|
209
|
+
let sessionId = null;
|
|
210
|
+
let startedAtMs = null;
|
|
211
|
+
let endedAtMs = null;
|
|
212
|
+
const { skipped, records } = await parseJsonl(path, (obj, lineNo) => {
|
|
213
|
+
if (!obj || typeof obj !== 'object')
|
|
214
|
+
return null;
|
|
215
|
+
const rec = obj;
|
|
216
|
+
const tsStr = typeof rec.timestamp === 'string' ? rec.timestamp : null;
|
|
217
|
+
const tsMs = tsStr ? Date.parse(tsStr) : NaN;
|
|
218
|
+
const ts = Number.isFinite(tsMs) ? tsMs : null;
|
|
219
|
+
if (ts !== null) {
|
|
220
|
+
if (startedAtMs === null || ts < startedAtMs)
|
|
221
|
+
startedAtMs = ts;
|
|
222
|
+
if (endedAtMs === null || ts > endedAtMs)
|
|
223
|
+
endedAtMs = ts;
|
|
224
|
+
}
|
|
225
|
+
const type = rec.type;
|
|
226
|
+
const payload = (rec.payload ?? {});
|
|
227
|
+
if (type === 'session_meta') {
|
|
228
|
+
if (typeof payload.cwd === 'string')
|
|
229
|
+
recordedCwd = payload.cwd;
|
|
230
|
+
if (typeof payload.id === 'string')
|
|
231
|
+
sessionId = payload.id;
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
if (type === 'response_item') {
|
|
235
|
+
const pt = payload.type;
|
|
236
|
+
if (pt === 'message') {
|
|
237
|
+
const role = payload.role;
|
|
238
|
+
const text = extractMessageText(payload.content);
|
|
239
|
+
if (!text)
|
|
240
|
+
return null;
|
|
241
|
+
if (role === 'user') {
|
|
242
|
+
// Skip environment_context noise.
|
|
243
|
+
if (/^<environment_context>/m.test(text))
|
|
244
|
+
return null;
|
|
245
|
+
const ev = {
|
|
246
|
+
lineNo,
|
|
247
|
+
timestampMs: ts,
|
|
248
|
+
kind: 'user_message',
|
|
249
|
+
text,
|
|
250
|
+
};
|
|
251
|
+
return ev;
|
|
252
|
+
}
|
|
253
|
+
if (role === 'assistant') {
|
|
254
|
+
const ev = {
|
|
255
|
+
lineNo,
|
|
256
|
+
timestampMs: ts,
|
|
257
|
+
kind: 'assistant_message',
|
|
258
|
+
text,
|
|
259
|
+
};
|
|
260
|
+
return ev;
|
|
261
|
+
}
|
|
262
|
+
if (role === 'developer' || role === 'system') {
|
|
263
|
+
return null; // not useful in the handoff
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (pt === 'reasoning') {
|
|
267
|
+
// We intentionally skip Codex internal reasoning — too noisy & private.
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
if (pt === 'function_call') {
|
|
271
|
+
const d = describeFunctionCall(payload);
|
|
272
|
+
const ev = {
|
|
273
|
+
lineNo,
|
|
274
|
+
timestampMs: ts,
|
|
275
|
+
kind: 'tool_call',
|
|
276
|
+
text: d.text,
|
|
277
|
+
toolName: d.toolName,
|
|
278
|
+
command: d.command,
|
|
279
|
+
files: d.files,
|
|
280
|
+
};
|
|
281
|
+
return ev;
|
|
282
|
+
}
|
|
283
|
+
if (pt === 'function_call_output') {
|
|
284
|
+
const d = describeFunctionCallOutput(payload);
|
|
285
|
+
const ev = {
|
|
286
|
+
lineNo,
|
|
287
|
+
timestampMs: ts,
|
|
288
|
+
kind: 'tool_result',
|
|
289
|
+
text: d.text,
|
|
290
|
+
isError: d.isError,
|
|
291
|
+
};
|
|
292
|
+
return ev;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// event_msg / turn_context / token_count / etc. — ignore for the handoff.
|
|
296
|
+
return null;
|
|
297
|
+
});
|
|
298
|
+
events.push(...records);
|
|
299
|
+
return {
|
|
300
|
+
path,
|
|
301
|
+
recordedCwd,
|
|
302
|
+
recordedBranch: null,
|
|
303
|
+
sessionId,
|
|
304
|
+
startedAtMs,
|
|
305
|
+
endedAtMs,
|
|
306
|
+
parsedLines: events.length,
|
|
307
|
+
skippedLines: skipped,
|
|
308
|
+
events,
|
|
309
|
+
};
|
|
310
|
+
}
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort secret redaction for handoff prompts.
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally conservative: it should never block real content from
|
|
5
|
+
* being passed along, but it must catch the most common leak shapes. Users who
|
|
6
|
+
* need stronger guarantees can pipe `relay preview` through their own scrubber
|
|
7
|
+
* or run with `--no-redact` only when they trust the transcript.
|
|
8
|
+
*/
|
|
9
|
+
/** Redact secrets in a string. Returns the (possibly identical) result. */
|
|
10
|
+
export declare function redact(input: string): string;
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort secret redaction for handoff prompts.
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally conservative: it should never block real content from
|
|
5
|
+
* being passed along, but it must catch the most common leak shapes. Users who
|
|
6
|
+
* need stronger guarantees can pipe `relay preview` through their own scrubber
|
|
7
|
+
* or run with `--no-redact` only when they trust the transcript.
|
|
8
|
+
*/
|
|
9
|
+
function shortKeep(m) {
|
|
10
|
+
if (m.length <= 8)
|
|
11
|
+
return '[REDACTED]';
|
|
12
|
+
return `${m.slice(0, 4)}…${m.slice(-2)}[REDACTED]`;
|
|
13
|
+
}
|
|
14
|
+
const RULES = [
|
|
15
|
+
// Authorization headers (Bearer / Basic / opaque token)
|
|
16
|
+
{
|
|
17
|
+
name: 'auth-header',
|
|
18
|
+
pattern: /\b(Authorization\s*[:=]\s*)(Bearer\s+|Basic\s+)?([A-Za-z0-9._\-+/=]{8,})/gi,
|
|
19
|
+
replacer: (_m, prefix, scheme = '') => `${prefix}${scheme}[REDACTED]`,
|
|
20
|
+
},
|
|
21
|
+
// OpenAI-style keys
|
|
22
|
+
{
|
|
23
|
+
name: 'openai-key',
|
|
24
|
+
pattern: /\bsk-(?:proj-|live-|test-)?[A-Za-z0-9_\-]{16,}\b/g,
|
|
25
|
+
replacer: () => 'sk-[REDACTED]',
|
|
26
|
+
},
|
|
27
|
+
// Anthropic keys
|
|
28
|
+
{
|
|
29
|
+
name: 'anthropic-key',
|
|
30
|
+
pattern: /\bsk-ant-[A-Za-z0-9_\-]{16,}\b/g,
|
|
31
|
+
replacer: () => 'sk-ant-[REDACTED]',
|
|
32
|
+
},
|
|
33
|
+
// GitHub tokens
|
|
34
|
+
{
|
|
35
|
+
name: 'github-token',
|
|
36
|
+
pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
|
|
37
|
+
replacer: () => 'gh_[REDACTED]',
|
|
38
|
+
},
|
|
39
|
+
// AWS access key id
|
|
40
|
+
{
|
|
41
|
+
name: 'aws-access-key',
|
|
42
|
+
pattern: /\bAKIA[0-9A-Z]{12,}\b/g,
|
|
43
|
+
replacer: () => 'AKIA[REDACTED]',
|
|
44
|
+
},
|
|
45
|
+
// Google API key
|
|
46
|
+
{
|
|
47
|
+
name: 'google-api-key',
|
|
48
|
+
pattern: /\bAIza[0-9A-Za-z_\-]{20,}\b/g,
|
|
49
|
+
replacer: () => 'AIza[REDACTED]',
|
|
50
|
+
},
|
|
51
|
+
// JWT (three base64url segments separated by dots)
|
|
52
|
+
{
|
|
53
|
+
name: 'jwt',
|
|
54
|
+
pattern: /\beyJ[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}\b/g,
|
|
55
|
+
replacer: () => 'eyJ[REDACTED-JWT]',
|
|
56
|
+
},
|
|
57
|
+
// PEM private keys (multi-line)
|
|
58
|
+
{
|
|
59
|
+
name: 'pem-private-key',
|
|
60
|
+
pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g,
|
|
61
|
+
replacer: () => '[REDACTED-PRIVATE-KEY]',
|
|
62
|
+
},
|
|
63
|
+
// KEY=VALUE env-var-style secrets. The name must *contain* one of the
|
|
64
|
+
// secret tokens as a substring, anywhere in the identifier.
|
|
65
|
+
{
|
|
66
|
+
name: 'env-secret',
|
|
67
|
+
pattern: /\b([A-Z][A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API[_-]?KEY|APIKEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET|CREDENTIAL)[A-Z0-9_]*)(\s*[=:]\s*)(["']?)([^\s"']{6,})(\3)/g,
|
|
68
|
+
replacer: (_m, name, sep, q, value) => `${name}${sep}${q}${shortKeep(value)}${q}`,
|
|
69
|
+
},
|
|
70
|
+
// Set-Cookie headers
|
|
71
|
+
{
|
|
72
|
+
name: 'set-cookie',
|
|
73
|
+
pattern: /\b(Set-Cookie:\s*[^=]+=)([^\s;]+)/gi,
|
|
74
|
+
replacer: (_m, prefix) => `${prefix}[REDACTED]`,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
/** Redact secrets in a string. Returns the (possibly identical) result. */
|
|
78
|
+
export function redact(input) {
|
|
79
|
+
let out = input;
|
|
80
|
+
for (const rule of RULES) {
|
|
81
|
+
out = out.replace(rule.pattern, rule.replacer);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AgentName, GitContext, HandoffContent, ParsedSession, RelayOptions } from './types.js';
|
|
2
|
+
export interface RenderInput {
|
|
3
|
+
sourceAgent: AgentName;
|
|
4
|
+
targetAgent: AgentName;
|
|
5
|
+
git: GitContext;
|
|
6
|
+
session: ParsedSession;
|
|
7
|
+
/** Optional extra memory blob (Claude project memory). */
|
|
8
|
+
memorySummary?: string | null;
|
|
9
|
+
/** Optional current `git diff` blob to append. */
|
|
10
|
+
diff?: string | null;
|
|
11
|
+
options: RelayOptions;
|
|
12
|
+
}
|
|
13
|
+
export declare function renderHandoff(input: RenderInput): HandoffContent;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { clip } from './parse/jsonl.js';
|
|
2
|
+
import { redact } from './redact.js';
|
|
3
|
+
const STALE_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
/** Pull a compact digest out of a parsed session. */
|
|
5
|
+
function digest(session) {
|
|
6
|
+
const out = {
|
|
7
|
+
originalTask: '',
|
|
8
|
+
userInstructions: [],
|
|
9
|
+
assistantPlans: [],
|
|
10
|
+
filesTouched: [],
|
|
11
|
+
commands: [],
|
|
12
|
+
todos: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
recentMessages: [],
|
|
15
|
+
};
|
|
16
|
+
const events = session.events;
|
|
17
|
+
if (events.length === 0)
|
|
18
|
+
return out;
|
|
19
|
+
// First substantial user message is the original task — remember its lineNo
|
|
20
|
+
// so we exclude exactly that event (not by text, which loses identity after clipping).
|
|
21
|
+
let originalTaskLineNo = null;
|
|
22
|
+
for (const ev of events) {
|
|
23
|
+
if (ev.kind !== 'user_message')
|
|
24
|
+
continue;
|
|
25
|
+
if (ev.text.trim().length < 12)
|
|
26
|
+
continue;
|
|
27
|
+
out.originalTask = clip(ev.text.trim(), 1500);
|
|
28
|
+
originalTaskLineNo = ev.lineNo;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
// Walk events to bucketize.
|
|
32
|
+
const seenFiles = new Set();
|
|
33
|
+
for (let i = 0; i < events.length; i++) {
|
|
34
|
+
const ev = events[i];
|
|
35
|
+
if (ev.kind === 'user_message' && ev.lineNo !== originalTaskLineNo) {
|
|
36
|
+
out.userInstructions.push(clip(ev.text.trim(), 600));
|
|
37
|
+
}
|
|
38
|
+
if (ev.kind === 'assistant_message') {
|
|
39
|
+
const txt = ev.text.trim();
|
|
40
|
+
// Heuristic: short assistant messages are status pings; longer ones often
|
|
41
|
+
// contain plans, decisions, summaries. Keep the longer ones.
|
|
42
|
+
if (txt.length > 80) {
|
|
43
|
+
out.assistantPlans.push(clip(txt, 800));
|
|
44
|
+
}
|
|
45
|
+
// Pull explicit TODO/plan markers regardless of length.
|
|
46
|
+
const todoMatches = txt.match(/^[\-*]\s*(?:TODO|FIXME|Next)[:.\-]?\s+.+$/gim);
|
|
47
|
+
if (todoMatches) {
|
|
48
|
+
for (const m of todoMatches)
|
|
49
|
+
out.todos.push(m.trim());
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (ev.kind === 'tool_call') {
|
|
53
|
+
if (ev.files) {
|
|
54
|
+
for (const f of ev.files) {
|
|
55
|
+
if (!seenFiles.has(f)) {
|
|
56
|
+
seenFiles.add(f);
|
|
57
|
+
out.filesTouched.push(f);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (ev.command) {
|
|
62
|
+
// Determine if a subsequent result indicates failure.
|
|
63
|
+
let failed = false;
|
|
64
|
+
for (let j = i + 1; j < Math.min(events.length, i + 4); j++) {
|
|
65
|
+
const next = events[j];
|
|
66
|
+
if (next.kind === 'tool_result') {
|
|
67
|
+
failed = !!next.isError;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
if (next.kind === 'tool_call')
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
out.commands.push({ cmd: clip(ev.command, 200), failed });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (ev.kind === 'tool_result' && ev.isError && ev.text) {
|
|
77
|
+
out.errors.push(clip(ev.text, 240));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Recent conversation slice: last 6 user/assistant messages.
|
|
81
|
+
const tail = events.filter((e) => e.kind === 'user_message' || e.kind === 'assistant_message');
|
|
82
|
+
out.recentMessages = tail.slice(-6).map((e) => {
|
|
83
|
+
const who = e.kind === 'user_message' ? 'User' : 'Assistant';
|
|
84
|
+
return `${who}: ${clip(e.text.trim(), 500)}`;
|
|
85
|
+
});
|
|
86
|
+
// De-duplicate while keeping order.
|
|
87
|
+
out.userInstructions = dedupe(out.userInstructions).slice(-6);
|
|
88
|
+
out.assistantPlans = dedupe(out.assistantPlans).slice(-4);
|
|
89
|
+
out.todos = dedupe(out.todos).slice(0, 10);
|
|
90
|
+
out.errors = dedupe(out.errors).slice(-5);
|
|
91
|
+
out.filesTouched = dedupe(out.filesTouched).slice(0, 25);
|
|
92
|
+
// Keep the last N commands; failed ones get priority.
|
|
93
|
+
out.commands = pickCommands(out.commands, 12);
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
function dedupe(xs) {
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const x of xs) {
|
|
100
|
+
const key = x.trim();
|
|
101
|
+
if (!key || seen.has(key))
|
|
102
|
+
continue;
|
|
103
|
+
seen.add(key);
|
|
104
|
+
out.push(x);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
function pickCommands(cmds, limit) {
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
const unique = [];
|
|
111
|
+
for (const c of cmds) {
|
|
112
|
+
if (seen.has(c.cmd))
|
|
113
|
+
continue;
|
|
114
|
+
seen.add(c.cmd);
|
|
115
|
+
unique.push(c);
|
|
116
|
+
}
|
|
117
|
+
if (unique.length <= limit)
|
|
118
|
+
return unique;
|
|
119
|
+
// Keep all failed + most recent successful commands.
|
|
120
|
+
const failed = unique.filter((c) => c.failed);
|
|
121
|
+
const ok = unique.filter((c) => !c.failed);
|
|
122
|
+
const okTail = ok.slice(-(limit - failed.length));
|
|
123
|
+
return [...failed, ...okTail].slice(-limit);
|
|
124
|
+
}
|
|
125
|
+
function bullet(items, fallback) {
|
|
126
|
+
if (items.length === 0)
|
|
127
|
+
return `- ${fallback}`;
|
|
128
|
+
return items.map((x) => `- ${x.replace(/\n+/g, ' ')}`).join('\n');
|
|
129
|
+
}
|
|
130
|
+
export function renderHandoff(input) {
|
|
131
|
+
const { sourceAgent, targetAgent, git, session, options } = input;
|
|
132
|
+
const d = digest(session);
|
|
133
|
+
const mtime = session.endedAtMs ?? session.startedAtMs ?? 0;
|
|
134
|
+
const stale = mtime > 0 ? Date.now() - mtime > STALE_MS : false;
|
|
135
|
+
const sourceLine = `${session.path} (last activity ${mtime ? new Date(mtime).toISOString() : 'unknown'})`;
|
|
136
|
+
const sourceLabel = sourceAgent === 'codex' ? 'OpenAI Codex CLI' : 'Anthropic Claude Code';
|
|
137
|
+
const targetLabel = targetAgent === 'codex' ? 'OpenAI Codex CLI' : 'Anthropic Claude Code';
|
|
138
|
+
const sections = [];
|
|
139
|
+
sections.push(`You are continuing work in this repository after a context handoff from ${sourceLabel} to ${targetLabel}.`);
|
|
140
|
+
sections.push(`This handoff was synthesized by \`codex-claude-relay\` from the prior agent's native local transcript. ` +
|
|
141
|
+
`It is a *summary*, not a literal session import — treat it as background context, then verify the current repository state before acting.`);
|
|
142
|
+
if (stale) {
|
|
143
|
+
sections.push(`⚠ Source session is stale (last activity > 24h ago at ${new Date(mtime).toISOString()}). Other changes may have happened since.`);
|
|
144
|
+
}
|
|
145
|
+
if (!git.inRepo) {
|
|
146
|
+
sections.push(`⚠ The current working directory is not inside a git repository — file paths from the transcript may not resolve here.`);
|
|
147
|
+
}
|
|
148
|
+
sections.push([
|
|
149
|
+
'Repository:',
|
|
150
|
+
`- Path: ${git.root}`,
|
|
151
|
+
`- Branch: ${git.branch ?? '(unknown)'}`,
|
|
152
|
+
`- Repo name: ${git.repoName}`,
|
|
153
|
+
git.statusShort
|
|
154
|
+
? `- Git status summary:\n${indent(clip(git.statusShort, 1200), ' ')}`
|
|
155
|
+
: '- Git status summary: (clean or unavailable)',
|
|
156
|
+
].join('\n'));
|
|
157
|
+
sections.push([
|
|
158
|
+
'Original task:',
|
|
159
|
+
d.originalTask ? indent(d.originalTask, ' ') : ' - (no user message recorded)',
|
|
160
|
+
].join('\n'));
|
|
161
|
+
sections.push([
|
|
162
|
+
'Subsequent user instructions:',
|
|
163
|
+
bullet(d.userInstructions, '(no further instructions captured)'),
|
|
164
|
+
].join('\n'));
|
|
165
|
+
sections.push([
|
|
166
|
+
'What has already been done / key decisions:',
|
|
167
|
+
bullet(d.assistantPlans, '(no substantial assistant summaries captured)'),
|
|
168
|
+
].join('\n'));
|
|
169
|
+
sections.push([
|
|
170
|
+
'Files touched or inspected:',
|
|
171
|
+
bullet(d.filesTouched, '(none captured)'),
|
|
172
|
+
].join('\n'));
|
|
173
|
+
sections.push([
|
|
174
|
+
'Commands run (★ = errored):',
|
|
175
|
+
d.commands.length === 0
|
|
176
|
+
? '- (none captured)'
|
|
177
|
+
: d.commands.map((c) => `- ${c.failed ? '★ ' : ''}\`${c.cmd}\``).join('\n'),
|
|
178
|
+
].join('\n'));
|
|
179
|
+
if (d.errors.length > 0) {
|
|
180
|
+
sections.push([
|
|
181
|
+
'Errors observed:',
|
|
182
|
+
d.errors.map((e) => `- ${e.replace(/\n+/g, ' ')}`).join('\n'),
|
|
183
|
+
].join('\n'));
|
|
184
|
+
}
|
|
185
|
+
if (d.todos.length > 0) {
|
|
186
|
+
sections.push([
|
|
187
|
+
'Open TODOs from transcript:',
|
|
188
|
+
d.todos.map((t) => `- ${t}`).join('\n'),
|
|
189
|
+
].join('\n'));
|
|
190
|
+
}
|
|
191
|
+
sections.push([
|
|
192
|
+
'Recent conversation tail:',
|
|
193
|
+
d.recentMessages.length === 0
|
|
194
|
+
? '- (no recent messages)'
|
|
195
|
+
: d.recentMessages.map((m) => `- ${m}`).join('\n'),
|
|
196
|
+
].join('\n'));
|
|
197
|
+
if (input.memorySummary) {
|
|
198
|
+
sections.push([
|
|
199
|
+
'Claude Code auto-memory for this project:',
|
|
200
|
+
'```',
|
|
201
|
+
clip(input.memorySummary, 4000),
|
|
202
|
+
'```',
|
|
203
|
+
].join('\n'));
|
|
204
|
+
}
|
|
205
|
+
if (input.diff) {
|
|
206
|
+
sections.push([
|
|
207
|
+
'Current git diff (HEAD vs working tree):',
|
|
208
|
+
'```diff',
|
|
209
|
+
input.diff,
|
|
210
|
+
'```',
|
|
211
|
+
].join('\n'));
|
|
212
|
+
}
|
|
213
|
+
sections.push([
|
|
214
|
+
'Safety notes for you, the receiving agent:',
|
|
215
|
+
'- Do not assume the prior agent\'s conclusions are still correct.',
|
|
216
|
+
'- Re-check current file contents and `git status` / `git diff` before editing.',
|
|
217
|
+
'- Prefer the live repository state over anything implied by this transcript.',
|
|
218
|
+
'- If something here looks wrong or outdated, ask the user before destructive action.',
|
|
219
|
+
].join('\n'));
|
|
220
|
+
sections.push(`(Handoff generated by codex-claude-relay; source: ${sourceLine})`);
|
|
221
|
+
let text = sections.join('\n\n');
|
|
222
|
+
if (!options.noRedact)
|
|
223
|
+
text = redact(text);
|
|
224
|
+
// Hard cap. We try not to chop mid-section: if we overshoot, drop the
|
|
225
|
+
// memory + recent conversation tail first.
|
|
226
|
+
if (text.length > options.maxChars) {
|
|
227
|
+
text = text.slice(0, options.maxChars) + '\n... (handoff truncated)';
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
text,
|
|
231
|
+
stale,
|
|
232
|
+
source: sourceLine,
|
|
233
|
+
sourceAgent,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function indent(s, prefix) {
|
|
237
|
+
return s
|
|
238
|
+
.split('\n')
|
|
239
|
+
.map((line) => prefix + line)
|
|
240
|
+
.join('\n');
|
|
241
|
+
}
|