@bglocation/tune-context 2.5.0 → 3.0.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +80 -0
- package/README.md +138 -22
- package/bin/tune-context +1 -1
- package/bin/tune-context.mjs +167 -51
- package/cli/archive.mjs +322 -0
- package/cli/entry-point.mjs +73 -0
- package/cli/experiment.mjs +33 -1
- package/cli/version.mjs +118 -0
- package/eval/adoption-report.mjs +11 -5
- package/eval/analyze.mjs +141 -38
- package/eval/prompt-metrics.mjs +39 -18
- package/eval/report.mjs +56 -45
- package/eval/verdict.mjs +357 -33
- package/package.json +1 -1
- package/skills/token-efficiency/SKILL.md +1 -1
- package/skills/tune-context/SKILL.md +4 -2
package/eval/prompt-metrics.mjs
CHANGED
|
@@ -43,7 +43,9 @@ import { createInterface } from 'node:readline';
|
|
|
43
43
|
import { join } from 'node:path';
|
|
44
44
|
import { parseArgs } from 'node:util';
|
|
45
45
|
import { claudeBaseDir } from '../hooks/config-dir.mjs';
|
|
46
|
-
import {
|
|
46
|
+
import { endQuietlyOnClosedPipe, isEntryPoint } from '../cli/entry-point.mjs';
|
|
47
|
+
import { readStepLedger } from '../cli/experiment.mjs';
|
|
48
|
+
import { meterStamp } from '../cli/version.mjs';
|
|
47
49
|
import { annotateRows, formatArmSummary } from './arm.mjs';
|
|
48
50
|
import { formatCostLines, formatUsd, priceRow, summarizeCost, webSearchCost } from './pricing.mjs';
|
|
49
51
|
|
|
@@ -61,7 +63,7 @@ const USAGE = `
|
|
|
61
63
|
prompt-metrics — per-prompt time/token/tool metrics from Claude Code transcripts.
|
|
62
64
|
|
|
63
65
|
Usage:
|
|
64
|
-
prompt-metrics [--transcript <path>]... [--dir <path>]
|
|
66
|
+
prompt-metrics [--transcript <path>]... [--dir <path>] [--ledger <path>]
|
|
65
67
|
|
|
66
68
|
Options:
|
|
67
69
|
--transcript <path> A single .jsonl transcript. Repeatable.
|
|
@@ -70,6 +72,12 @@ Options:
|
|
|
70
72
|
(default when neither is given: <config dir>/projects,
|
|
71
73
|
where <config dir> is $CLAUDE_CONFIG_DIR if set,
|
|
72
74
|
else ~/.claude)
|
|
75
|
+
--ledger <path> The experiment ledger to assign arms by (default:
|
|
76
|
+
<config dir>/tune-context/experiment.jsonl). For
|
|
77
|
+
recomputing an archive — \`tune-context experiment
|
|
78
|
+
archive\` keeps the ledger next to the transcripts. A
|
|
79
|
+
path that does not exist, or is not a file, is an
|
|
80
|
+
error.
|
|
73
81
|
-h, --help Show this help
|
|
74
82
|
|
|
75
83
|
Writes JSONL to stdout, one row per prompt. A run summary — including how many
|
|
@@ -90,6 +98,12 @@ These five fields are added here, in the CLI. Rows obtained through the module
|
|
|
90
98
|
(aggregate, collectFiles) carry the measurement only — call annotateRows from
|
|
91
99
|
eval/arm.mjs to add the arm.
|
|
92
100
|
|
|
101
|
+
Every row also carries meter_version: the code that computed it — the bare
|
|
102
|
+
release number for an npm install, number+<sha> for a checkout, .dirty for
|
|
103
|
+
uncommitted changes in the meter's paths (EPIC-010). The first line of the
|
|
104
|
+
summary on stderr is that stamp, as \`meter <stamp>\`: it goes into the run
|
|
105
|
+
journal before the first pair. analyze refuses rows without it.
|
|
106
|
+
|
|
93
107
|
Each row also carries delegations[]: one record per Agent call that returned its
|
|
94
108
|
own usage, priced with the same table. That figure is a LOWER BOUND on what the
|
|
95
109
|
subagents cost and is never added to the row's own cost.
|
|
@@ -973,14 +987,21 @@ function defaultDir() {
|
|
|
973
987
|
return join(claudeBaseDir(), 'projects');
|
|
974
988
|
}
|
|
975
989
|
|
|
976
|
-
|
|
990
|
+
/**
|
|
991
|
+
* The CLI. `args` is everything after the script name, or after
|
|
992
|
+
* `tune-context eval prompt-metrics` — one parser behind both doors (EPIC-010).
|
|
993
|
+
*/
|
|
994
|
+
export async function main(args = process.argv.slice(2)) {
|
|
995
|
+
endQuietlyOnClosedPipe();
|
|
977
996
|
let paths;
|
|
997
|
+
let ledger;
|
|
978
998
|
try {
|
|
979
999
|
const { values } = parseArgs({
|
|
980
|
-
args
|
|
1000
|
+
args,
|
|
981
1001
|
options: {
|
|
982
1002
|
transcript: { type: 'string', multiple: true },
|
|
983
1003
|
dir: { type: 'string' },
|
|
1004
|
+
ledger: { type: 'string' },
|
|
984
1005
|
help: { type: 'boolean', short: 'h', default: false },
|
|
985
1006
|
},
|
|
986
1007
|
strict: true,
|
|
@@ -989,6 +1010,9 @@ async function main() {
|
|
|
989
1010
|
process.stdout.write(`${USAGE}\n`);
|
|
990
1011
|
return;
|
|
991
1012
|
}
|
|
1013
|
+
// Read now, before the transcripts: a --ledger that is not a file stops the
|
|
1014
|
+
// run here, with the same message analyze gives (TASK-098).
|
|
1015
|
+
ledger = readStepLedger(values.ledger, claudeBaseDir());
|
|
992
1016
|
// Both flags together are a UNION, deduplicated, not a precedence rule:
|
|
993
1017
|
// silently ignoring one of two flags the user passed is how a run measures
|
|
994
1018
|
// something other than what was asked for.
|
|
@@ -1016,11 +1040,6 @@ async function main() {
|
|
|
1016
1040
|
return;
|
|
1017
1041
|
}
|
|
1018
1042
|
|
|
1019
|
-
// Piping into `head` closes stdout early; that is a normal end, not a crash.
|
|
1020
|
-
process.stdout.on('error', (e) => {
|
|
1021
|
-
if (e.code !== 'EPIPE') throw e;
|
|
1022
|
-
});
|
|
1023
|
-
|
|
1024
1043
|
const collector = await collectFiles(paths);
|
|
1025
1044
|
const result = collector.result();
|
|
1026
1045
|
|
|
@@ -1028,19 +1047,21 @@ async function main() {
|
|
|
1028
1047
|
// transcript into rows and has no business knowing about the experiment, and
|
|
1029
1048
|
// the ledger is machine state rather than transcript content. The split also
|
|
1030
1049
|
// lets the same rules run over rows collected elsewhere (TASK-088).
|
|
1031
|
-
const
|
|
1032
|
-
const ledgerAvailable = existsSync(ledgerPath);
|
|
1033
|
-
const rows = annotateRows(result.rows, {
|
|
1034
|
-
ledger: ledgerAvailable ? readLedger(claudeBaseDir()) : [],
|
|
1035
|
-
ledgerAvailable,
|
|
1036
|
-
});
|
|
1050
|
+
const rows = annotateRows(result.rows, ledger);
|
|
1037
1051
|
|
|
1038
|
-
|
|
1039
|
-
process
|
|
1052
|
+
// Which code computed the rows rides on every row — added here, with the arm,
|
|
1053
|
+
// because it is a property of the process that ran, not of the transcript
|
|
1054
|
+
// (TASK-097). Last on the row, so nothing that already read a row by position
|
|
1055
|
+
// in a diff moves.
|
|
1056
|
+
const stamp = meterStamp();
|
|
1057
|
+
for (const row of rows) process.stdout.write(`${formatRow({ ...row, meter_version: stamp })}\n`);
|
|
1058
|
+
// First line of the summary, on its own: this is what the operator copies
|
|
1059
|
+
// into the run journal before the first pair.
|
|
1060
|
+
process.stderr.write(`meter ${stamp}\n${formatSummary(result)}\n`);
|
|
1040
1061
|
const arms = formatArmSummary(rows);
|
|
1041
1062
|
if (arms !== null) process.stderr.write(`${arms}\n`);
|
|
1042
1063
|
}
|
|
1043
1064
|
|
|
1044
|
-
if (
|
|
1065
|
+
if (isEntryPoint(import.meta.url)) {
|
|
1045
1066
|
await main();
|
|
1046
1067
|
}
|
package/eval/report.mjs
CHANGED
|
@@ -38,11 +38,13 @@
|
|
|
38
38
|
// (light: contrast WARN on aqua/yellow/magenta, so both charts ship direct
|
|
39
39
|
// labels AND a table twin — the documented relief), zero-baseline axes,
|
|
40
40
|
// hairline chrome, 2px surface gaps instead of borders.
|
|
41
|
-
import {
|
|
41
|
+
import { writeFileSync } from 'node:fs';
|
|
42
42
|
import { parseArgs } from 'node:util';
|
|
43
|
+
import { endQuietlyOnClosedPipe, isEntryPoint } from '../cli/entry-point.mjs';
|
|
44
|
+
import { meterStamp } from '../cli/version.mjs';
|
|
43
45
|
import { ARMS } from './analyze.mjs';
|
|
44
46
|
import { TOKEN_CLASS_FIELDS, formatUsd } from './pricing.mjs';
|
|
45
|
-
import { VERDICT_THRESHOLDS, verdict as computeVerdict } from './verdict.mjs';
|
|
47
|
+
import { VERDICT_THRESHOLDS, VerdictInputError, verdict as computeVerdict, inputErrorLine, readVerdictInput } from './verdict.mjs';
|
|
46
48
|
|
|
47
49
|
const USAGE = `
|
|
48
50
|
report — one self-contained HTML file from an analyze aggregate (TASK-091).
|
|
@@ -51,7 +53,7 @@ Usage:
|
|
|
51
53
|
report [--aggregate <path>] [--rejected-runs <path>] [--out <path>]
|
|
52
54
|
|
|
53
55
|
Options:
|
|
54
|
-
--aggregate <path> JSON written by analyze
|
|
56
|
+
--aggregate <path> JSON written by analyze (default: read stdin)
|
|
55
57
|
--rejected-runs <path> JSON array of the run journal's §7 rejections,
|
|
56
58
|
[{ session, task, arm, prompts, reason }]. OMITTING it
|
|
57
59
|
is not the same as passing a file containing []: the
|
|
@@ -438,7 +440,7 @@ function verdictSection(v) {
|
|
|
438
440
|
<p class="dim">Pool: paired measurement only (${num(v.n_pairs_used)} usable pair(s)). Natural work is never evidence.
|
|
439
441
|
${v.observation_only ? 'This report says "observation", never "proof" — the PO froze a minimum sample, not a level at which it becomes proof.' : ''}</p>
|
|
440
442
|
</div>
|
|
441
|
-
<h2>The
|
|
443
|
+
<h2>The ${v.gates.length} gates</h2>
|
|
442
444
|
<ul class="gates">${gates}</ul>
|
|
443
445
|
</section>`;
|
|
444
446
|
}
|
|
@@ -602,6 +604,22 @@ function thresholdsSection(t) {
|
|
|
602
604
|
</section>`;
|
|
603
605
|
}
|
|
604
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Which code computed this page, step by step — rows, aggregate, verdict
|
|
609
|
+
* (EPIC-010). In the header rather than the footer, because EPIC-009's verdict
|
|
610
|
+
* was voided over exactly this question, asked of files that could not answer
|
|
611
|
+
* it. Whether the stamps agree is the meter_mixed gate's business; this line
|
|
612
|
+
* only names them, all of them.
|
|
613
|
+
*/
|
|
614
|
+
function meterLine(agg, v) {
|
|
615
|
+
const rows = Object.entries(agg.meter_versions ?? {});
|
|
616
|
+
const stamp = (s) => (s ? `<code>${esc(s)}</code>` : '<span class="flag">no stamp</span>');
|
|
617
|
+
return (
|
|
618
|
+
`<p class="src">meter — rows: ${rows.length === 0 ? '—' : rows.map(([s, n]) => `${stamp(s)} ×${num(n)}`).join(', ')}` +
|
|
619
|
+
` · analyze: ${stamp(agg.analyzed_by)} · verdict: ${stamp(v.judged_by)}</p>`
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
605
623
|
// ─── the page ───────────────────────────────────────────────────────────────
|
|
606
624
|
|
|
607
625
|
/** Palette and chrome from the `dataviz` reference instance, as CSS custom
|
|
@@ -711,18 +729,18 @@ footer { max-width:980px; margin:0 auto; color:var(--muted); font-size:12px; }
|
|
|
711
729
|
* verdict() follow, and the reason a test can render a fabricated run without a
|
|
712
730
|
* fixture directory.
|
|
713
731
|
*
|
|
714
|
-
* @param agg an aggregate from analyze() (schema tune-context/analyze@
|
|
732
|
+
* @param agg an aggregate from analyze() (schema tune-context/analyze@2)
|
|
715
733
|
* @param verdict the decision from verdict(). Computed by the caller rather
|
|
716
734
|
* than here, because the journal of §7 rejections is an input this layer
|
|
717
735
|
* has no way to read — and a verdict computed without it holds gate 6,
|
|
718
736
|
* which is the correct answer, not a bug to route around.
|
|
719
737
|
*/
|
|
720
738
|
export function renderReport(agg, { verdict: v, generatedAt = null } = {}) {
|
|
721
|
-
if (!agg || typeof agg !== 'object' || agg.schema !== 'tune-context/analyze@
|
|
722
|
-
throw new TypeError('report: expected an aggregate from analyze() (schema tune-context/analyze@
|
|
739
|
+
if (!agg || typeof agg !== 'object' || agg.schema !== 'tune-context/analyze@2') {
|
|
740
|
+
throw new TypeError('report: expected an aggregate from analyze() (schema tune-context/analyze@2)');
|
|
723
741
|
}
|
|
724
|
-
if (v?.schema !== 'tune-context/verdict@
|
|
725
|
-
throw new TypeError('report: expected a verdict from verdict() (schema tune-context/verdict@
|
|
742
|
+
if (v?.schema !== 'tune-context/verdict@2') {
|
|
743
|
+
throw new TypeError('report: expected a verdict from verdict() (schema tune-context/verdict@2)');
|
|
726
744
|
}
|
|
727
745
|
|
|
728
746
|
// Pool-level composition: the two arms of ONE pool added together. The pools
|
|
@@ -760,6 +778,7 @@ export function renderReport(agg, { verdict: v, generatedAt = null } = {}) {
|
|
|
760
778
|
once with tune-context and RAG on, once with both parked. Generated${generatedAt ? ` ${esc(generatedAt)}` : ''} from
|
|
761
779
|
an aggregate of ${num(agg.counters.rows_in)} prompt row(s). Costs are <b>list price through the API</b> — what the
|
|
762
780
|
work would have cost, never money spent.</p>
|
|
781
|
+
${meterLine(agg, v)}
|
|
763
782
|
</header>
|
|
764
783
|
${verdictSection(v)}
|
|
765
784
|
${pairsSection(agg, v)}
|
|
@@ -794,17 +813,16 @@ ${thresholdsSection(v.thresholds)}
|
|
|
794
813
|
|
|
795
814
|
// ─── CLI ────────────────────────────────────────────────────────────────────
|
|
796
815
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
async function main() {
|
|
816
|
+
/**
|
|
817
|
+
* The CLI. `args` is everything after the script name, or after
|
|
818
|
+
* `tune-context eval report` — one parser behind both doors (EPIC-010).
|
|
819
|
+
*/
|
|
820
|
+
export async function main(args = process.argv.slice(2)) {
|
|
821
|
+
endQuietlyOnClosedPipe();
|
|
804
822
|
let values;
|
|
805
823
|
try {
|
|
806
824
|
({ values } = parseArgs({
|
|
807
|
-
args
|
|
825
|
+
args,
|
|
808
826
|
options: {
|
|
809
827
|
aggregate: { type: 'string' },
|
|
810
828
|
'rejected-runs': { type: 'string' },
|
|
@@ -823,48 +841,41 @@ async function main() {
|
|
|
823
841
|
return;
|
|
824
842
|
}
|
|
825
843
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
let agg;
|
|
835
|
-
let rejectedRuns;
|
|
836
|
-
try {
|
|
837
|
-
agg = JSON.parse(values.aggregate ? readFileSync(values.aggregate, 'utf8') : await readStdin());
|
|
838
|
-
// Left `undefined` when the flag is absent, and that is the whole point: it
|
|
839
|
-
// is what makes gate 6 hold instead of passing on a share whose denominator
|
|
840
|
-
// nobody supplied.
|
|
841
|
-
if (values['rejected-runs'] !== undefined) {
|
|
842
|
-
rejectedRuns = JSON.parse(readFileSync(values['rejected-runs'], 'utf8'));
|
|
843
|
-
}
|
|
844
|
-
} catch (e) {
|
|
845
|
-
process.stderr.write(`report: ${e.message}\n`);
|
|
846
|
-
process.exitCode = 1;
|
|
847
|
-
return;
|
|
848
|
-
}
|
|
849
|
-
|
|
844
|
+
// The same reader and the same boundary as `eval verdict` (TASK-096): one
|
|
845
|
+
// place decides what a readable aggregate and journal are, so this page can
|
|
846
|
+
// never accept an input the verdict refuses — and only those refusals become
|
|
847
|
+
// a message. A TypeError from inside a gate or the page is a bug and keeps its
|
|
848
|
+
// stacktrace, here as there. `rejectedRuns` stays undefined when the flag is
|
|
849
|
+
// absent — that is what makes gate 6 hold instead of passing on a share whose
|
|
850
|
+
// denominator nobody supplied.
|
|
850
851
|
let html;
|
|
851
852
|
try {
|
|
852
|
-
const
|
|
853
|
+
const { agg, rejectedRuns } = await readVerdictInput({ aggregate: values.aggregate, rejectedRuns: values['rejected-runs'] });
|
|
854
|
+
const v = computeVerdict(agg, { rejectedRuns, judgedBy: meterStamp() });
|
|
853
855
|
html = renderReport(agg, { verdict: v, generatedAt: new Date().toISOString().slice(0, 10) });
|
|
854
856
|
} catch (e) {
|
|
855
|
-
|
|
857
|
+
if (!(e instanceof VerdictInputError)) throw e;
|
|
858
|
+
process.stderr.write(`${inputErrorLine('report', e)}\n`);
|
|
856
859
|
process.exitCode = 1;
|
|
857
860
|
return;
|
|
858
861
|
}
|
|
859
862
|
|
|
860
863
|
if (values.out) {
|
|
861
|
-
|
|
864
|
+
try {
|
|
865
|
+
writeFileSync(values.out, html);
|
|
866
|
+
} catch (e) {
|
|
867
|
+
// Where to write is the operator's input too: a missing directory is a
|
|
868
|
+
// message, not a stacktrace.
|
|
869
|
+
process.stderr.write(`report: cannot write ${values.out}: ${e.message}\n`);
|
|
870
|
+
process.exitCode = 1;
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
862
873
|
process.stderr.write(`report: wrote ${values.out}\n`);
|
|
863
874
|
} else {
|
|
864
875
|
process.stdout.write(html);
|
|
865
876
|
}
|
|
866
877
|
}
|
|
867
878
|
|
|
868
|
-
if (
|
|
879
|
+
if (isEntryPoint(import.meta.url)) {
|
|
869
880
|
await main();
|
|
870
881
|
}
|