atris 3.47.0 → 3.48.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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: youtube
3
- description: "Process YouTube videos extract insights, answer questions, store as knowledge. 5 credits per video. Triggers on: youtube, video, process video, watch this, learn from video."
3
+ description: "Process YouTube videos: extract insights, answer questions, store as knowledge. 5 credits per video. Triggers on: youtube, video, process video, watch this, learn from video."
4
4
  version: 2.3.0
5
5
  tags:
6
6
  - youtube
@@ -15,9 +15,11 @@ Process any YouTube video through Atris transcript-first analysis. The CLI extra
15
15
 
16
16
  ## Route first: learning vs product
17
17
 
18
- Two rails process YouTube videos pick before running anything:
18
+ Two rails process YouTube videos: pick before running anything:
19
19
 
20
20
  - **Learning / work rail** → use the `alpha-learn` skill (ytnotes). Local yt-dlp + grok, zero credits, podcastnotes-style notes, tweet-feed output, `[claimable]` entries in today's journal for other agents. Use this when the goal is to LEARN from a video or mine it for Atris work.
21
+ Canonical ytnotes source is `scripts/det/ytnotes` (install: `ln -sf "$PWD/scripts/det/ytnotes" ~/.local/bin/ytnotes`).
22
+ Runs are scored by `node scripts/det/ytrail-eval.js`.
21
23
  - **Product rail** → this skill (`atris youtube process`). Credits-billed, stores knowledge in the Atris backend, customer-facing path. Use this when a customer/agent needs the video stored as Atris knowledge or answered via the API.
22
24
 
23
25
  If the user says "learn from", "notes on", "alpha", or "rabbit hole" → alpha-learn. If they say "process", "store", "add to knowledge" → this skill.
@@ -189,7 +191,7 @@ Every important insight should carry a timestamp when the transcript provides on
189
191
  | `401` | Token expired/invalid | `atris login --force` |
190
192
  | `402` | Not enough credits | Check balance, purchase at atris.ai |
191
193
  | `400` | Invalid YouTube URL | Check URL format |
192
- | `502` | Transcript or cloud processing failed | Retry credits auto-refunded when backend fails |
194
+ | `502` | Transcript or cloud processing failed | Retry; credits auto-refunded when backend fails |
193
195
 
194
196
  ---
195
197
 
package/bin/atris.js CHANGED
@@ -38,6 +38,7 @@ try {
38
38
  const {
39
39
  checkForUpdates,
40
40
  showUpdateNotification,
41
+ autoUpdate,
41
42
  inspectInstallGitState,
42
43
  formatInstallGitWarning,
43
44
  } = require('../utils/update-check');
@@ -106,6 +107,7 @@ if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'updat
106
107
  showUpdateNotification(updateInfo, {
107
108
  packageRoot: path.join(__dirname, '..'),
108
109
  });
110
+ autoUpdate(updateInfo, { packageRoot: path.join(__dirname, '..') });
109
111
  }
110
112
  return updateInfo;
111
113
  })
@@ -569,7 +571,7 @@ function showHelp() {
569
571
  console.log(' router - inspect ax lane outcomes and promote gated reflex overrides');
570
572
  console.log(' sign - Co-author trailer on every commit in an atris workspace (on/off/status)');
571
573
  console.log(' visualize - Generate a Slack/deck-ready visual from a prompt');
572
- console.log(' youtube - Process YouTube videos with timestamped transcript-first analysis');
574
+ console.log(' youtube - Free local notes or 5-credit cloud process for YouTube videos');
573
575
  console.log('');
574
576
  console.log('Experiments:');
575
577
  console.log(' experiments init [slug] - Prepare atris/experiments/ or scaffold a pack');
@@ -23,6 +23,7 @@ const {
23
23
  } = require('../lib/runner-command');
24
24
  const {
25
25
  resolveEngineForRoleWithPreference,
26
+ engineFailureHealthStatus,
26
27
  setEngineHealth,
27
28
  } = require('../lib/engine-registry');
28
29
  const {
@@ -378,28 +379,6 @@ function resolveMissionTickRunner(mission, root = process.cwd()) {
378
379
  };
379
380
  }
380
381
 
381
- function engineFailureHealthStatus(result) {
382
- if (!result || result.status !== 'errored') return null;
383
- const signalText = [
384
- result.reason,
385
- result.model_unavailable,
386
- result.claude && result.claude.summary,
387
- result.claude && result.claude.receipt_text,
388
- result.claude && result.claude.stderr,
389
- result.rate_limit_info && JSON.stringify(result.rate_limit_info),
390
- ].filter(Boolean).join('\n').toLowerCase();
391
- if (/usage[ _-]?limit|purchase more credits|insufficient credits|credit(?:s)?[ _-]?(?:out|limit)|rate[ _-]?limit/.test(signalText)) {
392
- return 'credit_out';
393
- }
394
- if (/timeout|model-unavailable/.test(signalText)) return 'not_installed';
395
- // Any other errored tick (claude-error, no-ready-engine's sibling failures,
396
- // etc.) is still a real failure signal for the engine that ran it. Falling
397
- // through to null here left the registry showing "ready" for an engine
398
- // that had just hard-failed (e.g. a 401), so auto routing kept sending
399
- // ticks back to it. Mark it "error" instead of silently doing nothing.
400
- return 'error';
401
- }
402
-
403
382
  function recordMissionEngineTickOutcome(engineId, result, root = process.cwd()) {
404
383
  if (!engineId) return null;
405
384
  const status = result && result.status === 'ran' ? 'ready' : engineFailureHealthStatus(result);
@@ -1,8 +1,12 @@
1
1
  const { apiRequestJson } = require('../utils/api');
2
2
  const { ensureValidCredentials } = require('../utils/auth');
3
3
  const { spawnSync } = require('child_process');
4
+ const path = require('path');
4
5
  const https = require('https');
5
6
 
7
+ const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [haiku|atris-fast|gemini|grok|codex|cursor]';
8
+ const YTNOTES_HINT = 'zero credits, local captions + a fast engine';
9
+
6
10
  const DEFAULT_QUERY = [
7
11
  'Create a timestamped YouTube brief for Atris.',
8
12
  'Include: metadata, timestamped outline, core claims with confidence, memorable examples, actionable takeaways, Atris/product implications, and next actions.',
@@ -19,9 +23,11 @@ const ALLOWED_CAPTION_HOST_SUFFIXES = [
19
23
 
20
24
  function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
21
25
  output('');
22
- output(`Usage: ${commandName} process <youtube-url> [options]`);
26
+ output(`Usage: ${commandName} notes <youtube-url> [engine]`);
27
+ output(` ${commandName} process <youtube-url> [options]`);
23
28
  output(` ${commandName} <youtube-url> [options]`);
24
29
  output('');
30
+ output('notes = free local notes, process = 5 credits cloud knowledge');
25
31
  output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
26
32
  output('Falls back to cloud video processing when local captions are unavailable.');
27
33
  output('');
@@ -37,6 +43,7 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
37
43
  output(' metadata -> timestamped outline -> claims -> examples -> takeaways -> Atris implications -> next actions');
38
44
  output('');
39
45
  output('Examples:');
46
+ output(` ${commandName} notes https://www.youtube.com/watch?v=VIDEO_ID`);
40
47
  output(` ${commandName} https://www.youtube.com/watch?v=VIDEO_ID`);
41
48
  output(` ${commandName} process https://youtu.be/VIDEO_ID --query "Key takeaways"`);
42
49
  output('');
@@ -437,8 +444,31 @@ function formatYoutubeResult(data) {
437
444
  return lines.join('\n');
438
445
  }
439
446
 
447
+ function runYoutubeNotes(args = [], deps = {}) {
448
+ const output = deps.output || ((line = '') => console.error(line));
449
+ const url = args[0];
450
+ const engine = args[1];
451
+ if (!url) {
452
+ output(YTNOTES_USAGE);
453
+ output(YTNOTES_HINT);
454
+ return 2;
455
+ }
456
+
457
+ const script = path.join(__dirname, '..', 'scripts', 'det', 'ytnotes');
458
+ const spawn = deps.spawnSync || spawnSync;
459
+ const childArgs = engine ? [url, engine] : [url];
460
+ const result = spawn(script, childArgs, { stdio: 'inherit' });
461
+ if (result.status == null) return 1;
462
+ return result.status;
463
+ }
464
+
440
465
  async function youtubeCommand(argv = process.argv.slice(3), deps = {}) {
441
466
  const output = deps.output || ((line = '') => console.log(line));
467
+ if (argv[0] === 'notes') {
468
+ const code = runYoutubeNotes(argv.slice(1), deps);
469
+ if (!deps.output && !deps.spawnSync) process.exit(code);
470
+ return code;
471
+ }
442
472
  const options = parseYoutubeArgs(argv);
443
473
  if (options.help) {
444
474
  showYoutubeHelp(output, deps.commandName || 'atris youtube');
package/lib/engine-ask.js CHANGED
@@ -9,7 +9,11 @@ const {
9
9
  DEFAULT_CLAUDE_RUNNER_MODEL,
10
10
  RUNNER_PROFILE_DEFS,
11
11
  } = require('./runner-command');
12
- const { canonicalEngineName } = require('./engine-registry');
12
+ const {
13
+ canonicalEngineName,
14
+ engineFailureHealthStatus,
15
+ setEngineHealth,
16
+ } = require('./engine-registry');
13
17
  const {
14
18
  appendEngineLiveLogChunk,
15
19
  createEngineLiveLog,
@@ -452,6 +456,15 @@ function answerStatus(answer) {
452
456
  return engineTerminalStatus(answer);
453
457
  }
454
458
 
459
+ function recordEngineAskHealth(answers, root) {
460
+ for (const answer of answers) {
461
+ const status = answer.ok
462
+ ? 'ready'
463
+ : engineFailureHealthStatus({ ...answer, status: 'errored' });
464
+ if (status) setEngineHealth(answer.engine, status, root);
465
+ }
466
+ }
467
+
455
468
  function engineAskReceipt(answers, { concurrency, timeoutMs, at = new Date().toISOString() }) {
456
469
  const receiptAnswers = answers.map((answer) => ({ ...answer, status: answerStatus(answer) }));
457
470
  const answered = receiptAnswers.filter((answer) => answer.status === 'answered').length;
@@ -582,6 +595,7 @@ async function runEngineAskCommand(args, root = process.cwd(), deps = {}) {
582
595
  signal: abort.signal,
583
596
  onOutputChunk: (chunk, stream) => appendLiveLog(liveLogPath, chunk, stream),
584
597
  });
598
+ recordEngineAskHealth(answers, root);
585
599
  const receipt = engineAskReceipt(answers, {
586
600
  concurrency: parsed.concurrency,
587
601
  timeoutMs: parsed.timeoutMs,
@@ -16,6 +16,27 @@ const ENGINE_ROLES = Object.freeze(['navigator', 'executor', 'validator']);
16
16
  const ENGINE_DUTIES = Object.freeze(['leader', 'errands', 'learning']);
17
17
  const ENGINE_HEALTH_STATUSES = Object.freeze(['ready', 'not_installed', 'credit_out', 'error']);
18
18
 
19
+ function engineFailureHealthStatus(result) {
20
+ if (!result || result.status !== 'errored') return null;
21
+ const signalText = [
22
+ result.reason,
23
+ result.model_unavailable,
24
+ result.report,
25
+ result.stdout,
26
+ result.stderr,
27
+ result.error,
28
+ result.claude && result.claude.summary,
29
+ result.claude && result.claude.receipt_text,
30
+ result.claude && result.claude.stderr,
31
+ result.rate_limit_info && JSON.stringify(result.rate_limit_info),
32
+ ].filter(Boolean).join('\n').toLowerCase();
33
+ if (/usage[ _-]?limit|purchase more credits|insufficient credits|credit(?:s)?[ _-]?(?:out|limit)|rate[ _-]?limit|not authenticated|please log in|login required|auth(?:entication)?[ _-]?expired|payment required|subscription/.test(signalText)) {
34
+ return 'credit_out';
35
+ }
36
+ if (/timeout|model-unavailable/.test(signalText)) return 'not_installed';
37
+ return 'error';
38
+ }
39
+
19
40
  const ENGINE_SEED_META = Object.freeze({
20
41
  'atris-fast': Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator']), models: Object.freeze(['atris fast']), duty: 'learning', fallback_order: 10 }),
21
42
  codex: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['codex']), fallback_order: 10 }),
@@ -356,6 +377,7 @@ module.exports = {
356
377
  resolveEngineForRoleRanked,
357
378
  resolveEngineForRole,
358
379
  resolveEngineForRoleWithPreference,
380
+ engineFailureHealthStatus,
359
381
  setEngineOverrides,
360
382
  setEngineHealth,
361
383
  };
package/lib/fleet.js CHANGED
@@ -23,6 +23,7 @@ const {
23
23
  worktreeBaseRef,
24
24
  } = require('./brief-ledger');
25
25
  const { RUNNER_PROFILE_DEFS, buildRunnerCommand } = require('./runner-command');
26
+ const { engineFailureHealthStatus, setEngineHealth } = require('./engine-registry');
26
27
  const { resolveDefaultVerifier } = require('./default-verifier');
27
28
  const { rankEnginesDetailed } = require('./router-brain');
28
29
  const {
@@ -560,6 +561,18 @@ function detectDeadEngineDispatch(result) {
560
561
  return { reason: 'nonzero_exit', exitCode };
561
562
  }
562
563
 
564
+ function recordDispatchEngineHealth(result, failure, root) {
565
+ if (!result || !result.engine) return null;
566
+ const status = failure
567
+ ? engineFailureHealthStatus({
568
+ ...result,
569
+ status: 'errored',
570
+ reason: [result.reason, failure.reason].filter(Boolean).join('\n'),
571
+ })
572
+ : 'ready';
573
+ return status ? setEngineHealth(result.engine, status, root) : null;
574
+ }
575
+
563
576
  function normalizeInstalledEngines(engines) {
564
577
  return [...new Set((engines || [])
565
578
  .map((entry) => (typeof entry === 'string' ? entry : entry && entry.name))
@@ -672,6 +685,7 @@ async function dispatchEntryWithRestaff({
672
685
 
673
686
  const first = await runOnce(engine);
674
687
  const deadEngine = detectDeadEngineDispatch(first);
688
+ recordDispatchEngineHealth(first, deadEngine, root);
675
689
  if (!deadEngine) return first;
676
690
  stampDispatchBrief(root, first.brief_id, 'fail', `restaffed from ${engine}: ${deadEngine.reason}`);
677
691
 
@@ -687,6 +701,7 @@ async function dispatchEntryWithRestaff({
687
701
 
688
702
  restaffState.used = true;
689
703
  const fallbackResult = await runOnce(fallback);
704
+ recordDispatchEngineHealth(fallbackResult, detectDeadEngineDispatch(fallbackResult), root);
690
705
  return {
691
706
  ...fallbackResult,
692
707
  restaffed: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.47.0",
3
+ "version": "3.48.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "commands/",
15
15
  "decks/",
16
16
  "scripts/agent_worktree.py",
17
+ "scripts/det/",
17
18
  "utils/",
18
19
  "lib/",
19
20
  "templates/",
@@ -0,0 +1,162 @@
1
+ # Deterministic task scripts
2
+
3
+ Small, zero-dependency scripts for jobs LLMs get asked to do constantly but that
4
+ are actually deterministic: extracting, converting, counting, reformatting text,
5
+ and drafting commit/PR text from git. A cheap model (or a human, or a cron) runs
6
+ the script instead of spending tokens and risking a wrong guess. The output is
7
+ exact and reproducible, not inferred.
8
+
9
+ ## Pick a tool (one read)
10
+
11
+ Find the row that matches the ask, run the command. All paths are under
12
+ `node scripts/det/`. Add `--json` to any script for structured output.
13
+
14
+ | If the ask is… | Run | Modes / notes |
15
+ |----------------|-----|---------------|
16
+ | pull links / emails / code / numbers out of text | `extract.js <mode> < in` | `urls` `emails` `code` `numbers` `ipv4` `hashtags` |
17
+ | reformat / validate / flatten JSON, or JSON to CSV | `json.js <mode> < in` | `pretty` `min` `validate` `keys` `csv` |
18
+ | dedupe / sort / count / slugify / trim lines | `text.js <mode> < in` | `dedupe` `sort` `rsort` `count` `slug` `trim` |
19
+ | base64 / hex encode-decode, sha256 / sha1 / md5 hash | `hash.js <mode> < in` | `b64` `b64d` `sha256` `sha1` `md5` `hexenc` `hexdec` |
20
+ | convert a timestamp, or get the weekday (all UTC) | `date.js <mode> < in` | `iso` `epoch` `epochms` `weekday` |
21
+ | write a commit message | `git add -A && commit-msg.js` | reads the staged diff |
22
+ | summarize what changed since a release | `changelog.js [ref]` | reads git log |
23
+ | write a PR description for this branch | `pr-description.js [base]` | reads the branch diff |
24
+
25
+ If no row matches, do the task normally. This library grows one verified script
26
+ at a time; never add one without a self-test.
27
+
28
+ ## How to call
29
+
30
+ The first five read stdin, write stdout, exit 0 on success and non-zero on bad
31
+ input. The last three read git directly (their input is the repo, not stdin).
32
+
33
+ You can call any script directly, or use the dispatcher as a discovery front door:
34
+
35
+ ```bash
36
+ node scripts/det/det.js # print the catalog (all 8 tools)
37
+ node scripts/det/det.js <script> <mode> < input # route stdin through it
38
+ ```
39
+
40
+ `det.js` lists every tool: the five stdin scripts it can route, plus the three
41
+ git-facing scripts (which it points you to run directly, since their input is the
42
+ repo). The stdin catalog is derived from the scripts' own exports, so it can never
43
+ drift from what actually runs. Trust the output; do not "improve" it.
44
+
45
+ ## stdin scripts
46
+
47
+ ### extract.js
48
+
49
+ ```bash
50
+ cat page.html | node scripts/det/extract.js urls
51
+ node scripts/det/extract.js emails < contacts.txt
52
+ node scripts/det/extract.js code < README.md # fenced blocks, contents only
53
+ node scripts/det/extract.js --json urls < page.html # JSON array
54
+ ```
55
+
56
+ Duplicates removed, first-seen order preserved. Unknown mode exits 2.
57
+
58
+ ### json.js
59
+
60
+ ```bash
61
+ cat data.json | node scripts/det/json.js pretty # 2-space indent
62
+ node scripts/det/json.js min < data.json # minified
63
+ node scripts/det/json.js validate < data.json # "valid" or errors (exit 2)
64
+ node scripts/det/json.js keys < data.json # top-level keys
65
+ node scripts/det/json.js csv < array.json # array of objects -> RFC-4180 CSV
66
+ ```
67
+
68
+ `csv` handles the escaping LLMs get wrong: fields with commas or quotes are
69
+ quoted, inner quotes doubled. Columns follow first-seen key order across rows.
70
+
71
+ ### text.js
72
+
73
+ ```bash
74
+ cat list.txt | node scripts/det/text.js dedupe # drop dup lines, keep first order
75
+ node scripts/det/text.js sort < list.txt # byte-order sort (rsort = reverse)
76
+ node scripts/det/text.js count < list.txt # lines / words / chars (tab-separated)
77
+ node scripts/det/text.js slug < titles.txt # each line -> url slug (accents folded)
78
+ node scripts/det/text.js trim < messy.txt # strip trailing ws, drop blank lines
79
+ ```
80
+
81
+ `count` is exact, no more eyeballed line/word totals. `slug` folds accents
82
+ (Café to cafe) so slugs are stable across inputs.
83
+
84
+ ### hash.js
85
+
86
+ ```bash
87
+ printf 'hi' | node scripts/det/hash.js b64 # base64 encode (b64d decodes)
88
+ node scripts/det/hash.js sha256 < file.txt # real hex sha256 (sha1, md5 too)
89
+ node scripts/det/hash.js hexenc < file.txt # raw <-> hex (hexdec reverses)
90
+ ```
91
+
92
+ A single trailing newline is stripped before encoding/hashing, so `echo hi` and
93
+ `printf 'hi'` give the same result. These are real crypto digests, not the
94
+ plausible-looking fakes an LLM emits.
95
+
96
+ ### date.js
97
+
98
+ ```bash
99
+ echo 1700000000 | node scripts/det/date.js iso # epoch (s or ms) -> ISO UTC
100
+ echo 2026-07-07 | node scripts/det/date.js epoch # date -> epoch seconds (epochms for ms)
101
+ echo 2026-07-07 | node scripts/det/date.js weekday # -> Tuesday
102
+ ```
103
+
104
+ Everything is UTC and machine-independent: epoch auto-detects seconds vs ms, and
105
+ a bare date string with no timezone is pinned to UTC instead of guessing local.
106
+
107
+ ## git-facing scripts
108
+
109
+ These replace LLM *generation*, not just data munging. They read git directly, so
110
+ there is no stdin and they sit outside the dispatcher catalog.
111
+
112
+ ### commit-msg.js
113
+
114
+ ```bash
115
+ git add -A && node scripts/det/commit-msg.js # print the drafted message
116
+ node scripts/det/commit-msg.js --json # {type,scope,subject,body,...}
117
+ ```
118
+
119
+ Type and scope come from the changed paths (`docs`/`test`/`chore`/`feat`/`fix`,
120
+ scope = deepest common dir); the body is exact diff stats. No intent-guessing.
121
+ Multi-file changes name the lead file (the added one, else the biggest churn), as
122
+ `add changelog.js (+2 more)`, never the vague `update 3 files`.
123
+
124
+ ### changelog.js
125
+
126
+ ```bash
127
+ node scripts/det/changelog.js # since the last tag -> markdown
128
+ node scripts/det/changelog.js v3.34.0 # since a specific ref
129
+ node scripts/det/changelog.js v3.34.0 HEAD # explicit range
130
+ node scripts/det/changelog.js --json # {sections,counts,breaking,...}
131
+ ```
132
+
133
+ Sections, order, and bullets come straight from the commit subjects grouped by
134
+ Conventional-Commits type (`feat` to Features, `fix` to Fixes, ...); `type!:`
135
+ commits surface under BREAKING CHANGES. Subjects that don't match the header
136
+ grammar land in "Other" so nothing is dropped. No paraphrase, no invented or
137
+ missing entries.
138
+
139
+ ### pr-description.js
140
+
141
+ ```bash
142
+ node scripts/det/pr-description.js # diff origin/master...HEAD -> markdown
143
+ node scripts/det/pr-description.js origin/main # different base branch
144
+ node scripts/det/pr-description.js origin/main HEAD # explicit base + head
145
+ node scripts/det/pr-description.js --json # {title,summary,testPlan,...}
146
+ ```
147
+
148
+ Title comes from the commits (one commit -> its subject; many -> dominant type
149
+ plus lead file); the summary is one bullet per changed area with counts and
150
+ churn; the test-plan lists the touched test files plus one check per non-test
151
+ area. Every line is backed by a real change in the diff, no invented rationale.
152
+
153
+ ## Verifying the library
154
+
155
+ ```bash
156
+ node scripts/det/test.js # runs every script against known input/output
157
+ ```
158
+
159
+ Runs fast, no deps, CI-safe. A script is not "done" until it appears here with a
160
+ passing test. This suite is also gated by the repo's `npm test` via
161
+ `test/det.test.js`, which runs it as a subprocess, so the library cannot silently
162
+ rot in CI.
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Grade the ax auto lane picker against the labeled gold set.
5
+ // Usage: node scripts/det/ax-lane-eval.js [--json] [--min-accuracy <0..1>]
6
+ // Cost-weighted error: routing up-lane work down (quality miss) counts 3x
7
+ // routing down-lane work up (cost miss), because a wrong cheap answer is
8
+ // worse than an overpriced right one.
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+ const { pickLane } = require('../../lib/ax-auto-lane');
13
+
14
+ const LANES = ['fast', 'pro', 'max', 'code-fast'];
15
+ // Depth order for miss direction; code-fast sits beside pro in cost.
16
+ const DEPTH = { fast: 0, 'code-fast': 1, pro: 1, max: 2 };
17
+ const QUALITY_MISS_WEIGHT = 3;
18
+ const COST_MISS_WEIGHT = 1;
19
+
20
+ function loadGold() {
21
+ const dataIndex = process.argv.indexOf('--data');
22
+ const file = dataIndex >= 0
23
+ ? path.resolve(process.argv[dataIndex + 1])
24
+ : path.join(__dirname, 'data', 'ax-lane-gold.jsonl');
25
+ return fs.readFileSync(file, 'utf8')
26
+ .split(/\r?\n/)
27
+ .filter((line) => line.trim())
28
+ .map((line) => JSON.parse(line));
29
+ }
30
+
31
+ function evaluate() {
32
+ const gold = loadGold();
33
+ const confusion = {};
34
+ for (const a of LANES) {
35
+ confusion[a] = {};
36
+ for (const b of LANES) confusion[a][b] = 0;
37
+ }
38
+ const misses = [];
39
+ let correct = 0;
40
+ let weightedError = 0;
41
+ let worstWeight = 0;
42
+ for (const row of gold) {
43
+ const picked = pickLane(row.message).lane;
44
+ confusion[row.lane][picked] += 1;
45
+ if (picked === row.lane) {
46
+ correct += 1;
47
+ continue;
48
+ }
49
+ const qualityMiss = DEPTH[picked] < DEPTH[row.lane];
50
+ const weight = qualityMiss ? QUALITY_MISS_WEIGHT : COST_MISS_WEIGHT;
51
+ weightedError += weight;
52
+ worstWeight += QUALITY_MISS_WEIGHT;
53
+ misses.push({
54
+ message: row.message.slice(0, 70),
55
+ gold: row.lane,
56
+ picked,
57
+ kind: qualityMiss ? 'quality-miss' : 'cost-miss',
58
+ why: row.why,
59
+ });
60
+ }
61
+ const perLane = LANES.map((lane) => {
62
+ const truePos = confusion[lane][lane];
63
+ const goldCount = LANES.reduce((sum, other) => sum + confusion[lane][other], 0);
64
+ const pickedCount = LANES.reduce((sum, other) => sum + confusion[other][lane], 0);
65
+ return {
66
+ lane,
67
+ gold: goldCount,
68
+ recall: goldCount ? truePos / goldCount : null,
69
+ precision: pickedCount ? truePos / pickedCount : null,
70
+ };
71
+ });
72
+ const total = gold.length;
73
+ return {
74
+ total,
75
+ correct,
76
+ accuracy: correct / total,
77
+ quality_misses: misses.filter((m) => m.kind === 'quality-miss').length,
78
+ cost_misses: misses.filter((m) => m.kind === 'cost-miss').length,
79
+ weighted_error_rate: worstWeight ? weightedError / (total * QUALITY_MISS_WEIGHT) : 0,
80
+ per_lane: perLane,
81
+ confusion,
82
+ misses,
83
+ };
84
+ }
85
+
86
+ function main() {
87
+ const args = process.argv.slice(2);
88
+ const json = args.includes('--json');
89
+ const minIndex = args.indexOf('--min-accuracy');
90
+ const minAccuracy = minIndex >= 0 ? Number(args[minIndex + 1]) : null;
91
+ const report = evaluate();
92
+ if (json) {
93
+ console.log(JSON.stringify(report, null, 2));
94
+ } else {
95
+ const pct = (value) => (value === null ? ' n/a' : `${(value * 100).toFixed(0)}%`.padStart(5));
96
+ console.log(`\nax auto lane eval: ${report.correct}/${report.total} correct (${pct(report.accuracy)})`);
97
+ console.log(`quality misses: ${report.quality_misses} cost misses: ${report.cost_misses} weighted error: ${(report.weighted_error_rate * 100).toFixed(1)}%\n`);
98
+ console.log('lane gold recall precision');
99
+ for (const row of report.per_lane) {
100
+ console.log(`${row.lane.padEnd(11)} ${String(row.gold).padStart(4)} ${pct(row.recall)} ${pct(row.precision)}`);
101
+ }
102
+ if (report.misses.length) {
103
+ console.log('\nmisses:');
104
+ for (const miss of report.misses) {
105
+ console.log(` [${miss.kind}] gold=${miss.gold} picked=${miss.picked} "${miss.message}"`);
106
+ }
107
+ }
108
+ console.log('');
109
+ }
110
+ if (minAccuracy !== null && report.accuracy < minAccuracy) {
111
+ console.error(`accuracy ${(report.accuracy * 100).toFixed(1)}% is below the ${(minAccuracy * 100).toFixed(0)}% floor`);
112
+ process.exit(1);
113
+ }
114
+ }
115
+
116
+ main();