@dashclaw/cli 0.2.0 → 0.3.1
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 +104 -0
- package/bin/dashclaw.js +903 -44
- package/lib/api.js +64 -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 +245 -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/doctor.js +209 -0
- package/lib/posture.js +45 -0
- package/package.json +21 -18
package/lib/api.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny direct-API helper for the DashClaw CLI.
|
|
3
|
+
*
|
|
4
|
+
* The CLI imports `DashClaw` from the PUBLISHED npm package, whose installed
|
|
5
|
+
* version can LAG this repo and lack newly-added SDK methods. Commands that
|
|
6
|
+
* call brand-new endpoints therefore go through this helper instead — a clean
|
|
7
|
+
* `fetch` with the `x-api-key` header against the already-resolved
|
|
8
|
+
* baseUrl/apiKey. No dependency on unreleased SDK methods.
|
|
9
|
+
*
|
|
10
|
+
* Error semantics mirror sdk/dashclaw.js `_request`: parse JSON defensively
|
|
11
|
+
* (fall back to {} on a non-JSON gateway/error body so the real status isn't
|
|
12
|
+
* lost), and throw a status-bearing Error on a non-ok response.
|
|
13
|
+
*
|
|
14
|
+
* Node 20 global `fetch` — no imports needed.
|
|
15
|
+
*
|
|
16
|
+
* @param {{ baseUrl: string, apiKey: string }} config
|
|
17
|
+
* @param {string} method
|
|
18
|
+
* @param {string} path
|
|
19
|
+
* @param {{ body?: any, query?: Record<string, any> }} [opts]
|
|
20
|
+
* @returns {Promise<any>}
|
|
21
|
+
*/
|
|
22
|
+
export async function apiRequest({ baseUrl, apiKey }, method, path, { body, query } = {}) {
|
|
23
|
+
let url = `${baseUrl}${path}`;
|
|
24
|
+
if (query) {
|
|
25
|
+
// Skip undefined/null values. Passing them straight into URLSearchParams
|
|
26
|
+
// serializes the literal strings "undefined"/"null", which routes treat as
|
|
27
|
+
// real filter values and match zero rows. Falsy-but-valid values (0, false,
|
|
28
|
+
// '') are preserved.
|
|
29
|
+
const qs = new URLSearchParams();
|
|
30
|
+
for (const [key, value] of Object.entries(query)) {
|
|
31
|
+
if (value !== undefined && value !== null) qs.append(key, String(value));
|
|
32
|
+
}
|
|
33
|
+
const s = qs.toString();
|
|
34
|
+
if (s) url += `?${s}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const res = await fetch(url, {
|
|
38
|
+
method,
|
|
39
|
+
headers: {
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
'x-api-key': apiKey,
|
|
42
|
+
},
|
|
43
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Parse the body defensively. A non-JSON error body (a Vercel 502/504/413
|
|
47
|
+
// gateway page, a 429 rate-limit page) makes res.json() reject with a
|
|
48
|
+
// SyntaxError, which would propagate instead of the status-bearing error
|
|
49
|
+
// below and lose res.status. Fall back to {} so the real status is thrown.
|
|
50
|
+
let data = {};
|
|
51
|
+
try {
|
|
52
|
+
data = await res.json();
|
|
53
|
+
} catch {
|
|
54
|
+
data = {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
const err = new Error(data.error || `Request failed with status ${res.status}`);
|
|
59
|
+
err.status = res.status;
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// `dashclaw code apply <manifestId>` — fetch a write plan from the server
|
|
2
|
+
// and apply it locally. The server emits the manifest (Phase 6) with a
|
|
3
|
+
// 24h TTL. This module is the only place the CLI writes to disk for the
|
|
4
|
+
// Optimal Files feature.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { _ensureInsideProject, applyMerge } from './vendored.js';
|
|
9
|
+
|
|
10
|
+
const SECRET_PATTERNS = [
|
|
11
|
+
{ name: 'stripe_test', re: /sk_test_[A-Za-z0-9]{8,}/g },
|
|
12
|
+
{ name: 'stripe_live', re: /sk_live_[A-Za-z0-9]{8,}/g },
|
|
13
|
+
{ name: 'stripe_webhook', re: /whsec_[A-Za-z0-9]{8,}/g },
|
|
14
|
+
{ name: 'openai_key', re: /sk-[A-Za-z0-9]{20,}/g },
|
|
15
|
+
{ name: 'anthropic_key', re: /sk-ant-[A-Za-z0-9_\-]{20,}/g },
|
|
16
|
+
{ name: 'github_pat', re: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g },
|
|
17
|
+
{ name: 'aws_access', re: /AKIA[0-9A-Z]{16}/g },
|
|
18
|
+
{ name: 'jwt', re: /eyJ[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}/g },
|
|
19
|
+
{ name: 'private_key', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g },
|
|
20
|
+
{ name: 'env_assign', re: /\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CRED|AUTH))\s*=\s*['"]?[^\s'"\n]+/g },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function scanForSecrets(content) {
|
|
24
|
+
let redactions = 0;
|
|
25
|
+
let out = String(content == null ? '' : content);
|
|
26
|
+
for (const p of SECRET_PATTERNS) {
|
|
27
|
+
out = out.replace(p.re, (_match, captured) => {
|
|
28
|
+
redactions++;
|
|
29
|
+
if (p.name === 'env_assign' && captured) return `${captured}=<REDACTED:${p.name}>`;
|
|
30
|
+
return `<REDACTED:${p.name}>`;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return { status: redactions === 0 ? 'passed' : 'redacted', redactions, redacted: out };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function fetchManifest(baseUrl, apiKey, manifestId, { fetchImpl = fetch }) {
|
|
37
|
+
const url = baseUrl.replace(/\/+$/, '') + '/api/code-sessions/manifests/' + encodeURIComponent(manifestId);
|
|
38
|
+
const res = await fetchImpl(url, {
|
|
39
|
+
headers: { 'x-api-key': apiKey },
|
|
40
|
+
});
|
|
41
|
+
let body = null;
|
|
42
|
+
try { body = await res.json(); } catch { /* null */ }
|
|
43
|
+
return { status: res.status, ok: res.ok, body };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runApply({
|
|
47
|
+
baseUrl,
|
|
48
|
+
apiKey,
|
|
49
|
+
manifestId,
|
|
50
|
+
dest,
|
|
51
|
+
yes = false,
|
|
52
|
+
allowRedactions = false,
|
|
53
|
+
allowOverwriteSideBySide = false,
|
|
54
|
+
fetchImpl = fetch,
|
|
55
|
+
logger = console,
|
|
56
|
+
}) {
|
|
57
|
+
if (!manifestId) throw new Error('runApply: manifestId is required');
|
|
58
|
+
if (!dest) throw new Error('runApply: --dest=<dir> is required');
|
|
59
|
+
if (!baseUrl || !apiKey) throw new Error('runApply: baseUrl and apiKey are required');
|
|
60
|
+
|
|
61
|
+
const destAbs = path.resolve(dest);
|
|
62
|
+
try { fs.mkdirSync(destAbs, { recursive: true }); }
|
|
63
|
+
catch (err) { throw new Error('Could not create destination ' + destAbs + ': ' + err.message); }
|
|
64
|
+
|
|
65
|
+
const { status, ok, body } = await fetchManifest(baseUrl, apiKey, manifestId, { fetchImpl });
|
|
66
|
+
if (!ok) throw new Error('Failed to load manifest: HTTP ' + status + (body?.error ? ' — ' + body.error : ''));
|
|
67
|
+
const plan = Array.isArray(body?.plan) ? body.plan : Array.isArray(body?.plan?.results) ? body.plan.results : null;
|
|
68
|
+
if (!plan) throw new Error('Manifest payload missing plan');
|
|
69
|
+
|
|
70
|
+
const results = [];
|
|
71
|
+
for (const entry of plan) {
|
|
72
|
+
const target = entry.path;
|
|
73
|
+
if (!target) {
|
|
74
|
+
results.push({ path: '(unknown)', status: 'invalid_entry' });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const abs = _ensureInsideProject(destAbs, target);
|
|
78
|
+
if (!abs) {
|
|
79
|
+
results.push({ path: target, status: 'unsafe_path' });
|
|
80
|
+
logger.warn(` ${target} -> unsafe_path (refused)`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const content = entry.content;
|
|
84
|
+
if (typeof content !== 'string') {
|
|
85
|
+
results.push({ path: target, status: 'no_content' });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const scan = scanForSecrets(content);
|
|
89
|
+
if (scan.status === 'redacted' && !allowRedactions) {
|
|
90
|
+
results.push({ path: target, status: 'redacted', redactions: scan.redactions });
|
|
91
|
+
logger.warn(` ${target} -> ${scan.redactions} secret pattern(s) detected, refused (use --allow-redactions)`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const mode = entry.mode || 'create';
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
if (mode === 'merge') {
|
|
98
|
+
if (!fs.existsSync(abs)) {
|
|
99
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
100
|
+
fs.writeFileSync(abs, scan.redacted, 'utf8');
|
|
101
|
+
results.push({ path: target, status: 'created', bytes: Buffer.byteLength(scan.redacted, 'utf8') });
|
|
102
|
+
logger.info(` ${target} -> created`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const existing = fs.readFileSync(abs, 'utf8');
|
|
106
|
+
const merged = applyMerge(existing, scan.redacted, {
|
|
107
|
+
acceptedHeadings: entry.acceptedHeadings || [],
|
|
108
|
+
acceptedBullets: entry.acceptedBullets || [],
|
|
109
|
+
});
|
|
110
|
+
fs.writeFileSync(abs, merged.merged, 'utf8');
|
|
111
|
+
results.push({ path: target, status: 'merged', additions: merged.additions });
|
|
112
|
+
logger.info(` ${target} -> merged`);
|
|
113
|
+
} else if (mode === 'side_by_side') {
|
|
114
|
+
const sideAbs = entry.absolutePath
|
|
115
|
+
? path.resolve(entry.absolutePath)
|
|
116
|
+
: sideBySidePath(abs);
|
|
117
|
+
const guardedSide = _ensureInsideProject(destAbs, path.relative(destAbs, sideAbs));
|
|
118
|
+
if (!guardedSide) {
|
|
119
|
+
results.push({ path: target, status: 'unsafe_path' });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
fs.mkdirSync(path.dirname(guardedSide), { recursive: true });
|
|
123
|
+
if (fs.existsSync(guardedSide) && !allowOverwriteSideBySide) {
|
|
124
|
+
results.push({ path: target, status: 'side_by_side_conflict' });
|
|
125
|
+
logger.warn(` ${target} -> side_by_side_conflict (pass --overwrite to replace)`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
fs.writeFileSync(guardedSide, scan.redacted, 'utf8');
|
|
129
|
+
results.push({ path: target, status: 'side_by_side', bytes: Buffer.byteLength(scan.redacted, 'utf8') });
|
|
130
|
+
logger.info(` ${target} -> side_by_side`);
|
|
131
|
+
} else if (mode === 'skip') {
|
|
132
|
+
results.push({ path: target, status: 'skipped' });
|
|
133
|
+
} else {
|
|
134
|
+
// 'create' or 'overwrite'
|
|
135
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
136
|
+
const existed = fs.existsSync(abs);
|
|
137
|
+
if (existed && mode !== 'overwrite' && !yes) {
|
|
138
|
+
results.push({ path: target, status: 'refused_overwrite_without_yes' });
|
|
139
|
+
logger.warn(` ${target} -> exists; pass --yes or rebuild manifest with mode='overwrite'`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
fs.writeFileSync(abs, scan.redacted, 'utf8');
|
|
143
|
+
results.push({ path: target, status: existed ? 'overwritten' : 'created' });
|
|
144
|
+
logger.info(` ${target} -> ${existed ? 'overwritten' : 'created'}`);
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
results.push({ path: target, status: 'error', error: err.message });
|
|
148
|
+
logger.warn(` ${target} -> error: ${err.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return results;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sideBySidePath(abs) {
|
|
155
|
+
const dir = path.dirname(abs);
|
|
156
|
+
const base = path.basename(abs);
|
|
157
|
+
const ext = path.extname(base);
|
|
158
|
+
if (!ext) return path.join(dir, base + '.NEW');
|
|
159
|
+
return path.join(dir, base.slice(0, -ext.length) + '.NEW' + ext);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Exported for the sync script + tests that want to assert the scan layer
|
|
163
|
+
// stays in lock-step with app/lib/claude-code/optimal-files/secret-scan.js.
|
|
164
|
+
export { scanForSecrets };
|
|
@@ -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
|
+
}
|