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/korean-lint.cjs
CHANGED
|
@@ -73,8 +73,21 @@ const METAPHOR_LEXICON = [
|
|
|
73
73
|
// `자리` is the single most common substitution for a plain noun. Physical
|
|
74
74
|
// seating is the rare literal use, so those modifiers are excluded and
|
|
75
75
|
// everything else is raised for confirmation.
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
//
|
|
77
|
+
// `except` (2026-10-02): counting digits is the other literal use. `한 자리
|
|
78
|
+
// 밀리초`, `세 자리 수`, `소수점 둘째 자리`, `일의 자리` name a digit
|
|
79
|
+
// position, and no plainer noun exists for it. A count followed by a particle
|
|
80
|
+
// (`한 자리에서 돈다`) is still raised: that one means a place. Corpus check:
|
|
81
|
+
// 149 lines before, 147 after, and the two dropped were both `한 자리 밀리초`.
|
|
82
|
+
{
|
|
83
|
+
re: /(?<!빈 |좌석 |앞 |뒤 |옆 )(?:^|(?<=\s))자리(?:에서|에서는|에|로|가|는|다|입니다|였습니다)?(?=\s|$|[.,·)])/,
|
|
84
|
+
except: /(?:^|\s)(?:\d+|한|두|세|네|다섯|여섯|일곱|여덟|아홉|열|몇) 자리(?=\s|$)|(?:첫째|둘째|셋째|넷째|다섯째|[일십백천만]의) 자리|자리 ?(?:수|숫자)(?![가-힣]{2})/,
|
|
85
|
+
fix: "'지점'·'상황'·'부분'처럼 뜻을 그대로 담은 명사로 바꿉니다",
|
|
86
|
+
},
|
|
87
|
+
// The lookbehind pins the match to the start of a Hangul run. Without it the
|
|
88
|
+
// engine retried `[가-힣]+` from every character of a long run that has no
|
|
89
|
+
// `의 흐름` in it, which is quadratic: a 200k-character run took 20 seconds.
|
|
90
|
+
{ re: /(?<![가-힣])[가-힣]+의\s*흐름/, fix: "'방향'·'순서'·'경과'로 바꿉니다" },
|
|
78
91
|
{ re: /닿(?:는다|습니다|아|는 지점)/, fix: "'겨냥하다'·'해당하다'처럼 동작을 그대로 서술합니다" },
|
|
79
92
|
{ re: /박(?:아 두|혀 있|아 넣)/, fix: "'명시하다'·'기록하다'로 바꿉니다" },
|
|
80
93
|
{ re: /걷어내/, fix: "'없애다'·'제거하다'로 바꿉니다" },
|
|
@@ -106,17 +119,24 @@ const METAPHOR_LEXICON = [
|
|
|
106
119
|
const TRANSLATIONESE = [
|
|
107
120
|
{ re: /에 대한/, fix: '서술어로 풀어 씁니다' },
|
|
108
121
|
{ re: /[을를] 위한/, fix: '서술어로 풀어 씁니다' },
|
|
109
|
-
|
|
122
|
+
// Every conjugation, not the stem alone: `되어졌다`, `되어진`, `되어질`.
|
|
123
|
+
{ re: /되어[지진질져졌집]/, fix: '이중 피동을 없애고 능동이나 단일 피동으로 씁니다' },
|
|
110
124
|
{ re: /하는 것을 통해/, fix: "'~해서'·'~함으로써'로 줄입니다" },
|
|
111
125
|
{ re: /라고 할 수 있다/, fix: '단정하거나 근거를 붙여 서술합니다' },
|
|
112
126
|
// Conservative additions (2026-09-13), sourced from 국립국어원 공공언어
|
|
113
127
|
// 지침·한글문화연대 교정 사례·쿠버네티스 한글화 가이드. Bar for inclusion:
|
|
114
128
|
// the form is nearly always an improvement to change, so a confirm-request
|
|
115
129
|
// on it is rarely noise. See presets/korean-style/supplement.md.
|
|
116
|
-
|
|
130
|
+
// `졌` is the past tense (`보여졌다`, `쓰여졌습니다`), which the first list missed.
|
|
131
|
+
{ re: /(?:보여|쓰여|불려|잊혀)[지집진질져졌]/, fix: "이중 피동입니다. '보인다'·'쓰인'·'불린'·'잊힌'처럼 단일 피동으로 씁니다" },
|
|
117
132
|
{ re: /에 다름 아니/, fix: "일본어 번역투입니다. '~일 뿐이다'·'바로 ~이다'로 바꿉니다" },
|
|
118
133
|
{ re: /지 않으면 안 [되된됩돼]/, fix: "이중 부정 번역투입니다. '~해야 합니다'로 바꿉니다" },
|
|
119
|
-
|
|
134
|
+
// `X에 있어서` also means "because it is at X", which is plain Korean. Two
|
|
135
|
+
// signs separate that reading without touching the calque: a noun that names
|
|
136
|
+
// a location or a container before it, or the clause ending right after it
|
|
137
|
+
// (the calque is never clause-final). Corpus check: 4 hits before, 3 after,
|
|
138
|
+
// the dropped one a quotation ending in `베드락에 있어서`.
|
|
139
|
+
{ re: /(?<!여기|거기|저기|어디|(?:^|\s)(?:안|밖|위|아래|옆|앞|뒤|속)|서버|폴더|디렉터리|디렉토리|경로|파일|목록|메모리|디스크|저장소|캐시|변수|로컬|원격)에 있어서(?!요|["'”’)\]]|[.!?]|$)/, fix: "'~에서'·'~에는'으로 바꿉니다" },
|
|
120
140
|
{ re: /(?:의미|특징|장점|단점|성격|가능성|중요성|효과)[을를] 가지고 있/, fix: "'~이다'·'~가 있다'로 바꿉니다 (have 직역)" },
|
|
121
141
|
];
|
|
122
142
|
|
|
@@ -201,6 +221,19 @@ function stripCode(text) {
|
|
|
201
221
|
return out;
|
|
202
222
|
}
|
|
203
223
|
|
|
224
|
+
/**
|
|
225
|
+
* First match of `re` whose surroundings are not one of the literal uses in
|
|
226
|
+
* `except`. Every match is tried, so a literal use early in the line does not
|
|
227
|
+
* hide a figurative one after it.
|
|
228
|
+
*/
|
|
229
|
+
function firstUnexcepted(line, re, except) {
|
|
230
|
+
for (const m of line.matchAll(new RegExp(re.source, 'g'))) {
|
|
231
|
+
const around = line.slice(Math.max(0, m.index - 8), m.index + m[0].length + 4);
|
|
232
|
+
if (!except.test(around)) return m;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
204
237
|
function hasKorean(s) {
|
|
205
238
|
return /[가-힣]/.test(s);
|
|
206
239
|
}
|
|
@@ -243,8 +276,8 @@ function lintKoreanText(text, { maxFindings = 20, code = false } = {}) {
|
|
|
243
276
|
if (m) push('번역체', m[0], fix);
|
|
244
277
|
}
|
|
245
278
|
|
|
246
|
-
for (const { re, fix } of METAPHOR_LEXICON) {
|
|
247
|
-
const m = line.match(re);
|
|
279
|
+
for (const { re, except, fix } of METAPHOR_LEXICON) {
|
|
280
|
+
const m = except ? firstUnexcepted(line, re, except) : line.match(re);
|
|
248
281
|
if (m) push('비유 어휘', m[0], fix);
|
|
249
282
|
}
|
|
250
283
|
|
package/src/litellm-budget.js
CHANGED
|
@@ -159,6 +159,12 @@ export function formatBudgetReport(state, now = new Date(), mode = 'icon') {
|
|
|
159
159
|
return lines;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/** 갱신을 시도했다는 기록입니다. 같은 게이트웨이의 금액만 이어받습니다. */
|
|
163
|
+
export function stampAttempt(state, base, now) {
|
|
164
|
+
const kept = state && state.base === base ? state : {};
|
|
165
|
+
return { ...kept, base, checkedAt: now };
|
|
166
|
+
}
|
|
167
|
+
|
|
162
168
|
/** 캐시가 오래됐으면 detached 자식으로 갱신을 예약합니다. 즉시 반환. */
|
|
163
169
|
export function maybeSpawnBudgetCheck(env = process.env) {
|
|
164
170
|
// 키 확보는 자식이 담당합니다. 렌더 경로에서 헬퍼를 돌리면 통계선이 느려집니다.
|
|
@@ -168,8 +174,11 @@ export function maybeSpawnBudgetCheck(env = process.env) {
|
|
|
168
174
|
const age = Date.now() - (Number(s.checkedAt) || 0);
|
|
169
175
|
if (s.base === base && age < CHECK_INTERVAL_MS) return false;
|
|
170
176
|
// 오프라인/오류 시 렌더마다 자식을 다시 띄우지 않도록 시도 시각을 먼저 기록합니다.
|
|
177
|
+
// 게이트웨이가 바뀌었으면 이전 게이트웨이의 금액은 버립니다. 금액을 남긴 채
|
|
178
|
+
// base 만 새 주소로 바꾸면, budgetWindow 의 base 격리를 통과해 갱신이 끝날
|
|
179
|
+
// 때까지(오프라인이면 계속) 다른 프록시의 지출이 새 프록시의 게이지로 나옵니다.
|
|
171
180
|
try {
|
|
172
|
-
writeBudgetState(
|
|
181
|
+
writeBudgetState(stampAttempt(s, base, Date.now()));
|
|
173
182
|
} catch (e) {
|
|
174
183
|
debug('litellm-budget:stamp', e);
|
|
175
184
|
return false;
|
package/src/model-rules.js
CHANGED
|
@@ -34,6 +34,7 @@ import { homedir } from 'node:os';
|
|
|
34
34
|
import { userDataDir } from './paths.js';
|
|
35
35
|
import { userLanguage } from './config.js';
|
|
36
36
|
import { agentPhrase, agentPhraseEn } from './agents.js';
|
|
37
|
+
import { writeStateFile } from './state-file.js';
|
|
37
38
|
import { signedUsd } from './money.js';
|
|
38
39
|
|
|
39
40
|
// Post-promotion delegated-category error rate above this flags the rule
|
|
@@ -72,9 +73,14 @@ export function wilsonLowerBound(errs, n) {
|
|
|
72
73
|
// close the cycle.
|
|
73
74
|
const DEFAULT_BUDGET = { T2: { calls: 8, out: 1500 }, T1: { calls: null, out: 8000 } };
|
|
74
75
|
|
|
76
|
+
/** The budget a rule is held to: its calibrated one, or the default of its tier. */
|
|
77
|
+
export function ruleBudget(rule) {
|
|
78
|
+
return rule.budget || DEFAULT_BUDGET[rule.tier] || DEFAULT_BUDGET.T2;
|
|
79
|
+
}
|
|
80
|
+
|
|
75
81
|
/** The cap half of a budget clause, e.g. "도구 호출 8회·출력 1500 토큰". */
|
|
76
82
|
export function budgetCapPhrase(rule, lang = userLanguage()) {
|
|
77
|
-
const b = rule
|
|
83
|
+
const b = ruleBudget(rule);
|
|
78
84
|
if (lang === 'ko') {
|
|
79
85
|
return b.calls ? `도구 호출 ${b.calls}회·출력 ${b.out} 토큰` : `출력 ${b.out} 토큰`;
|
|
80
86
|
}
|
|
@@ -125,15 +131,15 @@ function shortExample(text) {
|
|
|
125
131
|
return one.length <= EXAMPLE_MAX ? one : `${one.slice(0, EXAMPLE_MAX - 1)}…`;
|
|
126
132
|
}
|
|
127
133
|
|
|
128
|
-
export function modelRuleBaseText(g, lang = userLanguage()) {
|
|
134
|
+
export function modelRuleBaseText(g, lang = userLanguage(), { root } = {}) {
|
|
129
135
|
if (lang === 'ko') {
|
|
130
136
|
return g.tier === 'T2'
|
|
131
|
-
? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent)}에 위임`
|
|
137
|
+
? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent, { root })}에 위임`
|
|
132
138
|
: `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 model: sonnet에 위임`;
|
|
133
139
|
}
|
|
134
140
|
const example = g.exampleEn || g.example;
|
|
135
141
|
return g.tier === 'T2'
|
|
136
|
-
? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent)}`
|
|
142
|
+
? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent, { root })}`
|
|
137
143
|
: `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to a model: sonnet subagent`;
|
|
138
144
|
}
|
|
139
145
|
|
|
@@ -155,30 +161,30 @@ export function loadModelRules() {
|
|
|
155
161
|
}
|
|
156
162
|
|
|
157
163
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* Rules with no measured delegation contribute nothing, and an unreadable
|
|
164
|
-
* registry returns 0 rather than throwing: the statusline reads 0 as "draw no
|
|
165
|
-
* chip", which is the right outcome for anyone who never delegates.
|
|
164
|
+
* The one rule a delegated run in `projectDir` is credited to. A project rule
|
|
165
|
+
* for that project comes first, then a global rule promoted from it, then the
|
|
166
|
+
* first other global rule. Rule health (refreshModelRules) and the savings
|
|
167
|
+
* ledger (route-scan) both ask here, so a run is never credited to two rules
|
|
168
|
+
* and the two never disagree about whose it was.
|
|
166
169
|
*/
|
|
167
|
-
export function
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
|
|
170
|
+
export function ruleForProject(rules, tier, category, projectDir) {
|
|
171
|
+
let promotedHere = null;
|
|
172
|
+
let firstGlobal = null;
|
|
173
|
+
for (const r of rules || []) {
|
|
174
|
+
if (!r || r.status === 'off' || r.tier !== tier || r.category !== category) continue;
|
|
175
|
+
if (r.scope !== 'global') {
|
|
176
|
+
if (r.project === projectDir) return r;
|
|
177
|
+
} else if (r.project === projectDir) {
|
|
178
|
+
promotedHere = promotedHere || r;
|
|
179
|
+
} else {
|
|
180
|
+
firstGlobal = firstGlobal || r;
|
|
181
|
+
}
|
|
175
182
|
}
|
|
183
|
+
return promotedHere || firstGlobal;
|
|
176
184
|
}
|
|
177
185
|
|
|
178
186
|
export function saveModelRules(data) {
|
|
179
|
-
|
|
180
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
181
|
-
writeFileSync(modelRulesPath(), JSON.stringify(data, null, 2) + '\n');
|
|
187
|
+
writeStateFile(modelRulesPath(), JSON.stringify(data, null, 2) + '\n');
|
|
182
188
|
}
|
|
183
189
|
|
|
184
190
|
/** Add (or re-activate) a promoted rule; returns the stored entry. */
|
|
@@ -210,8 +216,12 @@ export function removeModelRule(index1) {
|
|
|
210
216
|
* The LLM reads this file as instructions, so it is written in the user's
|
|
211
217
|
* configured language — a Korean-only file would pull an English session's
|
|
212
218
|
* responses into Korean.
|
|
219
|
+
*
|
|
220
|
+
* `root` is where a named subagent is looked up: the project the file belongs
|
|
221
|
+
* to, or null for the global file, which may only name agents the user
|
|
222
|
+
* directory holds (see agentExists). Left out, it is the current directory.
|
|
213
223
|
*/
|
|
214
|
-
export function renderModelRatchet(rules, lang = userLanguage()) {
|
|
224
|
+
export function renderModelRatchet(rules, lang = userLanguage(), { root } = {}) {
|
|
215
225
|
const ko = lang === 'ko';
|
|
216
226
|
const lines = ko ? [
|
|
217
227
|
'# Model-Fitting Ratchet (sprag 자동 관리)',
|
|
@@ -377,9 +387,23 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
|
|
|
377
387
|
if (!byCategory.has(r.category)) byCategory.set(r.category, []);
|
|
378
388
|
byCategory.get(r.category).push(r);
|
|
379
389
|
}
|
|
390
|
+
// The sentence comes from this version's template in the file's language. The
|
|
391
|
+
// text stored on the rule is whatever wording and language were current when
|
|
392
|
+
// it was promoted, which is how an English line sat in a Korean file. A rule
|
|
393
|
+
// missing what the template needs still falls back to its stored text.
|
|
394
|
+
const baseTextOf = (r) => ((r.tier === 'T2' || r.tier === 'T1') && r.label && (ko || r.labelEn)
|
|
395
|
+
? modelRuleBaseText(r, lang, { root }) : r.rule);
|
|
380
396
|
for (const group of byCategory.values()) {
|
|
381
397
|
const t2 = group.find((r) => r.tier === 'T2');
|
|
382
398
|
const t1 = group.find((r) => r.tier === 'T1');
|
|
399
|
+
// A second rule of a tier the line already covers (two global rules of one
|
|
400
|
+
// category, promoted from different projects) says nothing the line does
|
|
401
|
+
// not, so it adds no line of its own. Its review flag and its numbers stay
|
|
402
|
+
// visible on the line that speaks for it.
|
|
403
|
+
const extras = t2 || t1 ? group.filter((r) => r !== t2 && r !== t1 && (r.tier === 'T2' || r.tier === 'T1')) : [];
|
|
404
|
+
const unknown = group.filter((r) => r !== t2 && r !== t1 && !extras.includes(r));
|
|
405
|
+
const extraHealth = extras.map(healthOf).join('');
|
|
406
|
+
const extraStats = extras.map((r) => ` / +${r.tier} ${statsOf(r)}`).join('');
|
|
383
407
|
if (t2 && t1) {
|
|
384
408
|
// Seeded rules carry a localized example; scan-produced ones only have the
|
|
385
409
|
// user's own prompt, so exampleEn is a preference, not a requirement.
|
|
@@ -390,19 +414,18 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
|
|
|
390
414
|
// clauses, so a rule the model re-reads on every request is short enough to
|
|
391
415
|
// match at a glance.
|
|
392
416
|
const rule = ko
|
|
393
|
-
? `"${t2.label}": 기본 ${agentPhrase(t2.agent)}(예: "${ex(t2)}"), ` +
|
|
417
|
+
? `"${t2.label}": 기본 ${agentPhrase(t2.agent, { root })}(예: "${ex(t2)}"), ` +
|
|
394
418
|
`여러 단계·여러 파일이 얽히면 model: sonnet(예: "${ex(t1)}"). ` +
|
|
395
419
|
mergedBudget(t2, t1)
|
|
396
|
-
: `"${t2.labelEn || t2.label}": ${agentPhraseEn(t2.agent)} by default (e.g. "${ex(t2)}"), ` +
|
|
420
|
+
: `"${t2.labelEn || t2.label}": ${agentPhraseEn(t2.agent, { root })} by default (e.g. "${ex(t2)}"), ` +
|
|
397
421
|
`model: sonnet when it spans multiple steps or file edits (e.g. "${ex(t1)}"). ` +
|
|
398
422
|
mergedBudget(t2, t1);
|
|
399
|
-
lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)} -->`);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
}
|
|
403
|
-
} else {
|
|
404
|
-
for (const r of group) lines.push(`- ${composeRuleText(r.rule, r, lang)}${healthOf(r)} <!-- ${statsOf(r)} -->`);
|
|
423
|
+
lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)}${extraHealth} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)}${extraStats} -->`);
|
|
424
|
+
} else if (t2 || t1) {
|
|
425
|
+
const r = t2 || t1;
|
|
426
|
+
lines.push(`- ${composeRuleText(baseTextOf(r), r, lang)}${healthOf(r)}${extraHealth} <!-- ${statsOf(r)}${extraStats} -->`);
|
|
405
427
|
}
|
|
428
|
+
for (const r of unknown) lines.push(`- ${composeRuleText(r.rule, r, lang)}${healthOf(r)} <!-- ${statsOf(r)} -->`);
|
|
406
429
|
}
|
|
407
430
|
return lines.join('\n') + '\n';
|
|
408
431
|
}
|
|
@@ -467,7 +490,10 @@ export function syncAllFiles({ previousPaths = [] } = {}) {
|
|
|
467
490
|
};
|
|
468
491
|
const written = [];
|
|
469
492
|
for (const [p, rules] of byPath) {
|
|
470
|
-
|
|
493
|
+
// Agents are looked up where the file's readers are: the project for a
|
|
494
|
+
// project file, the user directory alone for the global one.
|
|
495
|
+
const root = rules[0].scope === 'global' ? null : rules[0].targetRoot;
|
|
496
|
+
if (writeIfChanged(p, renderModelRatchet(rules, undefined, { root }))) written.push(p);
|
|
471
497
|
}
|
|
472
498
|
// A target that lost its last rule is emptied, NOT deleted: CLAUDE.md
|
|
473
499
|
// imports this path, and a dangling `@` import is worse than an empty file.
|
|
@@ -497,19 +523,40 @@ export function refreshModelRules(episodeStats, delegatedStats = new Map(), { no
|
|
|
497
523
|
let changed = false;
|
|
498
524
|
const pick = (stats, r) => stats.get(`${r.tier}|${r.category}|${r.project}`)
|
|
499
525
|
|| (r.scope === 'global' ? stats.get(`${r.tier}|${r.category}|*`) : null);
|
|
526
|
+
// Delegated outcomes are money, so each project's runs go to exactly one
|
|
527
|
+
// rule. Handing every global rule the `*` aggregate counted the same runs
|
|
528
|
+
// once per global rule of that tier and category.
|
|
529
|
+
const owned = new Map();
|
|
530
|
+
for (const [key, d] of delegatedStats) {
|
|
531
|
+
const [tier, category, ...rest] = key.split('|');
|
|
532
|
+
const project = rest.join('|');
|
|
533
|
+
if (project === '*') continue;
|
|
534
|
+
const rule = ruleForProject(data.rules, tier, category, project);
|
|
535
|
+
if (!rule) continue;
|
|
536
|
+
const o = owned.get(rule) || { runs: 0, errRuns: 0, outTokens: 0, savedUsd: 0 };
|
|
537
|
+
o.runs += d.runs || 0;
|
|
538
|
+
o.errRuns += d.errRuns || 0;
|
|
539
|
+
o.outTokens += d.outTokens || 0;
|
|
540
|
+
o.savedUsd += d.savedUsd || 0;
|
|
541
|
+
owned.set(rule, o);
|
|
542
|
+
}
|
|
500
543
|
|
|
501
544
|
for (const r of data.rules) {
|
|
502
545
|
const s = pick(episodeStats, r);
|
|
503
|
-
const d =
|
|
546
|
+
const d = owned.get(r);
|
|
504
547
|
if (!s && !d) {
|
|
505
548
|
// A rule whose category didn't appear at all this window keeps its last
|
|
506
|
-
// known recurrence
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
549
|
+
// known recurrence. Everything else here describes the window, and this
|
|
550
|
+
// window holds nothing: the delegation figures go to zero and a review
|
|
551
|
+
// flag is lifted, as they are below when only one of the two is missing.
|
|
552
|
+
// Leaving them untouched froze the last window that had data, so a rule
|
|
553
|
+
// flagged once stayed flagged, with its old saving, after it stopped
|
|
554
|
+
// firing altogether.
|
|
555
|
+
const cleared = { delegatedRuns: 0, delegatedErrRate: 0, savedUsd: 0, healthSource: 'proxy' };
|
|
556
|
+
for (const [k, v] of Object.entries(cleared)) {
|
|
557
|
+
if (r[k] !== v) { r[k] = v; changed = true; }
|
|
558
|
+
}
|
|
559
|
+
if (r.status === 'review') { r.status = 'active'; changed = true; }
|
|
513
560
|
continue;
|
|
514
561
|
}
|
|
515
562
|
|
package/src/month-spend.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 합계에서 빼고 unpriced 로 셉니다. 정가표로 대신 채우지 않습니다.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { sessionCost } from './claude-price.js';
|
|
12
|
+
import { sessionCost, pricedParts } from './claude-price.js';
|
|
13
13
|
|
|
14
14
|
/** 이번 달 1일 00:00(로컬)의 epoch ms. */
|
|
15
15
|
export function monthStartMs(now = new Date()) {
|
|
@@ -23,9 +23,9 @@ export function monthLabel(now = new Date()) {
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* 월초 이후 세션들의 지출 추정치 합계(USD).
|
|
26
|
-
* endTime 이 월초 이후인 세션만 집계합니다.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* endTime 이 월초 이후인 세션만 집계합니다. 월 경계에 걸친 세션은
|
|
27
|
+
* 호출하는 쪽이 parser.js 의 sessionsSince 로 월초 이후 요청만 남겨서
|
|
28
|
+
* 넘겨야 합니다. 그대로 넘기면 세션 전체가 이번 달 지출로 계산됩니다.
|
|
29
29
|
*
|
|
30
30
|
* @param {Array} sessions parseAllSessions 결과
|
|
31
31
|
* @param {Date} [now]
|
|
@@ -39,14 +39,20 @@ export function monthSpend(sessions, now = new Date()) {
|
|
|
39
39
|
for (const s of sessions || []) {
|
|
40
40
|
if (!s || !s.endTime || s.endTime.getTime() < since) continue;
|
|
41
41
|
if (!s.totals) continue;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
// 세션이 띄운 서브에이전트 실행도 같은 청구서에 들어가므로, 실행마다 그 모델의
|
|
43
|
+
// 단가로 계산해 더합니다. 세션 수에는 넣지 않습니다.
|
|
44
|
+
let priced = false;
|
|
45
|
+
for (const part of pricedParts(s)) {
|
|
46
|
+
try {
|
|
47
|
+
const c = sessionCost(part.totals, part.model);
|
|
48
|
+
if (!c) { unpriced += 1; continue; }
|
|
49
|
+
usd += c.actual;
|
|
50
|
+
if (part.own) priced = true;
|
|
51
|
+
} catch {
|
|
52
|
+
// 단가를 모르는 모델은 합계에서 빠집니다. 통계선에서는 침묵이 낫습니다.
|
|
53
|
+
}
|
|
49
54
|
}
|
|
55
|
+
if (priced) count += 1;
|
|
50
56
|
}
|
|
51
57
|
return { usd, sessions: count, unpriced, sinceMs: since, label: monthLabel(now) };
|
|
52
58
|
}
|