sprag-cli 3.55.0 → 3.56.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.ko.md +4 -0
- package/README.md +4 -0
- package/bin/cli.js +2 -2
- package/docs/CODEX.ko.md +40 -5
- package/docs/CODEX.md +69 -12
- package/package.json +1 -1
- package/src/claude-price.js +28 -0
- package/src/codex-brief.js +20 -0
- package/src/codex-cache-policy.js +26 -41
- package/src/codex-delegation.js +45 -5
- package/src/codex-doctor.js +1 -1
- package/src/codex-harness.js +20 -9
- package/src/codex-hooks.js +106 -18
- package/src/codex-ledger.js +94 -16
- package/src/codex-panel.js +12 -7
- package/src/codex-parser.js +41 -4
- package/src/codex-route-scan.js +14 -7
- package/src/codex-rule-health.js +18 -0
- package/src/commands/codex-delegate.js +12 -3
- package/src/commands/codex.js +18 -10
- package/src/commands/route-scan.js +4 -3
- package/src/cost.js +57 -3
- package/src/formatters/statusline.js +18 -7
- package/src/formatters/table.js +1 -0
- package/src/gateway-prices.js +39 -0
- package/src/litellm-models.js +72 -10
- package/src/model-rules.js +2 -1
- package/src/money.js +9 -0
- package/src/month-spend.js +10 -5
- package/src/openai-prices.js +33 -0
- package/src/route-scan.js +16 -8
- package/src/savings-ledger.js +10 -6
package/src/codex-hooks.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { mkdirSync, openSync, readFileSync, readSync, closeSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
5
6
|
import { loadConfig, userLanguage } from './config.js';
|
|
6
7
|
import { koreanStyleInjection, koreanStyleEnabled } from './korean-style.js';
|
|
7
8
|
import { cohesionInjection } from './cohesion.js';
|
|
@@ -10,8 +11,11 @@ import { findProjectRoot } from './harness.js';
|
|
|
10
11
|
import { runCodexBrief } from './codex-brief.js';
|
|
11
12
|
import { codexDocumentTool, codexDocumentNote, convertCodexDocument } from './codex-doc2md.js';
|
|
12
13
|
import { codexDelegationTool, codexRouteHint } from './codex-delegation.js';
|
|
14
|
+
import { bindCodexSubagent } from './codex-ledger.js';
|
|
13
15
|
import { seedOfferBlock } from './seed-rules.js';
|
|
14
16
|
import { readCodexRouteScan, openCodexCandidates, shouldRescanCodex } from './codex-route-scan.js';
|
|
17
|
+
import { userDataDir } from './paths.js';
|
|
18
|
+
import { debug } from './debug.js';
|
|
15
19
|
|
|
16
20
|
const require = createRequire(import.meta.url);
|
|
17
21
|
const lint = require('./korean-lint.cjs');
|
|
@@ -21,6 +25,80 @@ function context(event, text) {
|
|
|
21
25
|
return text ? { hookSpecificOutput: { hookEventName: event, additionalContext: text } } : null;
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Codex 0.159.2 keeps about 2,450 tokens of one hook's context and cuts the
|
|
30
|
+
* middle: a 4,500-token SessionStart kept its head and tail and dropped 2,042
|
|
31
|
+
* tokens, which was most of the Korean style guide, while the low-priority
|
|
32
|
+
* offers at the end survived. The budget leaves room for estimate error.
|
|
33
|
+
*/
|
|
34
|
+
export const CODEX_CONTEXT_TOKEN_BUDGET = 2000;
|
|
35
|
+
|
|
36
|
+
/** Overestimates on purpose: ASCII at 3.5 chars a token, anything else at 1.3. */
|
|
37
|
+
export function estimateCodexTokens(text) {
|
|
38
|
+
let ascii = 0, other = 0;
|
|
39
|
+
for (const ch of String(text ?? '')) { if (ch.codePointAt(0) < 128) ascii++; else other++; }
|
|
40
|
+
return Math.ceil(ascii / 3.5 + other / 1.3);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fit context parts into the budget whole, never cut. Parts are tried by
|
|
45
|
+
* priority (lower first); a part that does not fit moves to `overflowFile`
|
|
46
|
+
* when `spill` is set and is dropped otherwise. A pointer to the file goes
|
|
47
|
+
* first, since the head of a context is what Codex always keeps.
|
|
48
|
+
*/
|
|
49
|
+
export function fitCodexContext(parts, { budget = CODEX_CONTEXT_TOKEN_BUDGET, overflowFile, write = writeOverflow } = {}) {
|
|
50
|
+
const items = parts.map((p, i) => ({ ...p, i, tokens: estimateCodexTokens(p.text) })).filter((p) => p.text);
|
|
51
|
+
const pointer = (file) => `[Sprag] Some Sprag instructions exceed Codex's hook context limit. Read ${JSON.stringify(file)} before starting work and follow it like the instructions below.`;
|
|
52
|
+
const reserve = overflowFile && items.some((p) => p.spill) ? estimateCodexTokens(pointer(overflowFile)) : 0;
|
|
53
|
+
let used = reserve;
|
|
54
|
+
const kept = new Set(), spilled = [];
|
|
55
|
+
for (const p of [...items].sort((a, b) => a.priority - b.priority || a.i - b.i)) {
|
|
56
|
+
if (used + p.tokens <= budget) { kept.add(p.i); used += p.tokens; }
|
|
57
|
+
else if (p.spill && overflowFile) spilled.push(p);
|
|
58
|
+
}
|
|
59
|
+
const out = items.filter((p) => kept.has(p.i)).map((p) => p.text);
|
|
60
|
+
if (spilled.length) {
|
|
61
|
+
try {
|
|
62
|
+
write(overflowFile, spilled.sort((a, b) => a.i - b.i).map((p) => p.text).join('\n\n') + '\n');
|
|
63
|
+
out.unshift(pointer(overflowFile));
|
|
64
|
+
} catch (e) { debug('codex:overflow', e); }
|
|
65
|
+
}
|
|
66
|
+
return out.join('\n\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeOverflow(file, text) {
|
|
70
|
+
mkdirSync(join(file, '..'), { recursive: true, mode: 0o700 });
|
|
71
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
72
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
73
|
+
renameSync(tmp, file);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function overflowFileFor(root) {
|
|
77
|
+
const key = createHash('sha256').update(resolve(root || '.')).digest('hex').slice(0, 16);
|
|
78
|
+
return join(userDataDir(), 'codex-context', `${key}.md`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `codex exec` runs have no one to answer a question, yet their SessionStart
|
|
83
|
+
* payload says `source: "startup"` like an interactive one. The rollout's
|
|
84
|
+
* first record says who started it (`originator: "codex_exec"`).
|
|
85
|
+
*/
|
|
86
|
+
export function isCodexExecSession(transcriptPath) {
|
|
87
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath) return false;
|
|
88
|
+
let fd;
|
|
89
|
+
try {
|
|
90
|
+
fd = openSync(transcriptPath, 'r');
|
|
91
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
92
|
+
const length = readSync(fd, buffer, 0, buffer.length, 0);
|
|
93
|
+
const first = JSON.parse(buffer.subarray(0, length).toString('utf8').split('\n')[0]);
|
|
94
|
+
const meta = first?.type === 'session_meta' ? first.payload : null;
|
|
95
|
+
return meta?.originator === 'codex_exec' || meta?.source === 'exec';
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (e?.code !== 'ENOENT') debug('codex:exec-detect', e);
|
|
98
|
+
return false;
|
|
99
|
+
} finally { if (fd !== undefined) closeSync(fd); }
|
|
100
|
+
}
|
|
101
|
+
|
|
24
102
|
/**
|
|
25
103
|
* Project route candidates from the last scan. Never scans inline: session
|
|
26
104
|
* start must stay fast, so a stale scan is refreshed in a detached process
|
|
@@ -65,7 +143,7 @@ export function lintCodexTool(payload, { scope = 'all' } = {}) {
|
|
|
65
143
|
if (lint.isHtmlFile(file)) text = lint.stripHtml(text);
|
|
66
144
|
const findings = lint.lintKoreanText(text, { code: !lint.isProseFile(file) });
|
|
67
145
|
if (findings.length) messages.push(lint.formatFindings(file, findings));
|
|
68
|
-
} catch {
|
|
146
|
+
} catch (e) { if (e?.code !== 'ENOENT') debug('codex:lint', e); }
|
|
69
147
|
}
|
|
70
148
|
return messages.length ? messages.join('\n\n') : null;
|
|
71
149
|
}
|
|
@@ -74,45 +152,55 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig() } = {
|
|
|
74
152
|
if (!payload || typeof payload !== 'object') return null;
|
|
75
153
|
if (event === 'session-start' || event === 'subagent-start') {
|
|
76
154
|
if (event === 'subagent-start' && cfg?.codex?.delegate !== true) return null;
|
|
155
|
+
if (event === 'subagent-start') {
|
|
156
|
+
try { bindCodexSubagent(payload); } catch (e) { debug('codex:bind', e); }
|
|
157
|
+
}
|
|
77
158
|
const root = findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' });
|
|
78
159
|
const parts = [];
|
|
79
160
|
// Keep the preset file current with this version's wording before it is read.
|
|
80
161
|
if (event === 'session-start') {
|
|
81
|
-
try { (await import('./preset-ratchet.js')).preparePresetRatchet({ agent: 'codex' }); } catch {
|
|
162
|
+
try { (await import('./preset-ratchet.js')).preparePresetRatchet({ agent: 'codex' }); } catch (e) { debug('codex:preset', e); }
|
|
82
163
|
}
|
|
83
164
|
const { presetRatchetPath } = await import('./preset-ratchet.js');
|
|
165
|
+
// Lower priority survives the budget first. Rules and style move to a file
|
|
166
|
+
// when they do not fit; offers and notices are simply left out.
|
|
84
167
|
for (const [scope, ratchet] of [['global', codexHarnessPaths(root, 'global').ratchet],
|
|
85
168
|
['preset', presetRatchetPath({ agent: 'codex' })], ['project', codexHarnessPaths(root, 'project').ratchet]]) {
|
|
86
169
|
try {
|
|
87
170
|
if (statSync(ratchet).size > 32 * 1024) {
|
|
88
|
-
parts.push(`Read the ${scope} Sprag ratchet rules at ${JSON.stringify(ratchet)} before working; they exceed the automatic injection limit.`);
|
|
171
|
+
parts.push({ priority: 1, text: `Read the ${scope} Sprag ratchet rules at ${JSON.stringify(ratchet)} before working; they exceed the automatic injection limit.` });
|
|
89
172
|
} else {
|
|
90
173
|
const text = readFileSync(ratchet, 'utf8').trim();
|
|
91
174
|
// An empty preset file is only its header; injecting it would spend
|
|
92
175
|
// tokens every session on no rules.
|
|
93
|
-
if (text && (scope !== 'preset' || /^- /m.test(text))) parts.push(`[Sprag Codex ratchet: ${scope}]\n${text}`);
|
|
176
|
+
if (text && (scope !== 'preset' || /^- /m.test(text))) parts.push({ priority: 1, spill: true, text: `[Sprag Codex ratchet: ${scope}]\n${text}` });
|
|
94
177
|
}
|
|
95
|
-
} catch {
|
|
178
|
+
} catch (e) { if (e?.code !== 'ENOENT') debug('codex:ratchet', e); }
|
|
96
179
|
}
|
|
97
|
-
parts.push(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
180
|
+
if (event === 'subagent-start') parts.push({ priority: 0, text:
|
|
181
|
+
'[Sprag Codex delegation]\nStay within the assigned task and any caller-provided budget. Return findings with file references and actual verification results; state unfinished work. Use only available Codex tools and configured roles. Do not infer a model tier or invent a tool-call budget.' });
|
|
182
|
+
parts.push({ priority: 2, spill: true, text: koreanStyleInjection({ cfg }) },
|
|
183
|
+
{ priority: 3, spill: true, text: await cohesionInjection({ cfg }) });
|
|
184
|
+
if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) parts.push({ priority: 4, spill: true, text: codexDocumentNote() });
|
|
185
|
+
// Offers ask the user a question; a `codex exec` run has no one to answer it.
|
|
186
|
+
if (event === 'session-start' && payload.source !== 'compact' && !isCodexExecSession(payload.transcript_path)) {
|
|
187
|
+
try { parts.push({ priority: 5, text: await routeNotice(root) }); } catch (e) { debug('codex:route-notice', e); }
|
|
188
|
+
try { parts.push({ priority: 6, text: seedOfferBlock({ root, agent: 'codex' }) }); } catch (e) { debug('codex:seed-offer', e); }
|
|
102
189
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return context(event === 'subagent-start' ? 'SubagentStart' : 'SessionStart', parts.filter(Boolean).join('\n\n'));
|
|
190
|
+
return context(event === 'subagent-start' ? 'SubagentStart' : 'SessionStart',
|
|
191
|
+
fitCodexContext(parts, { overflowFile: overflowFileFor(root) }));
|
|
106
192
|
}
|
|
107
193
|
if (event === 'prompt') {
|
|
108
194
|
const parts = [];
|
|
109
195
|
if (cfg?.codex?.delegate === true) {
|
|
110
|
-
try {
|
|
111
|
-
|
|
196
|
+
try {
|
|
197
|
+
parts.push(codexRouteHint(payload, { root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }),
|
|
198
|
+
minContext: Number.isFinite(cfg?.codex?.delegateMinContext) ? cfg.codex.delegateMinContext : undefined }));
|
|
199
|
+
} catch (e) { debug('codex:route-hint', e); }
|
|
112
200
|
}
|
|
113
201
|
if (cfg?.codex?.brief !== false) {
|
|
114
202
|
try { parts.push(runCodexBrief({ sessionId: payload.session_id, transcriptPath: payload.transcript_path, cwd: payload.cwd })); }
|
|
115
|
-
catch {
|
|
203
|
+
catch (e) { debug('codex:brief', e); }
|
|
116
204
|
}
|
|
117
205
|
if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) {
|
|
118
206
|
parts.push(doc2md.contextForPrompt(payload, { lang: userLanguage(), agent: 'codex', convert: convertCodexDocument }));
|
package/src/codex-ledger.js
CHANGED
|
@@ -2,21 +2,30 @@ import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } fr
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
4
4
|
import { userDataDir } from './paths.js';
|
|
5
|
+
import { roundUsd } from './savings-ledger.js';
|
|
5
6
|
import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
|
|
6
|
-
import { codexRunCost, readCodexPrices } from './codex-cache-policy.js';
|
|
7
|
+
import { codexRunCost, codexPriceBook, readCodexPrices } from './codex-cache-policy.js';
|
|
7
8
|
|
|
8
9
|
/*
|
|
9
10
|
* Codex routing savings, kept apart from Claude's delegation ledger.
|
|
10
11
|
*
|
|
11
|
-
* A child rollout only counts when Sprag itself routed it:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* A child rollout only counts when Sprag itself routed it: either path writes
|
|
13
|
+
* a random route id and records the same id here at that moment, so the
|
|
14
|
+
* pending record (not the marker text a model could write by hand) is what
|
|
15
|
+
* shows a rule existed before the child ran. Two ways an id reaches a child:
|
|
16
|
+
* - the PreToolUse spawn rewrite (codexDelegationTool) writes it into the
|
|
17
|
+
* spawned message, for Codex versions that route spawn_agent through hooks;
|
|
18
|
+
* - on versions that don't (0.159.2), the prompt hint (codexRouteHint) can
|
|
19
|
+
* only ask the model to include the line, so SubagentStart binds the id to
|
|
20
|
+
* whichever spawned child matches the pending record (bindCodexSubagent),
|
|
21
|
+
* and refreshCodexLedger falls back to that binding when no marker is found.
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
export const ROUTE_ID_RE = /<!-- sprag:codex:route id=([0-9a-f]{16}) -->/;
|
|
18
25
|
const PENDING_TTL_MS = 7 * 86400000;
|
|
26
|
+
const BIND_TTL_MS = 30 * 60000;
|
|
19
27
|
const pendingFile = (dir) => join(dir, 'codex-delegations.jsonl');
|
|
28
|
+
const bindsFile = (dir) => join(dir, 'codex-delegation-binds.jsonl');
|
|
20
29
|
const ledgerFile = (dir) => join(dir, 'codex-delegation-ledger.json');
|
|
21
30
|
|
|
22
31
|
export function newRouteId() {
|
|
@@ -31,6 +40,21 @@ export function recordCodexDelegation(entry, { dir = userDataDir() } = {}) {
|
|
|
31
40
|
appendFileSync(pendingFile(dir), line + '\n', { mode: 0o600 });
|
|
32
41
|
}
|
|
33
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The pending file is append-only, so it only shrinks here: records past the
|
|
45
|
+
* pending TTL can never bind or join a child again. Rewritten in place through
|
|
46
|
+
* a rename, and only when something is actually stale.
|
|
47
|
+
*/
|
|
48
|
+
export function pruneCodexDelegations({ dir = userDataDir(), now = Date.now() } = {}) {
|
|
49
|
+
const keep = readCodexDelegations({ dir }).filter((r) => now - r.at < PENDING_TTL_MS);
|
|
50
|
+
let all;
|
|
51
|
+
try { all = readFileSync(pendingFile(dir), 'utf8').split('\n').filter(Boolean).length; } catch { return; }
|
|
52
|
+
if (keep.length === all) return;
|
|
53
|
+
const file = pendingFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
|
|
54
|
+
writeFileSync(tmp, keep.map((r) => JSON.stringify(r) + '\n').join(''), { mode: 0o600 });
|
|
55
|
+
renameSync(tmp, file);
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
export function readCodexDelegations({ dir = userDataDir() } = {}) {
|
|
35
59
|
let text;
|
|
36
60
|
try { text = readFileSync(pendingFile(dir), 'utf8'); } catch { return []; }
|
|
@@ -44,6 +68,45 @@ export function readCodexDelegations({ dir = userDataDir() } = {}) {
|
|
|
44
68
|
return out;
|
|
45
69
|
}
|
|
46
70
|
|
|
71
|
+
/** One append per bind, mirroring recordCodexDelegation's append-only shape. */
|
|
72
|
+
export function recordCodexBinding(entry, { dir = userDataDir() } = {}) {
|
|
73
|
+
mkdirSync(dir, { recursive: true });
|
|
74
|
+
appendFileSync(bindsFile(dir), JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function readCodexBindings({ dir = userDataDir() } = {}) {
|
|
78
|
+
let text;
|
|
79
|
+
try { text = readFileSync(bindsFile(dir), 'utf8'); } catch { return []; }
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const line of text.split('\n')) {
|
|
82
|
+
try {
|
|
83
|
+
const b = JSON.parse(line);
|
|
84
|
+
if (b && /^[0-9a-f]{16}$/.test(b.id) && typeof b.childSessionId === 'string' && Number.isFinite(b.at)) out.push(b);
|
|
85
|
+
} catch { /* Partial trailing line. */ }
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Bind a just-spawned SubagentStart child to the pending route it most likely
|
|
92
|
+
* fulfills: same parent thread, same target model, recent enough, and not
|
|
93
|
+
* already claimed by another child. Returns the bound route id, or null when
|
|
94
|
+
* nothing matches or this child is already bound.
|
|
95
|
+
*/
|
|
96
|
+
export function bindCodexSubagent(payload, { dir = userDataDir(), now = Date.now() } = {}) {
|
|
97
|
+
if (typeof payload?.agent_id !== 'string' || typeof payload?.session_id !== 'string' || typeof payload?.model !== 'string') return null;
|
|
98
|
+
const binds = readCodexBindings({ dir });
|
|
99
|
+
if (binds.some((b) => b.childSessionId === payload.agent_id)) return null;
|
|
100
|
+
const boundIds = new Set(binds.map((b) => b.id));
|
|
101
|
+
const pending = readCodexDelegations({ dir }).filter((r) =>
|
|
102
|
+
r.parentSessionId === payload.session_id && r.to === payload.model && now - r.at < BIND_TTL_MS && !boundIds.has(r.id));
|
|
103
|
+
if (!pending.length) return null;
|
|
104
|
+
// Oldest first: spawns are recorded in order, so the earliest unbound record belongs to the earliest child.
|
|
105
|
+
const chosen = pending.reduce((a, b) => (b.at < a.at ? b : a));
|
|
106
|
+
recordCodexBinding({ id: chosen.id, childSessionId: payload.agent_id, at: now }, { dir });
|
|
107
|
+
return chosen.id;
|
|
108
|
+
}
|
|
109
|
+
|
|
47
110
|
export function loadCodexLedger({ dir = userDataDir() } = {}) {
|
|
48
111
|
try {
|
|
49
112
|
const data = JSON.parse(readFileSync(ledgerFile(dir), 'utf8'));
|
|
@@ -59,38 +122,50 @@ function saveLedger(data, dir) {
|
|
|
59
122
|
renameSync(tmp, file);
|
|
60
123
|
}
|
|
61
124
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
125
|
+
/**
|
|
126
|
+
* The route id a child rollout carries, and its own tokens, or null. When no
|
|
127
|
+
* child turn carries the marker line (the prompt-hint path can only ask the
|
|
128
|
+
* model to write it, not guarantee it), `binds` — childSessionId -> id from
|
|
129
|
+
* SubagentStart — supplies the id instead.
|
|
130
|
+
*/
|
|
131
|
+
async function childRun(path, cutoffMs, binds) {
|
|
64
132
|
const s = await parseCodexTurns(path, { cutoffMs });
|
|
65
133
|
if (!s.isSubagent) return null;
|
|
66
134
|
let id = null;
|
|
67
135
|
const tokens = { input: 0, cached: 0, cacheWrite: 0, output: 0 };
|
|
68
|
-
let
|
|
136
|
+
let calls = 0, toolErrors = 0;
|
|
137
|
+
let model = null, startedAt = null, endedAt = null, complete = false, aborted = false, usageKnown = false, mixedModels = false;
|
|
69
138
|
for (const t of s.turns) {
|
|
70
139
|
// Only the child's own first message may carry the id; copied history does not count.
|
|
71
140
|
if (!id && t.text) { const m = ROUTE_ID_RE.exec(t.text); if (m) id = m[1]; }
|
|
72
141
|
tokens.input += t.input; tokens.cached += t.cached; tokens.cacheWrite += t.cacheWrite; tokens.output += t.out;
|
|
142
|
+
calls += t.calls; toolErrors += t.errors;
|
|
73
143
|
if (model && t.model && model !== t.model) mixedModels = true;
|
|
74
144
|
model ??= t.model;
|
|
75
145
|
usageKnown ||= t.usageKnown;
|
|
76
146
|
if (Number.isFinite(t.startedAt)) startedAt = Math.min(startedAt ?? t.startedAt, t.startedAt);
|
|
77
|
-
if (Number.isFinite(t.endedAt)) { endedAt = Math.max(endedAt ?? t.endedAt, t.endedAt); complete = !t.aborted; }
|
|
147
|
+
if (Number.isFinite(t.endedAt)) { endedAt = Math.max(endedAt ?? t.endedAt, t.endedAt); complete = !t.aborted; aborted = !!t.aborted; }
|
|
78
148
|
}
|
|
149
|
+
if (!id && binds) id = binds.get(s.sessionId) ?? null;
|
|
79
150
|
return id ? { id, sessionId: s.sessionId, parentThreadId: s.parentThreadId, provider: s.provider,
|
|
80
|
-
model, tokens, startedAt, endedAt, complete, usageKnown: usageKnown && !mixedModels } : null;
|
|
151
|
+
model, tokens, calls, toolErrors, startedAt, endedAt, complete, aborted, usageKnown: usageKnown && !mixedModels } : null;
|
|
81
152
|
}
|
|
82
153
|
|
|
83
154
|
/**
|
|
84
155
|
* Join child rollouts to pending routes and price them. Savings hold the
|
|
85
156
|
* child's tokens constant and price them at the parent model, the same
|
|
86
157
|
* approximation Claude's ledger states. A route to a pricier model is a loss,
|
|
87
|
-
* shown as one rather than clamped away.
|
|
158
|
+
* shown as one rather than clamped away. A child that was interrupted before
|
|
159
|
+
* it finished replaced no parent work (the parent did the task itself), so its
|
|
160
|
+
* whole cost is booked as a loss.
|
|
88
161
|
*/
|
|
89
162
|
export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date.now(), prices = readCodexPrices({ now, home, dir }) } = {}) {
|
|
163
|
+
try { pruneCodexDelegations({ dir, now }); } catch { /* Pruning is housekeeping; the TTL filter below still holds. */ }
|
|
90
164
|
const pending = readCodexDelegations({ dir }).filter((r) => now - r.at < PENDING_TTL_MS);
|
|
91
165
|
const ledger = loadCodexLedger({ dir });
|
|
92
166
|
if (!pending.length) return ledger;
|
|
93
167
|
const byId = new Map(pending.map((r) => [r.id, r]));
|
|
168
|
+
const bindMap = new Map(readCodexBindings({ dir }).filter((b) => byId.has(b.id)).map((b) => [b.childSessionId, b.id]));
|
|
94
169
|
const oldest = Math.min(...pending.map((r) => r.at));
|
|
95
170
|
const files = await discoverCodexSessionFiles({ home, days: Math.max(1, (now - oldest) / 86400000 + 1) });
|
|
96
171
|
let changed = false;
|
|
@@ -98,7 +173,7 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
|
|
|
98
173
|
for (const f of files.slice().reverse()) {
|
|
99
174
|
if (f.mtime < oldest) continue;
|
|
100
175
|
let run;
|
|
101
|
-
try { run = await childRun(f.path, oldest - 60000); } catch { continue; }
|
|
176
|
+
try { run = await childRun(f.path, oldest - 60000, bindMap); } catch { continue; }
|
|
102
177
|
const route = run && byId.get(run.id);
|
|
103
178
|
// The id must come from a spawn this parent made, after the record was written.
|
|
104
179
|
if (!route || (route.parentSessionId && run.parentThreadId && route.parentSessionId !== run.parentThreadId)) continue;
|
|
@@ -110,15 +185,18 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
|
|
|
110
185
|
const previous = ledger.events[route.id];
|
|
111
186
|
const provider = route.provider || run.provider;
|
|
112
187
|
const sameRun = previous?.childSessionId === run.sessionId && previous.to === to && previous.from === route.from;
|
|
188
|
+
// Priced by the path the child took: OpenAI list for direct calls, the gateway's prices for LiteLLM.
|
|
189
|
+
const book = codexPriceBook(provider, prices, { home });
|
|
113
190
|
const rates = sameRun && previous.rates ? previous.rates
|
|
114
|
-
:
|
|
191
|
+
: book ? { from: book.prices[route.from], to: book.prices[to], checkedAt: book.checkedAt } : null;
|
|
192
|
+
const priceSource = !rates ? null : sameRun && previous.rates ? previous.priceSource ?? null : book?.source ?? null;
|
|
115
193
|
const actual = rates && run.usageKnown ? codexRunCost(run.tokens, rates.to) : null;
|
|
116
194
|
const counterfactual = rates && run.usageKnown ? codexRunCost(run.tokens, rates.from) : null;
|
|
117
|
-
const usd = actual === null || counterfactual === null ? null : counterfactual - actual;
|
|
195
|
+
const usd = actual === null || counterfactual === null ? null : roundUsd(run.aborted ? -actual : counterfactual - actual);
|
|
118
196
|
const event = { ts: run.endedAt ?? route.at, parentSessionId: route.parentSessionId || run.parentThreadId || null,
|
|
119
197
|
childSessionId: run.sessionId, source: route.source, category: route.category ?? null, scope: route.scope ?? null,
|
|
120
|
-
from: route.from, to, provider, tokens: run.tokens, usd, rates: usd === null ? null : rates,
|
|
121
|
-
priceCheckedAt: rates?.checkedAt ?? null, complete: run.complete };
|
|
198
|
+
from: route.from, to, provider, tokens: run.tokens, calls: run.calls, toolErrors: run.toolErrors, usd, rates: usd === null ? null : rates,
|
|
199
|
+
priceCheckedAt: rates?.checkedAt ?? null, priceSource, complete: run.complete, ...(run.aborted ? { aborted: true } : {}) };
|
|
122
200
|
// A transient price outage must not erase a completed, unchanged priced run.
|
|
123
201
|
if (usd === null && sameRun && previous.complete && Number.isFinite(previous.usd) &&
|
|
124
202
|
JSON.stringify(previous.tokens) === JSON.stringify(run.tokens)) continue;
|
package/src/codex-panel.js
CHANGED
|
@@ -9,7 +9,7 @@ import { loadConfig, statuslineDefaults } from './config.js';
|
|
|
9
9
|
import { glyphsFor } from './glyphs.js';
|
|
10
10
|
import { gaugeBar, formatMoney } from './formatters/statusline.js';
|
|
11
11
|
import { codexCacheTimer } from './codex-cache.js';
|
|
12
|
-
import { readCodexCachePolicy, readCodexPrices } from './codex-cache-policy.js';
|
|
12
|
+
import { readCodexCachePolicy, readCodexPrices, isDirectOpenAI } from './codex-cache-policy.js';
|
|
13
13
|
import { codexIssues } from './codex-brief.js';
|
|
14
14
|
import { labelForKey } from './window-labels.js';
|
|
15
15
|
import { resolveLabelMode } from './statusline-mode.js';
|
|
@@ -28,20 +28,22 @@ export const CODEX_SEGMENT_ORDER = [
|
|
|
28
28
|
|
|
29
29
|
// Only cache full-session parses. A moving time cutoff would make an unchanged
|
|
30
30
|
// file's cached totals stale. The panel reports session totals, not window totals.
|
|
31
|
+
// Matches the candidate TTL: older runs could not contribute a candidate anyway.
|
|
32
|
+
const RATCHET_SIBLING_MS = 30 * 60000;
|
|
33
|
+
|
|
31
34
|
export function createPanelReader({ root = process.cwd(), home, sessionId: pinnedId, sessionFile, days = 7, readBudget = () => null,
|
|
32
35
|
readCachePolicy = (session) => readCodexCachePolicy(session, { home }), hasFlag = () => false } = {}) {
|
|
33
36
|
if (pinnedId && sessionFile) throw new Error('Choose --session or --session-file, not both.');
|
|
34
37
|
const cache = new Map();
|
|
35
38
|
const ratchetCache = new Map();
|
|
36
|
-
const ratchetFor = (session) => {
|
|
39
|
+
const ratchetFor = (session, siblings = []) => {
|
|
37
40
|
if (!session?.filePath) return [];
|
|
38
41
|
try {
|
|
39
|
-
const s = statSync(
|
|
40
|
-
const key = `${s.mtimeMs}:${s.size}`;
|
|
42
|
+
const key = [session.filePath, ...siblings].map((file) => { const s = statSync(file); return `${file}:${s.mtimeMs}:${s.size}`; }).join('|');
|
|
41
43
|
// Candidates expire by age, so a quiet file still needs a periodic re-read.
|
|
42
44
|
const hit = ratchetCache.get(session.filePath);
|
|
43
45
|
if (hit?.key === key && Date.now() - hit.at < 60000) return hit.value;
|
|
44
|
-
const value = codexRatchetCandidates(session.filePath);
|
|
46
|
+
const value = codexRatchetCandidates(session.filePath, { siblings });
|
|
45
47
|
ratchetCache.clear();
|
|
46
48
|
ratchetCache.set(session.filePath, { key, at: Date.now(), value });
|
|
47
49
|
return value;
|
|
@@ -65,6 +67,8 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
|
|
|
65
67
|
let selected = null;
|
|
66
68
|
let matches = 0;
|
|
67
69
|
const seen = new Set();
|
|
70
|
+
// Recent rollouts of the same project, for repeats across `codex exec` runs.
|
|
71
|
+
const recent = [];
|
|
68
72
|
for (const f of files.slice().reverse()) {
|
|
69
73
|
let entry = cache.get(f.path);
|
|
70
74
|
if (!entry || entry.mtime !== f.mtime || entry.size !== f.size) {
|
|
@@ -80,6 +84,7 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
|
|
|
80
84
|
if (seen.has(key)) continue;
|
|
81
85
|
seen.add(key);
|
|
82
86
|
matches++;
|
|
87
|
+
if (!sessionId && Date.now() - f.mtime < RATCHET_SIBLING_MS) recent.push(s.filePath);
|
|
83
88
|
// Activity timestamps survive archive moves that update filesystem mtime.
|
|
84
89
|
if (!selected || (s.lastActivity?.getTime() || 0) > (selected.lastActivity?.getTime() || 0)) selected = s;
|
|
85
90
|
}
|
|
@@ -98,10 +103,10 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
|
|
|
98
103
|
let route = [];
|
|
99
104
|
try { route = openCodexCandidates(readCodexRouteScan(), { root: project }); } catch { /* No scan yet. */ }
|
|
100
105
|
let routingSaved = null;
|
|
101
|
-
try { routingSaved = { ...codexRoutingSavedTotals(), pricesAvailable: !!readCodexPrices({ provider: codexBudgetProvider(home ? { home } : {}), ...(home ? { home } : {}) }) }; }
|
|
106
|
+
try { routingSaved = { ...codexRoutingSavedTotals(), pricesAvailable: isDirectOpenAI(selected?.provider, home ? { home } : {}) || !!readCodexPrices({ provider: codexBudgetProvider(home ? { home } : {}), ...(home ? { home } : {}) }) }; }
|
|
102
107
|
catch { /* Unreadable ledger reads as n/a. */ }
|
|
103
108
|
return { session: selected, matches, root, sessionId, selection, bindingPending: !!sessionFile && !sessionId, days, harness, rules,
|
|
104
|
-
ratchet: ratchetFor(selected), route, routingSaved,
|
|
109
|
+
ratchet: ratchetFor(selected, recent.filter((file) => file !== selected?.filePath)), route, routingSaved,
|
|
105
110
|
labelMode: resolveLabelMode({ cfg: statuslineDefaults(), hasFlag }).mode,
|
|
106
111
|
color: statuslineDefaults().color,
|
|
107
112
|
timer: !hasFlag('--no-timer') && (hasFlag('--timer') || statuslineDefaults().timer),
|
package/src/codex-parser.js
CHANGED
|
@@ -7,16 +7,45 @@ import { codexUserDir } from './agent.js';
|
|
|
7
7
|
const count = (n) => Number.isSafeInteger(n) && n >= 0 ? n : 0;
|
|
8
8
|
const emptyTotals = () => ({ input: 0, cacheRead: 0, cacheCreation: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 });
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* A child spawned with fork_turns "all" writes its own session_meta first,
|
|
12
|
+
* then a copy of the parent's session_meta and history, then its own work.
|
|
13
|
+
* The copy belongs to the parent: identity comes from the first session_meta,
|
|
14
|
+
* and the copied lines are skipped so the parent's request is not counted a
|
|
15
|
+
* second time. The copy ends at the child's own thread_settings_applied, or
|
|
16
|
+
* at the first line written after the batch copy, whichever comes first.
|
|
17
|
+
*/
|
|
18
|
+
export function forkedCopyFilter() {
|
|
19
|
+
let ownId = null, copyStamp = null;
|
|
20
|
+
return (e) => {
|
|
21
|
+
const p = e?.payload;
|
|
22
|
+
if (e?.type === 'session_meta') {
|
|
23
|
+
const id = p?.id || p?.session_id || null;
|
|
24
|
+
if (ownId === null) { ownId = id; return false; }
|
|
25
|
+
// A resumed rollout can repeat its own meta; only another thread's is a copy.
|
|
26
|
+
if (id && id !== ownId) { copyStamp = e.timestamp ?? null; return true; }
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (copyStamp === null) return false;
|
|
30
|
+
const own = e?.type === 'event_msg' && p?.type === 'thread_settings_applied' && p.thread_id === ownId;
|
|
31
|
+
if (own || e?.timestamp !== copyStamp) { copyStamp = null; return false; }
|
|
32
|
+
return true;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
function updateSnapshot(session, e) {
|
|
11
37
|
const p = e?.payload;
|
|
12
38
|
if (!p || typeof p !== 'object') return;
|
|
13
39
|
const activity = Date.parse(e.timestamp);
|
|
14
40
|
if (Number.isFinite(activity)) session.lastActivity = new Date(activity);
|
|
15
41
|
if (e.type === 'session_meta') {
|
|
42
|
+
// Callers drop forked copies, so a later meta can only be this thread's own repeat.
|
|
43
|
+
if (session.sessionId) return;
|
|
16
44
|
session.sessionId = p.id || p.session_id || null;
|
|
17
45
|
session.projectDir = typeof p.cwd === 'string' ? p.cwd : '';
|
|
18
46
|
session.source = p.source ?? null;
|
|
19
47
|
session.isSubagent = typeof p.source === 'object' && p.source !== null && 'subagent' in p.source;
|
|
48
|
+
session.forkedFrom = typeof p.forked_from_id === 'string' ? p.forked_from_id : null;
|
|
20
49
|
session.branch = typeof p.git?.branch === 'string' ? p.git.branch : null;
|
|
21
50
|
session.provider = typeof p.model_provider === 'string' ? p.model_provider : null;
|
|
22
51
|
}
|
|
@@ -63,8 +92,12 @@ export function readCodexSnapshot(filePath, { tailBytes = 256 * 1024 } = {}) {
|
|
|
63
92
|
const lines = readCodexTailLines(filePath, tailBytes);
|
|
64
93
|
if (!lines) return null;
|
|
65
94
|
const snapshot = {};
|
|
95
|
+
const copied = forkedCopyFilter();
|
|
66
96
|
for (const line of lines) {
|
|
67
|
-
try {
|
|
97
|
+
try {
|
|
98
|
+
const e = JSON.parse(line);
|
|
99
|
+
if (!copied(e)) updateSnapshot(snapshot, e);
|
|
100
|
+
} catch { /* Partial or malformed record. */ }
|
|
68
101
|
}
|
|
69
102
|
return snapshot;
|
|
70
103
|
}
|
|
@@ -164,7 +197,8 @@ const emptyTurn = (turnId) => ({ turnId, startedAt: null, endedAt: null, aborted
|
|
|
164
197
|
* structured event described, so a call is never counted twice.
|
|
165
198
|
*/
|
|
166
199
|
export async function parseCodexTurns(filePath, { cutoffMs = -Infinity } = {}) {
|
|
167
|
-
const session = { filePath, sessionId: null, projectDir: '', isSubagent: false, parentThreadId: null, provider: null, turns: [] };
|
|
200
|
+
const session = { filePath, sessionId: null, projectDir: '', isSubagent: false, parentThreadId: null, forkedFrom: null, provider: null, turns: [] };
|
|
201
|
+
const copied = forkedCopyFilter();
|
|
168
202
|
const turns = new Map();
|
|
169
203
|
const turnOf = (id) => {
|
|
170
204
|
if (!id) return null;
|
|
@@ -184,12 +218,14 @@ export async function parseCodexTurns(filePath, { cutoffMs = -Infinity } = {}) {
|
|
|
184
218
|
let e;
|
|
185
219
|
try { e = JSON.parse(line); } catch { continue; }
|
|
186
220
|
const p = e?.payload;
|
|
187
|
-
if (!p || typeof p !== 'object') continue;
|
|
221
|
+
if (!p || typeof p !== 'object' || copied(e)) continue;
|
|
188
222
|
if (e.type === 'session_meta') {
|
|
223
|
+
if (session.sessionId) continue;
|
|
189
224
|
session.sessionId = p.id || p.session_id || null;
|
|
190
225
|
session.projectDir = typeof p.cwd === 'string' ? p.cwd : '';
|
|
191
226
|
session.isSubagent = typeof p.source === 'object' && p.source !== null && 'subagent' in p.source;
|
|
192
227
|
session.parentThreadId = p.source?.subagent?.thread_spawn?.parent_thread_id || null;
|
|
228
|
+
session.forkedFrom = typeof p.forked_from_id === 'string' ? p.forked_from_id : null;
|
|
193
229
|
session.provider = typeof p.model_provider === 'string' ? p.model_provider : null;
|
|
194
230
|
continue;
|
|
195
231
|
}
|
|
@@ -303,6 +339,7 @@ export async function parseCodexSessionFile(filePath, { cutoffMs = -Infinity } =
|
|
|
303
339
|
lastContextTokens: null, rateLimits: null, lastActivity: null, effort: null,
|
|
304
340
|
cacheActivity: null,
|
|
305
341
|
};
|
|
342
|
+
const copied = forkedCopyFilter();
|
|
306
343
|
let previous = null;
|
|
307
344
|
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
|
308
345
|
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
@@ -310,7 +347,7 @@ export async function parseCodexSessionFile(filePath, { cutoffMs = -Infinity } =
|
|
|
310
347
|
let e;
|
|
311
348
|
try { e = JSON.parse(line); } catch { continue; }
|
|
312
349
|
const p = e?.payload;
|
|
313
|
-
if (!p) continue;
|
|
350
|
+
if (!p || copied(e)) continue;
|
|
314
351
|
updateSnapshot(session, e);
|
|
315
352
|
if (e.type !== 'event_msg' || p.type !== 'token_count') continue;
|
|
316
353
|
if (!p.info) continue;
|
package/src/codex-route-scan.js
CHANGED
|
@@ -8,7 +8,8 @@ import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
|
|
|
8
8
|
import { categorize, isSkippable, tierOf, calibrateThresholds, MIN_RECURRENCE,
|
|
9
9
|
RESCAN_MIN_INTERVAL_MS, RESCAN_BIG_DELTA_BYTES, RESCAN_MAX_AGE_MS } from './route-scan.js';
|
|
10
10
|
import { codexPromptCategory, loadCodexModelRules } from './codex-delegation.js';
|
|
11
|
-
import { codexRunCost, readCodexPrices } from './codex-cache-policy.js';
|
|
11
|
+
import { codexRunCost, codexPriceBook, isDirectOpenAI, readCodexPrices } from './codex-cache-policy.js';
|
|
12
|
+
import { OPENAI_PRICES_CHECKED_AT } from './openai-prices.js';
|
|
12
13
|
import { refreshCodexLedger } from './codex-ledger.js';
|
|
13
14
|
|
|
14
15
|
/*
|
|
@@ -81,11 +82,12 @@ export async function shouldRescanCodex(cache, { home, days = 14, now = Date.now
|
|
|
81
82
|
* a rule target, so a gateway that also serves unrelated models (embeddings,
|
|
82
83
|
* other vendors) never has one suggested blind.
|
|
83
84
|
*/
|
|
84
|
-
function pickTarget(group, { prices, known }) {
|
|
85
|
-
const
|
|
85
|
+
function pickTarget(group, { prices, known, home }) {
|
|
86
|
+
const book = codexPriceBook(group.provider, prices, { home });
|
|
87
|
+
const cost = (model) => book ? codexRunCost(group.tokens, book.prices[model]) : null;
|
|
86
88
|
const parent = cost(group.from);
|
|
87
89
|
if (parent === null) return { suggestedModel: null, alternatives: [], estSavedUsd: null };
|
|
88
|
-
const cheaper = Object.keys(
|
|
90
|
+
const cheaper = Object.keys(book.prices).filter((m) => m !== group.from && book.prices[m])
|
|
89
91
|
.map((m) => ({ model: m, usd: cost(m) })).filter((x) => x.usd !== null && x.usd < parent)
|
|
90
92
|
.sort((a, b) => a.usd - b.usd);
|
|
91
93
|
const eligible = cheaper.filter((x) => known.has(`${group.provider}|${x.model}`));
|
|
@@ -151,7 +153,11 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
|
|
|
151
153
|
(!r.provider || r.provider === g.provider) &&
|
|
152
154
|
(r.scope === 'global' || r.targetRoot === g.projectRoot));
|
|
153
155
|
const known = new Set([...observed, ...rules.filter((r) => r.provider).map((r) => `${r.provider}|${r.model}`)]);
|
|
154
|
-
|
|
156
|
+
const target = cfg?.codex?.delegateTarget?.model;
|
|
157
|
+
if (target) {
|
|
158
|
+
if (prices?.provider) known.add(`${prices.provider}|${target}`);
|
|
159
|
+
if (isDirectOpenAI('openai', { home })) known.add(`openai|${target}`);
|
|
160
|
+
}
|
|
155
161
|
const prev = readCodexRouteScan({ dir });
|
|
156
162
|
const resolved = new Set(prev?.resolved || []);
|
|
157
163
|
const candidates = [...groups.values()]
|
|
@@ -160,9 +166,10 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
|
|
|
160
166
|
.slice(0, MAX_CANDIDATES)
|
|
161
167
|
.map((g, i) => ({ id: i + 1, signature: g.signature, category: g.category, label: g.label, labelEn: g.labelEn,
|
|
162
168
|
from: g.from, provider: g.provider, projectRoot: g.projectRoot, count: g.count, tiers: g.tiers, example: g.example.slice(0, 160),
|
|
163
|
-
lastSeen: g.lastSeen || null, suggestedScope: 'project', ...pickTarget(g, { prices, known }) }));
|
|
169
|
+
lastSeen: g.lastSeen || null, suggestedScope: 'project', ...pickTarget(g, { prices, known, home }) }));
|
|
170
|
+
const priceCheckedAt = prices?.checkedAt ?? ([...groups.values()].some((g) => isDirectOpenAI(g.provider, { home })) ? OPENAI_PRICES_CHECKED_AT : null);
|
|
164
171
|
const data = { version: 1, scannedAt: new Date(now).toISOString(), days, dataBytes, totalEpisodes: episodes.length,
|
|
165
|
-
thresholds, observedModels: [...observed], priceCheckedAt
|
|
172
|
+
thresholds, observedModels: [...observed], priceCheckedAt, candidates, resolved: [...resolved] };
|
|
166
173
|
writeCache(data, dir);
|
|
167
174
|
if (refreshLedger) {
|
|
168
175
|
try { await refreshCodexLedger({ dir, home, now, prices }); } catch { /* The ledger catches up on the next scan. */ }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { loadCodexLedger } from './codex-ledger.js';
|
|
2
|
+
import { isFailedRun } from './route-scan.js';
|
|
3
|
+
import { wilsonLowerBound, HEALTH_ERR_RATE, HEALTH_MIN_SAMPLE_DELEGATED } from './model-rules.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Measured outcome of one Codex rule, judged as Claude's rule health judges
|
|
7
|
+
* its delegations: a run fails when interrupted or when its tool-error density
|
|
8
|
+
* crosses isFailedRun's line, and the rule goes to review only when the Wilson
|
|
9
|
+
* lower bound of the failure rate clears HEALTH_ERR_RATE over enough runs.
|
|
10
|
+
*/
|
|
11
|
+
export function codexRuleHealth(rule, { events = Object.values(loadCodexLedger().events) } = {}) {
|
|
12
|
+
const runs = events.filter((e) => e && e.category === rule.category && e.from === rule.from && e.to === rule.model &&
|
|
13
|
+
(!rule.provider || !e.provider || e.provider === rule.provider) && (e.complete || e.aborted));
|
|
14
|
+
const errs = runs.filter((e) => e.aborted || isFailedRun({ toolErrors: e.toolErrors, calls: e.calls })).length;
|
|
15
|
+
const saved = runs.filter((e) => Number.isFinite(e.usd)).reduce((sum, e) => sum + e.usd, 0);
|
|
16
|
+
const review = runs.length >= HEALTH_MIN_SAMPLE_DELEGATED && wilsonLowerBound(errs, runs.length) > HEALTH_ERR_RATE;
|
|
17
|
+
return { runs: runs.length, errs, rate: runs.length ? errs / runs.length : 0, saved, status: review ? 'review' : 'active' };
|
|
18
|
+
}
|