@icntswm/skillcheck 1.0.1 → 1.0.3
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.md +1 -0
- package/dist/agents/claude.js +70 -5
- package/dist/budget.js +43 -7
- package/dist/cli.js +11 -7
- package/dist/report.js +7 -4
- package/dist/results.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# skillcheck
|
|
2
2
|
|
|
3
|
+
[](https://github.com/icntswm/skillcheck/actions/workflows/ci.yml)
|
|
3
4
|
[](LICENSE)
|
|
4
5
|
[](https://www.npmjs.com/package/@icntswm/skillcheck)
|
|
5
6
|

|
package/dist/agents/claude.js
CHANGED
|
@@ -12,6 +12,23 @@ export const DEFAULT_DIRECTIVE =
|
|
|
12
12
|
function stringList(v) {
|
|
13
13
|
return Array.isArray(v) ? v.filter((s) => typeof s === "string") : null;
|
|
14
14
|
}
|
|
15
|
+
function count(v) {
|
|
16
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
|
|
17
|
+
}
|
|
18
|
+
/** Sum API `usage` objects; cache writes without a 1h/5m split count as 5m. */
|
|
19
|
+
function sumUsage(model, usages) {
|
|
20
|
+
const total = { model, input: 0, output: 0, cacheRead: 0, cacheWrite5m: 0, cacheWrite1h: 0 };
|
|
21
|
+
for (const u of usages) {
|
|
22
|
+
const split = u.cache_creation;
|
|
23
|
+
const write1h = count(split?.ephemeral_1h_input_tokens);
|
|
24
|
+
total.input += count(u.input_tokens);
|
|
25
|
+
total.output += count(u.output_tokens);
|
|
26
|
+
total.cacheRead += count(u.cache_read_input_tokens);
|
|
27
|
+
total.cacheWrite1h += write1h;
|
|
28
|
+
total.cacheWrite5m += Math.max(0, count(u.cache_creation_input_tokens) - write1h);
|
|
29
|
+
}
|
|
30
|
+
return total;
|
|
31
|
+
}
|
|
15
32
|
/**
|
|
16
33
|
* Incremental parser of claude's stream-json stdout. One event per line.
|
|
17
34
|
* Pushing lines also computes the early-stop signal, so the adapter can
|
|
@@ -23,6 +40,10 @@ export class ClaudeStream {
|
|
|
23
40
|
resultText = "";
|
|
24
41
|
sawResult = false;
|
|
25
42
|
costUsd = null;
|
|
43
|
+
model = null;
|
|
44
|
+
/** latest usage per API message; a message streams as several events */
|
|
45
|
+
messageUsage = new Map();
|
|
46
|
+
resultUsage = null;
|
|
26
47
|
availableSkills = null;
|
|
27
48
|
initSkillList = null;
|
|
28
49
|
initSlashList = null;
|
|
@@ -88,12 +109,20 @@ export class ClaudeStream {
|
|
|
88
109
|
}
|
|
89
110
|
if (typeof ev.total_cost_usd === "number")
|
|
90
111
|
this.costUsd = ev.total_cost_usd;
|
|
112
|
+
if (typeof ev.usage === "object" && ev.usage !== null)
|
|
113
|
+
this.resultUsage = ev.usage;
|
|
91
114
|
return true;
|
|
92
115
|
}
|
|
93
116
|
return false;
|
|
94
117
|
}
|
|
95
118
|
handleAssistant(ev) {
|
|
96
119
|
const message = ev.message;
|
|
120
|
+
if (typeof message?.model === "string")
|
|
121
|
+
this.model = message.model;
|
|
122
|
+
if (typeof message?.usage === "object" && message.usage !== null) {
|
|
123
|
+
const id = typeof message.id === "string" ? message.id : `#${this.messageUsage.size}`;
|
|
124
|
+
this.messageUsage.set(id, message.usage);
|
|
125
|
+
}
|
|
97
126
|
const blocks = message?.content;
|
|
98
127
|
if (!Array.isArray(blocks))
|
|
99
128
|
return false;
|
|
@@ -118,6 +147,18 @@ export class ClaudeStream {
|
|
|
118
147
|
this.sawSkill = true;
|
|
119
148
|
return sawSkillHere;
|
|
120
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Billed tokens: the result event's total, or the sum over assistant
|
|
152
|
+
* messages when the run was killed first. Streamed output counts are
|
|
153
|
+
* partial, so the latter runs a little low.
|
|
154
|
+
*/
|
|
155
|
+
get usage() {
|
|
156
|
+
if (this.resultUsage)
|
|
157
|
+
return sumUsage(this.model, [this.resultUsage]);
|
|
158
|
+
if (this.messageUsage.size === 0)
|
|
159
|
+
return null;
|
|
160
|
+
return sumUsage(this.model, [...this.messageUsage.values()]);
|
|
161
|
+
}
|
|
121
162
|
/** Error reported by claude itself in the result event, null if none. */
|
|
122
163
|
get error() {
|
|
123
164
|
return this.failure;
|
|
@@ -202,22 +243,45 @@ function spawnErrorMessage(e, bin) {
|
|
|
202
243
|
}
|
|
203
244
|
/** Live runs by process group id, with their tmp dirs; see abortActiveRuns. */
|
|
204
245
|
const active = new Map();
|
|
246
|
+
function groupAlive(pid) {
|
|
247
|
+
try {
|
|
248
|
+
process.kill(-pid, 0);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
205
255
|
/**
|
|
206
256
|
* Kill every running claude group and remove its tmp dir. Runs are detached
|
|
207
257
|
* process groups, so Ctrl+C on skillcheck does not reach them: without this
|
|
208
|
-
* they keep running (and billing) after skillcheck exits.
|
|
258
|
+
* they keep running (and billing) after skillcheck exits. Groups still alive
|
|
259
|
+
* after graceMs get SIGKILL, so a process that ignores SIGTERM dies too.
|
|
209
260
|
*/
|
|
210
|
-
export function abortActiveRuns() {
|
|
211
|
-
|
|
261
|
+
export async function abortActiveRuns(graceMs = KILL_GRACE_MS) {
|
|
262
|
+
const runs = [...active];
|
|
263
|
+
active.clear();
|
|
264
|
+
for (const [pid] of runs) {
|
|
212
265
|
try {
|
|
213
266
|
process.kill(-pid, "SIGTERM");
|
|
214
267
|
}
|
|
215
268
|
catch {
|
|
216
269
|
// already exited
|
|
217
270
|
}
|
|
271
|
+
}
|
|
272
|
+
const deadline = Date.now() + graceMs;
|
|
273
|
+
while (runs.some(([pid]) => groupAlive(pid)) && Date.now() < deadline) {
|
|
274
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
275
|
+
}
|
|
276
|
+
for (const [pid, workdir] of runs) {
|
|
277
|
+
try {
|
|
278
|
+
process.kill(-pid, "SIGKILL");
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// already exited
|
|
282
|
+
}
|
|
218
283
|
rmSync(workdir, { recursive: true, force: true });
|
|
219
284
|
}
|
|
220
|
-
active.clear();
|
|
221
285
|
}
|
|
222
286
|
const LOGIN_HINT = " — with --config-dir, auth comes from ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN (claude setup-token)";
|
|
223
287
|
/** A fresh config dir has no credentials; point the user at the env vars. */
|
|
@@ -242,7 +306,8 @@ export class ClaudeAdapter {
|
|
|
242
306
|
});
|
|
243
307
|
const { loaded, text, costUsd, availableSkills } = out.stream.result;
|
|
244
308
|
const error = withLoginHint(out.error, opts.configDir);
|
|
245
|
-
|
|
309
|
+
const usage = out.stream.usage;
|
|
310
|
+
return { loaded, text, costUsd, usage, availableSkills, error, stoppedEarly: out.stoppedEarly, durationMs: out.durationMs };
|
|
246
311
|
}
|
|
247
312
|
finally {
|
|
248
313
|
await rm(workdir, { recursive: true, force: true });
|
package/dist/budget.js
CHANGED
|
@@ -1,27 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokens in input-token equivalents. Anthropic prices every model the same
|
|
3
|
+
* way relative to its input price: output 5x, cache reads 0.1x, 5-minute
|
|
4
|
+
* cache writes 1.25x, 1-hour cache writes 2x.
|
|
5
|
+
*/
|
|
6
|
+
export function inputEquivalent(u) {
|
|
7
|
+
return u.input + 5 * u.output + 0.1 * u.cacheRead + 1.25 * u.cacheWrite5m + 2 * u.cacheWrite1h;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* USD per input token until a finished run shows the real rate. Rounded up
|
|
11
|
+
* where unsure: the budget is a cap, so overestimating is the safe side.
|
|
12
|
+
*/
|
|
13
|
+
function listInputPrice(model) {
|
|
14
|
+
if (model?.includes("haiku"))
|
|
15
|
+
return 1e-6;
|
|
16
|
+
if (model?.includes("sonnet"))
|
|
17
|
+
return 3e-6;
|
|
18
|
+
return 5e-6;
|
|
19
|
+
}
|
|
1
20
|
/**
|
|
2
21
|
* Running spend estimate for --budget. Runs killed by early-stop never report
|
|
3
|
-
* a cost, so
|
|
4
|
-
*
|
|
22
|
+
* a cost, so they are priced from their token usage: at the rate seen in
|
|
23
|
+
* finished runs, or at list price before any run finishes. Runs with neither
|
|
24
|
+
* cost nor usage count at the average known cost.
|
|
5
25
|
*/
|
|
6
26
|
export class Budget {
|
|
7
27
|
limitUsd;
|
|
8
28
|
knownSum = 0;
|
|
9
29
|
knownCount = 0;
|
|
10
30
|
unknownCount = 0;
|
|
31
|
+
rateCost = 0;
|
|
32
|
+
rateTokens = 0;
|
|
33
|
+
unpriced = [];
|
|
11
34
|
constructor(limitUsd) {
|
|
12
35
|
this.limitUsd = limitUsd;
|
|
13
36
|
}
|
|
14
|
-
add(cost) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
else {
|
|
37
|
+
add(cost, usage = null) {
|
|
38
|
+
const tokens = usage ? inputEquivalent(usage) : 0;
|
|
39
|
+
if (cost !== null) {
|
|
18
40
|
this.knownSum += cost;
|
|
19
41
|
this.knownCount++;
|
|
42
|
+
if (tokens > 0) {
|
|
43
|
+
this.rateCost += cost;
|
|
44
|
+
this.rateTokens += tokens;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else if (usage && tokens > 0) {
|
|
48
|
+
this.unpriced.push(usage);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
this.unknownCount++;
|
|
20
52
|
}
|
|
21
53
|
}
|
|
22
54
|
get spent() {
|
|
23
55
|
const avg = this.knownCount > 0 ? this.knownSum / this.knownCount : 0;
|
|
24
|
-
|
|
56
|
+
const estimated = this.unpriced.reduce((sum, u) => {
|
|
57
|
+
const rate = this.rateTokens > 0 ? this.rateCost / this.rateTokens : listInputPrice(u.model);
|
|
58
|
+
return sum + inputEquivalent(u) * rate;
|
|
59
|
+
}, 0);
|
|
60
|
+
return this.knownSum + estimated + this.unknownCount * avg;
|
|
25
61
|
}
|
|
26
62
|
get exceeded() {
|
|
27
63
|
return this.spent >= this.limitUsd;
|
package/dist/cli.js
CHANGED
|
@@ -440,7 +440,7 @@ class FatalStop {
|
|
|
440
440
|
this.message = error;
|
|
441
441
|
}
|
|
442
442
|
canStart(budget) {
|
|
443
|
-
return this.message === null &&
|
|
443
|
+
return this.message === null && !budget.exceeded;
|
|
444
444
|
}
|
|
445
445
|
}
|
|
446
446
|
async function runCommand(file, flags, io, deps) {
|
|
@@ -466,7 +466,8 @@ async function runCommand(file, flags, io, deps) {
|
|
|
466
466
|
const n = c.repeat ?? repeat;
|
|
467
467
|
return { c, jobIndex: i, threshold: c.threshold ?? threshold, runs: Array.from({ length: n }), done: 0 };
|
|
468
468
|
});
|
|
469
|
-
|
|
469
|
+
// without --budget it only estimates the spend for the report
|
|
470
|
+
const budget = new Budget(flags.budget ?? Infinity);
|
|
470
471
|
// "--json -" owns stdout, so the human report has to go to stderr
|
|
471
472
|
const reporter = new Reporter(flags.json === "-" ? io.stderr : io.stdout);
|
|
472
473
|
const startedAtMs = Date.now();
|
|
@@ -492,7 +493,8 @@ async function runCommand(file, flags, io, deps) {
|
|
|
492
493
|
unavailable,
|
|
493
494
|
confusion: pairs,
|
|
494
495
|
skipped: skipped.length,
|
|
495
|
-
budget: skipped.length > 0
|
|
496
|
+
budget: skipped.length > 0 ? { limitUsd: flags.budget, spent: budget.spent, notStartedRuns } : undefined,
|
|
497
|
+
estimatedUsd: budget.spent,
|
|
496
498
|
});
|
|
497
499
|
if (flags.batch) {
|
|
498
500
|
// cases that only errored have no answer to confirm
|
|
@@ -511,6 +513,7 @@ async function runCommand(file, flags, io, deps) {
|
|
|
511
513
|
cases: items.map((item) => ({ c: item.c, threshold: item.threshold, result: outcome.results[item.jobIndex] ?? null })),
|
|
512
514
|
unavailable,
|
|
513
515
|
confusion: pairs,
|
|
516
|
+
estimatedCostUsd: budget.spent,
|
|
514
517
|
budgetUsd: flags.budget ?? null,
|
|
515
518
|
budgetReached: skipped.length > 0,
|
|
516
519
|
});
|
|
@@ -558,7 +561,7 @@ async function runIndividually(adapter, items, flags, model, directive, repeat,
|
|
|
558
561
|
available.add(name);
|
|
559
562
|
}
|
|
560
563
|
const verdict = judge(job.item.c, r);
|
|
561
|
-
budget
|
|
564
|
+
budget.add(r.costUsd, r.usage);
|
|
562
565
|
job.item.runs[job.runIndex] = verdict;
|
|
563
566
|
job.item.done++;
|
|
564
567
|
if (job.item.done === job.item.runs.length) {
|
|
@@ -606,7 +609,7 @@ async function runBatched(runBatch, items, flags, model, repeat, reporter, budge
|
|
|
606
609
|
durationMs: br.durationMs,
|
|
607
610
|
};
|
|
608
611
|
const verdict = judge(item.c, r);
|
|
609
|
-
budget
|
|
612
|
+
budget.add(verdict.costUsd);
|
|
610
613
|
item.runs[job.round] = verdict;
|
|
611
614
|
item.done++;
|
|
612
615
|
if (item.done === item.runs.length) {
|
|
@@ -670,8 +673,9 @@ if (invokedDirectly()) {
|
|
|
670
673
|
// agent runs are detached process groups that Ctrl+C does not reach
|
|
671
674
|
for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143]]) {
|
|
672
675
|
process.once(signal, () => {
|
|
673
|
-
|
|
674
|
-
process.exit(code);
|
|
676
|
+
// a second Ctrl+C during the grace period exits at once
|
|
677
|
+
process.once(signal, () => process.exit(code));
|
|
678
|
+
void abortActiveRuns().finally(() => process.exit(code));
|
|
675
679
|
});
|
|
676
680
|
}
|
|
677
681
|
main(process.argv.slice(2)).then((code) => {
|
package/dist/report.js
CHANGED
|
@@ -68,7 +68,7 @@ export class Reporter {
|
|
|
68
68
|
const failed = results.filter((r) => !r.ok).length;
|
|
69
69
|
const skipped = extra?.skipped ?? 0;
|
|
70
70
|
const skippedPart = skipped > 0 ? `, ${skipped} skipped` : "";
|
|
71
|
-
this.write(`${failed} failed${skippedPart} of ${results.length + skipped} · runs ${verdicts.length} · ${costLine(verdicts.map((v) => v.costUsd))}\n`);
|
|
71
|
+
this.write(`${failed} failed${skippedPart} of ${results.length + skipped} · runs ${verdicts.length} · ${costLine(verdicts.map((v) => v.costUsd), extra?.estimatedUsd)}\n`);
|
|
72
72
|
}
|
|
73
73
|
paint(code, text) {
|
|
74
74
|
return this.useColor ? `\u001b[${code}m${text}${RESET}` : text;
|
|
@@ -77,12 +77,15 @@ export class Reporter {
|
|
|
77
77
|
this.out.write(text);
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
-
function costLine(costs) {
|
|
80
|
+
function costLine(costs, estimated) {
|
|
81
81
|
const known = costs.filter((c) => c !== null);
|
|
82
|
-
if (known.length === 0)
|
|
83
|
-
return "cost ?";
|
|
84
82
|
const sum = known.reduce((a, b) => a + b, 0);
|
|
85
83
|
const unknown = costs.length - known.length;
|
|
84
|
+
if (unknown > 0 && estimated !== undefined && estimated > sum) {
|
|
85
|
+
return `cost ~$${estimated.toFixed(2)} (${unknown} runs estimated from tokens)`;
|
|
86
|
+
}
|
|
87
|
+
if (known.length === 0)
|
|
88
|
+
return "cost ?";
|
|
86
89
|
const tail = unknown > 0 ? ` (+? for ${unknown} runs)` : "";
|
|
87
90
|
return `cost $${sum.toFixed(2)}${tail}`;
|
|
88
91
|
}
|
package/dist/results.js
CHANGED
|
@@ -19,6 +19,7 @@ export function buildReport(input) {
|
|
|
19
19
|
runs: verdicts.length,
|
|
20
20
|
costUsd: known.reduce((a, b) => a + b, 0),
|
|
21
21
|
unknownCostRuns: costs.length - known.length,
|
|
22
|
+
estimatedCostUsd: input.estimatedCostUsd,
|
|
22
23
|
diagnoses: verdicts.filter((v) => v.diagnosis).length,
|
|
23
24
|
budgetUsd: input.budgetUsd,
|
|
24
25
|
budgetReached: input.budgetReached,
|