@dashclaw/cli 0.7.4 → 0.8.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/README.md +0 -61
- package/bin/dashclaw.js +2 -770
- package/lib/claude/install.js +411 -402
- package/lib/codex/install.js +10 -9
- package/lib/up/db.js +14 -0
- package/package.json +37 -37
- package/lib/code/apply.js +0 -164
- package/lib/code/codex-parser.vendored.js +0 -360
- package/lib/code/ingest-codex.js +0 -244
- package/lib/code/ingest.js +0 -261
- package/lib/code/memo.js +0 -57
- package/lib/code/vendored.js +0 -219
- package/lib/cost.js +0 -108
- package/lib/env.js +0 -69
- package/lib/posture.js +0 -45
package/lib/codex/install.js
CHANGED
|
@@ -59,12 +59,10 @@ const HOOK_FILES = [
|
|
|
59
59
|
'dashclaw_pretool.py',
|
|
60
60
|
'dashclaw_posttool.py',
|
|
61
61
|
'dashclaw_stop.py',
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// SessionStart hook event was confirmed to fire (see PLUGIN_PARITY.md).
|
|
67
|
-
'dashclaw_session_digest.py',
|
|
62
|
+
// SessionStart enforcement-liveness probe (v8.2) — the SessionStart hook
|
|
63
|
+
// (wired once codex-cli 0.139.0's SessionStart event was confirmed to fire;
|
|
64
|
+
// see PLUGIN_PARITY.md). Replaced the retired session-digest hook.
|
|
65
|
+
'enforcement_liveness_probe.py',
|
|
68
66
|
];
|
|
69
67
|
const HOOK_INTEL_DIR = 'dashclaw_agent_intel';
|
|
70
68
|
|
|
@@ -192,7 +190,7 @@ export function buildConfigTomlBlock({
|
|
|
192
190
|
const pre = join(hooksDir, 'dashclaw_pretool.py');
|
|
193
191
|
const post = join(hooksDir, 'dashclaw_posttool.py');
|
|
194
192
|
const stop = join(hooksDir, 'dashclaw_stop.py');
|
|
195
|
-
const sessionStart = join(hooksDir, '
|
|
193
|
+
const sessionStart = join(hooksDir, 'enforcement_liveness_probe.py');
|
|
196
194
|
|
|
197
195
|
const lines = [
|
|
198
196
|
MANAGED_START,
|
|
@@ -244,11 +242,14 @@ export function buildConfigTomlBlock({
|
|
|
244
242
|
'',
|
|
245
243
|
// Codex CLI 0.139.0's hook-event enum contains SessionStart alongside
|
|
246
244
|
// Pre/Post/Stop; wired once that lifecycle was verified to fire
|
|
247
|
-
// (roadmap v3.7 item 6 — see PLUGIN_PARITY.md).
|
|
245
|
+
// (roadmap v3.7 item 6 — see PLUGIN_PARITY.md). Runs the enforcement-
|
|
246
|
+
// liveness probe (v8.2): `--source session-start` throttles it to once/12h
|
|
247
|
+
// and detaches, so session start is never delayed. No --agent-id — the
|
|
248
|
+
// probe forces its own synthetic identity internally.
|
|
248
249
|
'[[hooks.SessionStart]]',
|
|
249
250
|
'[[hooks.SessionStart.hooks]]',
|
|
250
251
|
'type = "command"',
|
|
251
|
-
`command = ${tomlString(`${py} ${sessionStart} --
|
|
252
|
+
`command = ${tomlString(`${py} ${sessionStart} --source session-start`)}`,
|
|
252
253
|
MANAGED_END,
|
|
253
254
|
);
|
|
254
255
|
|
package/lib/up/db.js
CHANGED
|
@@ -125,6 +125,19 @@ export function embeddedFailureMessage(err) {
|
|
|
125
125
|
return `${base}. Retry with --db docker or --db url.`;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Extra embedded-postgres options when running as root on POSIX (the fresh
|
|
130
|
+
* root-VPS case, caught live by the first `drill:fresh-linux --as-root` run):
|
|
131
|
+
* postgres refuses to run as root, and embedded-postgres's escape hatch is
|
|
132
|
+
* `createPostgresUser: true` (it provisions a `postgres` system user and runs
|
|
133
|
+
* the cluster as that user). No-op everywhere else — including Windows, where
|
|
134
|
+
* the elevated-token case is handled by the pg_ctl lifecycle instead.
|
|
135
|
+
*/
|
|
136
|
+
export function rootPostgresOptions({ platform = process.platform, getuid = process.getuid } = {}) {
|
|
137
|
+
if (platform === 'win32' || typeof getuid !== 'function') return {};
|
|
138
|
+
return getuid() === 0 ? { createPostgresUser: true } : {};
|
|
139
|
+
}
|
|
140
|
+
|
|
128
141
|
export const DEFAULT_DB_PORT = 5433;
|
|
129
142
|
// Scan window when the preferred port is taken: preferred+1 .. preferred+10.
|
|
130
143
|
const PORT_SCAN_SPAN = 10;
|
|
@@ -409,6 +422,7 @@ export async function provisionDatabase({
|
|
|
409
422
|
port,
|
|
410
423
|
persistent: true,
|
|
411
424
|
initdbFlags: INITDB_FLAGS,
|
|
425
|
+
...rootPostgresOptions(),
|
|
412
426
|
});
|
|
413
427
|
try {
|
|
414
428
|
// initdb refuses a non-empty data dir, so only initialise a cluster that
|
package/package.json
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@dashclaw/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DashClaw terminal client — approve agent actions and diagnose your instance",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"keywords": [
|
|
7
|
-
"dashclaw",
|
|
8
|
-
"cli",
|
|
9
|
-
"claude-code",
|
|
10
|
-
"ai-governance",
|
|
11
|
-
"agent-governance"
|
|
12
|
-
],
|
|
13
|
-
"bin": {
|
|
14
|
-
"dashclaw": "./bin/dashclaw.js"
|
|
15
|
-
},
|
|
16
|
-
"files": [
|
|
17
|
-
"bin/",
|
|
18
|
-
"lib/",
|
|
19
|
-
"README.md"
|
|
20
|
-
],
|
|
21
|
-
"engines": {
|
|
22
|
-
"node": ">=18.0.0"
|
|
23
|
-
},
|
|
24
|
-
"scripts": {
|
|
25
|
-
"test": "node --test test/**/*.test.js"
|
|
26
|
-
},
|
|
27
|
-
"dependencies": {
|
|
28
|
-
"dashclaw": "^2.2.1",
|
|
29
|
-
"embedded-postgres": "18.4.0-beta.17",
|
|
30
|
-
"pg": "^8.22.0",
|
|
31
|
-
"tar": "^7.4.0"
|
|
32
|
-
},
|
|
33
|
-
"license": "MIT",
|
|
34
|
-
"publishConfig": {
|
|
35
|
-
"access": "public"
|
|
36
|
-
}
|
|
37
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@dashclaw/cli",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "DashClaw terminal client — approve agent actions and diagnose your instance",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"dashclaw",
|
|
8
|
+
"cli",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"ai-governance",
|
|
11
|
+
"agent-governance"
|
|
12
|
+
],
|
|
13
|
+
"bin": {
|
|
14
|
+
"dashclaw": "./bin/dashclaw.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"lib/",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18.0.0"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test test/**/*.test.js"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"dashclaw": "^2.2.1",
|
|
29
|
+
"embedded-postgres": "18.4.0-beta.17",
|
|
30
|
+
"pg": "^8.22.0",
|
|
31
|
+
"tar": "^7.4.0"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/lib/code/apply.js
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
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 };
|
|
@@ -1,360 +0,0 @@
|
|
|
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
|
-
}
|