@event4u/agent-config 7.2.0 → 7.4.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.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +74 -0
  3. package/README.md +9 -10
  4. package/dist/agent-src/commands/fix/pr-comments.md +42 -13
  5. package/dist/agent-src/rules/cli-output-handling.md +18 -0
  6. package/dist/agent-src/skills/bug-analyzer/evals/evals.json +13 -0
  7. package/dist/agent-src/skills/code-review/evals/evals.json +21 -0
  8. package/dist/cli/registry.js +1 -0
  9. package/dist/cli/registry.js.map +1 -1
  10. package/dist/discovery/deprecation-report.md +1 -1
  11. package/dist/discovery/discovery-manifest.json +4 -4
  12. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  13. package/dist/discovery/discovery-manifest.summary.md +1 -1
  14. package/dist/discovery/orphan-report.md +1 -1
  15. package/dist/discovery/packs.json +2 -2
  16. package/dist/discovery/trust-report.md +1 -1
  17. package/dist/discovery/workspaces.json +2 -2
  18. package/dist/mcp/registry-manifest.json +2 -2
  19. package/dist/router.json +1 -1
  20. package/dist/server/schemas/settings.js +3 -0
  21. package/dist/server/schemas/settings.js.map +1 -1
  22. package/docs/contracts/governance-enforcement-projection.md +154 -0
  23. package/docs/enforcement-by-host.md +44 -0
  24. package/docs/governance-advantage.md +72 -0
  25. package/package.json +2 -1
  26. package/src/config/agent-settings.template.yml +9 -0
  27. package/src/scripts/_cli/cmd_analyze_session.ts +347 -0
  28. package/src/scripts/_dispatch.bash +8 -0
  29. package/src/scripts/_lib/eval_discrimination.ts +95 -0
  30. package/src/scripts/_lib/token_count.ts +110 -35
  31. package/src/scripts/_lib/trigger_routers.ts +268 -0
  32. package/src/scripts/audit_initial_context.ts +37 -12
  33. package/src/scripts/bench_ab_v2_run.ts +38 -0
  34. package/src/scripts/bench_quality_run.ts +253 -0
  35. package/src/scripts/bench_rule_load_latency.ts +178 -0
  36. package/src/scripts/check_kernel_prefix_stability.ts +0 -0
  37. package/src/scripts/check_quality_regression.ts +244 -0
  38. package/src/scripts/check_token_quality_golden.ts +253 -0
  39. package/src/scripts/check_token_regression.ts +266 -0
  40. package/src/scripts/cross_model_smoke.ts +245 -0
  41. package/src/scripts/hook_manifest.yaml +15 -3
  42. package/src/scripts/hooks/rtk_wrap_hook.ts +228 -0
  43. package/src/scripts/probe_host_compliance.ts +145 -0
  44. package/src/scripts/run_skill_evals.ts +38 -0
  45. package/src/scripts/skill_linter.ts +26 -10
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * `agent-config analyze-session` — post-session report from on-disk runtime
4
+ * state.
5
+ *
6
+ * Read-only diagnostic. No daemon, no network, no model calls. It reads two
7
+ * real data sources the work-engine / context-hygiene hook leave behind and
8
+ * prints a deterministic Markdown report:
9
+ *
10
+ * 1. the work_engine state envelope `.work-state.json` (version 1) — loaded
11
+ * via `load_state` from `explain_last/state_loader.ts`. Fields read:
12
+ * - `changes` — files touched (array of {kind, stack, file, summary}).
13
+ * - `outcomes` — directive → "success" / "blocked" / … .
14
+ * - `halts` — array of recorded halts.
15
+ * 2. `agents/runtime/state/context-hygiene.json` — fields read:
16
+ * - `tool_calls` (int)
17
+ * - `consecutive_same_tool`(int)
18
+ * - `loop_detected` (bool)
19
+ *
20
+ * There is NO general token/cost data source in this package (only
21
+ * video-specific telemetry), so the report deliberately does NOT report
22
+ * tokens or cost — it prints a single honest "not tracked" line instead.
23
+ *
24
+ * Exit codes (mirroring `cmd_explain`'s `last` subject):
25
+ * 0 report rendered (even when a source file is absent — graceful note).
26
+ * 1 the work-state file is missing or unreadable.
27
+ * 2 invocation error (bad project root, bad --state-file path).
28
+ */
29
+
30
+ import * as fs from 'node:fs';
31
+ import * as path from 'node:path';
32
+ import process from 'node:process';
33
+ import { fileURLToPath, pathToFileURL } from 'node:url';
34
+
35
+ import { ProjectRootError, resolve_project_root } from '../_lib/agent_settings.js';
36
+ import { StateLoadError, load_state } from './explain_last/state_loader.js';
37
+
38
+ type Dict = Record<string, unknown>;
39
+
40
+ const _HERE = fileURLToPath(import.meta.url);
41
+
42
+ const CONTEXT_HYGIENE_RELATIVE = path.join(
43
+ 'agents',
44
+ 'runtime',
45
+ 'state',
46
+ 'context-hygiene.json',
47
+ );
48
+
49
+ /** Python `raise SystemExit(code)` analogue — propagates an explicit code. */
50
+ class SystemExitError extends Error {
51
+ constructor(public readonly code: number) {
52
+ super(`system-exit-${code}`);
53
+ }
54
+ }
55
+
56
+ /** argparse-style usage / help exit (code 2 / 0). Caught at the CLI entry. */
57
+ class ArgparseExit extends Error {
58
+ constructor(public readonly code: number) {
59
+ super(`argparse-exit-${code}`);
60
+ }
61
+ }
62
+
63
+ function eprint(line = ''): void {
64
+ process.stderr.write(line + '\n');
65
+ }
66
+
67
+ function isPlainObject(value: unknown): value is Dict {
68
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
69
+ }
70
+
71
+ function _resolve_root(arg: string | null): string {
72
+ try {
73
+ const [root] = resolve_project_root(arg, { cwd: process.cwd() });
74
+ return root;
75
+ } catch (exc) {
76
+ if (exc instanceof ProjectRootError) {
77
+ eprint(`❌ analyze-session: ${exc.message}`);
78
+ throw new SystemExitError(2);
79
+ }
80
+ throw exc;
81
+ }
82
+ }
83
+
84
+ /** `Path.relative_to(base)` — returns null when `p` is not under `base`. */
85
+ function relativeToOrNull(p: string, base: string): string | null {
86
+ const rel = path.relative(base, p);
87
+ if (rel === '') return rel;
88
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
89
+ return null;
90
+ }
91
+ return rel;
92
+ }
93
+
94
+ /**
95
+ * Read the context-hygiene snapshot. Returns the parsed object, or `null`
96
+ * when the file is absent / unreadable / not an object — the caller renders
97
+ * a graceful "not available" note rather than failing the whole report.
98
+ */
99
+ function _load_context_hygiene(project_root: string): Dict | null {
100
+ const p = path.join(project_root, CONTEXT_HYGIENE_RELATIVE);
101
+ if (!fs.existsSync(p)) {
102
+ return null;
103
+ }
104
+ let text: string;
105
+ try {
106
+ text = fs.readFileSync(p, 'utf-8');
107
+ } catch {
108
+ return null;
109
+ }
110
+ let payload: unknown;
111
+ try {
112
+ payload = JSON.parse(text);
113
+ } catch {
114
+ return null;
115
+ }
116
+ return isPlainObject(payload) ? payload : null;
117
+ }
118
+
119
+ /** Render `value` as an integer string, or `'?'` when not a finite number. */
120
+ function intOrUnknown(value: unknown): string {
121
+ if (typeof value === 'number' && Number.isFinite(value)) {
122
+ return String(Math.trunc(value));
123
+ }
124
+ return '?';
125
+ }
126
+
127
+ /**
128
+ * Build the deterministic Markdown report from already-loaded state.
129
+ *
130
+ * Pure function — no I/O, no clock, no environment. Both inputs come from the
131
+ * caller so the report is unit-testable in isolation.
132
+ *
133
+ * @param state the parsed `.work-state.json` (version 1).
134
+ * @param hygiene the parsed context-hygiene snapshot, or `null` when absent.
135
+ */
136
+ export function render_report(state: Dict, hygiene: Dict | null): string {
137
+ const lines: string[] = [];
138
+ lines.push('# Session analysis');
139
+ lines.push('');
140
+
141
+ // --- Files touched (from state.changes) ---
142
+ const changes = Array.isArray(state['changes']) ? (state['changes'] as unknown[]) : [];
143
+ lines.push(`## Files touched (${changes.length})`);
144
+ lines.push('');
145
+ if (changes.length === 0) {
146
+ lines.push('_No files recorded as changed._');
147
+ } else {
148
+ for (const change of changes) {
149
+ if (!isPlainObject(change)) continue;
150
+ const file = typeof change['file'] === 'string' ? change['file'] : '(unknown file)';
151
+ const summary =
152
+ typeof change['summary'] === 'string' && change['summary'].length > 0
153
+ ? ` — ${change['summary']}`
154
+ : '';
155
+ lines.push(`- \`${file}\`${summary}`);
156
+ }
157
+ }
158
+ lines.push('');
159
+
160
+ // --- Per-directive outcomes (from state.outcomes) ---
161
+ const outcomes = isPlainObject(state['outcomes']) ? (state['outcomes'] as Dict) : {};
162
+ const directives = Object.keys(outcomes).sort(); // deterministic order
163
+ const blocked = directives.filter((d) => outcomes[d] === 'blocked');
164
+ lines.push(`## Outcomes (${directives.length})`);
165
+ lines.push('');
166
+ if (directives.length === 0) {
167
+ lines.push('_No directive outcomes recorded._');
168
+ } else {
169
+ for (const directive of directives) {
170
+ const result = outcomes[directive];
171
+ const marker = result === 'blocked' ? '⚠️ ' : '';
172
+ lines.push(`- ${marker}\`${directive}\`: ${String(result)}`);
173
+ }
174
+ }
175
+ lines.push('');
176
+ if (blocked.length > 0) {
177
+ lines.push(`**Blocked directives (${blocked.length}):** ${blocked.join(', ')}`);
178
+ lines.push('');
179
+ }
180
+
181
+ // --- Halts (from state.halts) ---
182
+ const halts = Array.isArray(state['halts']) ? (state['halts'] as unknown[]) : [];
183
+ lines.push(`## Halts (${halts.length})`);
184
+ lines.push('');
185
+ if (halts.length === 0) {
186
+ lines.push('_No halts recorded._');
187
+ } else {
188
+ lines.push(`This session recorded ${halts.length} halt(s).`);
189
+ }
190
+ lines.push('');
191
+
192
+ // --- Tool activity (from context-hygiene) ---
193
+ lines.push('## Tool activity');
194
+ lines.push('');
195
+ if (hygiene === null) {
196
+ lines.push('_Context-hygiene snapshot not available._');
197
+ } else {
198
+ const toolCalls = intOrUnknown(hygiene['tool_calls']);
199
+ const consecutive = intOrUnknown(hygiene['consecutive_same_tool']);
200
+ const loop = hygiene['loop_detected'] === true;
201
+ lines.push(`- Tool calls: ${toolCalls}`);
202
+ lines.push(`- Consecutive same-tool calls: ${consecutive}`);
203
+ lines.push(`- Loop detected: ${loop ? 'yes' : 'no'}`);
204
+ }
205
+ lines.push('');
206
+
207
+ // --- Honest token/cost note (no per-session source in this package) ---
208
+ lines.push('## Token / cost');
209
+ lines.push('');
210
+ lines.push('Token/cost: not tracked (no per-session source).');
211
+ lines.push('');
212
+
213
+ return lines.join('\n');
214
+ }
215
+
216
+ function _analyze_session(project_root: string, state_file: string | null): number {
217
+ const target_state = state_file || path.join(project_root, '.work-state.json');
218
+ let state: Dict;
219
+ try {
220
+ state = load_state(target_state) as Dict;
221
+ } catch (exc) {
222
+ if (exc instanceof StateLoadError) {
223
+ // Never print absolute paths in errors (username leakage).
224
+ const rel = relativeToOrNull(target_state, project_root);
225
+ const shown = rel !== null ? rel : path.basename(target_state);
226
+ const msg = exc.message.split(target_state).join(shown);
227
+ eprint(`❌ analyze-session: ${msg}`);
228
+ return exc.exitCode;
229
+ }
230
+ throw exc;
231
+ }
232
+ const hygiene = _load_context_hygiene(project_root);
233
+ process.stdout.write(render_report(state, hygiene));
234
+ return 0;
235
+ }
236
+
237
+ interface Opts {
238
+ project: string | null;
239
+ state_file: string | null;
240
+ }
241
+
242
+ const PROG = 'agent-config analyze-session';
243
+ const USAGE = `usage: ${PROG} [-h] [--project PROJECT] [--state-file STATE_FILE]\n`;
244
+
245
+ const _HELP = `\
246
+ usage: ${PROG} [--project PATH] [--state-file PATH]
247
+
248
+ Print a post-session report from on-disk runtime state. Read-only;
249
+ no daemon, no network, no model calls.
250
+
251
+ Sources read:
252
+ .work-state.json (version 1)
253
+ · changes — files touched
254
+ · outcomes — per-directive success / blocked
255
+ · halts — recorded halts
256
+ agents/runtime/state/context-hygiene.json
257
+ · tool_calls · consecutive_same_tool · loop_detected
258
+
259
+ Token/cost is NOT reported — this package has no per-session token
260
+ source (only video-specific telemetry).
261
+
262
+ options:
263
+ --project PATH project root (defaults to anchor walk from cwd)
264
+ --state-file PATH .work-state.json path (default <root>/.work-state.json)
265
+ -h, --help show this help and exit
266
+
267
+ exit codes:
268
+ 0 report rendered (a missing source file is noted, not fatal)
269
+ 1 no recent run found (state file missing or unreadable)
270
+ 2 invocation error (bad project root, bad --state-file path)
271
+ `;
272
+
273
+ function _argError(msg: string): never {
274
+ process.stderr.write(USAGE);
275
+ process.stderr.write(`${PROG}: error: ${msg}\n`);
276
+ throw new ArgparseExit(2);
277
+ }
278
+
279
+ function _parse(argv: string[]): Opts {
280
+ const opts: Opts = { project: null, state_file: null };
281
+ let i = 0;
282
+ while (i < argv.length) {
283
+ const a = argv[i] as string;
284
+ if (a === '-h' || a === '--help') {
285
+ process.stdout.write(_HELP);
286
+ throw new ArgparseExit(0);
287
+ }
288
+ const eq = a.startsWith('--') ? a.indexOf('=') : -1;
289
+ const flag = eq >= 0 ? a.slice(0, eq) : a;
290
+ const inlineVal = eq >= 0 ? a.slice(eq + 1) : null;
291
+ if (flag === '--project' || flag === '--state-file') {
292
+ const dest = flag === '--project' ? 'project' : 'state_file';
293
+ let value: string;
294
+ if (inlineVal !== null) {
295
+ value = inlineVal;
296
+ } else {
297
+ if (i + 1 >= argv.length) {
298
+ _argError(`argument ${flag}: expected one argument`);
299
+ }
300
+ value = argv[i + 1] as string;
301
+ i += 1;
302
+ }
303
+ opts[dest] = value;
304
+ i += 1;
305
+ continue;
306
+ }
307
+ _argError(`unrecognized arguments: ${a}`);
308
+ }
309
+ return opts;
310
+ }
311
+
312
+ export function main(argv: string[] | null = null): number {
313
+ const argv_list = argv !== null ? Array.from(argv) : process.argv.slice(2);
314
+ const opts = _parse(argv_list);
315
+ let project_root: string;
316
+ try {
317
+ project_root = _resolve_root(opts.project);
318
+ } catch (exc) {
319
+ if (exc instanceof SystemExitError) {
320
+ return exc.code;
321
+ }
322
+ throw exc;
323
+ }
324
+ return _analyze_session(project_root, opts.state_file);
325
+ }
326
+
327
+ // --- CLI entry ---
328
+
329
+ const _isCliEntry =
330
+ process.argv[1] !== undefined &&
331
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
332
+ if (_isCliEntry || process.argv[1] === _HERE) {
333
+ try {
334
+ process.exitCode = main(process.argv.slice(2));
335
+ } catch (e) {
336
+ if (e instanceof ArgparseExit) {
337
+ process.exitCode = e.code;
338
+ } else if (e instanceof SystemExitError) {
339
+ process.exitCode = e.code;
340
+ } else {
341
+ throw e;
342
+ }
343
+ }
344
+ }
345
+
346
+ export { _parse, _resolve_root, _load_context_hygiene, _analyze_session, ArgparseExit, SystemExitError };
347
+ export type { Opts };
@@ -933,6 +933,13 @@ cmd_explain() {
933
933
  exec_ts "$PACKAGE_ROOT/src/scripts/_cli/cmd_explain.ts" "$@"
934
934
  }
935
935
 
936
+ # `agent-config analyze-session` — read-only post-session report from on-disk
937
+ # runtime state (.work-state.json + context-hygiene.json). No daemon, no
938
+ # network, no model calls. See scripts/_cli/cmd_analyze_session.ts.
939
+ cmd_analyze_session() {
940
+ exec_ts "$PACKAGE_ROOT/src/scripts/_cli/cmd_analyze_session.ts" "$@"
941
+ }
942
+
936
943
  main() {
937
944
  local cmd="${1-}"
938
945
  [[ $# -gt 0 ]] && shift || true
@@ -991,6 +998,7 @@ main() {
991
998
  doctor) cmd_doctor "$@" ;;
992
999
  versions) cmd_versions "$@" ;;
993
1000
  explain) cmd_explain "$@" ;;
1001
+ analyze-session) cmd_analyze_session "$@" ;;
994
1002
  help|--help|-h|"")
995
1003
  # Optional `--tier=0|1|all` filter (default 0).
996
1004
  local tier_arg="0"
@@ -0,0 +1,95 @@
1
+ /**
2
+ * eval_discrimination — the "falsifier of the falsifier" for the cross-model
3
+ * parity smoke (roadmap `road-to-operator-runtime-harvest`, T-003).
4
+ *
5
+ * A green parity result is trustworthy only if the eval *can* go red — and not
6
+ * just on a gross violation, but on a subtle, just-over-threshold one. A gross
7
+ * negative control proves "the harness can fail"; the SUBTLE control proves it
8
+ * is sensitive enough that an "all hosts pass" reading (outcome (a) in T-006)
9
+ * is not the underpowered-null trap wearing a green check. `discrimination_ok`
10
+ * is therefore gated on the SUBTLE control being caught — a gross-only control
11
+ * set is explicitly insufficient.
12
+ *
13
+ * Pure, dependency-free, host-agnostic: the smoke computes each control's
14
+ * `caught` from its assertion results (via `controlCaughtFromAssertions`) and
15
+ * passes the set here. No vendor calls happen in this module.
16
+ */
17
+
18
+ export type ControlSeverity = 'gross' | 'subtle';
19
+
20
+ export interface NegativeControl {
21
+ /** Stable id of the planted-violation control scenario. */
22
+ id: string;
23
+ /** How blatant the planted RDP violation is. */
24
+ severity: ControlSeverity;
25
+ /**
26
+ * Did the harness flag this control (grade it RED)? A control is "caught"
27
+ * when NOT all of its assertions passed — i.e. the harness correctly
28
+ * detected the planted violation. Caught is the *desired* outcome.
29
+ */
30
+ caught: boolean;
31
+ }
32
+
33
+ export interface DiscriminationResult {
34
+ /**
35
+ * True only when the harness caught the SUBTLE control (and a gross one
36
+ * exists and was also caught). This is the strict gate the parity smoke
37
+ * checks before trusting an "all hosts pass" reading.
38
+ */
39
+ discrimination_ok: boolean;
40
+ gross_caught: boolean;
41
+ subtle_caught: boolean;
42
+ /** Human-readable reason, surfaced in the smoke's emitted summary. */
43
+ reason: string;
44
+ }
45
+
46
+ /**
47
+ * A control is "caught" iff at least one of its assertions explicitly failed.
48
+ * `pass: null` (e.g. an ungraded `rubric` assertion deferred to a sub-agent
49
+ * grader) is NOT a fail — an un-graded assertion cannot count as catching a
50
+ * violation, so only explicit `false` qualifies.
51
+ */
52
+ export function controlCaughtFromAssertions(
53
+ assertionResults: ReadonlyArray<{ pass: boolean | null }>,
54
+ ): boolean {
55
+ return assertionResults.some((r) => r.pass === false);
56
+ }
57
+
58
+ /**
59
+ * Decide whether the eval discriminates well enough to trust a green reading.
60
+ * Requires at least one `gross` AND one `subtle` control; gates on the subtle.
61
+ */
62
+ export function computeDiscrimination(
63
+ controls: ReadonlyArray<NegativeControl>,
64
+ ): DiscriminationResult {
65
+ const gross = controls.filter((c) => c.severity === 'gross');
66
+ const subtle = controls.filter((c) => c.severity === 'subtle');
67
+
68
+ const gross_caught = gross.length > 0 && gross.every((c) => c.caught);
69
+ const subtle_caught = subtle.length > 0 && subtle.every((c) => c.caught);
70
+
71
+ let discrimination_ok = false;
72
+ let reason: string;
73
+
74
+ if (gross.length === 0 || subtle.length === 0) {
75
+ reason =
76
+ 'GRADED control set incomplete: need at least one `gross` and one `subtle` ' +
77
+ 'negative control. A single severity cannot establish sensitivity — outcome (a) stays invalid.';
78
+ } else if (!gross_caught) {
79
+ reason =
80
+ 'Harness is blind to a GROSS violation — it cannot fail at all. ' +
81
+ 'Any "all hosts pass" reading is meaningless.';
82
+ } else if (!subtle_caught) {
83
+ reason =
84
+ 'Underpowered: the gross control was caught but the SUBTLE one slipped through. ' +
85
+ 'This is the uninformative-null trap with a green check in front — outcome (a) is ' +
86
+ 'INVALID until the subtle control is caught.';
87
+ } else {
88
+ discrimination_ok = true;
89
+ reason =
90
+ 'Both gross and subtle negative controls were caught — the eval discriminates; ' +
91
+ 'an "all hosts pass" reading is trustworthy.';
92
+ }
93
+
94
+ return { discrimination_ok, gross_caught, subtle_caught, reason };
95
+ }
@@ -1,41 +1,80 @@
1
1
  /**
2
- * Real-tokenizer counting for the budget tooling (roadmap 0B.1).
2
+ * Real-tokenizer counting for the budget tooling
3
+ * (roadmap 0B.1 → token-saving Phase 0, "real tokenizer into the bench path").
3
4
  *
4
- * TypeScript twin of `src/scripts/_lib/token_count.py` (ADR-200 —
5
- * Python→TS migration, Phase 2 / Wave 1). Public API mirrors the
6
- * Python module exactly (snake_case kept deliberately).
5
+ * `char != token`. Every budget in this suite is historically in characters;
6
+ * this helper reports a token count *alongside* chars so chars stay the cheap
7
+ * proxy and tokens become the truth where a real tokenizer is available.
7
8
  *
8
- * `char != token`. Every budget in this suite is historically in
9
- * characters; this helper adds a token count *alongside* chars so chars
10
- * stay the cheap proxy and tokens become the truth where a real
11
- * tokenizer is available.
9
+ * Design — optional dependency, graceful proxy fallback, **synchronous** API:
12
10
  *
13
- * Design — no silent installs, no mandatory network:
11
+ * - **GPT** — uses `js-tiktoken` (encoding `cl100k_base`) when the dependency
12
+ * is present, producing EXACT BPE counts flagged `exact: true`. `js-tiktoken`
13
+ * is a build/measurement-time **devDependency** (consumed by the bench /
14
+ * audit tooling, never shipped to a consumer runtime). When it is absent —
15
+ * e.g. a consumer that installed without devDependencies — the module falls
16
+ * back to the documented `chars / 4` proxy flagged `exact: false`. The
17
+ * optional load is a synchronous `createRequire(...)` inside a try/catch so
18
+ * the public `measure()` / `gpt_tokens()` API stays synchronous and never
19
+ * hard-fails on a missing dependency (no mandatory install, no network).
20
+ * - **Claude** — no offline Claude tokenizer exists; documented `chars / 3.6`
21
+ * proxy, always `exact: false`. (Anthropic's exact count is API-only.)
14
22
  *
15
- * - **GPT** — the Python original uses `tiktoken` when installed; no
16
- * tokenizer dependency ships with the TS twin, so the documented
17
- * `chars / 4` proxy applies, flagged `exact: false`
18
- * (`TIKTOKEN_AVAILABLE` is always `false` here — matching the
19
- * Python module's behaviour when tiktoken is absent).
20
- * - **Claude** — no offline tokenizer; documented `chars / 3.6` proxy
21
- * flagged `exact: false`.
23
+ * Both proxies are intentionally conservative ratios drawn from English-prose +
24
+ * markdown samples; they are estimates, never gates.
22
25
  *
23
- * Both proxies are intentionally conservative ratios drawn from
24
- * English-prose + markdown samples; they are estimates, never gates.
26
+ * The `*_proxy` helpers (`gpt_tokens_proxy`, `measure_proxy`,
27
+ * `method_note_proxy`) expose the fallback math directly — they are used by the
28
+ * fallback regression guard in the test suite and by any report that wants to
29
+ * record both the exact and the proxy count to flag the delta.
25
30
  *
26
- * Parity notes: "chars" counts Unicode code points (Python `len(str)`),
27
- * not UTF-16 units; rounding is banker's rounding (Python `round()`).
31
+ * Parity notes: "chars" counts Unicode code points (`len(str)` parity), not
32
+ * UTF-16 units; proxy rounding is banker's rounding.
28
33
  */
29
34
 
35
+ import { createRequire } from "node:module";
36
+
30
37
  // Proxy ratios (chars per token) for the no-tokenizer fallback. Tuned for
31
38
  // English markdown rule/skill prose; deliberately conservative.
32
39
  const _GPT_CHARS_PER_TOKEN = 4.0;
33
40
  const _CLAUDE_CHARS_PER_TOKEN = 3.6;
34
41
 
35
- const _TIKTOKEN_ENCODING = "o200k_base"; // GPT-4o / GPT-4.1 family.
42
+ // Roadmap (token-saving Phase 0) mandates the `cl100k_base` encoding. The
43
+ // dependency also bundles `o200k_base` (GPT-4o / GPT-4.1 family); switching the
44
+ // GPT count basis is a one-line change to this constant. The exact-vs-proxy
45
+ // delta is recorded in every report via `method_note()`.
46
+ const _TIKTOKEN_ENCODING = "cl100k_base";
47
+
48
+ /** Minimal structural type for the js-tiktoken encoder we rely on. */
49
+ interface _Encoder {
50
+ encode(text: string): number[];
51
+ }
52
+
53
+ /**
54
+ * Load the optional `js-tiktoken` encoder synchronously, returning `null` when
55
+ * the dependency is absent or fails to initialise (graceful proxy fallback).
56
+ * `createRequire` resolves the CJS build so the call stays synchronous even
57
+ * though this module is ESM.
58
+ */
59
+ function _loadEncoder(): _Encoder | null {
60
+ try {
61
+ const require = createRequire(import.meta.url);
62
+ const mod = require("js-tiktoken") as {
63
+ getEncoding(name: string): _Encoder;
64
+ };
65
+ return mod.getEncoding(_TIKTOKEN_ENCODING);
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ // Resolved once at module load. js-tiktoken's `getEncoding` is synchronous with
72
+ // bundled rank tables, so eager init is cheap and keeps `TIKTOKEN_AVAILABLE` a
73
+ // plain const (the historical API shape).
74
+ const _ENCODER: _Encoder | null = _loadEncoder();
36
75
 
37
- // No tiktoken port ships with the TS twin — always the proxy path.
38
- export const TIKTOKEN_AVAILABLE = false;
76
+ /** True when the real tokenizer is available (exact GPT counts), else proxy. */
77
+ export const TIKTOKEN_AVAILABLE = _ENCODER !== null;
39
78
 
40
79
  /** A single token measurement and whether it is exact or a proxy. */
41
80
  export class TokenCount {
@@ -49,7 +88,7 @@ export class TokenCount {
49
88
  }
50
89
  }
51
90
 
52
- /** Count Unicode code points — Python `len(str)` parity. */
91
+ /** Count Unicode code points — `len(str)` parity. */
53
92
  function _len(text: string): number {
54
93
  let count = 0;
55
94
  // for..of iterates code points, so astral chars count once.
@@ -57,7 +96,7 @@ function _len(text: string): number {
57
96
  return count;
58
97
  }
59
98
 
60
- /** Python `round()` — round half to even (banker's rounding). */
99
+ /** Banker's rounding (round half to even) — proxy-path parity. */
61
100
  function _python_round(x: number): number {
62
101
  const floor = Math.floor(x);
63
102
  const diff = x - floor;
@@ -66,17 +105,31 @@ function _python_round(x: number): number {
66
105
  return floor % 2 === 0 ? floor : floor + 1;
67
106
  }
68
107
 
69
- /** Render a float like Python's f-string (4.0 → "4.0", 3.6 → "3.6"). */
108
+ /** Render a float like a Python f-string (4.0 → "4.0", 3.6 → "3.6"). */
70
109
  function _py_float_repr(x: number): string {
71
110
  return Number.isInteger(x) ? `${x}.0` : `${x}`;
72
111
  }
73
112
 
74
- /** GPT token count — char proxy (tiktoken unavailable in the TS twin). */
113
+ /**
114
+ * GPT token count — EXACT via js-tiktoken (`cl100k_base`) when available,
115
+ * otherwise the documented `chars / 4` proxy.
116
+ */
75
117
  export function gpt_tokens(text: string): TokenCount {
118
+ if (_ENCODER !== null) {
119
+ return new TokenCount(_ENCODER.encode(text).length, true);
120
+ }
121
+ return gpt_tokens_proxy(text);
122
+ }
123
+
124
+ /**
125
+ * GPT proxy count (`chars / 4`, banker's rounding) — the fallback path,
126
+ * exposed directly for the fallback regression guard and delta reporting.
127
+ */
128
+ export function gpt_tokens_proxy(text: string): TokenCount {
76
129
  return new TokenCount(_python_round(_len(text) / _GPT_CHARS_PER_TOKEN), false);
77
130
  }
78
131
 
79
- /** Claude token count — documented offline proxy (no local tokenizer). */
132
+ /** Claude token count — documented offline proxy (no local Claude tokenizer). */
80
133
  export function claude_tokens(text: string): TokenCount {
81
134
  return new TokenCount(
82
135
  _python_round(_len(text) / _CLAUDE_CHARS_PER_TOKEN),
@@ -95,9 +148,9 @@ export interface Measure {
95
148
  /**
96
149
  * Return chars + per-model token counts for one text blob.
97
150
  *
98
- * Keys: chars, tokens_gpt, tokens_gpt_exact, tokens_claude,
99
- * tokens_claude_exact. The `*_exact` booleans tell a report consumer
100
- * whether the number is a real tokenizer count or a proxy estimate.
151
+ * `tokens_gpt` is exact (js-tiktoken) when available; `tokens_gpt_exact` tells
152
+ * the report consumer which path produced it. `tokens_claude` is always the
153
+ * proxy (no offline Claude tokenizer).
101
154
  */
102
155
  export function measure(text: string): Measure {
103
156
  const g = gpt_tokens(text);
@@ -111,6 +164,31 @@ export function measure(text: string): Measure {
111
164
  };
112
165
  }
113
166
 
167
+ /**
168
+ * Proxy-only measure (forces the chars-based estimate for GPT regardless of
169
+ * tokenizer availability). Use to record both the exact and the proxy count
170
+ * and flag the delta, and as the fallback regression guard.
171
+ */
172
+ export function measure_proxy(text: string): Measure {
173
+ const g = gpt_tokens_proxy(text);
174
+ const c = claude_tokens(text);
175
+ return {
176
+ chars: _len(text),
177
+ tokens_gpt: g.tokens,
178
+ tokens_gpt_exact: g.exact,
179
+ tokens_claude: c.tokens,
180
+ tokens_claude_exact: c.exact,
181
+ };
182
+ }
183
+
184
+ /** Proxy-path provenance string (the fallback note). */
185
+ export function method_note_proxy(): string {
186
+ return (
187
+ `tokens_gpt: proxy (chars/${_py_float_repr(_GPT_CHARS_PER_TOKEN)}, tiktoken not installed); ` +
188
+ `tokens_claude: proxy (chars/${_py_float_repr(_CLAUDE_CHARS_PER_TOKEN)})`
189
+ );
190
+ }
191
+
114
192
  /** One-line provenance of how token counts were produced (for reports). */
115
193
  export function method_note(): string {
116
194
  if (TIKTOKEN_AVAILABLE) {
@@ -119,8 +197,5 @@ export function method_note(): string {
119
197
  `tokens_claude: proxy (chars/${_py_float_repr(_CLAUDE_CHARS_PER_TOKEN)})`
120
198
  );
121
199
  }
122
- return (
123
- `tokens_gpt: proxy (chars/${_py_float_repr(_GPT_CHARS_PER_TOKEN)}, tiktoken not installed); ` +
124
- `tokens_claude: proxy (chars/${_py_float_repr(_CLAUDE_CHARS_PER_TOKEN)})`
125
- );
200
+ return method_note_proxy();
126
201
  }