sprag-cli 3.58.1 → 3.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +17 -0
- package/README.md +23 -0
- package/bin/cli.js +59 -4
- package/package.json +1 -1
- package/src/caps-cache.js +3 -3
- package/src/codex-brief.js +4 -5
- package/src/codex-cache-policy.js +3 -3
- package/src/codex-delegation.js +3 -3
- package/src/codex-doc2md-ledger.js +9 -4
- package/src/codex-hooks.js +3 -3
- package/src/codex-installer.js +3 -3
- package/src/codex-ledger.js +13 -8
- package/src/codex-panel-session.js +3 -3
- package/src/codex-panel-state.js +51 -5
- package/src/codex-route-scan.js +3 -3
- package/src/commands/codex.js +2 -0
- package/src/commands/compact-window.js +6 -2
- package/src/commands/install.js +4 -1
- package/src/commands/korean.js +69 -3
- package/src/commands/route-scan.js +25 -4
- package/src/commands/saved.js +154 -0
- package/src/ctx-window-cache.js +14 -0
- package/src/doc2md-ledger.cjs +7 -1
- package/src/formatters/statusline.js +167 -18
- package/src/harness-analyzer.cjs +8 -2
- package/src/harness.js +32 -23
- package/src/installer.js +52 -0
- package/src/model-alias.js +3 -3
- package/src/model-rules.js +3 -1
- package/src/preset-ratchet.js +3 -3
- package/src/reply-language.cjs +167 -0
- package/src/route-inject.js +43 -3
- package/src/route-scan.js +51 -11
- package/src/saved-reset.cjs +118 -0
- package/src/savings-ledger.js +12 -4
- package/src/session-cache.js +12 -4
- package/src/state-file.js +99 -2
- package/src/statusline-data.js +97 -0
- package/src/update-check.js +46 -6
package/README.ko.md
CHANGED
|
@@ -109,6 +109,23 @@ LLMRouterBench(Findings of ACL 2026)에서 오프라인으로 측정했습니다
|
|
|
109
109
|
|
|
110
110
|
위임된 실행은 한 건마다 단가 차액을 기록하므로, `sprag route-scan savings` 가 어떤 금액이든 그 원인이 된 규칙까지 역추적해 보여 줍니다. 집계에서 무엇을 빼는지는 [명령 문서](https://github.com/rootstudioyaml/sprag/blob/main/docs/COMMANDS.ko.md)에 적어 두었습니다.
|
|
111
111
|
|
|
112
|
+
## 티어 기준의 근거가 된 연구
|
|
113
|
+
|
|
114
|
+
티어 기준은 감으로 정한 값이 아닙니다. 기준마다 공개된 라우팅 연구를 근거로 삼았고, 룰 문구에 나오는 수치(지식 회상 28% 대 54%, 도구 체인 작업의 +63%p 격차)는 아래 데이터셋으로 sprag가 직접 오프라인 실측한 값입니다.
|
|
115
|
+
|
|
116
|
+
| 기준 | 논문이 밝힌 내용 | 논문 |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| 보수적으로 내려보낸다 | 학습형 라우터는 예산이 커질수록 더 싼 모델로 충분한 경우에도 가장 비싼 모델을 고르는 쪽으로 수렴합니다. | Lai & Ye, [When Routing Collapses: On the Degenerate Convergence of LLM Routers](https://arxiv.org/abs/2602.03478) (2026) |
|
|
119
|
+
| 난이도는 결과로 판정한다 | 기존 라우터는 모두 오라클에 못 미치며, 주된 원인은 작은 모델로 충분한 경우를 알아보지 못하는 데 있습니다. 문항 난이도는 42개 모델 중 실제로 맞힌 모델 수로 정의합니다. | Lu et al., [RouterArena: An Open Platform for Comprehensive Comparison of LLM Routers](https://arxiv.org/abs/2510.00202) (2025) |
|
|
120
|
+
| 임계값은 내 로그에서 정한다 | 예측한 승률을 비용 임계값과 비교해 라우팅하고, 결과는 강한 모델로 보낸 호출 비율로 읽습니다. sprag는 스캔할 때마다 최근 14일 분포에서 이 임계값을 다시 계산합니다. | Ong et al., [RouteLLM: Learning to Route LLMs with Preference Data](https://arxiv.org/abs/2406.18665) (2024) |
|
|
121
|
+
| 실패하면 위로 올린다 | 캐스케이드는 싼 모델부터 시도하고, 답을 검사한 결과로 상위 모델로 올릴지 정합니다. GPT-4와 같은 성능을 최대 98% 낮은 비용으로 냈습니다. | Chen, Zaharia & Zou, [FrugalGPT](https://arxiv.org/abs/2305.05176) (2023) |
|
|
122
|
+
| 답이 어디에 있나 | 짧은 사실 회상 능력만 따로 측정합니다. 지식 회상 28% 대 54% 격차는 이 계열의 데이터셋에서 나왔습니다. | Wei et al., [Measuring short-form factuality in large language models (SimpleQA)](https://arxiv.org/abs/2411.04368) (2024) |
|
|
123
|
+
| 도구 오케스트레이션은 내리지 않는다 | 에이전트가 공유 환경에서 사용자와 함께 도구 호출을 조율해야 하는 벤치마크입니다. +63%p 격차를 여기서 측정했습니다. | Barres et al., [τ²-Bench](https://arxiv.org/abs/2506.07982) (2025) |
|
|
124
|
+
| 공개 벤치마크 실측 | 40만 문항, 21개 데이터셋, 33개 모델 규모이며, 상용 제품을 포함한 여러 라우터가 단순한 기준선을 안정적으로 넘지 못했습니다. | Li et al., [LLMRouterBench](https://arxiv.org/abs/2601.07206) (Findings of ACL 2026) |
|
|
125
|
+
| 라우팅과 캐스케이드 | 라우팅 결정을 언제 내리는지, 무엇을 읽는지, 어떻게 계산하는지로 기존 방식을 분류합니다. | Moslem & Kelleher, [Dynamic Model Routing and Cascading for Efficient LLM Inference: A Survey](https://arxiv.org/abs/2603.04445) (2026) |
|
|
126
|
+
|
|
127
|
+
논문 근거가 없는 기준도 두 개 있습니다. 같은 파일을 읽는 작업을 한 에이전트에 묶는 기준과, 설계와 코드 작성을 나누는 기준은 sprag의 비용 집계에서 나왔습니다. 서브에이전트에서는 생성보다 컨텍스트 적재 비용이 더 크기 때문입니다. 자세한 근거는 [티어 기준 문서](https://github.com/rootstudioyaml/sprag/blob/main/docs/TIER_CRITERIA.md)에 있습니다.
|
|
128
|
+
|
|
112
129
|
## 설치 한 번에 전부 들어 있고, 첫날부터 동작합니다
|
|
113
130
|
|
|
114
131
|
| | 하는 일 | 더 보기 |
|
package/README.md
CHANGED
|
@@ -140,6 +140,29 @@ ledger. Nothing here is a projection.
|
|
|
140
140
|
|
|
141
141
|
Every delegated run records its own price difference, so `sprag route-scan savings` traces any amount back to the rule that caused it. What the ledger deliberately leaves out is written down in [the command reference](https://github.com/rootstudioyaml/sprag/blob/main/docs/COMMANDS.md).
|
|
142
142
|
|
|
143
|
+
## Research behind the tier rules
|
|
144
|
+
|
|
145
|
+
The tier criteria are not a hunch. Each one traces back to published routing
|
|
146
|
+
research, and the percentages quoted in the rules (28% vs 54% recall, a +63pp
|
|
147
|
+
gap on chained tool work) are sprag's own offline measurement on the datasets
|
|
148
|
+
below.
|
|
149
|
+
|
|
150
|
+
| Criterion | What the paper found | Paper |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| Delegate down, conservatively | As budgets grow, learned routers default to the most expensive model even when a cheaper one already suffices. | Lai & Ye, [When Routing Collapses: On the Degenerate Convergence of LLM Routers](https://arxiv.org/abs/2602.03478) (2026) |
|
|
153
|
+
| Judge difficulty by outcome | Every router on the board falls short of the oracle, mainly because it cannot tell when a smaller model is enough. Query difficulty is defined by how many of 42 models actually answered it. | Lu et al., [RouterArena: An Open Platform for Comprehensive Comparison of LLM Routers](https://arxiv.org/abs/2510.00202) (2025) |
|
|
154
|
+
| Thresholds from your own logs | Routing compares a predicted win rate against a cost threshold, and results are read by the share of calls sent to the strong model. sprag sets that threshold from your last 14 days at every scan. | Ong et al., [RouteLLM: Learning to Route LLMs with Preference Data](https://arxiv.org/abs/2406.18665) (2024) |
|
|
155
|
+
| Escalate on failure | A cascade tries the cheap model first and lets a check on the answer decide whether to go up. It matched GPT-4 with up to 98% lower cost. | Chen, Zaharia & Zou, [FrugalGPT](https://arxiv.org/abs/2305.05176) (2023) |
|
|
156
|
+
| Where the answer lives | Short-form factual recall measured on its own. The 28% vs 54% recall gap comes from this class of set. | Wei et al., [Measuring short-form factuality in large language models (SimpleQA)](https://arxiv.org/abs/2411.04368) (2024) |
|
|
157
|
+
| Tool orchestration stays up | Agents must coordinate tool calls with a user in a shared environment. The +63pp gap was measured here. | Barres et al., [τ²-Bench](https://arxiv.org/abs/2506.07982) (2025) |
|
|
158
|
+
| The public benchmark run | 400K instances, 21 datasets, 33 models. Several routers, commercial ones included, fail to beat a simple baseline. | Li et al., [LLMRouterBench](https://arxiv.org/abs/2601.07206) (Findings of ACL 2026) |
|
|
159
|
+
| Routing vs cascading | A taxonomy of when a routing decision is made, what it reads, and how it is computed. | Moslem & Kelleher, [Dynamic Model Routing and Cascading for Efficient LLM Inference: A Survey](https://arxiv.org/abs/2603.04445) (2026) |
|
|
160
|
+
|
|
161
|
+
Two criteria have no paper behind them. Batching tasks that read the same files
|
|
162
|
+
and splitting design from code writing come from sprag's own cost accounting:
|
|
163
|
+
on a sub-agent, loading context costs more than generating. The full reasoning,
|
|
164
|
+
in Korean, is in [Tier criteria](https://github.com/rootstudioyaml/sprag/blob/main/docs/TIER_CRITERIA.md).
|
|
165
|
+
|
|
143
166
|
## Everything ships in one install, working from day one
|
|
144
167
|
|
|
145
168
|
| | What it does | More |
|
package/bin/cli.js
CHANGED
|
@@ -57,7 +57,7 @@ import { sessionCostAcross } from '../src/claude-price.js';
|
|
|
57
57
|
import { chipForIssues } from '../src/advice.js';
|
|
58
58
|
import { debug } from '../src/debug.js';
|
|
59
59
|
import { createArgs } from '../src/cli-args.js';
|
|
60
|
-
import { updateStatus, maybeSpawnUpdateCheck } from '../src/update-check.js';
|
|
60
|
+
import { updateStatus, maybeSpawnUpdateCheck, readUpdateState, updateCheckDisabled } from '../src/update-check.js';
|
|
61
61
|
import { selectAgent } from '../src/agent.js';
|
|
62
62
|
|
|
63
63
|
const selection = (() => {
|
|
@@ -86,7 +86,13 @@ const { getArg, hasFlag, numArg } = createArgs(args);
|
|
|
86
86
|
function readUpdateChip() {
|
|
87
87
|
try {
|
|
88
88
|
maybeSpawnUpdateCheck(PKG_VERSION);
|
|
89
|
-
|
|
89
|
+
const status = updateStatus(PKG_VERSION);
|
|
90
|
+
// When the registry was last asked, for the verbose "✓6h" on an up-to-date
|
|
91
|
+
// version. updateStatus() does not carry it, and a second read of the
|
|
92
|
+
// 200-byte state file is cheaper than a second status computation. With
|
|
93
|
+
// checks switched off the file is a leftover, so it says nothing.
|
|
94
|
+
const checkedAt = updateCheckDisabled() ? null : (Number(readUpdateState().checkedAt) || null);
|
|
95
|
+
return { ...status, checkedAt };
|
|
90
96
|
} catch (e) {
|
|
91
97
|
debug('update-check:chip', e);
|
|
92
98
|
return null;
|
|
@@ -106,7 +112,7 @@ function tableColor() {
|
|
|
106
112
|
const KNOWN_SUBCOMMANDS = new Set([
|
|
107
113
|
'last', 'brief', 'history', 'handoff', 'install', 'uninstall', 'mode', 'korean', 'cohesion',
|
|
108
114
|
'doc2md', 'delegate', 'harness', 'route-scan', 'compact-window', 'update-check', 'upgrade',
|
|
109
|
-
'seed', 'litellm-budget', 'profile-map', 'feedback',
|
|
115
|
+
'seed', 'litellm-budget', 'profile-map', 'feedback', 'saved',
|
|
110
116
|
]);
|
|
111
117
|
|
|
112
118
|
const USAGE = `sprag — Claude Code and Codex agent harness
|
|
@@ -148,6 +154,7 @@ Usage:
|
|
|
148
154
|
sprag doc2md on|off|<file> convert documents to Markdown before the model reads them
|
|
149
155
|
sprag delegate on|off|status add bounds and context to Task/Agent delegation prompts
|
|
150
156
|
sprag compact-window audit or pin autoCompactWindow for 1M sessions
|
|
157
|
+
sprag saved [reset|undo] show, reset or restore the saved-money counters (--agent codex for Codex)
|
|
151
158
|
sprag litellm-budget LiteLLM gateway key budget and spend
|
|
152
159
|
sprag update-check is a newer version out?
|
|
153
160
|
sprag brief --hook internal: the UserPromptSubmit hook entry point
|
|
@@ -305,6 +312,12 @@ async function main() {
|
|
|
305
312
|
return (await import('../src/commands/seed.js')).run({ args, hasFlag });
|
|
306
313
|
}
|
|
307
314
|
|
|
315
|
+
// Subcommand: saved — stopwatch reset for the lifetime "saved" counters.
|
|
316
|
+
// sprag saved | saved reset [routing|doc2md|all] | saved undo
|
|
317
|
+
if (args[0] === 'saved') {
|
|
318
|
+
return (await import('../src/commands/saved.js')).run({ args, hasFlag });
|
|
319
|
+
}
|
|
320
|
+
|
|
308
321
|
// Subcommand: harness — manage the project's CLAUDE.md harness rules.
|
|
309
322
|
// sprag harness init # write CLAUDE.md (5 sections) + ratchet.md
|
|
310
323
|
// sprag harness uninit # remove harness block from CLAUDE.md (backup kept)
|
|
@@ -816,9 +829,15 @@ async function main() {
|
|
|
816
829
|
// the statusline shows the same blank for "no delegation happened" and for
|
|
817
830
|
// "delegation happened and was silently discarded".
|
|
818
831
|
let unresolvedRuns = 0;
|
|
832
|
+
// The same cached scan says how old the savings figures are: the ledger is
|
|
833
|
+
// filled by route-scan, so a stale scan means a stale total.
|
|
834
|
+
let routeScanAt = null;
|
|
819
835
|
try {
|
|
820
836
|
const { readRouteScan } = await import('../src/route-scan.js');
|
|
821
|
-
|
|
837
|
+
const scan = readRouteScan();
|
|
838
|
+
unresolvedRuns = Number(scan?.unresolvedRuns) || 0;
|
|
839
|
+
const scannedAt = Date.parse(scan?.scannedAt);
|
|
840
|
+
routeScanAt = Number.isFinite(scannedAt) ? scannedAt : null;
|
|
822
841
|
} catch (e) {
|
|
823
842
|
debug('route-scan:unresolved', e);
|
|
824
843
|
}
|
|
@@ -833,6 +852,38 @@ async function main() {
|
|
|
833
852
|
debug('config:ttlBucket', e);
|
|
834
853
|
}
|
|
835
854
|
|
|
855
|
+
// Statusline-only additions. Each reuses data already in hand: the sessions
|
|
856
|
+
// parsed above (subagent runs ride on them) and the small model-rules.json
|
|
857
|
+
// the harness chip reads anyway.
|
|
858
|
+
//
|
|
859
|
+
// This session's subagent runs, matched to the session Claude Code is
|
|
860
|
+
// drawing for by the transcript path / session id in its payload.
|
|
861
|
+
let sessionDelegation;
|
|
862
|
+
// Delegation-rule counts, read only when `--segments` names `rules`: the
|
|
863
|
+
// chip is opt-in, so nothing else would look at them.
|
|
864
|
+
let ruleStats;
|
|
865
|
+
const rulesRequested = String(getArg('--segments') || '').split(',').some((s) => s.trim() === 'rules');
|
|
866
|
+
if (isStatusline) {
|
|
867
|
+
try {
|
|
868
|
+
const { sessionSubagentSummary } = await import('../src/statusline-data.js');
|
|
869
|
+
sessionDelegation = sessionSubagentSummary(sessions, {
|
|
870
|
+
transcriptPath: extractTranscriptPath(stdinJson),
|
|
871
|
+
sessionId: extractSessionId(stdinJson),
|
|
872
|
+
});
|
|
873
|
+
} catch (e) {
|
|
874
|
+
debug('statusline-data:session-delegation', e);
|
|
875
|
+
}
|
|
876
|
+
if (rulesRequested) {
|
|
877
|
+
try {
|
|
878
|
+
const { delegationRuleStats } = await import('../src/statusline-data.js');
|
|
879
|
+
const { findProjectRoot } = await import('../src/harness.js');
|
|
880
|
+
ruleStats = delegationRuleStats(findProjectRoot());
|
|
881
|
+
} catch (e) {
|
|
882
|
+
debug('statusline-data:rule-stats', e);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
836
887
|
const data = {
|
|
837
888
|
summary: sum,
|
|
838
889
|
trend,
|
|
@@ -855,6 +906,10 @@ async function main() {
|
|
|
855
906
|
delegationTotals,
|
|
856
907
|
doc2mdTotals,
|
|
857
908
|
unresolvedRuns,
|
|
909
|
+
// Statusline-only; left undefined elsewhere so --format json keeps its shape.
|
|
910
|
+
routeScanAt: isStatusline ? routeScanAt : undefined,
|
|
911
|
+
sessionDelegation,
|
|
912
|
+
ruleStats,
|
|
858
913
|
ttlBucket,
|
|
859
914
|
};
|
|
860
915
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.60.0",
|
|
4
4
|
"description": "Lower your Claude Code bill without changing how you work: simple tasks go to cheaper models, documents go in as compact Markdown, and the statusline shows spend and savings live. Works with Codex too.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/caps-cache.js
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* than `maxAgeMs` the loader returns null and the table view stays quiet.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { readFileSync,
|
|
12
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { userDataDir } from './paths.js';
|
|
15
|
+
import { writeViaTmp } from './state-file.js';
|
|
15
16
|
|
|
16
17
|
const CACHE_PATH = join(userDataDir(), 'last-caps.json');
|
|
17
18
|
|
|
@@ -41,8 +42,7 @@ export function persistSnapshot(snapshot) {
|
|
|
41
42
|
// tmp + rename: the statusline reads this every tick and must never see a
|
|
42
43
|
// half-written file.
|
|
43
44
|
const tmp = `${CACHE_PATH}.${process.pid}.tmp`;
|
|
44
|
-
|
|
45
|
-
renameSync(tmp, CACHE_PATH);
|
|
45
|
+
writeViaTmp(tmp, CACHE_PATH, JSON.stringify(payload) + '\n');
|
|
46
46
|
} catch {
|
|
47
47
|
// best-effort cache, never blocks the statusline
|
|
48
48
|
}
|
package/src/codex-brief.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { readFileSync,
|
|
2
|
+
import { readFileSync, mkdirSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { codexUserDir } from './agent.js';
|
|
5
5
|
import { userDataDir } from './paths.js';
|
|
6
|
+
import { writeViaTmp } from './state-file.js';
|
|
6
7
|
import { readCodexSnapshot } from './codex-parser.js';
|
|
7
8
|
import { loadCodexModelRules } from './codex-delegation.js';
|
|
8
9
|
import { loadCodexLedger } from './codex-ledger.js';
|
|
@@ -105,8 +106,7 @@ export function runCodexBrief({ sessionId, transcriptPath, cwd, root: projectRoo
|
|
|
105
106
|
const events = [...state.events, ...next.map((issue) => ({ ...issue, at, sessionId, project: cwd || snapshot.projectDir || '' }))].slice(-200);
|
|
106
107
|
mkdirSync(directory, { recursive: true });
|
|
107
108
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
108
|
-
|
|
109
|
-
renameSync(tmp, file);
|
|
109
|
+
writeViaTmp(tmp, file, JSON.stringify({ active: nextActive, events }) + '\n', { mode: 0o600 });
|
|
110
110
|
return next.length ? ['[Sprag Codex briefing]', ...next.map((issue) => `${issue.message} ${issue.advice}`)].join('\n') : null;
|
|
111
111
|
}
|
|
112
112
|
|
|
@@ -125,8 +125,7 @@ export function recordCodexHistoryEvent({ sessionId, project = '', key, level =
|
|
|
125
125
|
at: new Date(now).toISOString(), sessionId: owner, project }].slice(-200);
|
|
126
126
|
mkdirSync(directory, { recursive: true });
|
|
127
127
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
128
|
-
|
|
129
|
-
renameSync(tmp, file);
|
|
128
|
+
writeViaTmp(tmp, file, JSON.stringify({ ...state, events }) + '\n', { mode: 0o600 });
|
|
130
129
|
return true;
|
|
131
130
|
}
|
|
132
131
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
4
|
import { parse } from 'smol-toml';
|
|
5
5
|
import { codexUserDir } from './agent.js';
|
|
6
6
|
import { userDataDir } from './paths.js';
|
|
7
|
+
import { writeViaTmp } from './state-file.js';
|
|
7
8
|
import { codexBudgetProvider, codexProviderKey } from './codex-budget.js';
|
|
8
9
|
import { codexCachePolicy } from './codex-cache.js';
|
|
9
10
|
import { PRICE_MAX_AGE_MS, gatewayPrices } from './gateway-prices.js';
|
|
@@ -153,8 +154,7 @@ async function writeGatewaySnapshot(provider, { fetchImpl = fetch, execute, ...o
|
|
|
153
154
|
// Persist only policy and price fields, never raw model/info (which can contain secrets).
|
|
154
155
|
mkdirSync(join(opts.dir || userDataDir(), 'codex-cache-policy'), { recursive: true, mode: 0o700 });
|
|
155
156
|
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
156
|
-
|
|
157
|
-
renameSync(tmp, file);
|
|
157
|
+
writeViaTmp(tmp, file, JSON.stringify(snapshot) + '\n', { mode: 0o600 });
|
|
158
158
|
return snapshot;
|
|
159
159
|
}
|
|
160
160
|
|
package/src/codex-delegation.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, mkdirSync
|
|
1
|
+
import { readFileSync, readdirSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { parse } from 'smol-toml';
|
|
5
5
|
import { userDataDir } from './paths.js';
|
|
6
|
+
import { writeViaTmp } from './state-file.js';
|
|
6
7
|
import { codexUserDir, codexProviderName } from './agent.js';
|
|
7
8
|
import { categorize, ESCALATE_RE, EDIT_RE } from './route-scan.js';
|
|
8
9
|
import { looksPasted } from './route-inject.js';
|
|
@@ -41,8 +42,7 @@ export function loadCodexModelRules({ dir = userDataDir() } = {}) {
|
|
|
41
42
|
function saveRules(rules, dir) {
|
|
42
43
|
mkdirSync(dir, { recursive: true });
|
|
43
44
|
const file = rulesFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
|
|
44
|
-
|
|
45
|
-
renameSync(tmp, file);
|
|
45
|
+
writeViaTmp(tmp, file, JSON.stringify({ version: 1, rules }, null, 2) + '\n', { mode: 0o600 });
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export function addCodexModelRule({ category, model, effort, from, scope, root, provider }, { dir = userDataDir() } = {}) {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdirSync, readdirSync, readFileSync,
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
3
3
|
import { extname, join, resolve } from 'node:path';
|
|
4
4
|
import { codexUserDir } from './agent.js';
|
|
5
5
|
import { userDataDir } from './paths.js';
|
|
6
|
+
import { writeViaTmp } from './state-file.js';
|
|
7
|
+
import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
|
|
6
8
|
|
|
7
9
|
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
8
10
|
const ledgerDir = ({ home = codexUserDir(), dir = userDataDir() } = {}) => join(dir, 'codex-doc2md', hash(resolve(home)));
|
|
@@ -21,18 +23,21 @@ export function recordCodexDocument(source, result, opts) {
|
|
|
21
23
|
clipped: !!(meta.clipped || meta.truncated) };
|
|
22
24
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
23
25
|
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
24
|
-
|
|
25
|
-
renameSync(tmp, file);
|
|
26
|
+
writeViaTmp(tmp, file, JSON.stringify(event) + '\n', { mode: 0o600 });
|
|
26
27
|
} catch { /* Accounting must not prevent document conversion. */ }
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
// Records carry no timestamp field, so the reset mark is compared with the
|
|
31
|
+
// file's mtime. A source converted again rewrites its record and counts as new.
|
|
29
32
|
export function codexDocumentTotals(opts) {
|
|
30
|
-
const
|
|
33
|
+
const since = readResetMarks(opts?.dir ?? userDataDir())['codex-docs'];
|
|
34
|
+
const total = { docs: 0, byExt: [], scope: 'codex-total', since };
|
|
31
35
|
const byExt = new Map();
|
|
32
36
|
try {
|
|
33
37
|
const dir = ledgerDir(opts);
|
|
34
38
|
for (const file of readdirSync(dir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name))) {
|
|
35
39
|
try {
|
|
40
|
+
if (since !== null && isBeforeReset(statSync(join(dir, file)).mtimeMs, since)) continue;
|
|
36
41
|
const event = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
37
42
|
if (event?.version !== 1 || !['pdf', 'pptx', 'xlsx', 'xls', 'docx', 'fig'].includes(event.ext)) continue;
|
|
38
43
|
total.docs++;
|
package/src/codex-hooks.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { mkdirSync, openSync, readFileSync, readSync, closeSync,
|
|
4
|
+
import { mkdirSync, openSync, readFileSync, readSync, closeSync, statSync } from 'node:fs';
|
|
5
5
|
import { join, resolve } from 'node:path';
|
|
6
6
|
import { loadConfig, userLanguage } from './config.js';
|
|
7
7
|
import { koreanStyleInjection, koreanStyleEnabled } from './korean-style.js';
|
|
8
8
|
import { cohesionInjection } from './cohesion.js';
|
|
9
9
|
import { codexHarnessPaths } from './codex-harness.js';
|
|
10
|
+
import { writeViaTmp } from './state-file.js';
|
|
10
11
|
import { findProjectRoot } from './harness.js';
|
|
11
12
|
import { runCodexBrief } from './codex-brief.js';
|
|
12
13
|
import { codexDocumentTool, codexDocumentNote, convertCodexDocument } from './codex-doc2md.js';
|
|
@@ -69,8 +70,7 @@ export function fitCodexContext(parts, { budget = CODEX_CONTEXT_TOKEN_BUDGET, ov
|
|
|
69
70
|
function writeOverflow(file, text) {
|
|
70
71
|
mkdirSync(join(file, '..'), { recursive: true, mode: 0o700 });
|
|
71
72
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
72
|
-
|
|
73
|
-
renameSync(tmp, file);
|
|
73
|
+
writeViaTmp(tmp, file, text, { mode: 0o600 });
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
function overflowFileFor(root) {
|
package/src/codex-installer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
3
|
import { codexUserDir } from './agent.js';
|
|
4
|
+
import { writeViaTmp } from './state-file.js';
|
|
4
5
|
|
|
5
6
|
export const CODEX_HANDLERS = {
|
|
6
7
|
SessionStart: { matcher: 'startup|resume|clear|compact', command: 'sprag codex-hook --agent codex --event session-start', timeout: 15 },
|
|
@@ -72,8 +73,7 @@ export function configureCodexHooks({ remove = false, panelAuto } = {}) {
|
|
|
72
73
|
mkdirSync(dirname(file), { recursive: true });
|
|
73
74
|
if (existsSync(file)) writeFileSync(`${file}.bak-${Date.now()}`, readFileSync(file));
|
|
74
75
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
75
|
-
|
|
76
|
-
renameSync(tmp, file);
|
|
76
|
+
writeViaTmp(tmp, file, JSON.stringify(data, null, 2) + '\n');
|
|
77
77
|
}
|
|
78
78
|
return { file, action: remove ? 'removed' : 'configured' };
|
|
79
79
|
}
|
package/src/codex-ledger.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync, readFileSync
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
4
4
|
import { userDataDir } from './paths.js';
|
|
5
|
+
import { writeViaTmp } from './state-file.js';
|
|
5
6
|
import { roundUsd } from './savings-ledger.js';
|
|
7
|
+
import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
|
|
6
8
|
import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
|
|
7
9
|
import { codexRunCost, codexPriceBook, readCodexPrices } from './codex-cache-policy.js';
|
|
8
10
|
|
|
@@ -51,8 +53,7 @@ export function pruneCodexDelegations({ dir = userDataDir(), now = Date.now() }
|
|
|
51
53
|
try { all = readFileSync(pendingFile(dir), 'utf8').split('\n').filter(Boolean).length; } catch { return; }
|
|
52
54
|
if (keep.length === all) return;
|
|
53
55
|
const file = pendingFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
|
|
54
|
-
|
|
55
|
-
renameSync(tmp, file);
|
|
56
|
+
writeViaTmp(tmp, file, keep.map((r) => JSON.stringify(r) + '\n').join(''), { mode: 0o600 });
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
export function readCodexDelegations({ dir = userDataDir() } = {}) {
|
|
@@ -172,8 +173,7 @@ export function loadCodexLedger({ dir = userDataDir() } = {}) {
|
|
|
172
173
|
function saveLedger(data, dir) {
|
|
173
174
|
mkdirSync(dir, { recursive: true });
|
|
174
175
|
const file = ledgerFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
|
|
175
|
-
|
|
176
|
-
renameSync(tmp, file);
|
|
176
|
+
writeViaTmp(tmp, file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
/**
|
|
@@ -262,10 +262,15 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
|
|
|
262
262
|
return ledger;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
-
/**
|
|
265
|
+
/**
|
|
266
|
+
* Signed totals over priced runs; unpriced runs are counted, never summed as zero.
|
|
267
|
+
* Runs before the `codex-routing` reset mark are left out of every figure,
|
|
268
|
+
* `runs` and `priced` included; `since` reports the mark.
|
|
269
|
+
*/
|
|
266
270
|
export function codexRoutingSavedTotals({ dir = userDataDir(), now = Date.now() } = {}) {
|
|
267
|
-
const
|
|
268
|
-
const
|
|
271
|
+
const since = readResetMarks(dir)['codex-routing'];
|
|
272
|
+
const events = Object.values(loadCodexLedger({ dir }).events).filter((e) => !isBeforeReset(e.ts, since));
|
|
273
|
+
const totals = { week: 0, month: 0, total: 0, runs: events.length, priced: 0, since };
|
|
269
274
|
for (const e of events) {
|
|
270
275
|
if (!Number.isFinite(e.usd)) continue;
|
|
271
276
|
totals.priced++;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { writeViaTmp } from './state-file.js';
|
|
3
4
|
|
|
4
5
|
export function createPanelBinding(file) {
|
|
5
6
|
writeFileSync(file, JSON.stringify({ version: 1, sessionId: null, transcriptPath: null }), { mode: 0o600 });
|
|
@@ -27,9 +28,8 @@ export function bindPanelSession(event, payload, { file = process.env.SPRAG_CODE
|
|
|
27
28
|
if (current.version !== 1) return false;
|
|
28
29
|
if (payload.source === 'compact' && current.sessionId && current.sessionId !== payload.session_id) return false;
|
|
29
30
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
30
|
-
|
|
31
|
+
writeViaTmp(tmp, file, JSON.stringify({ version: 1, sessionId: payload.session_id,
|
|
31
32
|
transcriptPath: typeof payload.transcript_path === 'string' && isAbsolute(payload.transcript_path) ? payload.transcript_path : null }), { mode: 0o600 });
|
|
32
|
-
renameSync(tmp, file);
|
|
33
33
|
return true;
|
|
34
34
|
} catch { return false; }
|
|
35
35
|
}
|
package/src/codex-panel-state.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { readFileSync,
|
|
2
|
+
import { readFileSync, mkdirSync, readdirSync, lstatSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { userDataDir } from './paths.js';
|
|
5
5
|
import { codexUserDir } from './agent.js';
|
|
6
|
+
import { writeViaTmp, runHousekeeping, sweepStaleTmp } from './state-file.js';
|
|
7
|
+
|
|
8
|
+
// Every panel session leaves a `-frame` and a `-hook` file behind, keyed by a
|
|
9
|
+
// hash that is not reused once the session is gone. They are diagnostics for a
|
|
10
|
+
// live session, so a file untouched for a week describes one that is over.
|
|
11
|
+
const PANEL_STATE_FILE = /^codex-panel-[0-9a-f]{64}-(?:frame|hook)\.json$/;
|
|
12
|
+
export const PANEL_STATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
6
13
|
|
|
7
14
|
export function panelStatePath(root, kind, dir = userDataDir(), sessionId) {
|
|
8
15
|
const key = createHash('sha256').update(codexUserDir() + '\0' + resolve(root) + (sessionId ? `\0${sessionId}` : '')).digest('hex');
|
|
@@ -15,17 +22,56 @@ export function recordPanelState(root, kind, value, dir = userDataDir()) {
|
|
|
15
22
|
const file = panelStatePath(root, kind, dir);
|
|
16
23
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
17
24
|
const text = JSON.stringify({ ...value, at: new Date().toISOString() });
|
|
18
|
-
|
|
19
|
-
renameSync(tmp, file);
|
|
25
|
+
writeViaTmp(tmp, file, text);
|
|
20
26
|
if (value.sessionId) {
|
|
21
27
|
const sessionFile = panelStatePath(root, kind, dir, value.sessionId);
|
|
22
28
|
const sessionTmp = `${sessionFile}.${process.pid}.tmp`;
|
|
23
|
-
|
|
24
|
-
renameSync(sessionTmp, sessionFile);
|
|
29
|
+
writeViaTmp(sessionTmp, sessionFile, text);
|
|
25
30
|
}
|
|
31
|
+
// A live panel records a frame every couple of seconds, so the clean-up is
|
|
32
|
+
// rate-limited to once a day (see runHousekeeping) rather than listing the
|
|
33
|
+
// directory on every write. This session's own files, of both kinds, are
|
|
34
|
+
// never candidates: a session that has run for over a week keeps writing
|
|
35
|
+
// frames while its hook record is just as old.
|
|
36
|
+
runHousekeeping(dir, {
|
|
37
|
+
'stale-tmp': () => sweepStaleTmp(dir),
|
|
38
|
+
'codex-panel': () => sweepPanelState({
|
|
39
|
+
dir,
|
|
40
|
+
keep: ['frame', 'hook'].flatMap((k) => [
|
|
41
|
+
panelStatePath(root, k, dir),
|
|
42
|
+
...(value.sessionId ? [panelStatePath(root, k, dir, value.sessionId)] : []),
|
|
43
|
+
]),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
26
46
|
} catch { /* Diagnostics must not stop a session or panel. */ }
|
|
27
47
|
}
|
|
28
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Remove panel state files untouched for `maxAgeMs`. Only the exact
|
|
51
|
+
* `codex-panel-<64 hex>-(frame|hook).json` shape is considered, and anything
|
|
52
|
+
* in `keep` (full paths) is left alone whatever its mtime.
|
|
53
|
+
*
|
|
54
|
+
* @returns {number} how many files were removed
|
|
55
|
+
*/
|
|
56
|
+
export function sweepPanelState({ dir = userDataDir(), now = Date.now(), maxAgeMs = PANEL_STATE_MAX_AGE_MS, keep = [] } = {}) {
|
|
57
|
+
let names;
|
|
58
|
+
try { names = readdirSync(dir); } catch { return 0; }
|
|
59
|
+
const protectedPaths = new Set(keep);
|
|
60
|
+
let removed = 0;
|
|
61
|
+
for (const name of names) {
|
|
62
|
+
if (!PANEL_STATE_FILE.test(name)) continue;
|
|
63
|
+
const path = join(dir, name);
|
|
64
|
+
if (protectedPaths.has(path)) continue;
|
|
65
|
+
try {
|
|
66
|
+
const st = lstatSync(path);
|
|
67
|
+
if (!st.isFile() || now - st.mtimeMs <= maxAgeMs) continue;
|
|
68
|
+
unlinkSync(path);
|
|
69
|
+
removed += 1;
|
|
70
|
+
} catch { /* already gone, or not ours to remove */ }
|
|
71
|
+
}
|
|
72
|
+
return removed;
|
|
73
|
+
}
|
|
74
|
+
|
|
29
75
|
export function readPanelState(root, kind, dir = userDataDir(), sessionId) {
|
|
30
76
|
try { return JSON.parse(readFileSync(panelStatePath(root, kind, dir, sessionId), 'utf8')); }
|
|
31
77
|
catch { return null; }
|
package/src/codex-route-scan.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { userDataDir } from './paths.js';
|
|
5
|
+
import { writeViaTmp } from './state-file.js';
|
|
5
6
|
import { loadConfig } from './config.js';
|
|
6
7
|
import { findProjectRoot } from './harness.js';
|
|
7
8
|
import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
|
|
@@ -32,8 +33,7 @@ export function readCodexRouteScan({ dir = userDataDir() } = {}) {
|
|
|
32
33
|
function writeCache(data, dir) {
|
|
33
34
|
mkdirSync(dir, { recursive: true });
|
|
34
35
|
const file = cacheFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
|
|
35
|
-
|
|
36
|
-
renameSync(tmp, file);
|
|
36
|
+
writeViaTmp(tmp, file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/** Candidates not yet approved or dismissed; with `root`, only that project's. */
|
package/src/commands/codex.js
CHANGED
|
@@ -23,6 +23,7 @@ Usage: sprag <command> --agent codex [options]
|
|
|
23
23
|
brief [on|off|status] Current warnings or prompt-briefing preference
|
|
24
24
|
last / history Context/rate-limit warnings and handoffs; --days, --session, --project
|
|
25
25
|
handoff [--cwd path] Write Git state and Codex usage to HANDOFF-*.md
|
|
26
|
+
saved [reset|undo] Show, reset or restore the saved-money counters
|
|
26
27
|
delegate on|off|status Native model routing and SubagentStart guidance
|
|
27
28
|
delegate model <model> Default target for spawns without an explicit model
|
|
28
29
|
delegate rules Codex-only category rules; add/rm with explicit scope
|
|
@@ -144,6 +145,7 @@ export async function run({ args, version }) {
|
|
|
144
145
|
return;
|
|
145
146
|
}
|
|
146
147
|
if (cmd === 'delegate') return (await import('./codex-delegate.js')).run({ args, getArg, hasFlag, root });
|
|
148
|
+
if (cmd === 'saved') return (await import('./saved.js')).run({ args, hasFlag, agent: 'codex' });
|
|
147
149
|
if (cmd === 'seed') return (await import('./seed.js')).run({ args, hasFlag, agent: 'codex', root });
|
|
148
150
|
if (cmd === 'route-scan') {
|
|
149
151
|
const rs = await import('../codex-route-scan.js');
|
|
@@ -58,9 +58,13 @@ export async function run({ args, hasFlag }) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
if (!sub || sub === 'status' || sub === 'check') {
|
|
61
|
-
|
|
61
|
+
// The statusline knows the real window; the settings model id often does
|
|
62
|
+
// not (a bare `opus` carries no `[1m]`), so the last reported size decides.
|
|
63
|
+
let liveWindow = null;
|
|
64
|
+
try { liveWindow = (await import('../ctx-window-cache.js')).latestLiveWindow(); } catch { /* id-only fallback */ }
|
|
65
|
+
const s = cw.compactWindowStatus({ root, liveWindow });
|
|
62
66
|
console.log(`model: ${s.model || '(not set — Claude Code default)'}${s.modelSource ? ` [${s.modelSource}]` : ''}`);
|
|
63
|
-
console.log(`window: ${s.is1m ? '1M context' : '200k context'}`);
|
|
67
|
+
console.log(`window: ${s.is1m ? '1M context' : '200k context'}${liveWindow ? ` [statusline: ${liveWindow >= 1e6 ? `${liveWindow / 1e6}M` : fmt(liveWindow)}]` : ''}`);
|
|
64
68
|
console.log(`autoCompactWindow: ${s.window === null ? '(unset)' : `${s.window} (${fmt(s.window)})`}${s.windowSource ? ` [${s.windowSource}${s.windowPath ? ` → ${s.windowPath}` : ''}]` : ''}`);
|
|
65
69
|
if (s.ok && s.reason === 'not-1m') {
|
|
66
70
|
console.log(ko
|
package/src/commands/install.js
CHANGED
|
@@ -251,8 +251,11 @@ export async function run({ hasFlag }) {
|
|
|
251
251
|
// good, and upgrading would silently leave shell-written files
|
|
252
252
|
// unchecked. installKoreanLintHook widens in place and is idempotent.
|
|
253
253
|
if (ks.koreanStyleEnabled()) {
|
|
254
|
-
const { installKoreanLintHook } = await import('../installer.js');
|
|
254
|
+
const { installKoreanLintHook, installReplyLanguageHook } = await import('../installer.js');
|
|
255
255
|
const migrated = installKoreanLintHook();
|
|
256
|
+
// Older installs have the lint hook but no Stop hook; add it here so
|
|
257
|
+
// an upgrade closes the English-reply gap without a manual `korean on`.
|
|
258
|
+
installReplyLanguageHook();
|
|
256
259
|
if (migrated.action === 'updated') {
|
|
257
260
|
console.log(lang === 'ko'
|
|
258
261
|
? ' 검사 범위 갱신: Bash 로 쓴 파일도 이제 확인합니다'
|