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/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
|
|
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
|
-
|
|
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
|
|
335
|
-
(
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
+
}
|
package/src/route-inject.js
CHANGED
|
@@ -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 {
|
|
88
|
+
* @returns {{cat: object, t2: object|undefined, t1: object|undefined}|null}
|
|
91
89
|
*/
|
|
92
|
-
export function
|
|
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
|