@dashclaw/cli 0.2.0 → 0.3.2
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/README.md +179 -0
- package/bin/dashclaw.js +1143 -166
- package/lib/api.js +64 -0
- package/lib/claude/install.js +386 -0
- package/lib/code/apply.js +164 -0
- package/lib/code/codex-parser.vendored.js +360 -0
- package/lib/code/ingest-codex.js +244 -0
- package/lib/code/ingest.js +261 -0
- package/lib/code/memo.js +57 -0
- package/lib/code/vendored.js +219 -0
- package/lib/codex/install.js +405 -0
- package/lib/codex/notify.js +203 -0
- package/lib/config.js +160 -0
- package/lib/cost.js +108 -0
- package/lib/doctor.js +270 -0
- package/lib/env.js +69 -0
- package/lib/posture.js +45 -0
- package/package.json +20 -4
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// VENDORED from app/lib/codex/parser.ts — GENERATED, do not edit by hand.
|
|
2
|
+
// The CLI runs as raw Node ESM and is published without app/, so it cannot import
|
|
3
|
+
// the app TypeScript source. Regenerate by compiling app/lib/codex/parser.ts.
|
|
4
|
+
// See cli/lib/code/vendored.js for the same sibling-package rationale.
|
|
5
|
+
|
|
6
|
+
// app/lib/codex/parser.js
|
|
7
|
+
//
|
|
8
|
+
// Codex CLI session JSONL parser.
|
|
9
|
+
//
|
|
10
|
+
// Codex writes session rollouts to `~/.codex/sessions/rollout-<ts>-<uuid>.jsonl`.
|
|
11
|
+
// Each line has the shape:
|
|
12
|
+
//
|
|
13
|
+
// { "timestamp": "...", "type": "session_meta"|"response_item"|...,
|
|
14
|
+
// "payload": { ... } }
|
|
15
|
+
//
|
|
16
|
+
// We normalize the rollout into a session object that's intentionally
|
|
17
|
+
// parallel to the Claude Code parser output (app/lib/claude-code/parser.js)
|
|
18
|
+
// so downstream insights, alerts, and rules engines can be reused with
|
|
19
|
+
// minimal branching. The shape differs only where Codex's data model
|
|
20
|
+
// fundamentally diverges from Claude's (token semantics, reasoning tokens).
|
|
21
|
+
//
|
|
22
|
+
// Token mapping (Codex → normalized):
|
|
23
|
+
// input_tokens → input_tokens
|
|
24
|
+
// output_tokens → output_tokens (excludes reasoning)
|
|
25
|
+
// cached_input_tokens → cache_read_tokens
|
|
26
|
+
// reasoning_output_tokens → reasoning_tokens (Codex-only field)
|
|
27
|
+
// total_tokens → recomputed at the request level for safety
|
|
28
|
+
//
|
|
29
|
+
// There is no Codex equivalent to Claude's `cache_creation_tokens`. We emit
|
|
30
|
+
// 0 for that field on Codex sessions to keep the schema compatible.
|
|
31
|
+
//
|
|
32
|
+
// This parser is deliberately tolerant: malformed lines are counted in
|
|
33
|
+
// `skippedLines` and never throw. It returns a partial session even when
|
|
34
|
+
// the rollout was truncated mid-write.
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import readline from 'node:readline';
|
|
37
|
+
export const CODEX_PARSER_VERSION = 1;
|
|
38
|
+
function safeParse(line) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(line);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function previewText(value, max = 200) {
|
|
47
|
+
if (typeof value !== 'string')
|
|
48
|
+
return '';
|
|
49
|
+
return value.slice(0, max);
|
|
50
|
+
}
|
|
51
|
+
// Best-effort agent-kind tag for downstream branching. Always 'codex' here.
|
|
52
|
+
export const CODEX_AGENT_KIND = 'codex';
|
|
53
|
+
function _initState({ sourceFile, sourceMtime }) {
|
|
54
|
+
return {
|
|
55
|
+
session: {
|
|
56
|
+
agentKind: CODEX_AGENT_KIND,
|
|
57
|
+
sourceFile: sourceFile || null,
|
|
58
|
+
sourceMtime: sourceMtime || null,
|
|
59
|
+
sessionUuid: null,
|
|
60
|
+
projectSlug: null,
|
|
61
|
+
cwd: null,
|
|
62
|
+
gitBranch: null,
|
|
63
|
+
gitCommit: null,
|
|
64
|
+
gitRepoUrl: null,
|
|
65
|
+
cliVersion: null,
|
|
66
|
+
originator: null,
|
|
67
|
+
modelProvider: null,
|
|
68
|
+
modelPrimary: null,
|
|
69
|
+
startedAt: null,
|
|
70
|
+
endedAt: null,
|
|
71
|
+
parserVersion: CODEX_PARSER_VERSION,
|
|
72
|
+
// counters
|
|
73
|
+
jsonlRecords: 0,
|
|
74
|
+
responseItems: 0,
|
|
75
|
+
eventMessages: 0,
|
|
76
|
+
compactedItems: 0,
|
|
77
|
+
skippedLines: 0,
|
|
78
|
+
turnCount: 0,
|
|
79
|
+
toolCallCount: 0,
|
|
80
|
+
// token totals
|
|
81
|
+
totals: {
|
|
82
|
+
input_tokens: 0,
|
|
83
|
+
output_tokens: 0,
|
|
84
|
+
cache_read_tokens: 0,
|
|
85
|
+
cache_creation_tokens: 0, // always 0 for Codex; kept for schema parity
|
|
86
|
+
reasoning_tokens: 0,
|
|
87
|
+
},
|
|
88
|
+
// per-turn token snapshots, in arrival order
|
|
89
|
+
turns: [], // { turnId, inputTokens, outputTokens, cachedInputTokens, reasoningTokens, total, at }
|
|
90
|
+
// canonical message timeline (user + assistant + reasoning + tool_call + tool_result)
|
|
91
|
+
messages: [],
|
|
92
|
+
// tool uses across the session
|
|
93
|
+
toolUses: [],
|
|
94
|
+
},
|
|
95
|
+
seenTurnIds: new Set(),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function _handleSessionMeta(state, payload, ts) {
|
|
99
|
+
const s = state.session;
|
|
100
|
+
if (!s.sessionUuid && payload?.id)
|
|
101
|
+
s.sessionUuid = payload.id;
|
|
102
|
+
if (!s.cwd && payload?.cwd)
|
|
103
|
+
s.cwd = payload.cwd;
|
|
104
|
+
if (!s.cliVersion && payload?.cli_version)
|
|
105
|
+
s.cliVersion = payload.cli_version;
|
|
106
|
+
if (!s.originator && payload?.originator)
|
|
107
|
+
s.originator = payload.originator;
|
|
108
|
+
if (!s.modelProvider && payload?.model_provider)
|
|
109
|
+
s.modelProvider = payload.model_provider;
|
|
110
|
+
if (!s.startedAt)
|
|
111
|
+
s.startedAt = ts || payload?.timestamp || null;
|
|
112
|
+
if (payload?.git && typeof payload.git === 'object') {
|
|
113
|
+
const git = payload.git;
|
|
114
|
+
if (!s.gitBranch && git.branch)
|
|
115
|
+
s.gitBranch = git.branch;
|
|
116
|
+
if (!s.gitCommit && git.commit_hash)
|
|
117
|
+
s.gitCommit = git.commit_hash;
|
|
118
|
+
if (!s.gitRepoUrl && git.repository_url)
|
|
119
|
+
s.gitRepoUrl = git.repository_url;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function _handleResponseItem(state, payload, ts) {
|
|
123
|
+
const s = state.session;
|
|
124
|
+
s.responseItems += 1;
|
|
125
|
+
if (!payload || typeof payload !== 'object')
|
|
126
|
+
return;
|
|
127
|
+
const role = payload.role;
|
|
128
|
+
// Response items can be of several shapes:
|
|
129
|
+
// - { role: 'user'|'assistant'|'system', content: [...] }
|
|
130
|
+
// - { type: 'function_call', name, arguments, call_id }
|
|
131
|
+
// - { type: 'function_call_output', call_id, output }
|
|
132
|
+
// - { type: 'reasoning', summary: [...] }
|
|
133
|
+
if (payload.type === 'function_call' || (payload.name && payload.call_id)) {
|
|
134
|
+
s.toolCallCount += 1;
|
|
135
|
+
s.toolUses.push({
|
|
136
|
+
name: payload.name || 'unknown',
|
|
137
|
+
tool_use_id: payload.call_id || payload.id || null,
|
|
138
|
+
arguments_preview: previewText(payload.arguments, 240),
|
|
139
|
+
target: safeToolTarget(payload),
|
|
140
|
+
at: ts,
|
|
141
|
+
});
|
|
142
|
+
s.messages.push({
|
|
143
|
+
role: 'tool_call',
|
|
144
|
+
name: payload.name,
|
|
145
|
+
tool_use_id: payload.call_id || null,
|
|
146
|
+
preview: previewText(payload.arguments, 240),
|
|
147
|
+
at: ts,
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (payload.type === 'function_call_output') {
|
|
152
|
+
s.messages.push({
|
|
153
|
+
role: 'tool_result',
|
|
154
|
+
tool_use_id: payload.call_id || null,
|
|
155
|
+
preview: previewText(typeof payload.output === 'string' ? payload.output : JSON.stringify(payload.output ?? ''), 240),
|
|
156
|
+
at: ts,
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (payload.type === 'reasoning') {
|
|
161
|
+
// Reasoning items are first-class in Codex — we keep a stub on the
|
|
162
|
+
// timeline so downstream tools can correlate cost with thinking.
|
|
163
|
+
s.messages.push({ role: 'reasoning', preview: '(reasoning)', at: ts });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (role && Array.isArray(payload.content)) {
|
|
167
|
+
s.messages.push({
|
|
168
|
+
role,
|
|
169
|
+
preview: extractTextPreview(payload.content),
|
|
170
|
+
at: ts,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function extractTextPreview(content) {
|
|
175
|
+
for (const c of content) {
|
|
176
|
+
if (!c)
|
|
177
|
+
continue;
|
|
178
|
+
if (typeof c === 'string')
|
|
179
|
+
return previewText(c);
|
|
180
|
+
const part = c;
|
|
181
|
+
if (part.type === 'input_text' && typeof part.text === 'string')
|
|
182
|
+
return previewText(part.text);
|
|
183
|
+
if (part.type === 'output_text' && typeof part.text === 'string')
|
|
184
|
+
return previewText(part.text);
|
|
185
|
+
if (part.type === 'text' && typeof part.text === 'string')
|
|
186
|
+
return previewText(part.text);
|
|
187
|
+
}
|
|
188
|
+
return '';
|
|
189
|
+
}
|
|
190
|
+
function safeToolTarget(p) {
|
|
191
|
+
// Codex tools include `local_shell` (command exec) and a generic `apply_patch`
|
|
192
|
+
// among many MCP tools. We try to extract a SHORT non-secret identifier.
|
|
193
|
+
try {
|
|
194
|
+
const args = typeof p.arguments === 'string'
|
|
195
|
+
? JSON.parse(p.arguments)
|
|
196
|
+
: p.arguments;
|
|
197
|
+
if (!args || typeof args !== 'object')
|
|
198
|
+
return null;
|
|
199
|
+
if (p.name === 'local_shell' || p.name === 'shell') {
|
|
200
|
+
const cmd = typeof args.command === 'string'
|
|
201
|
+
? args.command
|
|
202
|
+
: Array.isArray(args.command) ? args.command.join(' ') : '';
|
|
203
|
+
const head = cmd.trim().split(/\s+/)[0] || '';
|
|
204
|
+
return head ? head.slice(0, 60) : null;
|
|
205
|
+
}
|
|
206
|
+
if (typeof args.file_path === 'string')
|
|
207
|
+
return args.file_path.slice(0, 160);
|
|
208
|
+
if (typeof args.path === 'string')
|
|
209
|
+
return args.path.slice(0, 160);
|
|
210
|
+
if (typeof args.url === 'string') {
|
|
211
|
+
try {
|
|
212
|
+
return new URL(args.url).host;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch { /* fall through */ }
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
function _handleEventMsg(state, payload, ts) {
|
|
223
|
+
const s = state.session;
|
|
224
|
+
s.eventMessages += 1;
|
|
225
|
+
if (!payload || typeof payload !== 'object')
|
|
226
|
+
return;
|
|
227
|
+
const type = payload.type;
|
|
228
|
+
if (type === 'token_count') {
|
|
229
|
+
// Payload shape: { type: 'token_count', info: { total_token_usage, last_token_usage, model_context_window } }
|
|
230
|
+
const last = payload.info?.last_token_usage;
|
|
231
|
+
if (last) {
|
|
232
|
+
const input = Number(last.input_tokens) || 0;
|
|
233
|
+
const output = Number(last.output_tokens) || 0;
|
|
234
|
+
const cachedInput = Number(last.cached_input_tokens) || 0;
|
|
235
|
+
const reasoning = Number(last.reasoning_output_tokens) || 0;
|
|
236
|
+
const total = Number(last.total_tokens) || (input + output + cachedInput + reasoning);
|
|
237
|
+
s.totals.input_tokens += input;
|
|
238
|
+
s.totals.output_tokens += output;
|
|
239
|
+
s.totals.cache_read_tokens += cachedInput;
|
|
240
|
+
s.totals.reasoning_tokens += reasoning;
|
|
241
|
+
// Turn snapshot — Codex emits one token_count per model turn. We use
|
|
242
|
+
// the event ordinal as a synthetic turn id since the payload doesn't
|
|
243
|
+
// carry one. The token-count event always corresponds to one turn.
|
|
244
|
+
const turnId = `t${s.turns.length + 1}`;
|
|
245
|
+
s.turns.push({
|
|
246
|
+
turnId,
|
|
247
|
+
inputTokens: input,
|
|
248
|
+
outputTokens: output,
|
|
249
|
+
cachedInputTokens: cachedInput,
|
|
250
|
+
reasoningTokens: reasoning,
|
|
251
|
+
total,
|
|
252
|
+
at: ts,
|
|
253
|
+
});
|
|
254
|
+
s.turnCount = s.turns.length;
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (type === 'session_configured') {
|
|
259
|
+
if (!s.modelPrimary && payload.model)
|
|
260
|
+
s.modelPrimary = payload.model;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (type === 'agent_message') {
|
|
264
|
+
if (typeof payload.message === 'string') {
|
|
265
|
+
s.messages.push({ role: 'assistant', preview: previewText(payload.message), at: ts });
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (type === 'user_message') {
|
|
270
|
+
if (typeof payload.message === 'string') {
|
|
271
|
+
s.messages.push({ role: 'user', preview: previewText(payload.message), at: ts });
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function _handleCompacted(state, payload, ts) {
|
|
277
|
+
const s = state.session;
|
|
278
|
+
s.compactedItems += 1;
|
|
279
|
+
s.messages.push({
|
|
280
|
+
role: 'system',
|
|
281
|
+
preview: '(compacted) ' + previewText(payload?.message || '', 180),
|
|
282
|
+
at: ts,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
function _processLine(state, line, lineNo) {
|
|
286
|
+
if (!line || !line.trim())
|
|
287
|
+
return;
|
|
288
|
+
const rec = safeParse(line);
|
|
289
|
+
const s = state.session;
|
|
290
|
+
if (!rec || typeof rec !== 'object') {
|
|
291
|
+
s.skippedLines += 1;
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
s.jsonlRecords += 1;
|
|
295
|
+
const ts = typeof rec.timestamp === 'string' ? rec.timestamp : null;
|
|
296
|
+
if (ts) {
|
|
297
|
+
if (!s.startedAt || ts < s.startedAt)
|
|
298
|
+
s.startedAt = ts;
|
|
299
|
+
if (!s.endedAt || ts > s.endedAt)
|
|
300
|
+
s.endedAt = ts;
|
|
301
|
+
}
|
|
302
|
+
// RolloutLine flattens `type` to the top level (serde flatten).
|
|
303
|
+
const type = rec.type;
|
|
304
|
+
const payload = rec.payload;
|
|
305
|
+
switch (type) {
|
|
306
|
+
case 'session_meta':
|
|
307
|
+
_handleSessionMeta(state, payload, ts);
|
|
308
|
+
break;
|
|
309
|
+
case 'response_item':
|
|
310
|
+
_handleResponseItem(state, payload, ts);
|
|
311
|
+
break;
|
|
312
|
+
case 'event_msg':
|
|
313
|
+
_handleEventMsg(state, payload, ts);
|
|
314
|
+
break;
|
|
315
|
+
case 'compacted':
|
|
316
|
+
_handleCompacted(state, payload, ts);
|
|
317
|
+
break;
|
|
318
|
+
case 'turn_context':
|
|
319
|
+
// No-op for now; turn_context carries the per-turn model + tool list
|
|
320
|
+
// but we don't yet use it for normalization.
|
|
321
|
+
break;
|
|
322
|
+
default:
|
|
323
|
+
// Unknown line type — counted in jsonlRecords but no further action.
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Parse an array of raw JSONL lines (in-memory). Returns the normalized
|
|
329
|
+
* session object. The server uses this so it never touches the filesystem.
|
|
330
|
+
*/
|
|
331
|
+
export function parseCodexSessionLines(lines, { sourceFile = null, sourceMtime = null } = {}) {
|
|
332
|
+
if (!Array.isArray(lines)) {
|
|
333
|
+
throw new TypeError('parseCodexSessionLines: lines must be an array of strings');
|
|
334
|
+
}
|
|
335
|
+
const state = _initState({ sourceFile, sourceMtime });
|
|
336
|
+
for (let i = 0; i < lines.length; i++) {
|
|
337
|
+
_processLine(state, lines[i], i + 1);
|
|
338
|
+
}
|
|
339
|
+
return state.session;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Stream-parse a Codex JSONL file from disk. Used by the CLI ingest path.
|
|
343
|
+
*/
|
|
344
|
+
export async function parseCodexSessionFile(filePath, { mtime } = {}) {
|
|
345
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
346
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
347
|
+
let actualMtime = mtime;
|
|
348
|
+
if (!actualMtime) {
|
|
349
|
+
try {
|
|
350
|
+
actualMtime = fs.statSync(filePath).mtime.toISOString();
|
|
351
|
+
}
|
|
352
|
+
catch { /* keep null */ }
|
|
353
|
+
}
|
|
354
|
+
const state = _initState({ sourceFile: filePath, sourceMtime: actualMtime });
|
|
355
|
+
let lineNo = 0;
|
|
356
|
+
for await (const line of rl) {
|
|
357
|
+
_processLine(state, line, ++lineNo);
|
|
358
|
+
}
|
|
359
|
+
return state.session;
|
|
360
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// cli/lib/code/ingest-codex.js
|
|
2
|
+
//
|
|
3
|
+
// Codex JSONL backfill. Walks `codexSessionsDir` (default ~/.codex/sessions),
|
|
4
|
+
// parses each rollout file via app/lib/codex/parser.js, and either:
|
|
5
|
+
//
|
|
6
|
+
// (a) writes the normalized session JSON to an --out directory (default
|
|
7
|
+
// ~/.dashclaw/codex-sessions/) for later server upload, or
|
|
8
|
+
// (b) POSTs it to --endpoint <url> directly.
|
|
9
|
+
//
|
|
10
|
+
// Phase 3 ships option (a) — local-only. Option (b) becomes useful once
|
|
11
|
+
// the server-side ingest route accepts source_host='codex-jsonl' (deferred
|
|
12
|
+
// until the AgentLens absorption lands, to avoid editing files already
|
|
13
|
+
// being modified by another agent).
|
|
14
|
+
//
|
|
15
|
+
// Output JSON shape is the raw parseCodexSessionFile return value. The
|
|
16
|
+
// server, when it grows codex ingestion, will deserialize it and persist.
|
|
17
|
+
//
|
|
18
|
+
// Logs per file: { file, status, reason?, session_uuid?, turns?, tokens? }.
|
|
19
|
+
// NEVER logs raw message text — could leak user transcripts.
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
|
|
25
|
+
// Vendored: the CLI is published without app/, so it can't import the app's
|
|
26
|
+
// TypeScript parser directly. See codex-parser.vendored.js.
|
|
27
|
+
import { parseCodexSessionFile } from './codex-parser.vendored.js';
|
|
28
|
+
|
|
29
|
+
const MAX_FILE_BYTES = 100 * 1024 * 1024; // codex rollouts can grow large
|
|
30
|
+
|
|
31
|
+
export function defaultCodexSessionsDir(env = process.env) {
|
|
32
|
+
if (env.CODEX_SESSIONS_DIR) return env.CODEX_SESSIONS_DIR;
|
|
33
|
+
const root = process.platform === 'win32'
|
|
34
|
+
? (env.USERPROFILE || homedir())
|
|
35
|
+
: (env.HOME || homedir());
|
|
36
|
+
// Codex defaults to <codex_home>/sessions; codex_home defaults to ~/.codex.
|
|
37
|
+
return path.join(env.CODEX_HOME || path.join(root, '.codex'), 'sessions');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function defaultCodexOutDir(env = process.env) {
|
|
41
|
+
if (env.DASHCLAW_CODEX_OUT_DIR) return env.DASHCLAW_CODEX_OUT_DIR;
|
|
42
|
+
const root = process.platform === 'win32'
|
|
43
|
+
? (env.USERPROFILE || homedir())
|
|
44
|
+
: (env.HOME || homedir());
|
|
45
|
+
return path.join(root, '.dashclaw', 'codex-sessions');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function listJsonlFiles(rootDir) {
|
|
49
|
+
const out = [];
|
|
50
|
+
let entries;
|
|
51
|
+
try { entries = fs.readdirSync(rootDir, { withFileTypes: true }); }
|
|
52
|
+
catch { return out; }
|
|
53
|
+
for (const e of entries) {
|
|
54
|
+
const p = path.join(rootDir, e.name);
|
|
55
|
+
if (e.isDirectory()) {
|
|
56
|
+
// Recurse one level — codex doesn't currently nest, but archived
|
|
57
|
+
// sessions sometimes land in `<sessions>/archived/`.
|
|
58
|
+
let inner;
|
|
59
|
+
try { inner = fs.readdirSync(p, { withFileTypes: true }); }
|
|
60
|
+
catch { continue; }
|
|
61
|
+
for (const f of inner) {
|
|
62
|
+
if (f.isFile() && f.name.endsWith('.jsonl')) {
|
|
63
|
+
out.push(path.join(p, f.name));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} else if (e.isFile() && e.name.endsWith('.jsonl')) {
|
|
67
|
+
out.push(p);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function bytesOf(filePath) {
|
|
74
|
+
try { return fs.statSync(filePath).size; }
|
|
75
|
+
catch { return 0; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build the per-session output JSON. Stable shape that the server can
|
|
80
|
+
* consume once codex ingestion lands.
|
|
81
|
+
*/
|
|
82
|
+
function buildOutputPayload(session) {
|
|
83
|
+
return {
|
|
84
|
+
parser_version: session.parserVersion,
|
|
85
|
+
agent_kind: session.agentKind,
|
|
86
|
+
session_uuid: session.sessionUuid,
|
|
87
|
+
cwd: session.cwd,
|
|
88
|
+
cli_version: session.cliVersion,
|
|
89
|
+
originator: session.originator,
|
|
90
|
+
model_provider: session.modelProvider,
|
|
91
|
+
model_primary: session.modelPrimary,
|
|
92
|
+
started_at: session.startedAt,
|
|
93
|
+
ended_at: session.endedAt,
|
|
94
|
+
git_branch: session.gitBranch,
|
|
95
|
+
git_commit: session.gitCommit,
|
|
96
|
+
git_repo_url: session.gitRepoUrl,
|
|
97
|
+
jsonl_records: session.jsonlRecords,
|
|
98
|
+
skipped_lines: session.skippedLines,
|
|
99
|
+
response_items: session.responseItems,
|
|
100
|
+
event_messages: session.eventMessages,
|
|
101
|
+
compacted_items: session.compactedItems,
|
|
102
|
+
tool_call_count: session.toolCallCount,
|
|
103
|
+
turn_count: session.turnCount,
|
|
104
|
+
totals: session.totals,
|
|
105
|
+
turns: session.turns,
|
|
106
|
+
// Messages and tool uses are large — keep them in the payload so the
|
|
107
|
+
// server can persist them when ready. CLI does not log them.
|
|
108
|
+
messages: session.messages,
|
|
109
|
+
tool_uses: session.toolUses,
|
|
110
|
+
source: {
|
|
111
|
+
host: 'codex-jsonl',
|
|
112
|
+
file: session.sourceFile,
|
|
113
|
+
mtime: session.sourceMtime,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function writeLocalOut({ outDir, payload }) {
|
|
119
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
120
|
+
const id = payload.session_uuid || `unknown-${Date.now()}`;
|
|
121
|
+
const target = path.join(outDir, `${id}.json`);
|
|
122
|
+
fs.writeFileSync(target, JSON.stringify(payload, null, 2) + '\n');
|
|
123
|
+
return target;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function postPayload({ endpoint, apiKey, payload, timeoutMs = 10000 }) {
|
|
127
|
+
const url = new URL(endpoint);
|
|
128
|
+
const body = JSON.stringify(payload);
|
|
129
|
+
const headers = {
|
|
130
|
+
'content-type': 'application/json',
|
|
131
|
+
'x-api-key': apiKey,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const { request: httpReq } = await import('node:http');
|
|
135
|
+
const { request: httpsReq } = await import('node:https');
|
|
136
|
+
const lib = url.protocol === 'https:' ? httpsReq : httpReq;
|
|
137
|
+
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
const req = lib({
|
|
140
|
+
method: 'POST',
|
|
141
|
+
host: url.hostname,
|
|
142
|
+
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
143
|
+
path: url.pathname + url.search,
|
|
144
|
+
headers: { ...headers, 'content-length': Buffer.byteLength(body).toString() },
|
|
145
|
+
}, (res) => {
|
|
146
|
+
let chunks = '';
|
|
147
|
+
res.setEncoding('utf8');
|
|
148
|
+
res.on('data', (c) => { chunks += c; });
|
|
149
|
+
res.on('end', () => {
|
|
150
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
151
|
+
resolve({ status: 'ingested', http: res.statusCode });
|
|
152
|
+
} else {
|
|
153
|
+
resolve({ status: 'error', reason: `http_${res.statusCode}`, detail: chunks.slice(0, 200) });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
req.on('error', (err) => resolve({ status: 'error', reason: 'network', detail: err.message }));
|
|
158
|
+
req.setTimeout(timeoutMs, () => { req.destroy(); resolve({ status: 'error', reason: 'timeout' }); });
|
|
159
|
+
req.write(body);
|
|
160
|
+
req.end();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Top-level orchestration. Returns an array of result objects, one per
|
|
166
|
+
* jsonl file found in `sessionsDir`.
|
|
167
|
+
*/
|
|
168
|
+
export async function runCodexIngest({
|
|
169
|
+
sessionsDir = defaultCodexSessionsDir(),
|
|
170
|
+
outDir = defaultCodexOutDir(),
|
|
171
|
+
endpoint = null, // when set, POST instead of write-local
|
|
172
|
+
apiKey = null,
|
|
173
|
+
dryRun = false,
|
|
174
|
+
logger = console,
|
|
175
|
+
} = {}) {
|
|
176
|
+
if (!fs.existsSync(sessionsDir)) {
|
|
177
|
+
logger.warn?.(`codex ingest: sessions dir does not exist: ${sessionsDir}`);
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
const files = listJsonlFiles(sessionsDir);
|
|
181
|
+
if (files.length === 0) {
|
|
182
|
+
logger.info?.(`codex ingest: no .jsonl files in ${sessionsDir}`);
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const results = [];
|
|
187
|
+
for (const file of files) {
|
|
188
|
+
const size = bytesOf(file);
|
|
189
|
+
if (size === 0) {
|
|
190
|
+
results.push({ file, status: 'skipped', reason: 'empty' });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (size > MAX_FILE_BYTES) {
|
|
194
|
+
results.push({ file, status: 'skipped', reason: 'too_large', bytes: size });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let session;
|
|
199
|
+
try {
|
|
200
|
+
session = await parseCodexSessionFile(file);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
results.push({ file, status: 'error', reason: 'parse', detail: err.message });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const payload = buildOutputPayload(session);
|
|
206
|
+
|
|
207
|
+
if (dryRun) {
|
|
208
|
+
results.push({
|
|
209
|
+
file,
|
|
210
|
+
status: 'dry_run',
|
|
211
|
+
session_uuid: session.sessionUuid,
|
|
212
|
+
turns: session.turnCount,
|
|
213
|
+
tokens: session.totals,
|
|
214
|
+
});
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (endpoint) {
|
|
219
|
+
const httpResult = await postPayload({ endpoint, apiKey, payload });
|
|
220
|
+
results.push({
|
|
221
|
+
file,
|
|
222
|
+
...httpResult,
|
|
223
|
+
session_uuid: session.sessionUuid,
|
|
224
|
+
turns: session.turnCount,
|
|
225
|
+
});
|
|
226
|
+
} else {
|
|
227
|
+
try {
|
|
228
|
+
const target = await writeLocalOut({ outDir, payload });
|
|
229
|
+
results.push({
|
|
230
|
+
file,
|
|
231
|
+
status: 'written_local',
|
|
232
|
+
session_uuid: session.sessionUuid,
|
|
233
|
+
out: target,
|
|
234
|
+
turns: session.turnCount,
|
|
235
|
+
tokens: session.totals,
|
|
236
|
+
});
|
|
237
|
+
} catch (err) {
|
|
238
|
+
results.push({ file, status: 'error', reason: 'write', detail: err.message });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return results;
|
|
244
|
+
}
|