atris 3.61.0 → 3.62.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 (41) hide show
  1. package/README.md +37 -5
  2. package/atris/skills/engines/SKILL.md +9 -9
  3. package/bin/atris.js +1 -0
  4. package/commands/autoland.js +36 -5
  5. package/commands/autopilot.js +105 -6
  6. package/commands/engine.js +96 -21
  7. package/commands/host.js +56 -4
  8. package/commands/land.js +129 -8
  9. package/commands/member.js +33 -8
  10. package/commands/mission.js +112 -3
  11. package/commands/one-lap.js +14 -10
  12. package/commands/stream.js +56 -22
  13. package/commands/team.js +82 -10
  14. package/commands/worktree.js +39 -0
  15. package/commands/youtube.js +36 -26
  16. package/lib/engine-ask.js +112 -9
  17. package/lib/engine-registry.js +283 -39
  18. package/lib/fleet.js +444 -26
  19. package/lib/functional-owner.js +15 -6
  20. package/lib/host.js +296 -170
  21. package/lib/member-engine.js +11 -1
  22. package/lib/member-park.js +138 -0
  23. package/lib/member-scaffold.js +37 -0
  24. package/lib/roster-markdown.js +1 -1
  25. package/lib/roster-prep.js +289 -0
  26. package/lib/roster-runs.js +389 -0
  27. package/lib/roster-suggest.js +145 -0
  28. package/lib/router-brain.js +111 -3
  29. package/lib/self-drive.js +26 -2
  30. package/lib/state-detection.js +3 -3
  31. package/lib/task-db.js +38 -3
  32. package/lib/team-lineup.js +7 -2
  33. package/lib/todo.js +1 -1
  34. package/lib/wish-audit.js +1 -1
  35. package/package.json +1 -1
  36. package/scripts/det/cpu-hotspots.js +216 -0
  37. package/scripts/det/ytnotes +105 -19
  38. package/scripts/det/ytquote-repair.js +18 -4
  39. package/templates/members/host/MEMBER.md +5 -3
  40. package/templates/members/host/MISSION.md +1 -1
  41. package/templates/members/host/skills/host/SKILL.md +64 -16
package/lib/self-drive.js CHANGED
@@ -141,6 +141,8 @@ function defaultDependencies() {
141
141
  taskDb,
142
142
  resolveEngineForRole: require('./engine-registry').resolveEngineForRole,
143
143
  dispatchToEngine: require('./fleet').dispatchToEngine,
144
+ recordDispatchOutcome: require('./fleet').recordDispatchOutcome,
145
+ clock: Date.now,
144
146
  createAgentWorktree: require('../commands/worktree').createAgentWorktree,
145
147
  };
146
148
  }
@@ -272,7 +274,13 @@ function handleMissionBlocker({ mission, stopReason, workspaceRoot, appendEvent
272
274
  agent: engine.id,
273
275
  task: `mission-blocker-${String(ref).toLowerCase()}`,
274
276
  });
275
- deps.dispatchToEngine({
277
+ const pin = {
278
+ model: engine.roster_model || '',
279
+ effort: engine.roster_effort || '',
280
+ maxSeconds: Number(engine.roster_max_seconds) || 0,
281
+ };
282
+ const startedMs = deps.clock();
283
+ const dispatched = deps.dispatchToEngine({
276
284
  task,
277
285
  engine: engine.id,
278
286
  worktreePath: worktree.path,
@@ -281,14 +289,30 @@ function handleMissionBlocker({ mission, stopReason, workspaceRoot, appendEvent
281
289
  ...(engine.roster_effort ? { effort: engine.roster_effort } : {}),
282
290
  ...(engine.roster_max_seconds ? { maxSeconds: engine.roster_max_seconds } : {}),
283
291
  });
292
+ // How the engine did goes through the same health and run-record calls
293
+ // as every other dispatch, so a stalled blocker build benches it.
294
+ const run = dispatched && typeof dispatched === 'object' && typeof dispatched.then !== 'function'
295
+ ? deps.recordDispatchOutcome(scopedRoot, {
296
+ engine: engine.id,
297
+ pin,
298
+ startedMs,
299
+ endedMs: deps.clock(),
300
+ result: dispatched,
301
+ job: 'build',
302
+ task: ref,
303
+ source: 'mission blocker',
304
+ member: mission.owner || '',
305
+ })
306
+ : null;
284
307
  projectTasks(deps.taskDb, db, scopedRoot);
285
308
  appendEvent?.('mission_blocker_dispatched', {
286
309
  task_id: ref,
287
310
  engine: engine.id,
288
311
  blocker_class: blockerClass,
289
312
  worktree_path: worktree.path,
313
+ ...(run ? { outcome: run.outcome } : {}),
290
314
  });
291
- return { taskId: ref, dispatched: true, engine: engine.id, reason: 'dispatched' };
315
+ return { taskId: ref, dispatched: true, engine: engine.id, reason: 'dispatched', ...(run ? { outcome: run.outcome, ...(run.detail ? { detail: run.detail } : {}) } : {}) };
292
316
  } catch (error) {
293
317
  return { taskId: ref, dispatched: false, engine: engine.id, reason: error.message || String(error) };
294
318
  }
@@ -131,7 +131,7 @@ function getTasksFromDbBucket(atrisDir, bucketName) {
131
131
  const taskDb = require('./task-db');
132
132
  if (!fs.existsSync(taskDb.getDbPath())) return null;
133
133
  const workspaceRoot = taskDb.workspaceRoot(path.dirname(path.resolve(atrisDir)));
134
- const rows = taskDb.listTasks(taskDb.open(), { workspaceRoot, limit: 500 });
134
+ const rows = taskDb.listTasksCached(taskDb.open(), { workspaceRoot, limit: 500 });
135
135
  if (!rows.length) return null;
136
136
  return rows
137
137
  .filter(row => statuses.has(String(row.status || '').toLowerCase()))
@@ -151,7 +151,7 @@ function getTaskCounts(atrisDir) {
151
151
  const taskDb = require('./task-db');
152
152
  if (fs.existsSync(taskDb.getDbPath())) {
153
153
  const workspaceRoot = taskDb.workspaceRoot(path.dirname(path.resolve(atrisDir)));
154
- const rows = taskDb.listTasks(taskDb.open(), { workspaceRoot, limit: 500 });
154
+ const rows = taskDb.listTasksCached(taskDb.open(), { workspaceRoot, limit: 500 });
155
155
  if (rows.length) {
156
156
  const counts = { backlog: 0, active: 0, review: 0, reviewCertified: 0, source: 'db' };
157
157
  for (const row of rows) {
@@ -190,7 +190,7 @@ function getTaskGlance(atrisDir, sampleSize = 3) {
190
190
  const taskDb = require('./task-db');
191
191
  if (fs.existsSync(taskDb.getDbPath())) {
192
192
  const workspaceRoot = taskDb.workspaceRoot(path.dirname(path.resolve(atrisDir)));
193
- const rows = taskDb.listTasks(taskDb.open(), { workspaceRoot, limit: 500 });
193
+ const rows = taskDb.listTasksCached(taskDb.open(), { workspaceRoot, limit: 500 });
194
194
  if (rows.length) {
195
195
  const glance = {
196
196
  backlog: 0, active: 0, review: 0, reviewCertified: 0,
package/lib/task-db.js CHANGED
@@ -333,10 +333,14 @@ function taskDisplayRef(row, index) {
333
333
  return `${workspaceRefPrefix(row && row.workspace_root)}-${Number(index) + 1}`;
334
334
  }
335
335
 
336
- function shortestUniqueTaskRef(id, ids, minLength = 8) {
336
+ // normalizedIds: the ids already passed through normalizeTaskRef, so a caller
337
+ // resolving every row of a list normalizes the list once, not once per row.
338
+ function shortestUniqueTaskRef(id, ids, minLength = 8, normalizedIds = null) {
337
339
  const normalized = normalizeTaskRef(id);
338
340
  if (!normalized) return '';
339
- const all = (Array.isArray(ids) ? ids : []).map(normalizeTaskRef).filter(Boolean);
341
+ const all = Array.isArray(normalizedIds)
342
+ ? normalizedIds
343
+ : (Array.isArray(ids) ? ids : []).map(normalizeTaskRef).filter(Boolean);
340
344
  for (let length = Math.min(minLength, normalized.length); length <= normalized.length; length += 1) {
341
345
  const prefix = normalized.slice(0, length);
342
346
  const matches = all.filter(candidate => candidate.startsWith(prefix));
@@ -364,10 +368,11 @@ function withTaskDisplayRefs(rows, refRows = rows) {
364
368
  const sorted = [...group]
365
369
  .sort((a, b) => (Number(a.created_at || 0) - Number(b.created_at || 0)) || String(a.id || '').localeCompare(String(b.id || '')))
366
370
  const ids = sorted.map(row => row && row.id);
371
+ const normalizedIds = ids.map(normalizeTaskRef).filter(Boolean);
367
372
  sorted.forEach((row, index) => {
368
373
  refs.set(row.id, {
369
374
  display_id: row.display_id || taskDisplayRef(row, index),
370
- legacy_ref: row.legacy_ref || shortestUniqueTaskRef(row.id, ids, 8),
375
+ legacy_ref: row.legacy_ref || shortestUniqueTaskRef(row.id, ids, 8, normalizedIds),
371
376
  });
372
377
  });
373
378
  }
@@ -470,6 +475,35 @@ function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit }) {
470
475
  }));
471
476
  }
472
477
 
478
+ // Boot reads the same task list from several places (the TODO buckets and
479
+ // the status glance). Share one read per connection while nothing changed:
480
+ // PRAGMA data_version moves when another connection commits, and
481
+ // total_changes() moves when this connection writes. Callers must treat the
482
+ // rows as read-only.
483
+ const listTasksMemo = new WeakMap();
484
+
485
+ function listTasksCached(db, opts = {}) {
486
+ let stamp;
487
+ try {
488
+ const version = db.prepare('PRAGMA data_version').get();
489
+ const changes = db.prepare('SELECT total_changes() AS n').get();
490
+ stamp = `${Object.values(version || {})[0]}:${changes && changes.n}`;
491
+ } catch {
492
+ return listTasks(db, opts);
493
+ }
494
+ const key = JSON.stringify([opts.workspaceRoot || '', opts.status || '', opts.claimedBy || '', opts.limit || 0]);
495
+ let byKey = listTasksMemo.get(db);
496
+ if (!byKey) {
497
+ byKey = new Map();
498
+ listTasksMemo.set(db, byKey);
499
+ }
500
+ const hit = byKey.get(key);
501
+ if (hit && hit.stamp === stamp) return hit.rows;
502
+ const rows = listTasks(db, opts);
503
+ byKey.set(key, { stamp, rows });
504
+ return rows;
505
+ }
506
+
473
507
  function getTask(db, id) {
474
508
  if (!id) throw new Error('id required');
475
509
  const row = db.prepare(`
@@ -2243,6 +2277,7 @@ module.exports = {
2243
2277
  addTask,
2244
2278
  getTask,
2245
2279
  listTasks,
2280
+ listTasksCached,
2246
2281
  claimTask,
2247
2282
  releaseTask,
2248
2283
  reopenTask,
@@ -6,6 +6,7 @@
6
6
 
7
7
  const { engineRunsView, modelLabel } = require('./roster-models');
8
8
  const { rosterToolLabel } = require('./engine-registry');
9
+ const { suggestionCount } = require('./roster-suggest');
9
10
 
10
11
  const BUILT_IN_JOBS = Object.freeze(['build', 'review', 'search']);
11
12
  const CLAUDE_FAMILY = Object.freeze(['claude', 'fable', 'haiku']);
@@ -51,6 +52,8 @@ function readLineupSafe(root = process.cwd(), now = new Date()) {
51
52
 
52
53
  // One boot line: the tool and model that runs each built-in job right now,
53
54
  // custom jobs only when all of them still fit. '' when nothing resolves.
55
+ // When the run record suggests a new order for any job, a short marker
56
+ // points at the roster view; only the end of the record is read.
54
57
  function bootTeamLine(root = process.cwd(), { width = 69, now = new Date(), jobs = null } = {}) {
55
58
  let rows = jobs;
56
59
  if (!rows) {
@@ -65,9 +68,11 @@ function bootTeamLine(root = process.cwd(), { width = 69, now = new Date(), jobs
65
68
  const builtIn = ordered.filter((row) => BUILT_IN_JOBS.includes(row.job) && !row.key).map(part).filter(Boolean);
66
69
  const custom = ordered.filter((row) => !BUILT_IN_JOBS.includes(row.job) || row.key).map(part).filter(Boolean);
67
70
  if (!builtIn.length && !custom.length) return '';
71
+ const count = suggestionCount(ordered, root, { now });
72
+ const marker = count ? ` · ${count} suggestion${count === 1 ? '' : 's'} (atris engine roster)` : '';
68
73
  const all = [...builtIn, ...custom].join(' · ');
69
- if (all.length <= width || !builtIn.length) return all;
70
- return builtIn.join(' · ');
74
+ if (all.length + marker.length <= width || !builtIn.length) return `${all}${marker}`;
75
+ return `${builtIn.join(' · ')}${marker}`;
71
76
  }
72
77
 
73
78
  function runsText(view) {
package/lib/todo.js CHANGED
@@ -41,7 +41,7 @@ function dbToShimRow(row) {
41
41
  function dbBuckets(workspaceRoot) {
42
42
  const taskDb = require('./task-db');
43
43
  const db = taskDb.open();
44
- const rows = taskDb.withTaskDisplayRefs(taskDb.listTasks(db, { workspaceRoot, limit: 500 }));
44
+ const rows = taskDb.withTaskDisplayRefs(taskDb.listTasksCached(db, { workspaceRoot, limit: 500 }));
45
45
  // Need raw source_key for merge dedup, plus the shim shape for callers.
46
46
  const backlog = [];
47
47
  const inProgress = [];
package/lib/wish-audit.js CHANGED
@@ -424,7 +424,7 @@ function policyValidator(root, excludeId = '') {
424
424
  .filter((engine) => engine.id !== String(excludeId || ''))
425
425
  .sort((a, b) => Number(a.fallback_order) - Number(b.fallback_order)
426
426
  || String(a.id).localeCompare(String(b.id)));
427
- return ranked.find((engine) => engine.health && engine.health.status === 'ready') || ranked[0] || null;
427
+ return ranked.find((engine) => engineRegistry.engineReadyAt(engine)) || ranked[0] || null;
428
428
  } catch {
429
429
  return null;
430
430
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.61.0",
3
+ "version": "3.62.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": {
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ // det/cpu-hotspots.js - where does an atris command spend its time?
3
+ // Runs one command under `node --cpu-prof` in a temp profile dir, then prints
4
+ // the total profiled ms, the top self-time hotspots, and the top call chains
5
+ // that end in spawnSync (application frames only, so node internals and
6
+ // node_modules drop out). Optional plain wall-clock runs give a median.
7
+ //
8
+ // Usage:
9
+ // node scripts/det/cpu-hotspots.js [options] -- <script.js> [args...]
10
+ // node scripts/det/cpu-hotspots.js --cwd ../repo -- bin/atris.js atris.md
11
+ // node scripts/det/cpu-hotspots.js --wall 5 --top 15 -- bin/atris.js team
12
+ //
13
+ // Options:
14
+ // --cwd <dir> working directory for the profiled command (default: here)
15
+ // --top <n> rows per table (default 10)
16
+ // --wall <n> also time n unprofiled runs and print the median wall ms
17
+ // --app <dir> frames under this dir count as application frames
18
+ // (default: the repo that holds this script)
19
+ // --keep keep the temp profile dir and print its path
20
+ // --json structured output
21
+ //
22
+ // The command's own stdout is discarded; its exit code is reported.
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+ const { spawnSync } = require('child_process');
30
+
31
+ const DEFAULT_APP_ROOT = path.resolve(__dirname, '..', '..');
32
+
33
+ function parseArgs(argv) {
34
+ const opts = { cwd: process.cwd(), top: 10, wall: 0, app: DEFAULT_APP_ROOT, keep: false, json: false, command: [] };
35
+ const sep = argv.indexOf('--');
36
+ const head = sep === -1 ? argv : argv.slice(0, sep);
37
+ opts.command = sep === -1 ? [] : argv.slice(sep + 1);
38
+ for (let i = 0; i < head.length; i += 1) {
39
+ const arg = head[i];
40
+ if (arg === '--cwd') opts.cwd = path.resolve(head[++i] || '.');
41
+ else if (arg === '--top') opts.top = Math.max(1, Number(head[++i]) || 10);
42
+ else if (arg === '--wall') opts.wall = Math.max(0, Number(head[++i]) || 0);
43
+ else if (arg === '--app') opts.app = path.resolve(head[++i] || '.');
44
+ else if (arg === '--keep') opts.keep = true;
45
+ else if (arg === '--json') opts.json = true;
46
+ else if (arg === '-h' || arg === '--help') opts.help = true;
47
+ else opts.command.push(arg);
48
+ }
49
+ return opts;
50
+ }
51
+
52
+ function frameFile(url) {
53
+ return String(url || '').replace(/^file:\/\//, '');
54
+ }
55
+
56
+ function isAppFrame(frame, appRoot) {
57
+ const file = frameFile(frame.url);
58
+ if (!file || !file.startsWith(appRoot)) return false;
59
+ return !file.includes(`${path.sep}node_modules${path.sep}`);
60
+ }
61
+
62
+ function frameLabel(frame, appRoot) {
63
+ const file = frameFile(frame.url);
64
+ const rel = appRoot && file.startsWith(appRoot) ? path.relative(appRoot, file) : (file || '(native)');
65
+ const name = frame.functionName || '(anonymous)';
66
+ return `${name} ${rel}:${Number(frame.lineNumber) + 1}`;
67
+ }
68
+
69
+ // Pure core: a parsed .cpuprofile in, tables out. Unit-testable.
70
+ function analyzeProfile(profile, { appRoot = DEFAULT_APP_ROOT, top = 10 } = {}) {
71
+ const nodes = new Map();
72
+ const parent = new Map();
73
+ for (const node of profile.nodes || []) {
74
+ nodes.set(node.id, node);
75
+ for (const child of node.children || []) parent.set(child, node.id);
76
+ }
77
+ const selfUs = new Map();
78
+ const samples = profile.samples || [];
79
+ const deltas = profile.timeDeltas || [];
80
+ for (let i = 0; i < samples.length; i += 1) {
81
+ const dt = Math.max(0, Number(deltas[i]) || 0);
82
+ selfUs.set(samples[i], (selfUs.get(samples[i]) || 0) + dt);
83
+ }
84
+ const totalUs = [...selfUs.values()].reduce((a, b) => a + b, 0)
85
+ || Math.max(0, Number(profile.endTime) - Number(profile.startTime)) || 0;
86
+
87
+ const byFrame = new Map();
88
+ for (const [id, us] of selfUs) {
89
+ const node = nodes.get(id);
90
+ if (!node) continue;
91
+ const label = frameLabel(node.callFrame || {}, appRoot);
92
+ byFrame.set(label, (byFrame.get(label) || 0) + us);
93
+ }
94
+ const hotspots = [...byFrame.entries()]
95
+ .sort((a, b) => b[1] - a[1])
96
+ .slice(0, top)
97
+ .map(([frame, us]) => ({ frame, ms: Math.round(us / 1000) }));
98
+
99
+ // Time under each spawnSync node = self time of every descendant sample.
100
+ const subtreeUs = new Map();
101
+ for (const [id, us] of selfUs) {
102
+ let cur = id;
103
+ const seen = new Set();
104
+ while (cur !== undefined && !seen.has(cur)) {
105
+ seen.add(cur);
106
+ subtreeUs.set(cur, (subtreeUs.get(cur) || 0) + us);
107
+ cur = parent.get(cur);
108
+ }
109
+ }
110
+ const chains = new Map();
111
+ for (const node of nodes.values()) {
112
+ const fn = node.callFrame && node.callFrame.functionName;
113
+ if (fn !== 'spawnSync') continue;
114
+ // Only the outermost spawnSync on a path counts (child_process wraps an
115
+ // internal spawnSync of the same name).
116
+ let up = parent.get(node.id);
117
+ let nested = false;
118
+ while (up !== undefined) {
119
+ const upNode = nodes.get(up);
120
+ if (upNode && upNode.callFrame && upNode.callFrame.functionName === 'spawnSync') { nested = true; break; }
121
+ up = parent.get(up);
122
+ }
123
+ if (nested) continue;
124
+ const us = subtreeUs.get(node.id) || 0;
125
+ if (!us) continue;
126
+ const frames = [];
127
+ let cur = parent.get(node.id);
128
+ while (cur !== undefined) {
129
+ const n = nodes.get(cur);
130
+ if (n && n.callFrame && isAppFrame(n.callFrame, appRoot)) frames.push(frameLabel(n.callFrame, appRoot));
131
+ cur = parent.get(cur);
132
+ }
133
+ const key = frames.slice(0, 6).join(' < ') || '(no app frames)';
134
+ chains.set(key, (chains.get(key) || 0) + us);
135
+ }
136
+ const spawnTotalUs = [...chains.values()].reduce((a, b) => a + b, 0);
137
+ const spawnChains = [...chains.entries()]
138
+ .sort((a, b) => b[1] - a[1])
139
+ .slice(0, top)
140
+ .map(([chain, us]) => ({ chain, ms: Math.round(us / 1000) }));
141
+ return { total_ms: Math.round(totalUs / 1000), spawn_ms: Math.round(spawnTotalUs / 1000), hotspots, spawn_chains: spawnChains };
142
+ }
143
+
144
+ function median(values) {
145
+ if (!values.length) return 0;
146
+ const sorted = [...values].sort((a, b) => a - b);
147
+ const mid = Math.floor(sorted.length / 2);
148
+ return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
149
+ }
150
+
151
+ function timeRun(command, cwd, extraArgs = []) {
152
+ const start = process.hrtime.bigint();
153
+ const result = spawnSync(process.execPath, [...extraArgs, ...command], { cwd, stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8' });
154
+ const ms = Number(process.hrtime.bigint() - start) / 1e6;
155
+ return { ms: Math.round(ms), status: result.status, stderr: result.stderr || '' };
156
+ }
157
+
158
+ function resolveCommand(command) {
159
+ if (!command.length) return command;
160
+ const [script, ...rest] = command;
161
+ const abs = path.isAbsolute(script) ? script : path.resolve(process.cwd(), script);
162
+ return [fs.existsSync(abs) ? abs : script, ...rest];
163
+ }
164
+
165
+ function main() {
166
+ const opts = parseArgs(process.argv.slice(2));
167
+ if (opts.help || !opts.command.length) {
168
+ process.stderr.write('usage: node scripts/det/cpu-hotspots.js [--cwd dir] [--top n] [--wall n] [--json] -- <script.js> [args...]\n');
169
+ return opts.help ? 0 : 2;
170
+ }
171
+ const command = resolveCommand(opts.command);
172
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-cpu-prof-'));
173
+ const profiled = timeRun(command, opts.cwd, ['--cpu-prof', `--cpu-prof-dir=${dir}`]);
174
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.cpuprofile'))
175
+ .map((f) => path.join(dir, f))
176
+ .sort((a, b) => fs.statSync(b).size - fs.statSync(a).size);
177
+ if (!files.length) {
178
+ process.stderr.write(`no profile written (exit ${profiled.status})\n${profiled.stderr.slice(0, 800)}`);
179
+ return 1;
180
+ }
181
+ const report = analyzeProfile(JSON.parse(fs.readFileSync(files[0], 'utf8')), { appRoot: opts.app, top: opts.top });
182
+ report.command = command.join(' ');
183
+ report.exit_code = profiled.status;
184
+ report.profiled_wall_ms = profiled.ms;
185
+ if (opts.wall) {
186
+ const walls = [];
187
+ for (let i = 0; i < opts.wall; i += 1) walls.push(timeRun(command, opts.cwd).ms);
188
+ report.wall_runs_ms = walls;
189
+ report.wall_median_ms = median(walls);
190
+ }
191
+ if (opts.keep) report.profile_dir = dir;
192
+ else fs.rmSync(dir, { recursive: true, force: true });
193
+
194
+ if (opts.json) {
195
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
196
+ return 0;
197
+ }
198
+ const out = [];
199
+ out.push(`command: ${report.command} (exit ${report.exit_code})`);
200
+ out.push(`profiled: ${report.total_ms} ms cpu profile, ${report.profiled_wall_ms} ms wall, ${report.spawn_ms} ms in spawnSync`);
201
+ if (opts.wall) out.push(`wall median of ${opts.wall}: ${report.wall_median_ms} ms (${report.wall_runs_ms.join(', ')})`);
202
+ out.push('');
203
+ out.push('top self time:');
204
+ for (const row of report.hotspots) out.push(` ${String(row.ms).padStart(6)} ms ${row.frame}`);
205
+ out.push('');
206
+ out.push('top spawnSync chains:');
207
+ if (!report.spawn_chains.length) out.push(' (none)');
208
+ for (const row of report.spawn_chains) out.push(` ${String(row.ms).padStart(6)} ms ${row.chain}`);
209
+ if (report.profile_dir) out.push('', `profile dir: ${report.profile_dir}`);
210
+ process.stdout.write(`${out.join('\n')}\n`);
211
+ return 0;
212
+ }
213
+
214
+ if (require.main === module) process.exitCode = main();
215
+
216
+ module.exports = { analyzeProfile, median, parseArgs };
@@ -1,11 +1,15 @@
1
1
  #!/bin/bash
2
2
  # ytnotes <youtube-url> [engine]: local YouTube notes, tweet-feed one-screen.
3
3
  # Zero Atris credits. yt-dlp pulls captions, a fast engine writes the notes.
4
- # Engines: haiku (default), atris-fast, gemini, grok, codex, cursor.
4
+ # No captions: if mlx_whisper is installed (uv tool install mlx-whisper, Apple
5
+ # silicon), the audio is transcribed on this machine instead. Otherwise the
6
+ # script prints the paid command. ATRIS_YTNOTES_LOCAL_TRANSCRIBE=0 turns it off.
7
+ # Engines: auto (default: Gemini Flash through agy, Haiku if that fails),
8
+ # gemini, haiku, atris-fast, grok, codex, cursor. ATRIS_YTNOTES_ENGINE sets the default.
5
9
  # Outputs: $WORK/yt_${ID}.clean.txt (transcript), $WORK/yt_${ID}.md (notes).
6
10
  set -euo pipefail
7
11
 
8
- USAGE="usage: ytnotes <youtube-url> [haiku|atris-fast|gemini|grok|codex|cursor]"
12
+ USAGE="usage: ytnotes <youtube-url> [auto|gemini|haiku|atris-fast|grok|codex|cursor]"
9
13
 
10
14
  case "${1:-}" in
11
15
  *youtube.com*|*youtu.be*|*youtube-nocookie.com*) ;;
@@ -13,16 +17,22 @@ case "${1:-}" in
13
17
  esac
14
18
 
15
19
  URL="$1"
16
- ENGINE="${2:-haiku}"
20
+ ENGINE="${2:-${ATRIS_YTNOTES_ENGINE:-auto}}"
17
21
  CALLER_CWD="$PWD"
18
22
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
19
23
  WORK="${TMPDIR:-/tmp}/ytnotes"
24
+ # uv tool installs (yt-dlp, mlx_whisper) live here; appended so it never
25
+ # shadows anything already on PATH.
26
+ PATH="$PATH:$HOME/.local/bin"
27
+ WHISPER_MODEL="${ATRIS_YTNOTES_WHISPER_MODEL:-mlx-community/whisper-large-v3-turbo}"
20
28
  mkdir -p "$WORK"
21
29
  cd "$WORK"
22
30
 
23
31
  # Printed metadata and a written English VTT are a hit even when yt-dlp exits 429.
24
32
  # A leaked WARNING|ERROR print line is not the video id.
25
33
  # Manual English is --write-subs. Auto English is often en-orig / en-US / en-GB.
34
+ # Plain auto "en" is YouTube's machine translation and returns HTTP 429, which
35
+ # made yt-dlp quit before writing en-orig. Ask for en-orig first, keep going.
26
36
  # Teach parses leftover yt_<id>.clean.txt stamps as cues.
27
37
  # Empty --print still keeps a VTT written in this run (id from the watch /
28
38
  # shorts / embed / live / e / nocookie embed URL or the new filename). A copied
@@ -30,13 +40,13 @@ cd "$WORK"
30
40
  # gone, leftover yt_<id>.clean.txt from a prior notes run is still a keep.
31
41
  # Another video's leftover clean.txt is not invent-keep.
32
42
  existing=""
33
- for name in yt_*.en.vtt yt_*.en-orig.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
43
+ for name in yt_*.en-orig.vtt yt_*.en.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
34
44
  [ -e "$name" ] || continue
35
45
  existing="$existing $name"
36
46
  done
37
47
 
38
48
  META=$(yt-dlp --no-update --quiet --no-warnings --skip-download \
39
- --write-subs --write-auto-subs --sub-format vtt --sub-langs "en,en-orig,en-US,en-GB" \
49
+ --write-subs --write-auto-subs --sub-format vtt --sub-langs "en-orig,en,en-US,en-GB" --ignore-errors \
40
50
  --no-simulate \
41
51
  -o "yt_%(id)s" \
42
52
  --print "%(id)s|%(title)s|%(channel)s|%(duration_string)s" \
@@ -114,7 +124,7 @@ fi
114
124
 
115
125
  VTT=""
116
126
  if [ -n "$ID" ]; then
117
- for name in "yt_${ID}.en.vtt" "yt_${ID}.en-orig.vtt" "yt_${ID}.en-US.vtt" "yt_${ID}.en-GB.vtt"; do
127
+ for name in "yt_${ID}.en-orig.vtt" "yt_${ID}.en.vtt" "yt_${ID}.en-US.vtt" "yt_${ID}.en-GB.vtt" "yt_${ID}.local.vtt"; do
118
128
  if [ -s "$name" ]; then
119
129
  VTT="$name"
120
130
  break
@@ -122,7 +132,7 @@ if [ -n "$ID" ]; then
122
132
  done
123
133
  fi
124
134
  if [ -z "$VTT" ]; then
125
- for name in yt_*.en.vtt yt_*.en-orig.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
135
+ for name in yt_*.en-orig.vtt yt_*.en.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
126
136
  [ -s "$name" ] || continue
127
137
  case " $existing " in
128
138
  *" $name "*) continue ;;
@@ -135,11 +145,67 @@ if [ -z "$VTT" ]; then
135
145
  break
136
146
  done
137
147
  fi
148
+ # No captions: transcribe the audio on this machine. Writes yt_<id>.local.vtt
149
+ # and deletes the audio. Returns non-zero, with the reason in LOCAL_FAIL, when
150
+ # the model is missing or a step fails.
151
+ LOCAL_FAIL=""
152
+ transcribe_locally() {
153
+ if [ "${ATRIS_YTNOTES_LOCAL_TRANSCRIBE:-1}" = "0" ]; then
154
+ LOCAL_FAIL="off"; return 1
155
+ fi
156
+ if ! command -v mlx_whisper >/dev/null 2>&1; then
157
+ LOCAL_FAIL="no-model"; return 1
158
+ fi
159
+ echo "No captions on this video. Transcribing the audio on this computer (free, about 1 minute per 15 minutes of video)." >&2
160
+ rm -f "yt_${ID}".audio.*
161
+ # YouTube refuses some media requests at random (HTTP 403) and allows the
162
+ # next one, so try the download three times before giving up.
163
+ local attempt downloaded=0
164
+ for attempt in 1 2 3; do
165
+ if yt-dlp --no-update --quiet --no-warnings --no-playlist \
166
+ -f "bestaudio[ext=m4a]/bestaudio/best" -o "yt_${ID}.audio.%(ext)s" "$URL" 2>"yt_${ID}.audio.err"; then
167
+ downloaded=1; break
168
+ fi
169
+ [ "$attempt" = 3 ] || sleep "$((attempt * ${ATRIS_YTNOTES_RETRY_SECONDS:-3}))"
170
+ done
171
+ if [ "$downloaded" != 1 ]; then
172
+ LOCAL_FAIL="download: $(grep -m1 ERROR "yt_${ID}.audio.err" 2>/dev/null || echo "yt-dlp failed")"
173
+ rm -f "yt_${ID}".audio.*
174
+ return 1
175
+ fi
176
+ local audio=""
177
+ for f in "yt_${ID}".audio.*; do
178
+ case "$f" in *.err|*.part) continue ;; esac
179
+ [ -s "$f" ] && audio="$f" && break
180
+ done
181
+ if [ -z "$audio" ]; then
182
+ LOCAL_FAIL="download: no audio file"; rm -f "yt_${ID}".audio.*; return 1
183
+ fi
184
+ if ! mlx_whisper "$audio" --model "$WHISPER_MODEL" --output-format vtt \
185
+ --output-dir "$WORK" --output-name "whisper_${ID}" --verbose False >"yt_${ID}.whisper.log" 2>&1; then
186
+ LOCAL_FAIL="transcribe: $(tail -n 1 "yt_${ID}.whisper.log")"
187
+ rm -f "yt_${ID}".audio.* "whisper_${ID}.vtt"
188
+ return 1
189
+ fi
190
+ # mlx_whisper drops anything after a dot in --output-name, so write a
191
+ # dot-free name and rename it.
192
+ mv -f "whisper_${ID}.vtt" "yt_${ID}.local.vtt" 2>/dev/null || true
193
+ rm -f "yt_${ID}".audio.* "yt_${ID}.whisper.log"
194
+ [ -s "yt_${ID}.local.vtt" ] || { LOCAL_FAIL="transcribe: empty transcript"; return 1; }
195
+ }
196
+
138
197
  if [ -z "$VTT" ]; then
139
198
  if [ -n "$ID" ] && [ -s "yt_${ID}.clean.txt" ]; then
140
199
  : # leftover cleaned transcript from a prior notes run
200
+ elif [ -n "$ID" ] && transcribe_locally; then
201
+ VTT="yt_${ID}.local.vtt"
141
202
  else
142
- echo "No English captions found for $URL. Falling back is manual (atris youtube process, 5 credits)." >&2
203
+ echo "No English captions found for $URL." >&2
204
+ case "$LOCAL_FAIL" in
205
+ no-model) echo "This computer has no local speech model to transcribe it." >&2 ;;
206
+ download:*|transcribe:*) echo "Local transcription failed (${LOCAL_FAIL})." >&2 ;;
207
+ esac
208
+ echo "Paid notes, 5 credits, refunded if it fails: atris youtube process \"$URL\"" >&2
143
209
  exit 2
144
210
  fi
145
211
  fi
@@ -150,10 +216,12 @@ fi
150
216
  # Leftover clean.txt is already cleaned; do not overwrite it with an empty VTT pass.
151
217
  if [ -n "$VTT" ]; then
152
218
  sed -E 's/<[^>]*>//g; s/&gt;/>/g; s/&amp;/\&/g; s/&quot;/"/g' "$VTT" | awk '
153
- function seconds(ts, parts) {
154
- split(ts, parts, ":")
219
+ function seconds(ts, parts, n) {
220
+ n = split(ts, parts, ":")
221
+ if (n == 2) return (parts[1] * 60) + int(parts[2])
155
222
  return (parts[1] * 3600) + (parts[2] * 60) + int(parts[3])
156
223
  }
224
+ BEGIN { last_bucket = -1 }
157
225
  /^(WEBVTT|Kind:|Language:)/ { next }
158
226
  /-->/ { cue_seconds = seconds($1); next }
159
227
  {
@@ -184,7 +252,7 @@ fi
184
252
 
185
253
  TRANSCRIPT=$(<"yt_${ID}.clean.txt")
186
254
 
187
- STYLE_PROMPT="Write one-screen tweet-feed notes on this YouTube transcript ($INFO). Markdown only, start with # title then a 'Channel · Duration' line, 6-8 beats each opening with a **Bold hook.**, 4-6 verbatim quotes in double quotes with [mm:ss] timestamps on their own '> ' lines, end with a 2-item **Takeaway**. No preamble, no em dashes, fits one screen."
255
+ STYLE_PROMPT="Write one-screen tweet-feed notes on this YouTube transcript ($INFO). Markdown only, start with # title then a 'Channel · Duration' line, 6-8 beats each opening with a **Bold hook.**, 4-6 verbatim quotes, each on its own line shaped exactly > \"quote\" [mm:ss], end with a 2-item **Takeaway**. No preamble, no em dashes, fits one screen."
188
256
 
189
257
  PROMPT="$STYLE_PROMPT
190
258
 
@@ -282,26 +350,44 @@ run_haiku() {
282
350
  run_with_timeout 240 claude -p "$PROMPT" --model claude-haiku-4-5
283
351
  }
284
352
 
353
+ # Gemini Flash through the Antigravity CLI (agy). On an 85-minute transcript it
354
+ # writes notes in about 8s; Haiku takes 30-40s. The old gemini CLI is retired.
355
+ run_gemini() {
356
+ command -v agy >/dev/null 2>&1 || { echo "agy is not installed" >&2; return 127; }
357
+ run_with_timeout 120 agy -p "$PROMPT" --model "${ATRIS_YTNOTES_GEMINI_MODEL:-gemini-3.8-flash}" \
358
+ --effort low --print-timeout 110s </dev/null
359
+ }
360
+
361
+ # Fastest writer that answers: Gemini, then Haiku. Buffered so a failed or
362
+ # empty Gemini answer never reaches the notes file.
363
+ run_auto() {
364
+ local out=""
365
+ if out=$(run_gemini 2>"$WORK/yt_${ID}.engine.err") && printf '%s' "$out" | grep -q '[[:alnum:]]'; then
366
+ printf '%s\n' "$out"
367
+ return 0
368
+ fi
369
+ echo "Gemini did not answer; writing the notes with Haiku instead." >&2
370
+ run_haiku
371
+ }
372
+
285
373
  {
286
374
  case "$ENGINE" in
375
+ auto)
376
+ run_auto
377
+ ;;
287
378
  haiku)
288
379
  run_haiku
289
380
  ;;
290
381
  atris-fast|fast|atris)
291
382
  run_atris_fast
292
383
  ;;
293
- gemini|gemini-3.7-flash)
294
- run_with_timeout 120 gemini -m gemini-3.7-flash --skip-trust \
295
- --approval-mode plan -o text -p "$PROMPT"
296
- ;;
297
- gemini-3.6-flash)
298
- run_with_timeout 120 gemini -m gemini-3.6-flash --skip-trust \
299
- --approval-mode plan -o text -p "$PROMPT"
384
+ gemini|agy)
385
+ run_gemini
300
386
  ;;
301
387
  grok) run_grok ;;
302
388
  codex) run_with_timeout 240 codex exec --sandbox read-only --skip-git-repo-check "$PROMPT" </dev/null ;;
303
389
  cursor) run_with_timeout 240 cursor-agent --trust -p "$PROMPT" ;;
304
- *) echo "unknown engine: $ENGINE (haiku|atris-fast|gemini|grok|codex|cursor)" >&2; exit 1 ;;
390
+ *) echo "unknown engine: $ENGINE (auto|gemini|haiku|atris-fast|grok|codex|cursor)" >&2; exit 1 ;;
305
391
  esac
306
392
  } | perl -0pe 's/\A.*?(?=# )//s' | tee "yt_${ID}.md"
307
393