sprag-cli 3.54.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 +17 -5
- package/README.md +23 -6
- package/bin/cli.js +6 -6
- package/docs/CODEX.ko.md +57 -10
- package/docs/CODEX.md +89 -20
- package/package.json +6 -3
- 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 +114 -19
- package/src/codex-ledger.js +94 -16
- package/src/codex-panel-setup.js +98 -0
- 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 +41 -11
- package/src/commands/harness.js +13 -2
- package/src/commands/install.js +106 -10
- package/src/commands/route-scan.js +8 -3
- package/src/commands/seed.js +7 -4
- package/src/commands/uninstall.js +23 -0
- package/src/commands/upgrade.js +13 -0
- 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/harness-templates.js +33 -1
- package/src/harness.js +26 -1
- 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/preset-ratchet.js +133 -0
- package/src/route-scan.js +16 -8
- package/src/savings-ledger.js +10 -6
- package/src/seed-rules.js +32 -7
|
@@ -6,6 +6,10 @@ import { codexUserDir } from './agent.js';
|
|
|
6
6
|
import { userDataDir } from './paths.js';
|
|
7
7
|
import { codexBudgetProvider, codexProviderKey } from './codex-budget.js';
|
|
8
8
|
import { codexCachePolicy } from './codex-cache.js';
|
|
9
|
+
import { PRICE_MAX_AGE_MS, gatewayPrices } from './gateway-prices.js';
|
|
10
|
+
import { OPENAI_LIST_PRICES, OPENAI_PRICES_CHECKED_AT } from './openai-prices.js';
|
|
11
|
+
|
|
12
|
+
export { PRICE_MAX_AGE_MS, rate, deploymentPrice, gatewayPrices, codexRunCost } from './gateway-prices.js';
|
|
9
13
|
|
|
10
14
|
export const CACHE_POLICY_MAX_AGE_MS = 300000;
|
|
11
15
|
const OPENAI_POLICY = 'https://developers.openai.com/api/docs/guides/prompt-caching#cache-lifetime';
|
|
@@ -51,42 +55,6 @@ export function gatewayCachePolicies(payload) {
|
|
|
51
55
|
policies[0] && policies.every((p) => JSON.stringify(p) === JSON.stringify(policies[0])) ? policies[0] : null]));
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
export const PRICE_MAX_AGE_MS = 7 * 86400000;
|
|
55
|
-
const rate = (value) => Number.isFinite(value) && value >= 0 && value < 1 ? value : null;
|
|
56
|
-
|
|
57
|
-
function deploymentPrice(row) {
|
|
58
|
-
const info = row?.model_info || {};
|
|
59
|
-
const mode = typeof info.mode === 'string' ? info.mode : null;
|
|
60
|
-
if (mode && !['chat', 'responses', 'completion'].includes(mode)) return null;
|
|
61
|
-
const input = rate(info.input_cost_per_token), output = rate(info.output_cost_per_token);
|
|
62
|
-
if (input === null || output === null) return null;
|
|
63
|
-
return { input, output, cacheRead: rate(info.cache_read_input_token_cost), cacheWrite: rate(info.cache_creation_input_token_cost) };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Per-token prices by alias. Load-balanced deployments must agree, like cache policy. */
|
|
67
|
-
export function gatewayPrices(payload) {
|
|
68
|
-
if (!Array.isArray(payload?.data)) throw new Error('LiteLLM model/info returned no model list');
|
|
69
|
-
const groups = new Map();
|
|
70
|
-
for (const row of payload.data) {
|
|
71
|
-
if (typeof row?.model_name !== 'string' || row.model_name.length > 256) continue;
|
|
72
|
-
groups.set(row.model_name, [...(groups.get(row.model_name) || []), deploymentPrice(row)]);
|
|
73
|
-
}
|
|
74
|
-
return Object.fromEntries([...groups].map(([name, prices]) => [name,
|
|
75
|
-
prices[0] && prices.every((p) => JSON.stringify(p) === JSON.stringify(prices[0])) ? prices[0] : null]));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* USD for a token mix at one model's prices, or null when a needed rate is
|
|
80
|
-
* missing. Cached input without a cache-read rate is unknown, not free.
|
|
81
|
-
*/
|
|
82
|
-
export function codexRunCost({ input = 0, cached = 0, cacheWrite = 0, output = 0 }, price) {
|
|
83
|
-
if (!price || price.input === null || price.output === null) return null;
|
|
84
|
-
if (cached > 0 && price.cacheRead === null) return null;
|
|
85
|
-
// cache_write_input_tokens is billed at the creation rate when one is published, else as input.
|
|
86
|
-
const uncached = Math.max(0, input - cached - cacheWrite);
|
|
87
|
-
return uncached * price.input + cached * (price.cacheRead ?? 0) + cacheWrite * (price.cacheWrite ?? price.input) + output * price.output;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
58
|
function cacheFile(provider, { home = codexUserDir(), dir = userDataDir() } = {}) {
|
|
91
59
|
const key = createHash('sha256').update(JSON.stringify([resolve(home), provider.name, provider.base])).digest('hex').slice(0, 24);
|
|
92
60
|
return join(dir, 'codex-cache-policy', `${key}.json`);
|
|
@@ -97,14 +65,31 @@ function gatewayProvider(session, opts) {
|
|
|
97
65
|
return codexBudgetProvider({ ...opts, providerName: session.provider });
|
|
98
66
|
}
|
|
99
67
|
|
|
100
|
-
|
|
101
|
-
|
|
68
|
+
/** True when Codex's OpenAI provider talks to api.openai.com itself, not to a proxy. No config = direct. */
|
|
69
|
+
export function isDirectOpenAI(provider, { home = codexUserDir() } = {}) {
|
|
70
|
+
if (provider !== 'openai') return false;
|
|
102
71
|
try {
|
|
103
72
|
let cfg = {};
|
|
104
73
|
try { cfg = parse(readFileSync(join(home, 'config.toml'), 'utf8')); }
|
|
105
|
-
catch (e) { if (e.code !== 'ENOENT') return
|
|
106
|
-
if (cfg.openai_base_url && new URL(cfg.openai_base_url).hostname !== 'api.openai.com') return
|
|
107
|
-
} catch { return
|
|
74
|
+
catch (e) { if (e.code !== 'ENOENT') return false; }
|
|
75
|
+
if (cfg.openai_base_url && new URL(cfg.openai_base_url).hostname !== 'api.openai.com') return false;
|
|
76
|
+
} catch { return false; }
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The price table a Codex provider's calls are billed by: OpenAI list prices for
|
|
82
|
+
* direct calls, the gateway's own /model/info prices for a LiteLLM provider.
|
|
83
|
+
* Never a fallback chain, so a model the gateway does not price stays unpriced.
|
|
84
|
+
*/
|
|
85
|
+
export function codexPriceBook(provider, litellm, opts = {}) {
|
|
86
|
+
if (isDirectOpenAI(provider, opts)) return { source: 'list', provider: 'openai', checkedAt: OPENAI_PRICES_CHECKED_AT, prices: OPENAI_LIST_PRICES };
|
|
87
|
+
if (litellm?.provider === provider) return { ...litellm, source: 'litellm' };
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function directPolicy(session, { home = codexUserDir(), now = Date.now() } = {}) {
|
|
92
|
+
if (!codexCachePolicy(session?.model) || !isDirectOpenAI(session?.provider, { home })) return null;
|
|
108
93
|
return { backend: 'OpenAI', model: session.model, seconds: codexCachePolicy(session.model),
|
|
109
94
|
requestedModel: session.model, provider: session.provider, checkedAt: now, reference: OPENAI_POLICY };
|
|
110
95
|
}
|
package/src/codex-delegation.js
CHANGED
|
@@ -7,6 +7,7 @@ import { codexUserDir, codexProviderName } from './agent.js';
|
|
|
7
7
|
import { categorize, ESCALATE_RE, EDIT_RE } from './route-scan.js';
|
|
8
8
|
import { looksPasted } from './route-inject.js';
|
|
9
9
|
import { newRouteId, recordCodexDelegation } from './codex-ledger.js';
|
|
10
|
+
import { readCodexTailLines } from './codex-parser.js';
|
|
10
11
|
|
|
11
12
|
export const CODEX_ROUTE_CATEGORIES = ['paste', 'translate', 'explore', 'read', 'check', 'run'];
|
|
12
13
|
const MARKER = '<!-- sprag:codex:delegation -->';
|
|
@@ -88,13 +89,50 @@ export function matchCodexModelRule(text, { model, root, provider = codexProvide
|
|
|
88
89
|
return usable.find((r) => r.scope === 'project') || usable[0] || null;
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Input tokens from the rollout's last token_count record, or 0 when the
|
|
94
|
+
* transcript is missing or unreadable. Only the tail is read: the newest
|
|
95
|
+
* measurement is always near the end of the file.
|
|
96
|
+
*/
|
|
97
|
+
export function codexContextTokens(transcriptPath) {
|
|
98
|
+
const lines = readCodexTailLines(transcriptPath, 512 * 1024);
|
|
99
|
+
if (!lines) return 0;
|
|
100
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
101
|
+
const line = lines[i];
|
|
102
|
+
if (!line || !line.includes('token_count')) continue;
|
|
103
|
+
let e;
|
|
104
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
105
|
+
const n = e?.payload?.type === 'token_count' ? e.payload?.info?.last_token_usage?.input_tokens : undefined;
|
|
106
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A prompt-time routing suggestion for Codex 0.159.2, where `spawn_agent`
|
|
113
|
+
* never reaches PreToolUse (see codexDelegationTool below). Gated on parent
|
|
114
|
+
* context size: a small parent's own coordination overhead outweighs the
|
|
115
|
+
* savings from a delegated child (measured 2026-09-30, see docs/CODEX.md).
|
|
116
|
+
*/
|
|
91
117
|
export function codexRouteHint(payload, opts = {}) {
|
|
92
|
-
const
|
|
118
|
+
const provider = payload?.model_provider || codexProviderName();
|
|
119
|
+
const rule = matchCodexModelRule(payload?.prompt, { provider, ...opts, model: payload?.model, root: opts.root || payload?.cwd });
|
|
93
120
|
if (!rule) return null;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
121
|
+
const minContext = opts.minContext ?? 60000;
|
|
122
|
+
const ctx = opts.contextTokens ?? codexContextTokens(payload?.transcript_path);
|
|
123
|
+
if (ctx < minContext) return null;
|
|
124
|
+
const id = newRouteId();
|
|
125
|
+
const record = opts.record ?? recordCodexDelegation;
|
|
126
|
+
try {
|
|
127
|
+
record({ id, parentSessionId: payload?.session_id ?? null, turnId: payload?.turn_id ?? null, via: 'prompt',
|
|
128
|
+
source: 'rule', category: rule.category, scope: rule.scope, from: payload?.model, to: rule.model,
|
|
129
|
+
provider, effort: rule.effort ?? null, contextTokens: ctx }, { dir: opts.dir });
|
|
130
|
+
} catch { /* Accounting is optional; the hint still applies. */ }
|
|
131
|
+
return `[Sprag model routing] This request matches the approved ${rule.category} rule (route ${id}). `
|
|
132
|
+
+ `Spawn one sub-agent with model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''} and fork_turns "none". `
|
|
133
|
+
+ `Give it a self-contained task message that ends with the line <!-- sprag:codex:route id=${id} -->. `
|
|
134
|
+
+ 'Wait for it to finish (call wait_agent with timeout_ms of at least 120000, and again if it times out) and do not do the delegated task yourself while it runs; then verify its result and report unfinished work. Do not spawn another CLI process. '
|
|
135
|
+
+ 'If sub-agent tools or the target model are unavailable, keep the task on the main agent and state that delegation was unavailable.';
|
|
98
136
|
}
|
|
99
137
|
|
|
100
138
|
function customRole(role, root, home) {
|
|
@@ -120,6 +158,8 @@ function customRole(role, root, home) {
|
|
|
120
158
|
return false;
|
|
121
159
|
}
|
|
122
160
|
|
|
161
|
+
// Kept for Codex versions that do send spawn_agent through PreToolUse; on
|
|
162
|
+
// 0.159.2 the hook never fires for it, so codexRouteHint above is the live path.
|
|
123
163
|
export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules, dir = userDataDir(), record = recordCodexDelegation } = {}) {
|
|
124
164
|
if (cfg.codex?.delegate !== true) return null;
|
|
125
165
|
const tool = payload?.tool_name?.replace(/^(?:functions|tools)\./, '');
|
package/src/codex-doctor.js
CHANGED
|
@@ -12,7 +12,7 @@ export const CODEX_CAPABILITIES = [
|
|
|
12
12
|
{ feature: 'reports', support: 'supported', command: 'sprag --agent codex --format json', note: 'Token usage, cached input, reasoning, session and project filters.' },
|
|
13
13
|
{ feature: 'harness', support: 'supported', command: 'sprag harness check --agent codex', note: 'AGENTS.md, overrides, explicit-scope ratchet rule management.' },
|
|
14
14
|
{ feature: 'brief', support: 'supported', command: 'sprag brief --agent codex', note: 'Fresh context and limit warnings; change-triggered prompt hook.' },
|
|
15
|
-
{ feature: 'history', support: 'supported', command: 'sprag history --agent codex', note: 'Codex-only
|
|
15
|
+
{ feature: 'history', support: 'supported', command: 'sprag history --agent codex', note: 'Codex-only records of context (80%/95%) and 5h/weekly rate-limit (90%) warnings, plus handoffs. Cache and TTL chips are not recorded; last shows the latest event.' },
|
|
16
16
|
{ feature: 'handoff', support: 'supported', command: 'sprag handoff --agent codex', note: 'Git state and recorded Codex session usage; never reads Claude caps.' },
|
|
17
17
|
{ feature: 'korean', support: 'supported', command: 'sprag korean status --agent codex', note: 'Session guidance and recognized write-target lint; shared preference.' },
|
|
18
18
|
{ feature: 'cohesion', support: 'supported', command: 'sprag cohesion status --agent codex', note: 'Shared English guidance preference.' },
|
package/src/codex-harness.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { codexUserDir } from './agent.js';
|
|
5
|
-
import { codexToolEvents, codexLegacyToolEvent, readCodexTailLines } from './codex-parser.js';
|
|
5
|
+
import { codexToolEvents, codexLegacyToolEvent, readCodexTailLines, forkedCopyFilter } from './codex-parser.js';
|
|
6
6
|
|
|
7
7
|
export const CODEX_BEGIN = '<!-- sprag:codex:harness:begin -->';
|
|
8
8
|
export const CODEX_END = '<!-- sprag:codex:harness:end -->';
|
|
@@ -23,7 +23,7 @@ export function codexHarnessPaths(root, scope = 'project') {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function codexHarnessBlock() {
|
|
26
|
-
return `${CODEX_BEGIN}\n## Sprag Harness\n\n${sections.map(([name, text], i) => `### ${i + 1}. ${name}\n${text}`).join('\n\n')}\n\nRead the global and project Codex ratchet files before working when present. Run \`sprag harness list --agent codex --global\` and \`sprag harness list --agent codex --project\` to retrieve them. These are instructions, not Claude-style @ imports.\nUse \`sprag doc2md <file> --agent codex\` before reading PDF, Office, or Figma documents. Delegate only through available Codex tools and configured roles; do not assume Claude model names or Task/Agent arguments.\n${CODEX_END}\n`;
|
|
26
|
+
return `${CODEX_BEGIN}\n## Sprag Harness\n\n${sections.map(([name, text], i) => `### ${i + 1}. ${name}\n${text}`).join('\n\n')}\n\nRead the global and project Codex ratchet files before working when present. Run \`sprag harness list --agent codex --global\` and \`sprag harness list --agent codex --project\` to retrieve them. These are instructions, not Claude-style @ imports.\nUse \`sprag doc2md <file> --agent codex\` before reading PDF, Office, or Figma documents. Delegate only through available Codex tools and configured roles; do not assume Claude model names or Task/Agent arguments. A [Sprag model routing] note on a request means the user has asked for that request to be delegated: spawn the sub-agent it describes (its model, reasoning_effort, fork_turns "none", and a self-contained message ending with the route line it gives), wait for it to finish instead of doing the same task yourself, then verify the result before replying.\n${CODEX_END}\n`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function range(content) {
|
|
@@ -133,11 +133,8 @@ function countable(ev) {
|
|
|
133
133
|
return true;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
/**
|
|
137
|
-
|
|
138
|
-
* only the file tail, which is where the current session's recent work is.
|
|
139
|
-
*/
|
|
140
|
-
export function codexRatchetCandidates(filePath, { now = Date.now(), turns: window = 30, ttlMs = 30 * 60000, tailBytes = 2 * 1024 * 1024, home } = {}) {
|
|
136
|
+
/** Countable failures in one rollout's last `window` turns. Reads only the file tail. */
|
|
137
|
+
function rolloutFailures(filePath, { window, tailBytes }) {
|
|
141
138
|
const lines = readCodexTailLines(filePath, tailBytes);
|
|
142
139
|
if (!lines) return [];
|
|
143
140
|
const order = [];
|
|
@@ -147,11 +144,14 @@ export function codexRatchetCandidates(filePath, { now = Date.now(), turns: wind
|
|
|
147
144
|
const outputs = [];
|
|
148
145
|
let cwd = null;
|
|
149
146
|
let current = null;
|
|
147
|
+
const copied = forkedCopyFilter();
|
|
150
148
|
for (const line of lines) {
|
|
151
149
|
// Cheap prefilter: these candidate lines are a small share of a rollout.
|
|
152
|
-
if (!/"(?:item_completed|exec_command_end|patch_apply_end|task_started|turn_context|session_meta|function_call|function_call_output|custom_tool_call|custom_tool_call_output)"/.test(line)) continue;
|
|
150
|
+
if (!/"(?:item_completed|exec_command_end|patch_apply_end|task_started|turn_context|session_meta|thread_settings_applied|function_call|function_call_output|custom_tool_call|custom_tool_call_output)"/.test(line)) continue;
|
|
153
151
|
let e;
|
|
154
152
|
try { e = JSON.parse(line); } catch { continue; }
|
|
153
|
+
// A forked child's copy of the parent history holds the parent's failures, not its own.
|
|
154
|
+
if (copied(e)) continue;
|
|
155
155
|
const p = e?.payload;
|
|
156
156
|
if (e?.type === 'session_meta' && typeof p?.cwd === 'string') cwd ??= p.cwd;
|
|
157
157
|
if (e?.type === 'turn_context' && typeof p?.cwd === 'string') cwd = p.cwd;
|
|
@@ -178,9 +178,20 @@ export function codexRatchetCandidates(filePath, { now = Date.now(), turns: wind
|
|
|
178
178
|
if (countable(ev)) failures.push(ev);
|
|
179
179
|
}
|
|
180
180
|
const recent = new Set(order.slice(-window));
|
|
181
|
+
return failures.filter((ev) => recent.has(ev.turn));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Repeated failures in recent turns, newest-first by count. `siblings` are
|
|
186
|
+
* other rollouts of the same project: every `codex exec` run writes its own
|
|
187
|
+
* file, so a mistake repeated across runs only shows up when they are read
|
|
188
|
+
* together. The TTL applies to all of them.
|
|
189
|
+
*/
|
|
190
|
+
export function codexRatchetCandidates(filePath, { now = Date.now(), turns: window = 30, ttlMs = 30 * 60000, tailBytes = 2 * 1024 * 1024, home, siblings = [] } = {}) {
|
|
191
|
+
const failures = [filePath, ...siblings].filter(Boolean).flatMap((file) => rolloutFailures(file, { window, tailBytes }));
|
|
181
192
|
const groups = new Map();
|
|
182
193
|
for (const ev of failures) {
|
|
183
|
-
if (!
|
|
194
|
+
if (!Number.isFinite(ev.at) || now - ev.at > ttlMs) continue;
|
|
184
195
|
const pattern = codexErrorSignature(ev, { cwd: ev.cwd, home });
|
|
185
196
|
const g = groups.get(pattern) || { pattern, count: 0, lastAt: 0 };
|
|
186
197
|
g.count++;
|
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,38 +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
|
-
|
|
80
|
-
|
|
160
|
+
// Keep the preset file current with this version's wording before it is read.
|
|
161
|
+
if (event === 'session-start') {
|
|
162
|
+
try { (await import('./preset-ratchet.js')).preparePresetRatchet({ agent: 'codex' }); } catch (e) { debug('codex:preset', e); }
|
|
163
|
+
}
|
|
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.
|
|
167
|
+
for (const [scope, ratchet] of [['global', codexHarnessPaths(root, 'global').ratchet],
|
|
168
|
+
['preset', presetRatchetPath({ agent: 'codex' })], ['project', codexHarnessPaths(root, 'project').ratchet]]) {
|
|
81
169
|
try {
|
|
82
170
|
if (statSync(ratchet).size > 32 * 1024) {
|
|
83
|
-
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.` });
|
|
84
172
|
} else {
|
|
85
173
|
const text = readFileSync(ratchet, 'utf8').trim();
|
|
86
|
-
|
|
174
|
+
// An empty preset file is only its header; injecting it would spend
|
|
175
|
+
// tokens every session on no rules.
|
|
176
|
+
if (text && (scope !== 'preset' || /^- /m.test(text))) parts.push({ priority: 1, spill: true, text: `[Sprag Codex ratchet: ${scope}]\n${text}` });
|
|
87
177
|
}
|
|
88
|
-
} catch {
|
|
178
|
+
} catch (e) { if (e?.code !== 'ENOENT') debug('codex:ratchet', e); }
|
|
89
179
|
}
|
|
90
|
-
parts.push(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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); }
|
|
95
189
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
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) }));
|
|
99
192
|
}
|
|
100
193
|
if (event === 'prompt') {
|
|
101
194
|
const parts = [];
|
|
102
195
|
if (cfg?.codex?.delegate === true) {
|
|
103
|
-
try {
|
|
104
|
-
|
|
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); }
|
|
105
200
|
}
|
|
106
201
|
if (cfg?.codex?.brief !== false) {
|
|
107
202
|
try { parts.push(runCodexBrief({ sessionId: payload.session_id, transcriptPath: payload.transcript_path, cwd: payload.cwd })); }
|
|
108
|
-
catch {
|
|
203
|
+
catch (e) { debug('codex:brief', e); }
|
|
109
204
|
}
|
|
110
205
|
if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) {
|
|
111
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;
|