sprag-cli 3.56.0 → 3.57.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 +1 -1
- package/README.md +1 -1
- package/bin/cli.js +58 -17
- package/bin/postinstall.js +22 -0
- package/docs/CODEX.ko.md +5 -1
- package/docs/CODEX.md +5 -0
- package/package.json +2 -2
- package/src/agents.js +16 -2
- package/src/brief.js +17 -8
- package/src/claude-price.js +23 -2
- package/src/codex-brief.js +35 -2
- package/src/codex-delegation.js +12 -4
- package/src/codex-harness.js +43 -9
- package/src/codex-hooks.js +24 -3
- package/src/codex-ledger.js +61 -5
- package/src/codex-panel.js +42 -22
- package/src/codex-rule-health.js +24 -1
- package/src/commands/brief.js +14 -3
- package/src/commands/codex-delegate.js +7 -4
- package/src/commands/codex.js +2 -0
- package/src/commands/harness.js +6 -0
- package/src/compact-window.js +12 -4
- package/src/config.js +3 -3
- package/src/cost.js +5 -2
- package/src/ctx-window-cache.js +61 -0
- package/src/delegation-guard.js +15 -9
- package/src/doc2md.cjs +22 -8
- package/src/fig2md.cjs +7 -1
- package/src/formatters/statusline.js +4 -4
- package/src/formatters/table.js +18 -10
- package/src/gateway-auth.js +31 -3
- package/src/harness.js +43 -12
- package/src/history.js +6 -2
- package/src/hook-manager.js +54 -32
- package/src/installer.js +286 -153
- package/src/korean-lint.cjs +40 -7
- package/src/litellm-budget.js +10 -1
- package/src/model-rules.js +88 -41
- package/src/month-spend.js +17 -11
- package/src/parser.js +232 -56
- package/src/postinstall.js +32 -0
- package/src/route-caps.js +74 -0
- package/src/route-inject.js +31 -7
- package/src/route-scan.js +78 -26
- package/src/savings-ledger.js +33 -5
- package/src/seed-rules.js +3 -4
- package/src/session-cache.js +35 -1
- package/src/state-file.js +43 -0
- package/src/stats.js +20 -5
- package/src/stdin-payload.js +18 -4
package/src/codex-hooks.js
CHANGED
|
@@ -11,7 +11,7 @@ import { findProjectRoot } from './harness.js';
|
|
|
11
11
|
import { runCodexBrief } from './codex-brief.js';
|
|
12
12
|
import { codexDocumentTool, codexDocumentNote, convertCodexDocument } from './codex-doc2md.js';
|
|
13
13
|
import { codexDelegationTool, codexRouteHint } from './codex-delegation.js';
|
|
14
|
-
import { bindCodexSubagent } from './codex-ledger.js';
|
|
14
|
+
import { bindCodexSubagent, closeStaleCodexRoutes, unsettledCodexRoutes } from './codex-ledger.js';
|
|
15
15
|
import { seedOfferBlock } from './seed-rules.js';
|
|
16
16
|
import { readCodexRouteScan, openCodexCandidates, shouldRescanCodex } from './codex-route-scan.js';
|
|
17
17
|
import { userDataDir } from './paths.js';
|
|
@@ -120,6 +120,20 @@ function defaultRescan() {
|
|
|
120
120
|
{ detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Prices the delegated runs the ledger has not joined yet, in a detached
|
|
125
|
+
* process. The ledger was refreshed only by the route scan, which the rescan
|
|
126
|
+
* gate holds back for up to a day, so "Routing saved" showed a delegation that
|
|
127
|
+
* long after it happened. Claude's side has the same shortcut on its
|
|
128
|
+
* PostToolUse hook; Codex has no hook for a finished child, so the next prompt
|
|
129
|
+
* of the parent is the earliest point that knows one was spawned.
|
|
130
|
+
*/
|
|
131
|
+
function defaultLedgerRefresh() {
|
|
132
|
+
if (process.env.CTS_NO_ROUTE_SCAN === '1') return;
|
|
133
|
+
spawn(process.execPath, [process.argv[1], 'route-scan', 'savings', '--refresh', '--quiet', '--agent', 'codex'],
|
|
134
|
+
{ detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
135
|
+
}
|
|
136
|
+
|
|
123
137
|
export function lintCodexTool(payload, { scope = 'all' } = {}) {
|
|
124
138
|
if (!payload || typeof payload !== 'object') return null;
|
|
125
139
|
const input = payload.tool_input;
|
|
@@ -148,7 +162,7 @@ export function lintCodexTool(payload, { scope = 'all' } = {}) {
|
|
|
148
162
|
return messages.length ? messages.join('\n\n') : null;
|
|
149
163
|
}
|
|
150
164
|
|
|
151
|
-
export async function codexHookOutput(event, payload, { cfg = loadConfig() } = {}) {
|
|
165
|
+
export async function codexHookOutput(event, payload, { cfg = loadConfig(), refreshLedger = defaultLedgerRefresh } = {}) {
|
|
152
166
|
if (!payload || typeof payload !== 'object') return null;
|
|
153
167
|
if (event === 'session-start' || event === 'subagent-start') {
|
|
154
168
|
if (event === 'subagent-start' && cfg?.codex?.delegate !== true) return null;
|
|
@@ -193,13 +207,20 @@ export async function codexHookOutput(event, payload, { cfg = loadConfig() } = {
|
|
|
193
207
|
if (event === 'prompt') {
|
|
194
208
|
const parts = [];
|
|
195
209
|
if (cfg?.codex?.delegate === true) {
|
|
210
|
+
// Before the hint for this prompt is recorded: a hint from an earlier turn
|
|
211
|
+
// that no child claimed is closed, and children that did run get priced.
|
|
212
|
+
try { closeStaleCodexRoutes(payload); } catch (e) { debug('codex:close-routes', e); }
|
|
213
|
+
try { if (unsettledCodexRoutes() > 0) refreshLedger(); } catch (e) { debug('codex:ledger-refresh', e); }
|
|
196
214
|
try {
|
|
197
215
|
parts.push(codexRouteHint(payload, { root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }),
|
|
198
216
|
minContext: Number.isFinite(cfg?.codex?.delegateMinContext) ? cfg.codex.delegateMinContext : undefined }));
|
|
199
217
|
} catch (e) { debug('codex:route-hint', e); }
|
|
200
218
|
}
|
|
201
219
|
if (cfg?.codex?.brief !== false) {
|
|
202
|
-
try {
|
|
220
|
+
try {
|
|
221
|
+
parts.push(runCodexBrief({ sessionId: payload.session_id, transcriptPath: payload.transcript_path, cwd: payload.cwd,
|
|
222
|
+
root: findProjectRoot(payload.cwd || process.cwd(), { agent: 'codex' }) }));
|
|
223
|
+
}
|
|
203
224
|
catch (e) { debug('codex:brief', e); }
|
|
204
225
|
}
|
|
205
226
|
if (process.env.CTS_NO_DOC2MD !== '1' && cfg?.codex?.doc2md !== false) {
|
package/src/codex-ledger.js
CHANGED
|
@@ -87,19 +87,57 @@ export function readCodexBindings({ dir = userDataDir() } = {}) {
|
|
|
87
87
|
return out;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Route ids a later prompt closed before any child claimed them. Kept in the binds file as `{ id, closed: true }` lines. */
|
|
91
|
+
function closedCodexRouteIds({ dir = userDataDir() } = {}) {
|
|
92
|
+
let text;
|
|
93
|
+
try { text = readFileSync(bindsFile(dir), 'utf8'); } catch { return new Set(); }
|
|
94
|
+
const out = new Set();
|
|
95
|
+
for (const line of text.split('\n')) {
|
|
96
|
+
try {
|
|
97
|
+
const b = JSON.parse(line);
|
|
98
|
+
if (b?.closed === true && /^[0-9a-f]{16}$/.test(b.id)) out.add(b.id);
|
|
99
|
+
} catch { /* Partial trailing line. */ }
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Close the routes this parent was hinted in an earlier turn and never spawned.
|
|
106
|
+
*
|
|
107
|
+
* A hint asks for a spawn in the turn that carries it. Left open, its pending
|
|
108
|
+
* record kept matching for the whole bind window, so a child the parent spawned
|
|
109
|
+
* for an unrelated reason twenty minutes later, on the same model, was bound to
|
|
110
|
+
* it and its cost was booked as a routing saving. A new prompt from the user is
|
|
111
|
+
* what ends the hinted turn, so that is where the record is closed. Returns how
|
|
112
|
+
* many were closed; writes nothing when there are none.
|
|
113
|
+
*/
|
|
114
|
+
export function closeStaleCodexRoutes(payload, { dir = userDataDir(), now = Date.now() } = {}) {
|
|
115
|
+
if (typeof payload?.session_id !== 'string') return 0;
|
|
116
|
+
const sameTurn = (r) => typeof payload.turn_id === 'string' && r.turnId === payload.turn_id;
|
|
117
|
+
const open = readCodexDelegations({ dir }).filter((r) =>
|
|
118
|
+
r.via === 'prompt' && r.parentSessionId === payload.session_id && now - r.at < BIND_TTL_MS && !sameTurn(r));
|
|
119
|
+
if (!open.length) return 0;
|
|
120
|
+
const taken = new Set([...readCodexBindings({ dir }).map((b) => b.id), ...closedCodexRouteIds({ dir })]);
|
|
121
|
+
const stale = open.filter((r) => !taken.has(r.id));
|
|
122
|
+
for (const r of stale) appendFileSync(bindsFile(dir), JSON.stringify({ id: r.id, closed: true, at: now }) + '\n', { mode: 0o600 });
|
|
123
|
+
return stale.length;
|
|
124
|
+
}
|
|
125
|
+
|
|
90
126
|
/**
|
|
91
127
|
* Bind a just-spawned SubagentStart child to the pending route it most likely
|
|
92
128
|
* fulfills: same parent thread, same target model, recent enough, and not
|
|
93
|
-
* already claimed by another child
|
|
94
|
-
*
|
|
129
|
+
* already claimed by another child or closed by a later prompt. When both the
|
|
130
|
+
* record and the payload name a turn, the turns must agree. Returns the bound
|
|
131
|
+
* route id, or null when nothing matches or this child is already bound.
|
|
95
132
|
*/
|
|
96
133
|
export function bindCodexSubagent(payload, { dir = userDataDir(), now = Date.now() } = {}) {
|
|
97
134
|
if (typeof payload?.agent_id !== 'string' || typeof payload?.session_id !== 'string' || typeof payload?.model !== 'string') return null;
|
|
98
135
|
const binds = readCodexBindings({ dir });
|
|
99
136
|
if (binds.some((b) => b.childSessionId === payload.agent_id)) return null;
|
|
100
|
-
const boundIds = new Set(binds.map((b) => b.id));
|
|
137
|
+
const boundIds = new Set([...binds.map((b) => b.id), ...closedCodexRouteIds({ dir })]);
|
|
138
|
+
const otherTurn = (r) => typeof payload.turn_id === 'string' && typeof r.turnId === 'string' && r.turnId !== payload.turn_id;
|
|
101
139
|
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));
|
|
140
|
+
r.parentSessionId === payload.session_id && r.to === payload.model && now - r.at < BIND_TTL_MS && !boundIds.has(r.id) && !otherTurn(r));
|
|
103
141
|
if (!pending.length) return null;
|
|
104
142
|
// Oldest first: spawns are recorded in order, so the earliest unbound record belongs to the earliest child.
|
|
105
143
|
const chosen = pending.reduce((a, b) => (b.at < a.at ? b : a));
|
|
@@ -107,6 +145,22 @@ export function bindCodexSubagent(payload, { dir = userDataDir(), now = Date.now
|
|
|
107
145
|
return chosen.id;
|
|
108
146
|
}
|
|
109
147
|
|
|
148
|
+
/**
|
|
149
|
+
* How many bound routes the ledger has not finished pricing. A child was
|
|
150
|
+
* spawned for each, so its saving exists but is not in the ledger until the
|
|
151
|
+
* next refresh, and the route scan that refreshes it runs at most daily when
|
|
152
|
+
* little new data arrives. Callers use a non-zero count to refresh now. Bound
|
|
153
|
+
* within the bind window only: a child still running after that settles at the
|
|
154
|
+
* regular scan.
|
|
155
|
+
*/
|
|
156
|
+
export function unsettledCodexRoutes({ dir = userDataDir(), now = Date.now() } = {}) {
|
|
157
|
+
const recent = readCodexBindings({ dir }).filter((b) => now - b.at < BIND_TTL_MS);
|
|
158
|
+
if (!recent.length) return 0;
|
|
159
|
+
const pending = new Set(readCodexDelegations({ dir }).filter((r) => now - r.at < PENDING_TTL_MS).map((r) => r.id));
|
|
160
|
+
const events = loadCodexLedger({ dir }).events;
|
|
161
|
+
return recent.filter((b) => pending.has(b.id) && !(events[b.id]?.complete || events[b.id]?.aborted)).length;
|
|
162
|
+
}
|
|
163
|
+
|
|
110
164
|
export function loadCodexLedger({ dir = userDataDir() } = {}) {
|
|
111
165
|
try {
|
|
112
166
|
const data = JSON.parse(readFileSync(ledgerFile(dir), 'utf8'));
|
|
@@ -195,7 +249,9 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
|
|
|
195
249
|
const usd = actual === null || counterfactual === null ? null : roundUsd(run.aborted ? -actual : counterfactual - actual);
|
|
196
250
|
const event = { ts: run.endedAt ?? route.at, parentSessionId: route.parentSessionId || run.parentThreadId || null,
|
|
197
251
|
childSessionId: run.sessionId, source: route.source, category: route.category ?? null, scope: route.scope ?? null,
|
|
198
|
-
from: route.from, to, provider,
|
|
252
|
+
from: route.from, to, provider,
|
|
253
|
+
...(route.targetRoot !== undefined ? { targetRoot: route.targetRoot } : {}),
|
|
254
|
+
...(route.ruleCreatedAt !== undefined ? { ruleCreatedAt: route.ruleCreatedAt } : {}), tokens: run.tokens, calls: run.calls, toolErrors: run.toolErrors, usd, rates: usd === null ? null : rates,
|
|
199
255
|
priceCheckedAt: rates?.checkedAt ?? null, priceSource, complete: run.complete, ...(run.aborted ? { aborted: true } : {}) };
|
|
200
256
|
// A transient price outage must not erase a completed, unchanged priced run.
|
|
201
257
|
if (usd === null && sameRun && previous.complete && Number.isFinite(previous.usd) &&
|
package/src/codex-panel.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve, relative, isAbsolute } from 'node:path';
|
|
1
|
+
import { resolve, relative, isAbsolute, basename } from 'node:path';
|
|
2
2
|
import { realpathSync, statSync } from 'node:fs';
|
|
3
3
|
import { stat } from 'node:fs/promises';
|
|
4
4
|
import { readPanelBinding } from './codex-panel-session.js';
|
|
@@ -17,7 +17,9 @@ import { formatResetIn } from './format-time.js';
|
|
|
17
17
|
import { codexDocumentTotals } from './codex-doc2md-ledger.js';
|
|
18
18
|
import { codexHookStatus } from './codex-installer.js';
|
|
19
19
|
import { koreanStyleEnabled } from './korean-style.js';
|
|
20
|
-
import { codexRoutingSavedTotals } from './codex-ledger.js';
|
|
20
|
+
import { codexRoutingSavedTotals, loadCodexLedger } from './codex-ledger.js';
|
|
21
|
+
import { loadCodexModelRules } from './codex-delegation.js';
|
|
22
|
+
import { codexRulesInReview } from './codex-rule-health.js';
|
|
21
23
|
import { readCodexRouteScan, openCodexCandidates } from './codex-route-scan.js';
|
|
22
24
|
import { codexBudgetProvider } from './codex-budget.js';
|
|
23
25
|
|
|
@@ -64,29 +66,41 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
|
|
|
64
66
|
files ??= sessionFile && !sessionId ? [] : await discoverCodexSessionFiles({ home, days: sessionId ? Infinity : days });
|
|
65
67
|
const existing = new Set(files.map((f) => f.path));
|
|
66
68
|
for (const path of cache.keys()) if (!existing.has(path)) cache.delete(path);
|
|
69
|
+
// A rollout is named after its session id. With an id to look for, the
|
|
70
|
+
// files carrying it are parsed first; without this the first frame of a
|
|
71
|
+
// pinned panel parsed every rollout Codex has ever written to find one
|
|
72
|
+
// session. The full search still runs when no file by that name holds the
|
|
73
|
+
// session (a renamed rollout).
|
|
74
|
+
const named = sessionId ? files.filter((f) => basename(f.path).includes(sessionId)) : [];
|
|
75
|
+
const passes = named.length && named.length < files.length ? [named, files] : [files];
|
|
67
76
|
let selected = null;
|
|
68
77
|
let matches = 0;
|
|
69
|
-
const seen = new Set();
|
|
70
78
|
// Recent rollouts of the same project, for repeats across `codex exec` runs.
|
|
71
|
-
|
|
72
|
-
for (const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
let recent = [];
|
|
80
|
+
for (const pass of passes) {
|
|
81
|
+
if (selected) break;
|
|
82
|
+
matches = 0;
|
|
83
|
+
recent = [];
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
for (const f of pass.slice().reverse()) {
|
|
86
|
+
let entry = cache.get(f.path);
|
|
87
|
+
if (!entry || entry.mtime !== f.mtime || entry.size !== f.size) {
|
|
88
|
+
try {
|
|
89
|
+
entry = { ...f, session: await parseCodexSessionFile(f.path) };
|
|
90
|
+
cache.set(f.path, entry);
|
|
91
|
+
} catch { continue; }
|
|
92
|
+
}
|
|
93
|
+
const s = entry.session;
|
|
94
|
+
if ((!sessionId || sessionFile) && s.isSubagent) continue;
|
|
95
|
+
if (sessionId ? s.sessionId !== sessionId : !insideProject(root, s.projectDir)) continue;
|
|
96
|
+
const key = s.sessionId || s.filePath;
|
|
97
|
+
if (seen.has(key)) continue;
|
|
98
|
+
seen.add(key);
|
|
99
|
+
matches++;
|
|
100
|
+
if (!sessionId && Date.now() - f.mtime < RATCHET_SIBLING_MS) recent.push(s.filePath);
|
|
101
|
+
// Activity timestamps survive archive moves that update filesystem mtime.
|
|
102
|
+
if (!selected || (s.lastActivity?.getTime() || 0) > (selected.lastActivity?.getTime() || 0)) selected = s;
|
|
79
103
|
}
|
|
80
|
-
const s = entry.session;
|
|
81
|
-
if ((!sessionId || sessionFile) && s.isSubagent) continue;
|
|
82
|
-
if (sessionId ? s.sessionId !== sessionId : !insideProject(root, s.projectDir)) continue;
|
|
83
|
-
const key = s.sessionId || s.filePath;
|
|
84
|
-
if (seen.has(key)) continue;
|
|
85
|
-
seen.add(key);
|
|
86
|
-
matches++;
|
|
87
|
-
if (!sessionId && Date.now() - f.mtime < RATCHET_SIBLING_MS) recent.push(s.filePath);
|
|
88
|
-
// Activity timestamps survive archive moves that update filesystem mtime.
|
|
89
|
-
if (!selected || (s.lastActivity?.getTime() || 0) > (selected.lastActivity?.getTime() || 0)) selected = s;
|
|
90
104
|
}
|
|
91
105
|
const project = selected?.projectDir ? findProjectRoot(selected.projectDir, { agent: 'codex' }) : root;
|
|
92
106
|
const cfg = loadConfig();
|
|
@@ -102,11 +116,16 @@ export function createPanelReader({ root = process.cwd(), home, sessionId: pinne
|
|
|
102
116
|
}
|
|
103
117
|
let route = [];
|
|
104
118
|
try { route = openCodexCandidates(readCodexRouteScan(), { root: project }); } catch { /* No scan yet. */ }
|
|
119
|
+
let ruleReview = [];
|
|
120
|
+
try {
|
|
121
|
+
const modelRules = loadCodexModelRules();
|
|
122
|
+
if (modelRules.length) ruleReview = codexRulesInReview({ root: project, rules: modelRules, events: Object.values(loadCodexLedger().events) }).map((r) => r.index);
|
|
123
|
+
} catch { /* Unreadable rules or ledger show no chip. */ }
|
|
105
124
|
let routingSaved = null;
|
|
106
125
|
try { routingSaved = { ...codexRoutingSavedTotals(), pricesAvailable: isDirectOpenAI(selected?.provider, home ? { home } : {}) || !!readCodexPrices({ provider: codexBudgetProvider(home ? { home } : {}), ...(home ? { home } : {}) }) }; }
|
|
107
126
|
catch { /* Unreadable ledger reads as n/a. */ }
|
|
108
127
|
return { session: selected, matches, root, sessionId, selection, bindingPending: !!sessionFile && !sessionId, days, harness, rules,
|
|
109
|
-
ratchet: ratchetFor(selected, recent.filter((file) => file !== selected?.filePath)), route, routingSaved,
|
|
128
|
+
ratchet: ratchetFor(selected, recent.filter((file) => file !== selected?.filePath)), route, ruleReview, routingSaved,
|
|
110
129
|
labelMode: resolveLabelMode({ cfg: statuslineDefaults(), hasFlag }).mode,
|
|
111
130
|
color: statuslineDefaults().color,
|
|
112
131
|
timer: !hasFlag('--no-timer') && (hasFlag('--timer') || statuslineDefaults().timer),
|
|
@@ -266,6 +285,7 @@ function panelGroups(data, { now, version = '', full = false, mode = full ? 'tex
|
|
|
266
285
|
chip(`Ratchet ${data.rules?.global ?? '?'}/${data.rules?.project ?? '?'}`),
|
|
267
286
|
// A repeated failure outranks a delegation candidate, as in the Claude statusline.
|
|
268
287
|
data.ratchet?.length ? chip(`ratchet? #${data.ratchet[0].id} x${data.ratchet[0].count}`, 33) : null,
|
|
288
|
+
data.ruleReview?.length ? chip(`rule-health #${data.ruleReview[0]}${data.ruleReview.length > 1 ? ` +${data.ruleReview.length - 1}` : ''}`, 33) : null,
|
|
269
289
|
data.route?.length ? chip(`route? R${data.route[0].id} ${terminalText(data.route[0].category)}${data.route.length > 1 ? ` +${data.route.length - 1}` : ''}`, 33) : null,
|
|
270
290
|
]);
|
|
271
291
|
register('korean', 'preferences', [
|
package/src/codex-rule-health.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
1
2
|
import { loadCodexLedger } from './codex-ledger.js';
|
|
2
3
|
import { isFailedRun } from './route-scan.js';
|
|
3
4
|
import { wilsonLowerBound, HEALTH_ERR_RATE, HEALTH_MIN_SAMPLE_DELEGATED } from './model-rules.js';
|
|
@@ -9,10 +10,32 @@ import { wilsonLowerBound, HEALTH_ERR_RATE, HEALTH_MIN_SAMPLE_DELEGATED } from '
|
|
|
9
10
|
* lower bound of the failure rate clears HEALTH_ERR_RATE over enough runs.
|
|
10
11
|
*/
|
|
11
12
|
export function codexRuleHealth(rule, { events = Object.values(loadCodexLedger().events) } = {}) {
|
|
13
|
+
const since = Date.parse(rule.createdAt);
|
|
12
14
|
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)
|
|
15
|
+
(!rule.provider || !e.provider || e.provider === rule.provider) && (e.complete || e.aborted) &&
|
|
16
|
+
(e.scope == null || e.scope === rule.scope) &&
|
|
17
|
+
// A project run counts only for the project it was recorded in; events from before the field existed cannot be placed.
|
|
18
|
+
(rule.scope !== 'project' || (typeof e.targetRoot === 'string' && e.targetRoot === rule.targetRoot)) &&
|
|
19
|
+
// A rule deleted and added again starts a fresh record.
|
|
20
|
+
(!Number.isFinite(since) || (Number.isFinite(e.ts) && e.ts >= since)));
|
|
14
21
|
const errs = runs.filter((e) => e.aborted || isFailedRun({ toolErrors: e.toolErrors, calls: e.calls })).length;
|
|
15
22
|
const saved = runs.filter((e) => Number.isFinite(e.usd)).reduce((sum, e) => sum + e.usd, 0);
|
|
16
23
|
const review = runs.length >= HEALTH_MIN_SAMPLE_DELEGATED && wilsonLowerBound(errs, runs.length) > HEALTH_ERR_RATE;
|
|
17
24
|
return { runs: runs.length, errs, rate: runs.length ? errs / runs.length : 0, saved, status: review ? 'review' : 'active' };
|
|
18
25
|
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rules in review that apply to the project at `root`: global rules, and
|
|
29
|
+
* project rules whose targetRoot is `root`; a null `root` lists every rule. `index` is the 1-based number the
|
|
30
|
+
* rule has in `delegate rules --agent codex`, so every surface names it alike.
|
|
31
|
+
*/
|
|
32
|
+
export function codexRulesInReview({ root, rules, events }) {
|
|
33
|
+
const here = root ? resolve(root) : null;
|
|
34
|
+
const out = [];
|
|
35
|
+
(rules || []).forEach((rule, i) => {
|
|
36
|
+
if (here && rule.scope === 'project' && rule.targetRoot !== here) return;
|
|
37
|
+
const health = codexRuleHealth(rule, { events });
|
|
38
|
+
if (health.status === 'review') out.push({ index: i + 1, rule, health });
|
|
39
|
+
});
|
|
40
|
+
return out;
|
|
41
|
+
}
|
package/src/commands/brief.js
CHANGED
|
@@ -25,7 +25,10 @@ export async function run({ hasFlag }) {
|
|
|
25
25
|
const parts = [];
|
|
26
26
|
try {
|
|
27
27
|
const { runBrief } = await import('../brief.js');
|
|
28
|
-
|
|
28
|
+
// `root` is the session's cwd for the same reason routeHint takes it below:
|
|
29
|
+
// the settings that name the model and the compaction window are the
|
|
30
|
+
// project's, not those of whatever directory the hook was spawned in.
|
|
31
|
+
const out = await runBrief({ sessionId: ctx.session_id, transcriptPath: ctx.transcript_path, root: ctx.cwd || process.cwd() });
|
|
29
32
|
if (out) parts.push(out);
|
|
30
33
|
} catch (e) { debug('brief:hook', e); /* briefing is best-effort — never block a prompt */ }
|
|
31
34
|
// State the matching delegation rule as a fact about this request. Registered
|
|
@@ -34,7 +37,7 @@ export async function run({ hasFlag }) {
|
|
|
34
37
|
// the match here removes both steps. Silent unless it is sure — see
|
|
35
38
|
// src/route-inject.js.
|
|
36
39
|
try {
|
|
37
|
-
const { routeHint, sessionModelRank } = await import('../route-inject.js');
|
|
40
|
+
const { routeHint, routeMatch, matchCaps, sessionModelRank } = await import('../route-inject.js');
|
|
38
41
|
// The payload names the session but not its model, so the rank comes from
|
|
39
42
|
// the transcript: without it a Sonnet session is told to delegate to
|
|
40
43
|
// Sonnet, which saves nothing.
|
|
@@ -46,8 +49,16 @@ export async function run({ hasFlag }) {
|
|
|
46
49
|
// differ. Claude Code sends `cwd` for that reason and the PreToolUse hook
|
|
47
50
|
// already reads it (src/delegation-guard.js). A payload without the field
|
|
48
51
|
// leaves root undefined, which is the behaviour this had before.
|
|
49
|
-
const
|
|
52
|
+
const match = routeMatch(ctx.prompt, { sessionRank });
|
|
53
|
+
const hint = routeHint(ctx.prompt, { sessionRank, root: ctx.cwd, match });
|
|
50
54
|
if (hint) parts.push(hint);
|
|
55
|
+
// The delegation guard repeats these caps to the subagent, so the limit it
|
|
56
|
+
// works under is the one this hint states. A prompt with no match clears
|
|
57
|
+
// the record of the turn before it.
|
|
58
|
+
try {
|
|
59
|
+
const { recordRouteCaps } = await import('../route-caps.js');
|
|
60
|
+
recordRouteCaps(ctx.session_id, matchCaps(match));
|
|
61
|
+
} catch (e) { debug('brief:route-caps', e); }
|
|
51
62
|
} catch (e) { debug('brief:route-hint', e); }
|
|
52
63
|
if (parts.length) console.log(parts.join('\n'));
|
|
53
64
|
return;
|
|
@@ -3,7 +3,7 @@ import { configureCodexHooks } from '../codex-installer.js';
|
|
|
3
3
|
import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule } from '../codex-delegation.js';
|
|
4
4
|
import { findCodexCandidate, resolveCodexCandidate } from '../codex-route-scan.js';
|
|
5
5
|
import { codexRoutingSavedTotals, loadCodexLedger } from '../codex-ledger.js';
|
|
6
|
-
import { codexRuleHealth } from '../codex-rule-health.js';
|
|
6
|
+
import { codexRuleHealth, codexRulesInReview } from '../codex-rule-health.js';
|
|
7
7
|
import { signedUsd } from '../money.js';
|
|
8
8
|
import { codexProviderName } from '../agent.js';
|
|
9
9
|
|
|
@@ -15,7 +15,6 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
15
15
|
throw new Error(`${flag} requires a value.`);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
-
const rules = loadCodexModelRules();
|
|
19
18
|
if (sub === 'rules') {
|
|
20
19
|
if (args[2] === 'add') {
|
|
21
20
|
if (hasFlag('--global') === hasFlag('--project')) throw new Error('Choose --global or --project for the rule.');
|
|
@@ -35,10 +34,11 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
35
34
|
if (!rules.length) console.log('No Codex model rules. Claude rules are not imported.');
|
|
36
35
|
let events = [];
|
|
37
36
|
try { events = Object.values(loadCodexLedger().events); } catch { /* No ledger yet: rules list without outcomes. */ }
|
|
37
|
+
const inReview = new Set(codexRulesInReview({ rules, events, root: null }).map((x) => x.index));
|
|
38
38
|
rules.forEach((r, i) => {
|
|
39
39
|
const h = codexRuleHealth(r, { events });
|
|
40
40
|
const outcome = h.runs ? ` | measured x${h.runs}, err ${Math.round(h.rate * 100)}%, saved ${signedUsd(h.saved, 4)}` : '';
|
|
41
|
-
const warn =
|
|
41
|
+
const warn = inReview.has(i + 1) ? ` | rule-health: ${h.errs}/${h.runs} runs failed, narrow the condition or remove (delegate rules rm ${i + 1} --agent codex)` : '';
|
|
42
42
|
console.log(`#${i + 1} ${r.category} | ${r.from} -> ${r.model} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}${outcome}${warn}`);
|
|
43
43
|
});
|
|
44
44
|
return;
|
|
@@ -59,7 +59,10 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
59
59
|
}
|
|
60
60
|
console.log(`Codex delegate: ${cfg.codex?.delegate === true ? 'on' : 'off'} (model routing and guidance)`);
|
|
61
61
|
console.log(`Default target: ${cfg.codex?.delegateTarget?.model || 'inherit (no override)'}`);
|
|
62
|
-
|
|
62
|
+
// Turning delegation off must work even when the rule file is damaged.
|
|
63
|
+
let ruleCount;
|
|
64
|
+
try { ruleCount = String(loadCodexModelRules().length); } catch (e) { ruleCount = `unreadable (${e.message})`; }
|
|
65
|
+
console.log(`Codex rules: ${ruleCount}`);
|
|
63
66
|
console.log('Explicit spawn models and custom roles are preserved. Targets must be available from your Codex provider.');
|
|
64
67
|
const saved = codexRoutingSavedTotals();
|
|
65
68
|
console.log(saved.priced
|
package/src/commands/codex.js
CHANGED
|
@@ -151,6 +151,8 @@ export async function run({ args, version }) {
|
|
|
151
151
|
if (args[1] === 'savings') {
|
|
152
152
|
const { refreshCodexLedger, loadCodexLedger, codexRoutingSavedTotals } = await import('../codex-ledger.js');
|
|
153
153
|
if (hasFlag('--refresh')) await refreshCodexLedger();
|
|
154
|
+
// The prompt hook runs this detached to price finished delegations.
|
|
155
|
+
if (hasFlag('--quiet')) return;
|
|
154
156
|
const events = Object.entries(loadCodexLedger().events).map(([id, e]) => ({ id, ...e })).sort((a, b) => b.ts - a.ts);
|
|
155
157
|
const totals = codexRoutingSavedTotals();
|
|
156
158
|
if (format === 'json') { console.log(JSON.stringify({ agent: 'codex', totals, events }, null, 2)); return; }
|
package/src/commands/harness.js
CHANGED
|
@@ -106,6 +106,12 @@ export async function run({ args, hasFlag }) {
|
|
|
106
106
|
for (const p of r.backedUp) console.log(`Backed up: ${p}`);
|
|
107
107
|
for (const p of r.wrote) console.log(`Wrote: ${p}`);
|
|
108
108
|
for (const p of r.skipped) console.log(`Skipped: ${p}`);
|
|
109
|
+
if (r.brokenBlock) {
|
|
110
|
+
// Nothing was written to CLAUDE.md, so claiming success would hide it.
|
|
111
|
+
console.log('\n🅷 Harness not initialized: repair the block markers in CLAUDE.md, then run this again.');
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
109
115
|
console.log('\n🅷 Harness initialized. Statusline will show 🅷 5/5 on next refresh.');
|
|
110
116
|
return;
|
|
111
117
|
}
|
package/src/compact-window.js
CHANGED
|
@@ -111,6 +111,10 @@ export function isOneMillionModel(model) {
|
|
|
111
111
|
return typeof model === 'string' && ONE_M_RE.test(model);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// A window Claude Code itself reported for the session (statusline payload).
|
|
115
|
+
// Anything this large can only be the 1M window.
|
|
116
|
+
export const LIVE_1M_MIN = 900_000;
|
|
117
|
+
|
|
114
118
|
/** Effective autoCompactWindow, with the source that won. */
|
|
115
119
|
export function effectiveWindow(root) {
|
|
116
120
|
const env = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
|
@@ -137,10 +141,14 @@ export function effectiveWindow(root) {
|
|
|
137
141
|
*
|
|
138
142
|
* `reason` names why it is not ok: 'unset' (no autoCompactWindow anywhere) or
|
|
139
143
|
* 'too-large' (set, but above 700k — still lets context run away).
|
|
144
|
+
*
|
|
145
|
+
* `liveWindow` is the window Claude Code reported for the running session. The
|
|
146
|
+
* model id alone misses every model that is 1M without a `[1m]` suffix, so a
|
|
147
|
+
* reported size settles it whenever the caller has one.
|
|
140
148
|
*/
|
|
141
|
-
export function compactWindowStatus({ root = process.cwd() } = {}) {
|
|
149
|
+
export function compactWindowStatus({ root = process.cwd(), liveWindow = null } = {}) {
|
|
142
150
|
const { model, source: modelSource } = resolveModelId(root);
|
|
143
|
-
const is1m = isOneMillionModel(model);
|
|
151
|
+
const is1m = isOneMillionModel(model) || (Number.isFinite(liveWindow) && liveWindow >= LIVE_1M_MIN);
|
|
144
152
|
const win = effectiveWindow(root);
|
|
145
153
|
const base = {
|
|
146
154
|
model,
|
|
@@ -163,10 +171,10 @@ export function compactWindowStatus({ root = process.cwd() } = {}) {
|
|
|
163
171
|
* Statusline chip text, or null when there is nothing to say. Kept to the same
|
|
164
172
|
* short shape as the other 🅷⚠ warnings so the line stays single-width.
|
|
165
173
|
*/
|
|
166
|
-
export function compactWindowWarningForStatusline(root = process.cwd(), cfg = null) {
|
|
174
|
+
export function compactWindowWarningForStatusline(root = process.cwd(), cfg = null, { liveWindow = null } = {}) {
|
|
167
175
|
if (cfg && cfg.compactWindow && cfg.compactWindow.enabled === false) return null;
|
|
168
176
|
try {
|
|
169
|
-
const s = compactWindowStatus({ root });
|
|
177
|
+
const s = compactWindowStatus({ root, liveWindow });
|
|
170
178
|
return s.ok ? null : 'compact-window?';
|
|
171
179
|
} catch (e) {
|
|
172
180
|
debug('compact-window:statusline', e);
|
package/src/config.js
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
* Linux: $XDG_CONFIG_HOME/claude-token-saver/config.json or ~/.config/...
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { readFileSync,
|
|
12
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { userDataDir } from './paths.js';
|
|
15
15
|
import { labelPreference } from './statusline-mode.js';
|
|
16
|
+
import { writeStateFile } from './state-file.js';
|
|
16
17
|
|
|
17
18
|
const CONFIG_DIR = userDataDir();
|
|
18
19
|
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
@@ -31,8 +32,7 @@ export function loadConfig() {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export function saveConfig(cfg) {
|
|
34
|
-
|
|
35
|
-
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
|
35
|
+
writeStateFile(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Each keyword maps to a single statusline option toggle.
|
package/src/cost.js
CHANGED
|
@@ -91,8 +91,11 @@ function detectPricingTier(model) {
|
|
|
91
91
|
|
|
92
92
|
if (m.includes('haiku')) {
|
|
93
93
|
if (/haiku[-_.]?4[-_.]?5/.test(m)) return 'claude-haiku-4-5';
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
// The 3.x ids put the version first (`claude-3-5-haiku-20241022`,
|
|
95
|
+
// `claude-3-haiku-20240307`), so both orders are matched. With only the
|
|
96
|
+
// family-first order, 3.5 Haiku fell through to the 4.5 rate.
|
|
97
|
+
if (/haiku[-_.]?3[-_.]?5|3[-_.]?5[-_.]?haiku/.test(m)) return 'claude-haiku-3-5';
|
|
98
|
+
if (/haiku[-_.]?3\b|\b3[-_.]?haiku/.test(m)) return 'claude-haiku-3';
|
|
96
99
|
return 'claude-haiku-4-5';
|
|
97
100
|
}
|
|
98
101
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context window Claude Code reports for each session.
|
|
3
|
+
*
|
|
4
|
+
* Only the statusline payload carries it (`context_window.context_window_size`).
|
|
5
|
+
* The UserPromptSubmit hook gets a session id and a transcript path, so the
|
|
6
|
+
* briefing used to guess the window from the configured model id, and a model
|
|
7
|
+
* that is 1M without a `[1m]` suffix was judged against 200k: the 80% warning
|
|
8
|
+
* fired at 160k while the statusline of the same session said "Ctx 1M". The
|
|
9
|
+
* statusline records the size it was given here, and the briefing reads it back
|
|
10
|
+
* by session id before it falls back to guessing.
|
|
11
|
+
*
|
|
12
|
+
* State: <stateDir>/ctx-window.json
|
|
13
|
+
* { sessions: { [session_id]: { size, ts } } }
|
|
14
|
+
* Entries untouched for 7 days are pruned on write.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { userDataDir } from './paths.js';
|
|
20
|
+
import { writeStateFile } from './state-file.js';
|
|
21
|
+
|
|
22
|
+
const PRUNE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
|
+
// The statusline renders every few seconds. An unchanged size is rewritten only
|
|
24
|
+
// this often, which is enough to keep a long session's entry from being pruned.
|
|
25
|
+
const REFRESH_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
export function ctxWindowCachePath() {
|
|
28
|
+
return join(userDataDir(), 'ctx-window.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function load() {
|
|
32
|
+
try {
|
|
33
|
+
const s = JSON.parse(readFileSync(ctxWindowCachePath(), 'utf8'));
|
|
34
|
+
return s && s.sessions && typeof s.sessions === 'object' && !Array.isArray(s.sessions) ? s : { sessions: {} };
|
|
35
|
+
} catch {
|
|
36
|
+
return { sessions: {} };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const validSize = (size) => Number.isFinite(size) && size > 0;
|
|
41
|
+
|
|
42
|
+
/** Record the window the statusline payload reported. Returns true when the file was written. */
|
|
43
|
+
export function recordLiveWindow(sessionId, size, now = Date.now()) {
|
|
44
|
+
if (typeof sessionId !== 'string' || sessionId === '' || !validSize(size)) return false;
|
|
45
|
+
const state = load();
|
|
46
|
+
const prev = state.sessions[sessionId];
|
|
47
|
+
if (prev && prev.size === size && now - prev.ts < REFRESH_MS) return false;
|
|
48
|
+
state.sessions[sessionId] = { size, ts: now };
|
|
49
|
+
for (const [id, s] of Object.entries(state.sessions)) {
|
|
50
|
+
if (!s || !Number.isFinite(s.ts) || now - s.ts > PRUNE_MS) delete state.sessions[id];
|
|
51
|
+
}
|
|
52
|
+
writeStateFile(ctxWindowCachePath(), JSON.stringify(state) + '\n');
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The recorded window for a session, or null when the statusline never reported one. */
|
|
57
|
+
export function liveWindowFor(sessionId) {
|
|
58
|
+
if (typeof sessionId !== 'string' || sessionId === '') return null;
|
|
59
|
+
const s = load().sessions[sessionId];
|
|
60
|
+
return s && validSize(s.size) ? s.size : null;
|
|
61
|
+
}
|
package/src/delegation-guard.js
CHANGED
|
@@ -26,6 +26,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
26
26
|
import { homedir } from 'node:os';
|
|
27
27
|
import { loadConfig, saveConfig } from './config.js';
|
|
28
28
|
import { recentToolPaths } from './session-paths.js';
|
|
29
|
+
import { routeCapsFor } from './route-caps.js';
|
|
29
30
|
|
|
30
31
|
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
32
|
|
|
@@ -114,7 +115,7 @@ function ratchetImportMissing(home = homedir()) {
|
|
|
114
115
|
* English-only prompt, an already-imported ratchet.md, or a session with
|
|
115
116
|
* nothing read yet each drop their own section without blocking the others.
|
|
116
117
|
*/
|
|
117
|
-
export async function buildAppendix(payload, { cfg = loadConfig(), home = homedir() } = {}) {
|
|
118
|
+
export async function buildAppendix(payload, { cfg = loadConfig(), home = homedir(), routeCaps = routeCapsFor(payload?.session_id) } = {}) {
|
|
118
119
|
const sections = [];
|
|
119
120
|
|
|
120
121
|
// Read from `model` first — it is the more specific field when both are set
|
|
@@ -125,12 +126,17 @@ export async function buildAppendix(payload, { cfg = loadConfig(), home = homedi
|
|
|
125
126
|
|
|
126
127
|
const bounds = boundsText();
|
|
127
128
|
if (bounds) {
|
|
128
|
-
//
|
|
129
|
-
// (
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
// Haiku delegations get a tighter leash (8 tool calls / 1,500 output
|
|
130
|
+
// tokens) than everything else (20 / 8,000). Those are the defaults of the
|
|
131
|
+
// model-fitting rules. When this turn's prompt matched a rule, the rule's
|
|
132
|
+
// own calibrated budget for the tier replaces them, so the subagent is told
|
|
133
|
+
// the same limit the main model was (see route-caps.js). A T1 rule sets no
|
|
134
|
+
// call count, and the default one stays in that case.
|
|
135
|
+
const fallback = isHaiku ? { calls: 8, out: 1500 } : { calls: 20, out: 8000 };
|
|
136
|
+
const matched = routeCaps?.[isHaiku ? 'T2' : 'T1'];
|
|
137
|
+
const calls = Number.isFinite(matched?.calls) && matched.calls > 0 ? matched.calls : fallback.calls;
|
|
138
|
+
const out = Number.isFinite(matched?.out) && matched.out > 0 ? matched.out : fallback.out;
|
|
139
|
+
const capLine = `Cap for this delegation: ${calls} tool calls, ${out.toLocaleString('en-US')} output tokens.`;
|
|
134
140
|
sections.push(['## Bounds and output shape', bounds, capLine].join('\n\n'));
|
|
135
141
|
}
|
|
136
142
|
|
|
@@ -234,7 +240,7 @@ export async function buildAppendix(payload, { cfg = loadConfig(), home = homedi
|
|
|
234
240
|
* call, so the caller's contract stays "print nothing, let the tool run
|
|
235
241
|
* exactly as given" for every one of them.
|
|
236
242
|
*/
|
|
237
|
-
export async function decideForDelegation(payload, { cfg = loadConfig(), home = homedir() } = {}) {
|
|
243
|
+
export async function decideForDelegation(payload, { cfg = loadConfig(), home = homedir(), routeCaps = routeCapsFor(payload?.session_id) } = {}) {
|
|
238
244
|
if (!delegateEnabled(cfg)) return null;
|
|
239
245
|
if (payload?.tool_name !== 'Task' && payload?.tool_name !== 'Agent') return null;
|
|
240
246
|
|
|
@@ -246,7 +252,7 @@ export async function decideForDelegation(payload, { cfg = loadConfig(), home =
|
|
|
246
252
|
// same tool_name, must not stack the appendix onto itself.
|
|
247
253
|
if (prompt.includes(DELEGATION_MARKER)) return null;
|
|
248
254
|
|
|
249
|
-
const appendix = await buildAppendix(payload, { cfg, home });
|
|
255
|
+
const appendix = await buildAppendix(payload, { cfg, home, routeCaps });
|
|
250
256
|
if (!appendix) return null;
|
|
251
257
|
|
|
252
258
|
return { updatedInput: { ...toolInput, prompt: `${prompt}\n\n${appendix}` } };
|