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/README.ko.md
CHANGED
|
@@ -185,7 +185,7 @@ JetBrains IDE 내장 터미널에서는 이모지 대신 한 칸 글리프로
|
|
|
185
185
|
|
|
186
186
|
| 명령 | 하는 일 |
|
|
187
187
|
|---|---|
|
|
188
|
-
| `sprag` | 최근
|
|
188
|
+
| `sprag` | 최근 30일 진단 리포트 |
|
|
189
189
|
| `sprag handoff` | 한도에 막히기 전에 작업 상태를 백업 |
|
|
190
190
|
| `sprag route-scan` | 내 기록에서 위임 후보 찾기 (LLM 호출 0회) |
|
|
191
191
|
| `sprag route-scan savings` | 라우팅 절감 원장 조회 |
|
package/README.md
CHANGED
|
@@ -229,7 +229,7 @@ work types, and once each has a rule there is nothing left to promote.
|
|
|
229
229
|
|
|
230
230
|
| Command | What it does |
|
|
231
231
|
|---|---|
|
|
232
|
-
| `sprag` | Last-
|
|
232
|
+
| `sprag` | Last-30-day diagnostic report |
|
|
233
233
|
| `sprag handoff` | Back work up to markdown before a cap blocks you |
|
|
234
234
|
| `sprag route-scan` | Find delegation candidates in your own history (0 LLM calls) |
|
|
235
235
|
| `sprag route-scan savings` | The routing-savings ledger |
|
package/bin/cli.js
CHANGED
|
@@ -36,12 +36,13 @@ import {
|
|
|
36
36
|
extractContextUsage,
|
|
37
37
|
extractEffort,
|
|
38
38
|
extractModel,
|
|
39
|
+
extractSessionId,
|
|
39
40
|
extractTranscriptPath,
|
|
40
41
|
} from '../src/stdin-payload.js';
|
|
41
42
|
|
|
42
43
|
import { effortChipLevel } from '../src/ultracode.js';
|
|
43
44
|
|
|
44
|
-
import { parseAllSessions, getLastUserMessageTime } from '../src/parser.js';
|
|
45
|
+
import { parseAllSessions, sessionsSince, getLastUserMessageTime } from '../src/parser.js';
|
|
45
46
|
import {
|
|
46
47
|
dailyTrend,
|
|
47
48
|
ttlBreakdown,
|
|
@@ -92,6 +93,15 @@ function readUpdateChip() {
|
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Whether the table report may use ANSI codes. The formatter looked at NO_COLOR
|
|
98
|
+
* alone, so `--no-color` did nothing there and a piped report (`sprag | less`,
|
|
99
|
+
* a redirect to a file) carried escape codes into its output.
|
|
100
|
+
*/
|
|
101
|
+
function tableColor() {
|
|
102
|
+
return !hasFlag('--no-color') && !process.env.NO_COLOR && !!process.stdout.isTTY;
|
|
103
|
+
}
|
|
104
|
+
|
|
95
105
|
// Subcommands this build knows how to run. Used only by the guard below.
|
|
96
106
|
const KNOWN_SUBCOMMANDS = new Set([
|
|
97
107
|
'last', 'brief', 'history', 'handoff', 'install', 'uninstall', 'mode', 'korean', 'cohesion',
|
|
@@ -131,6 +141,16 @@ Usage:
|
|
|
131
141
|
sprag last most recent warning + how to handle it
|
|
132
142
|
sprag history recent warning transitions
|
|
133
143
|
sprag handoff write a session handoff file
|
|
144
|
+
sprag mode [keywords] statusline preferences (icon/text, en/ko, window, ttl)
|
|
145
|
+
sprag seed register the bundled starter rules, one answer at a time
|
|
146
|
+
sprag korean on|off|status Korean writing guidance + write-time check
|
|
147
|
+
sprag cohesion on|off|status English sentence-connection guidance
|
|
148
|
+
sprag doc2md on|off|<file> convert documents to Markdown before the model reads them
|
|
149
|
+
sprag delegate on|off|status add bounds and context to Task/Agent delegation prompts
|
|
150
|
+
sprag compact-window audit or pin autoCompactWindow for 1M sessions
|
|
151
|
+
sprag litellm-budget LiteLLM gateway key budget and spend
|
|
152
|
+
sprag update-check is a newer version out?
|
|
153
|
+
sprag brief --hook internal: the UserPromptSubmit hook entry point
|
|
134
154
|
sprag feedback "<msg>" file a bug report / feature request (no browser needed)
|
|
135
155
|
sprag upgrade install the latest release
|
|
136
156
|
sprag --install-hook install cache-monitor PostToolUse hook
|
|
@@ -434,7 +454,7 @@ async function main() {
|
|
|
434
454
|
const { buildTableDemoData } = await import('../src/demo.js');
|
|
435
455
|
const { formatReport } = await import('../src/formatters/table.js');
|
|
436
456
|
const data = buildTableDemoData({ version: PKG_VERSION });
|
|
437
|
-
console.log(formatReport(data));
|
|
457
|
+
console.log(formatReport(data, { color: tableColor() }));
|
|
438
458
|
return;
|
|
439
459
|
}
|
|
440
460
|
|
|
@@ -529,7 +549,10 @@ async function main() {
|
|
|
529
549
|
const excludeSessionPath =
|
|
530
550
|
getArg('--exclude-session') || process.env.CACHE_MONITOR_EXCLUDE_SESSION || undefined;
|
|
531
551
|
|
|
532
|
-
|
|
552
|
+
// The statusline also reports this month's spend. Naming the month start here
|
|
553
|
+
// lets the one pass over the transcripts trim boundary sessions to it too.
|
|
554
|
+
const monthSince = isStatusline ? (await import('../src/month-spend.js')).monthStartMs() : undefined;
|
|
555
|
+
const sessions = await parseAllSessions({ days, projectFilter, sinceMs: monthSince });
|
|
533
556
|
|
|
534
557
|
if (sessions.length === 0) {
|
|
535
558
|
// Statusline must always emit a single line (no multi-line help spam every
|
|
@@ -552,6 +575,15 @@ async function main() {
|
|
|
552
575
|
// Resolved the same way as on the populated line below, so the chip does
|
|
553
576
|
// not change its mind about the level when the analysis window empties.
|
|
554
577
|
const effort = effortChipLevel(extractEffort(stdinJson), extractTranscriptPath(stdinJson));
|
|
578
|
+
// Recorded here too: a fresh session can render before it has any request
|
|
579
|
+
// in the analysis window, and its first prompt is briefed from this.
|
|
580
|
+
const ctxLive = extractContextUsage(stdinJson);
|
|
581
|
+
if (ctxLive?.size) {
|
|
582
|
+
try {
|
|
583
|
+
const { recordLiveWindow } = await import('../src/ctx-window-cache.js');
|
|
584
|
+
recordLiveWindow(extractSessionId(stdinJson), ctxLive.size);
|
|
585
|
+
} catch (e) { debug('ctx-window-cache:record', e); }
|
|
586
|
+
}
|
|
555
587
|
if (caps || model) {
|
|
556
588
|
try {
|
|
557
589
|
const { persistSnapshot } = await import('../src/caps-cache.js');
|
|
@@ -618,6 +650,16 @@ async function main() {
|
|
|
618
650
|
? effortChipLevel(extractEffort(stdinJson), extractTranscriptPath(stdinJson))
|
|
619
651
|
: extractEffort(stdinJson);
|
|
620
652
|
const ctxLive = extractContextUsage(stdinJson);
|
|
653
|
+
// The prompt-submit briefing has no payload field for the window, so it reads
|
|
654
|
+
// back what this render was told (see src/ctx-window-cache.js).
|
|
655
|
+
if (isStatusline && ctxLive?.size) {
|
|
656
|
+
try {
|
|
657
|
+
const { recordLiveWindow } = await import('../src/ctx-window-cache.js');
|
|
658
|
+
recordLiveWindow(extractSessionId(stdinJson), ctxLive.size);
|
|
659
|
+
} catch (e) {
|
|
660
|
+
debug('ctx-window-cache:record', e);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
621
663
|
if (isStatusline && (caps || model)) {
|
|
622
664
|
try {
|
|
623
665
|
const { persistSnapshot } = await import('../src/caps-cache.js');
|
|
@@ -730,28 +772,23 @@ async function main() {
|
|
|
730
772
|
if (isStatusline) {
|
|
731
773
|
try {
|
|
732
774
|
const { monthSpend, monthStartMs } = await import('../src/month-spend.js');
|
|
733
|
-
const
|
|
775
|
+
const since = monthStartMs();
|
|
776
|
+
const daysNeeded = (Date.now() - since) / 86400000;
|
|
734
777
|
const monthSessions = days >= daysNeeded
|
|
735
778
|
? sessions
|
|
736
|
-
: await parseAllSessions({ days: Math.max(1, Math.ceil(daysNeeded)), projectFilter });
|
|
737
|
-
|
|
779
|
+
: await parseAllSessions({ days: Math.max(1, Math.ceil(daysNeeded)), projectFilter, sinceMs: since });
|
|
780
|
+
// 월 경계에 걸친 세션은 월초 이후 요청만 남긴다. 그러지 않으면 1일 자정을
|
|
781
|
+
// 넘긴 세션의 전월분이 이번 달 지출에 그대로 들어온다.
|
|
782
|
+
monthSpendInfo = monthSpend(await sessionsSince(monthSessions, since));
|
|
738
783
|
} catch (e) {
|
|
739
784
|
debug('month-spend', e);
|
|
740
785
|
}
|
|
741
786
|
}
|
|
742
787
|
|
|
743
|
-
// What delegation has measurably saved, read straight from the registry
|
|
744
|
-
// route-scan maintains. A lookup, never a scan: the statusline re-renders
|
|
745
|
-
// every few seconds and a scan parses tens of MB of transcripts.
|
|
746
|
-
let delegationSaved = 0;
|
|
747
|
-
try {
|
|
748
|
-
const { delegationSavedUsd } = await import('../src/model-rules.js');
|
|
749
|
-
delegationSaved = delegationSavedUsd();
|
|
750
|
-
} catch (e) {
|
|
751
|
-
debug('model-rules:saved', e); // an unreadable registry just hides the chip
|
|
752
|
-
}
|
|
753
788
|
// Rolling week/month/lifetime totals from the delegation ledger — the
|
|
754
789
|
// statusline's headline line. Falls back to null (chip hidden) on any error.
|
|
790
|
+
// A lookup, never a scan: the statusline re-renders every few seconds and a
|
|
791
|
+
// scan parses tens of MB of transcripts.
|
|
755
792
|
let delegationTotals = null;
|
|
756
793
|
try {
|
|
757
794
|
const { delegationSavedTotals } = await import('../src/savings-ledger.js');
|
|
@@ -759,6 +796,10 @@ async function main() {
|
|
|
759
796
|
} catch (e) {
|
|
760
797
|
debug('savings-ledger:totals', e);
|
|
761
798
|
}
|
|
799
|
+
// The single-line layout shows the same lifetime figure as the headline
|
|
800
|
+
// line. It used to sum the rule registry instead, a scan-window snapshot
|
|
801
|
+
// priced against another baseline, so one label carried two amounts.
|
|
802
|
+
const delegationSaved = Number(delegationTotals?.total) || 0;
|
|
762
803
|
// Document conversions, same shape as the delegation totals: a lifetime sum
|
|
763
804
|
// plus a document count. A lookup of a small JSON file, never a scan.
|
|
764
805
|
let doc2mdTotals = null;
|
|
@@ -857,7 +898,7 @@ async function main() {
|
|
|
857
898
|
});
|
|
858
899
|
} else {
|
|
859
900
|
const { formatReport } = await import('../src/formatters/table.js');
|
|
860
|
-
output = formatReport(data);
|
|
901
|
+
output = formatReport(data, { color: tableColor() });
|
|
861
902
|
}
|
|
862
903
|
|
|
863
904
|
console.log(output);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* npm `postinstall` entry point: runs `sprag install` for a global install and
|
|
5
|
+
* says why it did not for anything else. See src/postinstall.js.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { postinstallDecision } from '../src/postinstall.js';
|
|
12
|
+
|
|
13
|
+
const decision = postinstallDecision(process.env);
|
|
14
|
+
if (!decision.run) {
|
|
15
|
+
console.log(`sprag: setup skipped (${decision.reason}). Run \`sprag install\` to register the hooks and the statusline.`);
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
const cli = join(dirname(fileURLToPath(import.meta.url)), 'cli.js');
|
|
19
|
+
spawnSync(process.execPath, [cli, 'install'], { stdio: 'inherit' });
|
|
20
|
+
// A failed setup must not fail the package install: the CLI is still usable
|
|
21
|
+
// and `sprag install` can be run again by hand.
|
|
22
|
+
process.exit(0);
|
package/docs/CODEX.ko.md
CHANGED
|
@@ -114,7 +114,11 @@ Codex 0.159.2에서는 `spawn_agent`가 `PreToolUse`를 거치지 않습니다(
|
|
|
114
114
|
"사용자가 위임을 요청했다"는 뜻으로 읽게 합니다. 자식은 `fork_turns "none"`으로 뜨고, 부모는
|
|
115
115
|
같은 일을 직접 하지 않고 `wait_agent`(제한 시간 120초 이상)로 자식이 끝나기를 기다립니다. 귀속은
|
|
116
116
|
자식이 route id를 되받아 적는 것에 기대지 않습니다. 대신 `SubagentStart`가 대기 중인 기록(같은
|
|
117
|
-
부모 세션, 같은 목표 모델, 30분 이내)에 새로 뜬 자식을 매칭해 묶습니다.
|
|
117
|
+
부모 세션, 같은 목표 모델, 30분 이내)에 새로 뜬 자식을 매칭해 묶습니다. 어떤 자식도 가져가지
|
|
118
|
+
않은 안내는 부모 세션에 다음 프롬프트가 들어올 때 닫히므로, 나중에 다른 이유로 뜬 자식이 그
|
|
119
|
+
안내의 절감으로 잡히지 않습니다. 같은 프롬프트 시점에 아직 장부에 반영되지 않은 위임 실행도
|
|
120
|
+
별도 프로세스로 계산하므로, `Routing saved`는 다음 route-scan을 기다리지 않고 위임 뒤 첫
|
|
121
|
+
프롬프트에서 갱신됩니다.
|
|
118
122
|
|
|
119
123
|
이 안내는 부모 세션의 컨텍스트가 `codex.delegateMinContext`(기본 60000 토큰) 아래면 붙지
|
|
120
124
|
않습니다. 2026-09-30 실측에서 작은 세션은 스폰·대기·검증이라는 조율 비용이 자식이 아끼는
|
package/docs/CODEX.md
CHANGED
|
@@ -166,6 +166,11 @@ with `fork_turns "none"`, then wait for it (`wait_agent` with a timeout of at le
|
|
|
166
166
|
route id back: `SubagentStart` binds the id to whichever spawned child matches
|
|
167
167
|
the pending route (same parent thread, same target model, within 30 minutes),
|
|
168
168
|
so a route is credited even when the child's own first message never repeats it.
|
|
169
|
+
A hint that no child claimed is closed when the parent's next prompt arrives, so
|
|
170
|
+
a child spawned later for some other reason is not credited to it. That same
|
|
171
|
+
prompt also prices any delegated run the ledger has not joined yet, in a
|
|
172
|
+
detached process, so `Routing saved` follows a delegation by one prompt instead
|
|
173
|
+
of waiting for the next route scan.
|
|
169
174
|
|
|
170
175
|
The hint is withheld below `codex.delegateMinContext` (default 60000 input
|
|
171
176
|
tokens, read from the parent's own rollout). Measured 2026-09-30: a small
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.57.0",
|
|
4
4
|
"description": "Cut the tokens Claude Code and Codex spend: routes simple work to cheaper models, converts documents to compact Markdown, and shows spend and savings live. Ratchet rules and Korean writing checks included.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"deploy": "node scripts/deploy.mjs",
|
|
12
12
|
"docs:statusline": "node scripts/docs-statusline.mjs",
|
|
13
|
-
"postinstall": "node bin/
|
|
13
|
+
"postinstall": "node bin/postinstall.js || true",
|
|
14
14
|
"publish:legacy": "node scripts/publish-legacy.mjs",
|
|
15
15
|
"release:notes": "node scripts/release-notes.mjs",
|
|
16
16
|
"site:docs": "node scripts/build-site-docs.mjs",
|
package/src/agents.js
CHANGED
|
@@ -20,12 +20,26 @@ const AGENT_MODEL = {
|
|
|
20
20
|
'haiku-runner': 'haiku',
|
|
21
21
|
'haiku-translate': 'haiku',
|
|
22
22
|
'sonnet-worker': 'sonnet',
|
|
23
|
+
// A T1 rule names its target by tier alone (`agent: 'sonnet'`), with no
|
|
24
|
+
// preset agent behind it. Without these the phrase fell through to the
|
|
25
|
+
// haiku default, and a sonnet rule told the session to delegate to haiku.
|
|
26
|
+
sonnet: 'sonnet',
|
|
27
|
+
haiku: 'haiku',
|
|
23
28
|
};
|
|
24
29
|
|
|
25
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* True when `<name>.md` exists in the project or user agents directory.
|
|
32
|
+
*
|
|
33
|
+
* `root: null` asks about the user directory alone. Text that every project
|
|
34
|
+
* reads (the global ratchet-model.md) has to be worded from what every project
|
|
35
|
+
* can see: checked against the current directory, the same rule read
|
|
36
|
+
* `haiku-explore(model: haiku)` when the scan ran in a project that defines
|
|
37
|
+
* the agent and `model: haiku` when it ran anywhere else, and each flip
|
|
38
|
+
* rewrote a file sitting in the prompt-cache prefix of every session.
|
|
39
|
+
*/
|
|
26
40
|
export function agentExists(name, root = process.cwd()) {
|
|
27
41
|
if (!name) return false;
|
|
28
|
-
return existsSync(join(root, '.claude', 'agents', `${name}.md`)) ||
|
|
42
|
+
return (root !== null && existsSync(join(root, '.claude', 'agents', `${name}.md`))) ||
|
|
29
43
|
existsSync(join(claudeUserDir(), 'agents', `${name}.md`));
|
|
30
44
|
}
|
|
31
45
|
|
package/src/brief.js
CHANGED
|
@@ -29,6 +29,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, openSync,
|
|
|
29
29
|
import { join } from 'node:path';
|
|
30
30
|
import { userDataDir } from './paths.js';
|
|
31
31
|
import { resolveModelId, isOneMillionModel, effectiveWindow } from './compact-window.js';
|
|
32
|
+
import { liveWindowFor } from './ctx-window-cache.js';
|
|
32
33
|
|
|
33
34
|
// Context tiers as a fraction of the session's context window. Tier 1 warns
|
|
34
35
|
// (compaction/cost territory ahead), tier 2 urges wrapping up. A session only
|
|
@@ -83,14 +84,21 @@ function saveState(state, now) {
|
|
|
83
84
|
*
|
|
84
85
|
* `observedMax` stays as a floor: it proves a 1M window even when the model id
|
|
85
86
|
* is unreadable (env override, settings we do not resolve).
|
|
87
|
+
*
|
|
88
|
+
* `liveWindow` outranks both. It is the size Claude Code reported to the
|
|
89
|
+
* statusline for this session, so the briefing and the statusline count against
|
|
90
|
+
* the same window. The model id cannot stand in for it: a model that is 1M
|
|
91
|
+
* without a `[1m]` suffix reads as 200k there.
|
|
86
92
|
*/
|
|
87
|
-
export function ctxWindowFor(observedMax = 0, root = process.cwd()) {
|
|
93
|
+
export function ctxWindowFor(observedMax = 0, root = process.cwd(), { liveWindow = null } = {}) {
|
|
88
94
|
let modelWindow = observedMax > WINDOW_1M_MIN_INPUT ? 1_000_000 : 200_000;
|
|
95
|
+
const reported = Number.isFinite(liveWindow) && liveWindow > 0;
|
|
96
|
+
if (reported) modelWindow = liveWindow;
|
|
89
97
|
let window = modelWindow;
|
|
90
98
|
let compactCapped = false;
|
|
91
99
|
try {
|
|
92
100
|
const { model } = resolveModelId(root);
|
|
93
|
-
if (isOneMillionModel(model)) modelWindow = window = 1_000_000;
|
|
101
|
+
if (!reported && isOneMillionModel(model)) modelWindow = window = 1_000_000;
|
|
94
102
|
const cap = effectiveWindow(root).value;
|
|
95
103
|
if (cap !== null && cap < window) {
|
|
96
104
|
window = cap;
|
|
@@ -104,7 +112,7 @@ export function ctxWindowFor(observedMax = 0, root = process.cwd()) {
|
|
|
104
112
|
* Last request's input size for THIS session, from the transcript tail.
|
|
105
113
|
* Reads at most TAIL_BYTES — prompt-submit hooks must stay fast.
|
|
106
114
|
*/
|
|
107
|
-
export function sessionCtx(transcriptPath, { root = process.cwd() } = {}) {
|
|
115
|
+
export function sessionCtx(transcriptPath, { root = process.cwd(), liveWindow = null } = {}) {
|
|
108
116
|
let size;
|
|
109
117
|
try { size = statSync(transcriptPath).size; } catch { return null; }
|
|
110
118
|
const start = Math.max(0, size - TAIL_BYTES);
|
|
@@ -130,7 +138,7 @@ export function sessionCtx(transcriptPath, { root = process.cwd() } = {}) {
|
|
|
130
138
|
if (total > 0) { input = total; maxInput = Math.max(maxInput, total); }
|
|
131
139
|
}
|
|
132
140
|
if (input == null) return null;
|
|
133
|
-
const { window, modelWindow, compactCapped } = ctxWindowFor(maxInput, root);
|
|
141
|
+
const { window, modelWindow, compactCapped } = ctxWindowFor(maxInput, root, { liveWindow });
|
|
134
142
|
return { input, window, modelWindow, compactCapped, pct: input / window };
|
|
135
143
|
}
|
|
136
144
|
|
|
@@ -166,14 +174,15 @@ export function seedSessionBriefed(sessionId, signatures, now = Date.now()) {
|
|
|
166
174
|
* (recorded via seedSessionBriefed) are skipped; every other fresh signature
|
|
167
175
|
* is emitted, including on the session's first event.
|
|
168
176
|
*/
|
|
169
|
-
export async function runBrief({ sessionId, transcriptPath, now = Date.now() }) {
|
|
177
|
+
export async function runBrief({ sessionId, transcriptPath, root = process.cwd(), now = Date.now() }) {
|
|
170
178
|
if (!sessionId) return null;
|
|
179
|
+
const liveWindow = liveWindowFor(sessionId);
|
|
171
180
|
const state = loadState();
|
|
172
181
|
const s = state.sessions[sessionId] || { ctxTier: 0, briefed: [] };
|
|
173
182
|
const items = [];
|
|
174
183
|
|
|
175
184
|
// ── context tier crossing (per-session) ──
|
|
176
|
-
const ctx = transcriptPath ? sessionCtx(transcriptPath) : null;
|
|
185
|
+
const ctx = transcriptPath ? sessionCtx(transcriptPath, { root, liveWindow }) : null;
|
|
177
186
|
if (ctx) {
|
|
178
187
|
const tier = ctxTierOf(ctx.pct);
|
|
179
188
|
// The tier is not monotonic: auto-compaction drops the live context back to
|
|
@@ -238,7 +247,7 @@ export async function runBrief({ sessionId, transcriptPath, now = Date.now() })
|
|
|
238
247
|
// never needed. 200k sessions are exempt (their window is already <= 200k).
|
|
239
248
|
try {
|
|
240
249
|
const cw = await import('./compact-window.js');
|
|
241
|
-
const st = cw.compactWindowStatus({ root
|
|
250
|
+
const st = cw.compactWindowStatus({ root, liveWindow });
|
|
242
251
|
if (!st.ok) {
|
|
243
252
|
const sig = `compact-window|${st.reason}`;
|
|
244
253
|
const briefed = new Set(s.briefed || []);
|
|
@@ -246,7 +255,7 @@ export async function runBrief({ sessionId, transcriptPath, now = Date.now() })
|
|
|
246
255
|
briefed.add(sig);
|
|
247
256
|
s.briefed = [...briefed];
|
|
248
257
|
const now = st.window ? `현재 ${fmtK(st.window)}` : '현재 미설정';
|
|
249
|
-
items.push(`1M 컨텍스트 모델(${st.model})인데 autoCompactWindow가 ${now}입니다 — 자동 압축이 80만 토큰 근처에서야 걸려 그전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 1M은 너무 크니 ${fmtK(st.recommendedMin)}~${fmtK(st.recommendedMax)} 범위를 권장합니다(그 범위 안이면 경고하지 않습니다). 1M 창 자체는 그대로 두고 압축 시점만 앞당깁니다. 등록: sprag compact-window set --global|--project [--value ${fmtK(st.recommendedMax)}] (기본 ${fmtK(st.recommended)}, 적용 범위는 사용자에게 확인) / 끄기: compact-window off`);
|
|
258
|
+
items.push(`1M 컨텍스트 모델(${st.model || 'Claude Code 기본 모델'})인데 autoCompactWindow가 ${now}입니다 — 자동 압축이 80만 토큰 근처에서야 걸려 그전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 1M은 너무 크니 ${fmtK(st.recommendedMin)}~${fmtK(st.recommendedMax)} 범위를 권장합니다(그 범위 안이면 경고하지 않습니다). 1M 창 자체는 그대로 두고 압축 시점만 앞당깁니다. 등록: sprag compact-window set --global|--project [--value ${fmtK(st.recommendedMax)}] (기본 ${fmtK(st.recommended)}, 적용 범위는 사용자에게 확인) / 끄기: compact-window off`);
|
|
250
259
|
}
|
|
251
260
|
}
|
|
252
261
|
} catch { /* settings unreadable — other briefings above still apply */ }
|
package/src/claude-price.js
CHANGED
|
@@ -16,10 +16,31 @@ export function sessionCost(totals, model, { env = process.env, now } = {}) {
|
|
|
16
16
|
return price ? estimateCost(totals, model, { price }) : null;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
19
|
+
/** One priced part per model that spent tokens, or the whole at its one model. */
|
|
20
|
+
function partsByModel(x) {
|
|
21
|
+
if (!x.modelTotals) return [{ totals: x.totals, model: x.model }];
|
|
22
|
+
return Object.entries(x.modelTotals).map(([model, totals]) => ({ totals, model }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What a session's cost is made of: the session itself and each subagent run
|
|
27
|
+
* it spawned (parser.js attaches them as `subagentRuns`). A run has its own
|
|
28
|
+
* model, so it is priced on its own instead of at the session's rate, and a
|
|
29
|
+
* session or run that switched model is split again so each model's tokens
|
|
30
|
+
* meet that model's rate. `own` marks the parts that are the session itself.
|
|
31
|
+
*/
|
|
32
|
+
export function pricedParts(session) {
|
|
33
|
+
if (!session) return [];
|
|
34
|
+
return [
|
|
35
|
+
...partsByModel(session).map((part) => ({ ...part, own: true })),
|
|
36
|
+
...(session.subagentRuns || []).flatMap(partsByModel),
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Like estimateCostAcross, plus how many priced parts (a session, a subagent run, or one model's share of either) had no price and were left out. */
|
|
20
41
|
export function sessionCostAcross(sessions, opts = {}) {
|
|
21
42
|
let unpriced = 0;
|
|
22
|
-
const total = estimateCostAcross(sessions, (totals, model) => {
|
|
43
|
+
const total = estimateCostAcross((sessions || []).flatMap(pricedParts), (totals, model) => {
|
|
23
44
|
const c = sessionCost(totals, model, opts);
|
|
24
45
|
if (!c) unpriced++;
|
|
25
46
|
return c;
|
package/src/codex-brief.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
4
|
import { codexUserDir } from './agent.js';
|
|
5
5
|
import { userDataDir } from './paths.js';
|
|
6
6
|
import { readCodexSnapshot } from './codex-parser.js';
|
|
7
|
+
import { loadCodexModelRules } from './codex-delegation.js';
|
|
8
|
+
import { loadCodexLedger } from './codex-ledger.js';
|
|
9
|
+
import { codexRulesInReview } from './codex-rule-health.js';
|
|
7
10
|
|
|
11
|
+
const RESET_TOLERANCE_S = 300;
|
|
8
12
|
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
9
13
|
const stateDir = (dir) => join(dir, 'codex', hash(codexUserDir()), 'sessions');
|
|
10
14
|
const fresh = (value, now) => {
|
|
@@ -45,7 +49,7 @@ function readState(file) {
|
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/** One file per home/session keeps unrelated sessions out of the same dedup state. */
|
|
48
|
-
export function runCodexBrief({ sessionId, transcriptPath, cwd, now = Date.now(), dir = userDataDir() } = {}) {
|
|
52
|
+
export function runCodexBrief({ sessionId, transcriptPath, cwd, root: projectRoot, now = Date.now(), dir = userDataDir() } = {}) {
|
|
49
53
|
if (typeof sessionId !== 'string' || !sessionId) return null;
|
|
50
54
|
const snapshot = readCodexSnapshot(transcriptPath);
|
|
51
55
|
if (!snapshot || (snapshot.sessionId && snapshot.sessionId !== sessionId)) return null;
|
|
@@ -54,6 +58,35 @@ export function runCodexBrief({ sessionId, transcriptPath, cwd, now = Date.now()
|
|
|
54
58
|
const file = join(directory, `${hash(sessionId)}.json`);
|
|
55
59
|
const state = readState(file);
|
|
56
60
|
const active = Array.isArray(state.active) ? state.active.filter((sig) => typeof sig === 'string') : [];
|
|
61
|
+
// Codex rewrites resets_at by about a second between records, so a limit
|
|
62
|
+
// already announced for the same window keeps its first signature.
|
|
63
|
+
for (const issue of issues) {
|
|
64
|
+
const m = /^limit:(primary|secondary):(\d+(?:\.\d+)?)$/.exec(issue.signature);
|
|
65
|
+
if (!m) continue;
|
|
66
|
+
const resetsAt = Number(m[2]);
|
|
67
|
+
const known = active.find((sig) => {
|
|
68
|
+
const k = /^limit:(primary|secondary):(\d+(?:\.\d+)?)$/.exec(sig);
|
|
69
|
+
return k && k[1] === m[1] && Math.abs(Number(k[2]) - resetsAt) <= RESET_TOLERANCE_S;
|
|
70
|
+
});
|
|
71
|
+
if (known) issue.signature = known;
|
|
72
|
+
}
|
|
73
|
+
// A failing model rule is announced once per session; the lines never block the other warnings.
|
|
74
|
+
try {
|
|
75
|
+
// The hook passes the project root its rule matching uses; a prompt sent from
|
|
76
|
+
// a subdirectory must still see the project's rules.
|
|
77
|
+
const root = resolve(projectRoot || cwd || snapshot.projectDir || '.');
|
|
78
|
+
const rules = loadCodexModelRules({ dir });
|
|
79
|
+
if (rules.length) {
|
|
80
|
+
const events = Object.values(loadCodexLedger({ dir }).events);
|
|
81
|
+
for (const { index, rule, health } of codexRulesInReview({ root, rules, events })) {
|
|
82
|
+
issues.push({ key: 'rule-health', tier: 1,
|
|
83
|
+
signature: `rule-health:${rule.scope}:${rule.targetRoot ?? ''}:${rule.category}:${rule.from}:${rule.model}:${rule.createdAt ?? ''}`,
|
|
84
|
+
level: 'warning',
|
|
85
|
+
message: `Codex model rule #${index} (${rule.category}, ${rule.from} -> ${rule.model}) failed ${health.errs} of ${health.runs} measured runs.`,
|
|
86
|
+
advice: `Narrow or remove it: sprag delegate rules rm ${index} --agent codex` });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch { /* An unreadable rule file or ledger only skips this line. */ }
|
|
57
90
|
const next = issues.filter((issue) => !active.includes(issue.signature) &&
|
|
58
91
|
!(issue.key === 'context' && issue.tier === 1 && active.includes('context:2')));
|
|
59
92
|
// Missing or stale measurements do not resolve an earlier warning. A fresh
|
package/src/codex-delegation.js
CHANGED
|
@@ -54,7 +54,10 @@ export function addCodexModelRule({ category, model, effort, from, scope, root,
|
|
|
54
54
|
const rules = loadCodexModelRules({ dir });
|
|
55
55
|
const targetRoot = scope === 'project' ? resolve(root) : null;
|
|
56
56
|
const rule = { category, model, effort, from, scope, targetRoot, ...(provider ? { provider } : {}), status: 'active', createdAt: new Date().toISOString() };
|
|
57
|
-
|
|
57
|
+
// A rule saved without a provider has no `provider` key, and a caller with
|
|
58
|
+
// none passes null. Compared as they are, undefined !== null, so re-adding
|
|
59
|
+
// the same rule stacked a duplicate instead of replacing it.
|
|
60
|
+
const old = rules.findIndex((r) => r.category === category && r.from === from && r.scope === scope && r.targetRoot === targetRoot && (r.provider ?? null) === (provider ?? null));
|
|
58
61
|
if (old < 0) rules.push(rule); else rules[old] = rule;
|
|
59
62
|
saveRules(rules, dir);
|
|
60
63
|
return rule;
|
|
@@ -126,7 +129,8 @@ export function codexRouteHint(payload, opts = {}) {
|
|
|
126
129
|
try {
|
|
127
130
|
record({ id, parentSessionId: payload?.session_id ?? null, turnId: payload?.turn_id ?? null, via: 'prompt',
|
|
128
131
|
source: 'rule', category: rule.category, scope: rule.scope, from: payload?.model, to: rule.model,
|
|
129
|
-
provider, effort: rule.effort ?? null, contextTokens: ctx
|
|
132
|
+
provider, effort: rule.effort ?? null, contextTokens: ctx,
|
|
133
|
+
targetRoot: rule.targetRoot ?? null, ruleCreatedAt: rule.createdAt ?? null }, { dir: opts.dir });
|
|
130
134
|
} catch { /* Accounting is optional; the hint still applies. */ }
|
|
131
135
|
return `[Sprag model routing] This request matches the approved ${rule.category} rule (route ${id}). `
|
|
132
136
|
+ `Spawn one sub-agent with model ${rule.model}${rule.effort ? `, reasoning_effort ${rule.effort}` : ''} and fork_turns "none". `
|
|
@@ -160,7 +164,7 @@ function customRole(role, root, home) {
|
|
|
160
164
|
|
|
161
165
|
// Kept for Codex versions that do send spawn_agent through PreToolUse; on
|
|
162
166
|
// 0.159.2 the hook never fires for it, so codexRouteHint above is the live path.
|
|
163
|
-
export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules, dir = userDataDir(), record = recordCodexDelegation } = {}) {
|
|
167
|
+
export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd || process.cwd(), home = codexUserDir(), rules: given, dir = userDataDir(), record = recordCodexDelegation } = {}) {
|
|
164
168
|
if (cfg.codex?.delegate !== true) return null;
|
|
165
169
|
const tool = payload?.tool_name?.replace(/^(?:functions|tools)\./, '');
|
|
166
170
|
if (!['spawn_agent', 'Agent'].includes(tool)) return null;
|
|
@@ -169,6 +173,9 @@ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd ||
|
|
|
169
173
|
const role = input.agent_type || 'default';
|
|
170
174
|
if (!['default', 'worker', 'explorer'].includes(role) || customRole(role, payload.cwd || root, home)) return null;
|
|
171
175
|
const explicit = Object.hasOwn(input, 'model');
|
|
176
|
+
// An unreadable rule file must not stop the default target or the spawn itself.
|
|
177
|
+
let rules = given;
|
|
178
|
+
if (!rules) { try { rules = loadCodexModelRules({ dir }); } catch { rules = []; } }
|
|
172
179
|
const provider = payload.model_provider || codexProviderName({ home });
|
|
173
180
|
const rule = explicit ? null : matchCodexModelRule(input.message, { model: payload.model, provider, root, rules });
|
|
174
181
|
const target = rule || (!explicit && cfg.codex?.delegateTarget);
|
|
@@ -184,7 +191,8 @@ export function codexDelegationTool(payload, { cfg = {}, root = payload?.cwd ||
|
|
|
184
191
|
try {
|
|
185
192
|
record({ id, parentSessionId: typeof payload.session_id === 'string' ? payload.session_id : null,
|
|
186
193
|
source: rule ? 'rule' : 'default', category: rule?.category ?? null, scope: rule?.scope ?? null,
|
|
187
|
-
from: payload.model, to: target.model, provider, effort: updatedInput.reasoning_effort ?? null
|
|
194
|
+
from: payload.model, to: target.model, provider, effort: updatedInput.reasoning_effort ?? null,
|
|
195
|
+
...(rule ? { targetRoot: rule.targetRoot ?? null, ruleCreatedAt: rule.createdAt ?? null } : {}) }, { dir });
|
|
188
196
|
routeLine = `\n<!-- sprag:codex:route id=${id} -->`;
|
|
189
197
|
} catch { /* Accounting is optional; the route itself still applies. */ }
|
|
190
198
|
}
|
package/src/codex-harness.js
CHANGED
|
@@ -14,14 +14,22 @@ const sections = [
|
|
|
14
14
|
['Default Safe Path', 'Ask before destructive or external actions unless already authorized. Proceed with reads, local edits, and tests within the granted permissions.'],
|
|
15
15
|
];
|
|
16
16
|
|
|
17
|
+
const scopeDir = (root, scope) => (scope === 'global' ? codexUserDir() : root);
|
|
18
|
+
|
|
17
19
|
export function codexHarnessPaths(root, scope = 'project') {
|
|
18
|
-
const dir = scope
|
|
20
|
+
const dir = scopeDir(root, scope);
|
|
19
21
|
// Codex reads the override instead of AGENTS.md when it is non-empty.
|
|
20
22
|
const override = join(dir, 'AGENTS.override.md');
|
|
21
23
|
const file = existsSync(override) && readFileSync(override, 'utf8').trim() ? override : join(dir, 'AGENTS.md');
|
|
22
24
|
return { file, ratchet: scope === 'global' ? join(dir, 'ratchet.md') : join(dir, '.codex', 'ratchet.md') };
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/** Every instruction file of a scope that init may have written to, active or not. */
|
|
28
|
+
function harnessFiles(root, scope) {
|
|
29
|
+
const dir = scopeDir(root, scope);
|
|
30
|
+
return [join(dir, 'AGENTS.override.md'), join(dir, 'AGENTS.md')];
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
export function codexHarnessBlock() {
|
|
26
34
|
return `${CODEX_BEGIN}\n## Sprag Harness\n\n${sections.map(([name, text], i) => `### ${i + 1}. ${name}\n${text}`).join('\n\n')}\n\nRead the global and project Codex ratchet files before working when present. Run \`sprag harness list --agent codex --global\` and \`sprag harness list --agent codex --project\` to retrieve them. These are instructions, not Claude-style @ imports.\nUse \`sprag doc2md <file> --agent codex\` before reading PDF, Office, or Figma documents. Delegate only through available Codex tools and configured roles; do not assume Claude model names or Task/Agent arguments. A [Sprag model routing] note on a request means the user has asked for that request to be delegated: spawn the sub-agent it describes (its model, reasoning_effort, fork_turns "none", and a self-contained message ending with the route line it gives), wait for it to finish instead of doing the same task yourself, then verify the result before replying.\n${CODEX_END}\n`;
|
|
27
35
|
}
|
|
@@ -40,8 +48,11 @@ export function initCodexHarness({ root, scope = 'project' }) {
|
|
|
40
48
|
const existing = existsSync(paths.file) ? readFileSync(paths.file, 'utf8') : '';
|
|
41
49
|
const span = range(existing);
|
|
42
50
|
const block = codexHarnessBlock();
|
|
51
|
+
// Trailing newlines are dropped before the separator is added, so the gap
|
|
52
|
+
// above the block is always one blank line however the file ended.
|
|
53
|
+
const body = existing.replace(/\n+$/, '');
|
|
43
54
|
const next = span ? existing.slice(0, span[0]) + block.trimEnd() + existing.slice(span[1])
|
|
44
|
-
:
|
|
55
|
+
: body + (body ? '\n\n' : '') + block;
|
|
45
56
|
mkdirSync(dirname(paths.file), { recursive: true });
|
|
46
57
|
if (next !== existing) {
|
|
47
58
|
if (existing) writeFileSync(`${paths.file}.bak-${Date.now()}`, existing);
|
|
@@ -54,15 +65,38 @@ export function initCodexHarness({ root, scope = 'project' }) {
|
|
|
54
65
|
return paths;
|
|
55
66
|
}
|
|
56
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The file without the block, and without the blank lines init put around it.
|
|
70
|
+
* Cutting the block alone left those behind, so each install and uninstall
|
|
71
|
+
* added three more.
|
|
72
|
+
*/
|
|
73
|
+
function withoutBlock(text, [begin, end]) {
|
|
74
|
+
const before = text.slice(0, begin).replace(/\n+$/, '');
|
|
75
|
+
const after = text.slice(end).replace(/^\n+/, '');
|
|
76
|
+
if (!before) return after;
|
|
77
|
+
return after ? `${before}\n\n${after}` : `${before}\n`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Removes the block from every file of the scope that holds one. The active
|
|
82
|
+
* file alone is not enough: init writes AGENTS.md, and an AGENTS.override.md
|
|
83
|
+
* created afterwards becomes the active file while the block stays in the
|
|
84
|
+
* other one, where a later uninit never looked.
|
|
85
|
+
*/
|
|
57
86
|
export function uninitCodexHarness({ root, scope = 'project' }) {
|
|
58
87
|
const { file } = codexHarnessPaths(root, scope);
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
88
|
+
const cleaned = [];
|
|
89
|
+
for (const candidate of harnessFiles(root, scope)) {
|
|
90
|
+
if (!existsSync(candidate)) continue;
|
|
91
|
+
const existing = readFileSync(candidate, 'utf8');
|
|
92
|
+
const span = range(existing);
|
|
93
|
+
if (!span) continue;
|
|
94
|
+
writeFileSync(`${candidate}.bak-${Date.now()}`, existing);
|
|
95
|
+
writeFileSync(candidate, withoutBlock(existing, span));
|
|
96
|
+
cleaned.push(candidate);
|
|
97
|
+
}
|
|
98
|
+
if (!cleaned.length) return { file, removed: false };
|
|
99
|
+
return { file: cleaned.includes(file) ? file : cleaned[0], removed: true, ...(cleaned.length > 1 ? { files: cleaned } : {}) };
|
|
66
100
|
}
|
|
67
101
|
|
|
68
102
|
export function codexHarnessStatus({ root, scope = 'project' }) {
|