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.
- package/README.md +37 -5
- package/atris/skills/engines/SKILL.md +9 -9
- package/bin/atris.js +1 -0
- package/commands/autoland.js +36 -5
- package/commands/autopilot.js +105 -6
- package/commands/engine.js +96 -21
- package/commands/host.js +56 -4
- package/commands/land.js +129 -8
- package/commands/member.js +33 -8
- package/commands/mission.js +112 -3
- package/commands/one-lap.js +14 -10
- package/commands/stream.js +56 -22
- package/commands/team.js +82 -10
- package/commands/worktree.js +39 -0
- package/commands/youtube.js +36 -26
- package/lib/engine-ask.js +112 -9
- package/lib/engine-registry.js +283 -39
- package/lib/fleet.js +444 -26
- package/lib/functional-owner.js +15 -6
- package/lib/host.js +296 -170
- package/lib/member-engine.js +11 -1
- package/lib/member-park.js +138 -0
- package/lib/member-scaffold.js +37 -0
- package/lib/roster-markdown.js +1 -1
- package/lib/roster-prep.js +289 -0
- package/lib/roster-runs.js +389 -0
- package/lib/roster-suggest.js +145 -0
- package/lib/router-brain.js +111 -3
- package/lib/self-drive.js +26 -2
- package/lib/state-detection.js +3 -3
- package/lib/task-db.js +38 -3
- package/lib/team-lineup.js +7 -2
- package/lib/todo.js +1 -1
- package/lib/wish-audit.js +1 -1
- package/package.json +1 -1
- package/scripts/det/cpu-hotspots.js +216 -0
- package/scripts/det/ytnotes +105 -19
- package/scripts/det/ytquote-repair.js +18 -4
- package/templates/members/host/MEMBER.md +5 -3
- package/templates/members/host/MISSION.md +1 -1
- 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
|
-
|
|
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
|
}
|
package/lib/state-detection.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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,
|
package/lib/team-lineup.js
CHANGED
|
@@ -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.
|
|
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) =>
|
|
427
|
+
return ranked.find((engine) => engineRegistry.engineReadyAt(engine)) || ranked[0] || null;
|
|
428
428
|
} catch {
|
|
429
429
|
return null;
|
|
430
430
|
}
|
package/package.json
CHANGED
|
@@ -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 };
|
package/scripts/det/ytnotes
CHANGED
|
@@ -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
|
-
#
|
|
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|
|
|
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:-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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/>/>/g; s/&/\&/g; s/"/"/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
|
|
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|
|
|
294
|
-
|
|
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|
|
|
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
|
|