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.
Files changed (50) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/bin/cli.js +58 -17
  4. package/bin/postinstall.js +22 -0
  5. package/docs/CODEX.ko.md +5 -1
  6. package/docs/CODEX.md +5 -0
  7. package/package.json +2 -2
  8. package/src/agents.js +16 -2
  9. package/src/brief.js +17 -8
  10. package/src/claude-price.js +23 -2
  11. package/src/codex-brief.js +35 -2
  12. package/src/codex-delegation.js +12 -4
  13. package/src/codex-harness.js +43 -9
  14. package/src/codex-hooks.js +24 -3
  15. package/src/codex-ledger.js +61 -5
  16. package/src/codex-panel.js +42 -22
  17. package/src/codex-rule-health.js +24 -1
  18. package/src/commands/brief.js +14 -3
  19. package/src/commands/codex-delegate.js +7 -4
  20. package/src/commands/codex.js +2 -0
  21. package/src/commands/harness.js +6 -0
  22. package/src/compact-window.js +12 -4
  23. package/src/config.js +3 -3
  24. package/src/cost.js +5 -2
  25. package/src/ctx-window-cache.js +61 -0
  26. package/src/delegation-guard.js +15 -9
  27. package/src/doc2md.cjs +22 -8
  28. package/src/fig2md.cjs +7 -1
  29. package/src/formatters/statusline.js +4 -4
  30. package/src/formatters/table.js +18 -10
  31. package/src/gateway-auth.js +31 -3
  32. package/src/harness.js +43 -12
  33. package/src/history.js +6 -2
  34. package/src/hook-manager.js +54 -32
  35. package/src/installer.js +286 -153
  36. package/src/korean-lint.cjs +40 -7
  37. package/src/litellm-budget.js +10 -1
  38. package/src/model-rules.js +88 -41
  39. package/src/month-spend.js +17 -11
  40. package/src/parser.js +232 -56
  41. package/src/postinstall.js +32 -0
  42. package/src/route-caps.js +74 -0
  43. package/src/route-inject.js +31 -7
  44. package/src/route-scan.js +78 -26
  45. package/src/savings-ledger.js +33 -5
  46. package/src/seed-rules.js +3 -4
  47. package/src/session-cache.js +35 -1
  48. package/src/state-file.js +43 -0
  49. package/src/stats.js +20 -5
  50. package/src/stdin-payload.js +18 -4
package/src/parser.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createReadStream, openSync, readSync, closeSync, fstatSync } from 'node:fs';
2
2
  import { readdir, stat } from 'node:fs/promises';
3
3
  import { createInterface } from 'node:readline';
4
- import { join, isAbsolute } from 'node:path';
4
+ import { join, isAbsolute, basename } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
- import { loadCache, getCached, putCached, saveCache } from './session-cache.js';
6
+ import { loadCache, getCached, putCached, saveCache, getCachedSince, putCachedSince } from './session-cache.js';
7
7
  import { resolveModelAlias, isGatewayModelId } from './model-alias.js';
8
8
 
9
9
  const CLAUDE_DIR = join(homedir(), '.claude', 'projects');
@@ -59,6 +59,12 @@ export async function parseSessionFile(filePath) {
59
59
  if (!msg?.usage || !msg.id) continue;
60
60
 
61
61
  const usage = msg.usage;
62
+ // "<synthetic>" marks a stub Claude Code wrote locally, such as an error or
63
+ // an interrupt notice. No API call was made and no token was billed, so it
64
+ // is not a request, and a transcript holding nothing else is not a session.
65
+ // Counted, they put hundreds of calls that never happened into the report.
66
+ if (msg.model === '<synthetic>' && !(usage.input_tokens || usage.output_tokens ||
67
+ usage.cache_creation_input_tokens || usage.cache_read_input_tokens)) continue;
62
68
  const cc = usage.cache_creation || {};
63
69
  const reqId = entry.requestId || msg.id;
64
70
 
@@ -87,7 +93,6 @@ export async function parseSessionFile(filePath) {
87
93
  }
88
94
 
89
95
  const reqs = [...requests.values()];
90
- let maxContextPerRequest = 0;
91
96
  // Representative model: the one that handled the most requests, skipping
92
97
  // "<synthetic>" (Claude Code's local error-stub placeholder — no real API
93
98
  // call). Taking reqs[0] blindly let an error stub at session start
@@ -104,20 +109,7 @@ export async function parseSessionFile(filePath) {
104
109
  if (n > best) { best = n; model = m; }
105
110
  }
106
111
  if (best === 0 && reqs.length > 0) model = reqs[0].model || 'unknown';
107
- const totals = reqs.reduce(
108
- (acc, r) => {
109
- acc.input += r.inputTokens;
110
- acc.cacheCreation += r.cacheCreationTokens;
111
- acc.cacheRead += r.cacheReadTokens;
112
- acc.ephemeral5m += r.ephemeral5mTokens;
113
- acc.ephemeral1h += r.ephemeral1hTokens;
114
- acc.output += r.outputTokens;
115
- const ctx = r.inputTokens + r.cacheCreationTokens + r.cacheReadTokens;
116
- if (ctx > maxContextPerRequest) maxContextPerRequest = ctx;
117
- return acc;
118
- },
119
- { input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 },
120
- );
112
+ const { totals, maxContextPerRequest, modelTotals } = sumRequests(reqs);
121
113
 
122
114
  return {
123
115
  sessionId,
@@ -129,10 +121,48 @@ export async function parseSessionFile(filePath) {
129
121
  totals,
130
122
  maxContextPerRequest,
131
123
  model,
124
+ ...(modelTotals ? { modelTotals } : {}),
132
125
  gatewayObserved,
133
126
  };
134
127
  }
135
128
 
129
+ const emptyTotals = () => ({ input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 });
130
+
131
+ function addRequest(acc, r) {
132
+ acc.input += r.inputTokens;
133
+ acc.cacheCreation += r.cacheCreationTokens;
134
+ acc.cacheRead += r.cacheReadTokens;
135
+ acc.ephemeral5m += r.ephemeral5mTokens;
136
+ acc.ephemeral1h += r.ephemeral1hTokens;
137
+ acc.output += r.outputTokens;
138
+ }
139
+
140
+ /**
141
+ * Totals of a set of requests, plus the same totals split by model when more
142
+ * than one model spent tokens.
143
+ *
144
+ * `/model` switches model mid-session, and the session's one `model` field is
145
+ * only the model that answered most often. Pricing the whole session at that
146
+ * model's rate billed a Haiku stretch at the Fable rate, or the reverse. The
147
+ * split lets the cost side price each model's tokens at its own rate. It is
148
+ * left out for the usual single-model session, so those keep their old shape.
149
+ */
150
+ function sumRequests(reqs) {
151
+ const totals = emptyTotals();
152
+ const byModel = new Map();
153
+ let maxContextPerRequest = 0;
154
+ for (const r of reqs) {
155
+ addRequest(totals, r);
156
+ const ctx = r.inputTokens + r.cacheCreationTokens + r.cacheReadTokens;
157
+ if (ctx > maxContextPerRequest) maxContextPerRequest = ctx;
158
+ if (ctx + r.outputTokens === 0) continue;
159
+ const key = r.model || 'unknown';
160
+ if (!byModel.has(key)) byModel.set(key, emptyTotals());
161
+ addRequest(byModel.get(key), r);
162
+ }
163
+ return { totals, maxContextPerRequest, modelTotals: byModel.size > 1 ? Object.fromEntries(byModel) : null };
164
+ }
165
+
136
166
  /**
137
167
  * Find the most recent user-message timestamp in a session JSONL.
138
168
  * Used for statusline mode so the agent's own tool calls don't reset the TTL
@@ -284,6 +314,7 @@ export async function discoverSessionFiles(options = {}) {
284
314
  continue;
285
315
  }
286
316
 
317
+ const names = new Set(entries);
287
318
  for (const entry of entries) {
288
319
  if (!entry.endsWith('.jsonl')) continue;
289
320
  const fp = join(projPath, entry);
@@ -293,7 +324,15 @@ export async function discoverSessionFiles(options = {}) {
293
324
  if (s.mtimeMs >= cutoff) {
294
325
  // `size` pairs with `mtime` as the session-cache key — transcripts
295
326
  // are append-only, so the pair identifies a parse result exactly.
296
- files.push({ path: fp, projectDir: projDir, mtime: s.mtimeMs, size: s.size });
327
+ const file = { path: fp, projectDir: projDir, mtime: s.mtimeMs, size: s.size };
328
+ // The listing already says whether the session has a folder of its
329
+ // own, so sessions without subagents cost no extra syscall.
330
+ const sessionDir = entry.slice(0, -'.jsonl'.length);
331
+ if (names.has(sessionDir)) {
332
+ const subagentFiles = await listSubagentFiles(join(projPath, sessionDir), projDir, cutoff);
333
+ if (subagentFiles.length) file.subagentFiles = subagentFiles;
334
+ }
335
+ files.push(file);
297
336
  }
298
337
  } catch {
299
338
  continue;
@@ -301,7 +340,51 @@ export async function discoverSessionFiles(options = {}) {
301
340
  }
302
341
  }
303
342
 
304
- return files.sort((a, b) => a.mtime - b.mtime);
343
+ return dedupeSessionFiles(files).sort((a, b) => a.mtime - b.mtime);
344
+ }
345
+
346
+ /**
347
+ * Transcripts of the subagents a session spawned:
348
+ * `<session id>/subagents/agent-*.jsonl`. Their requests are billed like any
349
+ * other and appear nowhere in the session's own transcript.
350
+ */
351
+ async function listSubagentFiles(sessionDir, projectDir, cutoff) {
352
+ const dir = join(sessionDir, 'subagents');
353
+ let entries;
354
+ try {
355
+ entries = await readdir(dir);
356
+ } catch {
357
+ return [];
358
+ }
359
+ const files = [];
360
+ for (const entry of entries) {
361
+ if (!entry.startsWith('agent-') || !entry.endsWith('.jsonl')) continue;
362
+ try {
363
+ const fp = join(dir, entry);
364
+ const s = await stat(fp);
365
+ if (s.mtimeMs >= cutoff) files.push({ path: fp, projectDir, mtime: s.mtimeMs, size: s.size });
366
+ } catch {
367
+ continue;
368
+ }
369
+ }
370
+ return files;
371
+ }
372
+
373
+ /**
374
+ * One file per transcript name. A project folder that was renamed or copied
375
+ * leaves the same `<session id>.jsonl` under two project directories, and
376
+ * every aggregate then counted that session twice. Transcripts are
377
+ * append-only, so the larger copy is the superset; the newer one breaks a tie
378
+ * and the first one found wins an exact tie.
379
+ */
380
+ export function dedupeSessionFiles(files) {
381
+ const byName = new Map();
382
+ for (const f of files) {
383
+ const name = basename(f.path);
384
+ const kept = byName.get(name);
385
+ if (!kept || f.size > kept.size || (f.size === kept.size && f.mtime > kept.mtime)) byName.set(name, f);
386
+ }
387
+ return [...byName.values()];
305
388
  }
306
389
 
307
390
  /**
@@ -328,27 +411,14 @@ function spansCutoff(session, cutoffMs) {
328
411
  */
329
412
  function trimToCutoff(session, cutoffMs) {
330
413
  const reqs = (session.requests || []).filter((r) => r.ts == null || r.ts >= cutoffMs);
331
- let maxContextPerRequest = 0;
332
414
  let firstTs = null;
333
415
  let lastTs = null;
334
- const totals = reqs.reduce(
335
- (acc, r) => {
336
- acc.input += r.inputTokens;
337
- acc.cacheCreation += r.cacheCreationTokens;
338
- acc.cacheRead += r.cacheReadTokens;
339
- acc.ephemeral5m += r.ephemeral5mTokens;
340
- acc.ephemeral1h += r.ephemeral1hTokens;
341
- acc.output += r.outputTokens;
342
- const ctx = r.inputTokens + r.cacheCreationTokens + r.cacheReadTokens;
343
- if (ctx > maxContextPerRequest) maxContextPerRequest = ctx;
344
- if (r.ts != null) {
345
- if (firstTs === null || r.ts < firstTs) firstTs = r.ts;
346
- if (lastTs === null || r.ts > lastTs) lastTs = r.ts;
347
- }
348
- return acc;
349
- },
350
- { input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 },
351
- );
416
+ for (const r of reqs) {
417
+ if (r.ts == null) continue;
418
+ if (firstTs === null || r.ts < firstTs) firstTs = r.ts;
419
+ if (lastTs === null || r.ts > lastTs) lastTs = r.ts;
420
+ }
421
+ const { totals, maxContextPerRequest, modelTotals } = sumRequests(reqs);
352
422
  return {
353
423
  sessionId: session.sessionId,
354
424
  filePath: session.filePath,
@@ -359,10 +429,19 @@ function trimToCutoff(session, cutoffMs) {
359
429
  totals,
360
430
  maxContextPerRequest,
361
431
  model: session.model,
432
+ ...(modelTotals ? { modelTotals } : {}),
362
433
  gatewayObserved: session.gatewayObserved,
363
434
  };
364
435
  }
365
436
 
437
+ /** Attach the summary trimmed to `sinceMs` when the fully parsed session spans it. */
438
+ function withSinceTrim(summary, full, sinceMs) {
439
+ if (sinceMs !== null && spansCutoff(full, sinceMs)) {
440
+ summary.sinceTrim = { since: sinceMs, session: trimToCutoff(full, sinceMs) };
441
+ }
442
+ return summary;
443
+ }
444
+
366
445
  export async function parseAllSessions(options = {}) {
367
446
  const files = await discoverSessionFiles(options);
368
447
  const concurrency = 10;
@@ -378,31 +457,22 @@ export async function parseAllSessions(options = {}) {
378
457
  // stores the window-independent full parse.
379
458
  const days = options.days ?? 30;
380
459
  const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000;
460
+ // A second, fixed cutoff the caller will ask about next (sessionsSince). A
461
+ // file that has to be read here anyway is trimmed to it in the same pass.
462
+ const sinceMs = Number.isFinite(options.sinceMs) ? options.sinceMs : null;
381
463
 
382
464
  for (let i = 0; i < files.length; i += concurrency) {
383
465
  const batch = files.slice(i, i + concurrency);
384
466
  const parsed = await Promise.all(
385
467
  batch.map(async (f) => {
386
468
  try {
387
- if (useCache) {
388
- const hit = getCached(cache, f);
389
- if (hit) {
390
- if (!spansCutoff(hit, cutoffMs)) return hit;
391
- // Boundary session from cache: the cached summary has no
392
- // per-request data, so re-read the file to trim it.
393
- const full = await parseSessionFile(f.path);
394
- full.projectDir = f.projectDir;
395
- return trimToCutoff(full, cutoffMs);
396
- }
469
+ const summary = await parseOne(f);
470
+ const runs = await subagentRuns(f);
471
+ if (runs.length) summary.subagentRuns = runs;
472
+ if (summary.sinceTrim) {
473
+ const since = runs.filter((r) => r.endTime.getTime() >= sinceMs);
474
+ if (since.length) summary.sinceTrim.session.subagentRuns = since;
397
475
  }
398
- const session = await parseSessionFile(f.path);
399
- session.projectDir = f.projectDir;
400
- if (useCache) {
401
- putCached(cache, f, session);
402
- misses++;
403
- }
404
- if (spansCutoff(session, cutoffMs)) return trimToCutoff(session, cutoffMs);
405
- const { requests, ...summary } = session;
406
476
  return summary;
407
477
  } catch {
408
478
  return null;
@@ -415,4 +485,110 @@ export async function parseAllSessions(options = {}) {
415
485
  if (useCache && misses > 0) saveCache(cache);
416
486
 
417
487
  return results.filter((s) => s.requestCount > 0);
488
+
489
+ /**
490
+ * What a session's subagents spent, one entry per run with its own model so
491
+ * each is priced at its own rate. They ride on the session instead of being
492
+ * sessions themselves: the money counts, while the session count, context
493
+ * peaks and spike baselines keep describing conversations. A run is counted
494
+ * whole in the period it ended in; it lasts minutes, so it is not trimmed.
495
+ */
496
+ async function subagentRuns(f) {
497
+ const runs = [];
498
+ for (const a of f.subagentFiles || []) {
499
+ try {
500
+ let run = useCache ? getCached(cache, a) : null;
501
+ if (!run) {
502
+ run = await parseSessionFile(a.path);
503
+ if (useCache) {
504
+ putCached(cache, a, run);
505
+ misses++;
506
+ }
507
+ }
508
+ if (!run.requestCount || !run.endTime || run.endTime.getTime() < cutoffMs) continue;
509
+ runs.push({ model: run.model, totals: run.totals, requestCount: run.requestCount, endTime: run.endTime,
510
+ ...(run.modelTotals ? { modelTotals: run.modelTotals } : {}) });
511
+ } catch {
512
+ continue;
513
+ }
514
+ }
515
+ return runs;
516
+ }
517
+
518
+ async function parseOne(f) {
519
+ if (useCache) {
520
+ const hit = getCached(cache, f);
521
+ if (hit) {
522
+ if (!spansCutoff(hit, cutoffMs)) {
523
+ const since = sinceMs !== null && spansCutoff(hit, sinceMs) ? getCachedSince(cache, f, sinceMs) : null;
524
+ if (since) hit.sinceTrim = { since: sinceMs, session: since };
525
+ return hit;
526
+ }
527
+ // Boundary session from cache: the cached summary has no
528
+ // per-request data, so re-read the file to trim it.
529
+ const full = await parseSessionFile(f.path);
530
+ full.projectDir = f.projectDir;
531
+ return withSinceTrim(trimToCutoff(full, cutoffMs), full, sinceMs);
532
+ }
533
+ }
534
+ const session = await parseSessionFile(f.path);
535
+ session.projectDir = f.projectDir;
536
+ if (useCache) {
537
+ putCached(cache, f, session);
538
+ misses++;
539
+ }
540
+ const { requests, ...whole } = session;
541
+ const summary = spansCutoff(session, cutoffMs) ? trimToCutoff(session, cutoffMs) : whole;
542
+ withSinceTrim(summary, session, sinceMs);
543
+ if (useCache && summary.sinceTrim) putCachedSince(cache, f, sinceMs, summary.sinceTrim.session);
544
+ return summary;
545
+ }
546
+ }
547
+
548
+ /**
549
+ * The same sessions counted from `sinceMs` on: a session that started before
550
+ * it is re-aggregated from its per-request timestamps, and one that ended
551
+ * before it is dropped. A calendar-month total needs this, because a session
552
+ * left open across midnight on the 1st otherwise brings last month's requests
553
+ * with it.
554
+ *
555
+ * `sinceMs` is fixed for as long as the caller's period lasts, so the trimmed
556
+ * summary is cached next to the full one and the statusline re-reads a
557
+ * boundary transcript once, not on every refresh.
558
+ *
559
+ * @param {Array} sessions parseAllSessions result
560
+ * @param {number} sinceMs epoch ms
561
+ */
562
+ export async function sessionsSince(sessions, sinceMs, { noCache = false } = {}) {
563
+ const out = [];
564
+ let cache = null;
565
+ let stored = 0;
566
+ for (const s of sessions || []) {
567
+ if (!s || !s.endTime || s.endTime.getTime() < sinceMs) continue;
568
+ if (!spansCutoff(s, sinceMs)) { out.push(s); continue; }
569
+ if (s.sinceTrim && s.sinceTrim.since === sinceMs) {
570
+ if (s.sinceTrim.session.requestCount > 0) out.push(s.sinceTrim.session);
571
+ continue;
572
+ }
573
+ try {
574
+ const st = await stat(s.filePath);
575
+ const file = { path: s.filePath, projectDir: s.projectDir, mtime: st.mtimeMs, size: st.size };
576
+ if (!noCache) cache = cache || loadCache();
577
+ let trimmed = noCache ? null : getCachedSince(cache, file, sinceMs);
578
+ if (!trimmed) {
579
+ const full = await parseSessionFile(s.filePath);
580
+ full.projectDir = s.projectDir;
581
+ trimmed = trimToCutoff(full, sinceMs);
582
+ if (!noCache && putCachedSince(cache, file, sinceMs, trimmed)) stored++;
583
+ }
584
+ const runs = (s.subagentRuns || []).filter((r) => r.endTime.getTime() >= sinceMs);
585
+ if (runs.length) trimmed.subagentRuns = runs;
586
+ if (trimmed.requestCount > 0) out.push(trimmed);
587
+ } catch {
588
+ // Unreadable now (deleted or rotated since the scan): leave it out rather
589
+ // than count its whole history into this period.
590
+ }
591
+ }
592
+ if (stored > 0) saveCache(cache);
593
+ return out;
418
594
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Whether the npm `postinstall` step should run `sprag install`.
3
+ *
4
+ * `install` writes hooks and a statusline into ~/.claude/settings.json, and
5
+ * those call the `sprag` binary by name. That is what someone who typed
6
+ * `npm i -g sprag-cli` asked for. The script ran for every other install too:
7
+ * a project that lists the package as a dependency, the `npx` cache, a CI job,
8
+ * `npm install` in a checkout of this repository. None of those put `sprag` on
9
+ * PATH, so the hooks they registered pointed at a command that is not there,
10
+ * and a CI runner had its home directory rewritten for nothing.
11
+ *
12
+ * So setup runs only where it was asked for:
13
+ * - never in CI;
14
+ * - with npm, only for a global install (npm says so in `npm_config_global`);
15
+ * - with another package manager, as before: pnpm and bun do not run this
16
+ * script unless the user allowed it, and neither they nor yarn report a
17
+ * global install in a way worth guessing at.
18
+ * `SPRAG_POSTINSTALL=1` forces it and `SPRAG_POSTINSTALL=0` skips it.
19
+ */
20
+
21
+ const truthy = (v) => typeof v === 'string' && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
22
+
23
+ export function postinstallDecision(env = process.env) {
24
+ if (env.SPRAG_POSTINSTALL === '1') return { run: true, reason: 'SPRAG_POSTINSTALL=1' };
25
+ if (env.SPRAG_POSTINSTALL === '0') return { run: false, reason: 'SPRAG_POSTINSTALL=0' };
26
+ if (truthy(env.CI)) return { run: false, reason: 'CI environment' };
27
+ const agent = String(env.npm_config_user_agent || '');
28
+ if (/^npm\//.test(agent) && env.npm_config_global !== 'true') {
29
+ return { run: false, reason: 'not a global install' };
30
+ }
31
+ return { run: true, reason: /^npm\//.test(agent) ? 'global install' : 'package manager does not report scope' };
32
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The caps of the delegation rule the current prompt matched, kept per session
3
+ * so the PreToolUse guard can state the same numbers the rule does.
4
+ *
5
+ * The prompt hook tells the main model "delegate, cap 6,100 output tokens" from
6
+ * the rule's calibrated budget. The guard then rewrote the Task prompt with its
7
+ * own fixed pair (8 calls / 1,500 tokens for haiku, 20 / 8,000 otherwise), so
8
+ * the subagent was held to a different limit than the one the main model had
9
+ * just been given. The guard sees only the Task call, not the user prompt that
10
+ * matched the rule, so the prompt hook leaves the matched caps here for it.
11
+ *
12
+ * One record per session, replaced by every prompt: a prompt that matches no
13
+ * rule clears it, so a cap never outlives the turn it was stated for.
14
+ *
15
+ * State: <stateDir>/route-caps.json
16
+ * { sessions: { [session_id]: { ts, T2?: { calls, out }, T1?: { calls, out } } } }
17
+ */
18
+
19
+ import { readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { userDataDir } from './paths.js';
22
+ import { writeStateFile } from './state-file.js';
23
+
24
+ // A turn that is still delegating this long after its prompt is unusual; past
25
+ // it the record is treated as left over from a session that ended mid-turn.
26
+ const TTL_MS = 60 * 60 * 1000;
27
+ const PRUNE_MS = 24 * 60 * 60 * 1000;
28
+
29
+ export function routeCapsPath() {
30
+ return join(userDataDir(), 'route-caps.json');
31
+ }
32
+
33
+ function load() {
34
+ try {
35
+ const s = JSON.parse(readFileSync(routeCapsPath(), 'utf8'));
36
+ return s && s.sessions && typeof s.sessions === 'object' && !Array.isArray(s.sessions) ? s : { sessions: {} };
37
+ } catch {
38
+ return { sessions: {} };
39
+ }
40
+ }
41
+
42
+ const validBudget = (b) => b && Number.isFinite(b.out) && b.out > 0;
43
+
44
+ /**
45
+ * Record the caps this prompt's rule match carries, or clear the session's
46
+ * record when `caps` is null. Writes nothing when there is nothing to change.
47
+ */
48
+ export function recordRouteCaps(sessionId, caps, now = Date.now()) {
49
+ if (typeof sessionId !== 'string' || sessionId === '') return false;
50
+ const state = load();
51
+ const next = {};
52
+ for (const tier of ['T2', 'T1']) {
53
+ if (validBudget(caps?.[tier])) next[tier] = { calls: caps[tier].calls ?? null, out: caps[tier].out };
54
+ }
55
+ const has = Object.keys(next).length > 0;
56
+ if (!has && !state.sessions[sessionId]) return false;
57
+ if (has) state.sessions[sessionId] = { ts: now, ...next };
58
+ else delete state.sessions[sessionId];
59
+ for (const [id, s] of Object.entries(state.sessions)) {
60
+ if (!s || !Number.isFinite(s.ts) || now - s.ts > PRUNE_MS) delete state.sessions[id];
61
+ }
62
+ writeStateFile(routeCapsPath(), JSON.stringify(state) + '\n');
63
+ return true;
64
+ }
65
+
66
+ /** The caps recorded for this session's current turn, or null. */
67
+ export function routeCapsFor(sessionId, now = Date.now()) {
68
+ if (typeof sessionId !== 'string' || sessionId === '') return null;
69
+ const s = load().sessions[sessionId];
70
+ if (!s || !Number.isFinite(s.ts) || now - s.ts > TTL_MS) return null;
71
+ const out = {};
72
+ for (const tier of ['T2', 'T1']) if (validBudget(s[tier])) out[tier] = s[tier];
73
+ return Object.keys(out).length ? out : null;
74
+ }
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import { categorize, ESCALATE_RE, EDIT_RE, worthDelegating } from './route-scan.js';
29
- import { loadModelRules, budgetCapPhrase } from './model-rules.js';
29
+ import { loadModelRules, budgetCapPhrase, ruleBudget } from './model-rules.js';
30
30
  import { agentPhrase, agentPhraseEn } from './agents.js';
31
31
  import { userLanguage } from './config.js';
32
32
  import { aliasForRole, resolveModelAlias } from './model-alias.js';
@@ -77,19 +77,17 @@ export function sessionModelRank({ env = process.env, transcriptPath = null, sna
77
77
  }
78
78
 
79
79
  /**
80
+ * The rules a prompt matches, or null when the hint should stay quiet.
81
+ *
80
82
  * @param {string} text the prompt just submitted
81
83
  * @param {object} [opts]
82
84
  * @param {Array} [opts.rules] registered rules (defaults to the stored registry)
83
- * @param {string} [opts.lang] 'ko' | 'en'
84
- * @param {string} [opts.root] project root used to look for a subagent's .md.
85
- * The hint names the agent when that file exists and only its model tier when
86
- * it does not, so a caller that needs a predictable phrase pins this.
87
85
  * @param {number|null} [opts.sessionRank] price rank of the model reading the
88
86
  * hint (see sessionModelRank). null means unknown, and an unknown session
89
87
  * model filters nothing.
90
- * @returns {string|null} the line to inject, or null to stay quiet
88
+ * @returns {{cat: object, t2: object|undefined, t1: object|undefined}|null}
91
89
  */
92
- export function routeHint(text, { rules, lang = userLanguage(), root, sessionRank = null } = {}) {
90
+ export function routeMatch(text, { rules, sessionRank = null } = {}) {
93
91
  const t = String(text || '').trim();
94
92
  if (t.length < MIN_LEN) return null;
95
93
  // Judgement and irreversible work stay on the top tier. This check comes
@@ -125,6 +123,32 @@ export function routeHint(text, { rules, lang = userLanguage(), root, sessionRan
125
123
 
126
124
  const t2 = usable.find((r) => r.tier === 'T2');
127
125
  const t1 = usable.find((r) => r.tier === 'T1');
126
+ return t2 || t1 ? { cat, t2, t1 } : null;
127
+ }
128
+
129
+ /** The budgets a match states, by tier: what the delegation guard repeats to the subagent. */
130
+ export function matchCaps(match) {
131
+ if (!match) return null;
132
+ const caps = {};
133
+ if (match.t2) caps.T2 = ruleBudget(match.t2);
134
+ if (match.t1) caps.T1 = ruleBudget(match.t1);
135
+ return caps;
136
+ }
137
+
138
+ /**
139
+ * @param {string} text the prompt just submitted
140
+ * @param {object} [opts] as routeMatch, plus:
141
+ * @param {string} [opts.lang] 'ko' | 'en'
142
+ * @param {string} [opts.root] project root used to look for a subagent's .md.
143
+ * The hint names the agent when that file exists and only its model tier when
144
+ * it does not, so a caller that needs a predictable phrase pins this.
145
+ * @param {object|null} [opts.match] a routeMatch result the caller already has
146
+ * @returns {string|null} the line to inject, or null to stay quiet
147
+ */
148
+ export function routeHint(text, { rules, lang = userLanguage(), root, sessionRank = null, match } = {}) {
149
+ const found = match === undefined ? routeMatch(text, { rules, sessionRank }) : match;
150
+ if (!found) return null;
151
+ const { cat, t2, t1 } = found;
128
152
  const ko = lang === 'ko';
129
153
  const label = ko ? (cat.label || cat.id) : (cat.labelEn || cat.label || cat.id);
130
154
  // `root` is threaded through to agentPhrase, which names the subagent only