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/commands/team.js
CHANGED
|
@@ -7,6 +7,7 @@ const { canonicalEngineName } = require('../lib/engine-registry');
|
|
|
7
7
|
const taskDb = require('../lib/task-db');
|
|
8
8
|
const { buildTeamPresence, DEFAULT_FRESHNESS_WINDOW_MS, renderTeamPresence } = require('../lib/team-presence');
|
|
9
9
|
const { LINEUP_UNREADABLE, memberLineup, readLineupSafe, renderLineup } = require('../lib/team-lineup');
|
|
10
|
+
const { isMemberParked, isParkedFrontmatter, setMemberParked } = require('../lib/member-park');
|
|
10
11
|
const { readEngineRegistry } = require('./engine');
|
|
11
12
|
const { listMissions, listWorktreeRollupMissions } = require('./mission');
|
|
12
13
|
const { collectSnapshot, collectStreamEvents, repoRoot } = require('./stream');
|
|
@@ -34,12 +35,14 @@ function collectTeamPresence(deps = {}) {
|
|
|
34
35
|
const root = deps.root || repoRoot(deps.cwd || process.cwd());
|
|
35
36
|
const nowMs = typeof deps.now === 'function' ? deps.now() : Date.now();
|
|
36
37
|
const freshnessWindowMs = deps.freshnessWindowMs || DEFAULT_FRESHNESS_WINDOW_MS;
|
|
37
|
-
const
|
|
38
|
+
const skipLanding = Boolean(deps.skipLanding);
|
|
39
|
+
const stream = deps.stream || collectSnapshot({ root, deps: deps.streamDeps, skipLanding });
|
|
38
40
|
const streamEvents = deps.streamEvents || collectStreamEvents({
|
|
39
41
|
root,
|
|
40
42
|
sinceMs: nowMs - freshnessWindowMs,
|
|
41
43
|
nowMs,
|
|
42
44
|
deps: deps.streamDeps,
|
|
45
|
+
skipLanding,
|
|
43
46
|
});
|
|
44
47
|
return buildTeamPresence({
|
|
45
48
|
nowMs,
|
|
@@ -174,7 +177,9 @@ function escapeHtml(text) {
|
|
|
174
177
|
|
|
175
178
|
function collectTeamRoster(deps = {}) {
|
|
176
179
|
const root = deps.root || repoRoot(deps.cwd || process.cwd());
|
|
177
|
-
|
|
180
|
+
// The roster only reads who is awake, never the landing wait, so it skips
|
|
181
|
+
// the landing board entirely.
|
|
182
|
+
const presence = deps.presence || collectTeamPresence({ ...deps, skipLanding: true });
|
|
178
183
|
const awake = new Set(presence.members.map((member) => String(member.name || '').trim().toLowerCase()));
|
|
179
184
|
const engineRoster = deps.engineRoster || readEngineRegistry(root, { persist: false }).engines;
|
|
180
185
|
const engineByOwner = new Map();
|
|
@@ -206,6 +211,7 @@ function collectTeamRoster(deps = {}) {
|
|
|
206
211
|
now: rawNow,
|
|
207
212
|
focus,
|
|
208
213
|
active,
|
|
214
|
+
parked: isParkedFrontmatter(member?.frontmatter),
|
|
209
215
|
};
|
|
210
216
|
})
|
|
211
217
|
.filter((entry) => entry.name)
|
|
@@ -228,10 +234,30 @@ function wrapCommaNames(names, width = 80) {
|
|
|
228
234
|
return lines.join('\n');
|
|
229
235
|
}
|
|
230
236
|
|
|
231
|
-
|
|
232
|
-
|
|
237
|
+
// Parked members leave the lists and come back as one line, unless --all
|
|
238
|
+
// asks for them in place.
|
|
239
|
+
function parkedSummaryLine(parkedRows, width = 80) {
|
|
240
|
+
if (!parkedRows.length) return '';
|
|
241
|
+
const names = parkedRows.map((entry) => entry.name);
|
|
242
|
+
names[0] = `parked (${parkedRows.length}): ${names[0]}`;
|
|
243
|
+
names[names.length - 1] = `${names[names.length - 1]} · atris team --all to show them`;
|
|
244
|
+
return wrapCommaNames(names, width);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function withParkedLabels(rosterRows, all) {
|
|
248
|
+
const parkedRows = rosterRows.filter((entry) => entry.parked);
|
|
249
|
+
if (!all) return { rows: rosterRows.filter((entry) => !entry.parked), parkedRows };
|
|
250
|
+
return {
|
|
251
|
+
rows: rosterRows.map((entry) => (entry.parked ? { ...entry, name: `${entry.name} (parked)` } : entry)),
|
|
252
|
+
parkedRows: [],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function renderTeamRoster(allRows, deps = {}) {
|
|
257
|
+
if (!allRows.length) {
|
|
233
258
|
return 'no team members yet. create one with: atris member create <name> --role="..."';
|
|
234
259
|
}
|
|
260
|
+
const { rows: rosterRows, parkedRows } = withParkedLabels(allRows, Boolean(deps.all));
|
|
235
261
|
const activeRows = rosterRows.filter((entry) => entry.active);
|
|
236
262
|
const restRows = rosterRows.filter((entry) => !entry.active);
|
|
237
263
|
const termWidth = deps.termWidth || process.stdout.columns || 80;
|
|
@@ -260,6 +286,11 @@ function renderTeamRoster(rosterRows, deps = {}) {
|
|
|
260
286
|
} else {
|
|
261
287
|
lines.push('(none)');
|
|
262
288
|
}
|
|
289
|
+
const parkedLine = parkedSummaryLine(parkedRows, termWidth);
|
|
290
|
+
if (parkedLine) {
|
|
291
|
+
lines.push('');
|
|
292
|
+
lines.push(parkedLine);
|
|
293
|
+
}
|
|
263
294
|
return lines.join('\n');
|
|
264
295
|
}
|
|
265
296
|
|
|
@@ -273,7 +304,8 @@ function renderTeamWithLineup(rosterRows, lineup, deps = {}) {
|
|
|
273
304
|
return block ? `${block}\n\n${today}` : today;
|
|
274
305
|
}
|
|
275
306
|
|
|
276
|
-
function renderTeamRosterHtml(
|
|
307
|
+
function renderTeamRosterHtml(allRows, meta = {}) {
|
|
308
|
+
const rosterRows = withParkedLabels(allRows, Boolean(meta.all)).rows;
|
|
277
309
|
const activeRows = rosterRows.filter((entry) => entry.active);
|
|
278
310
|
const restRows = rosterRows.filter((entry) => !entry.active);
|
|
279
311
|
const generatedAt = meta.generatedAt || new Date().toISOString();
|
|
@@ -418,6 +450,7 @@ function writeTeamBoardHtml(rosterRows, deps = {}) {
|
|
|
418
450
|
const outPath = path.join(workspace, 'atris', 'team', 'team-board.html');
|
|
419
451
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
420
452
|
const html = renderTeamRosterHtml(rosterRows, {
|
|
453
|
+
all: Boolean(deps.all),
|
|
421
454
|
workspace,
|
|
422
455
|
generatedAt: deps.generatedAt || new Date().toISOString(),
|
|
423
456
|
});
|
|
@@ -465,6 +498,8 @@ function collectTeamPrune(deps = {}) {
|
|
|
465
498
|
for (const member of collectMembers(root, deps)) {
|
|
466
499
|
const name = String(member?.name || '').trim().toLowerCase();
|
|
467
500
|
if (!name) continue;
|
|
501
|
+
// A parked member is already set aside on purpose; flagging it again is noise.
|
|
502
|
+
if (isMemberParked(root, name)) continue;
|
|
468
503
|
const signalMs = newestSignalMs(member);
|
|
469
504
|
if (activeOwners.has(name) || (signalMs && nowMs - signalMs < days * DAY_MS)) {
|
|
470
505
|
activeCount += 1;
|
|
@@ -497,14 +532,47 @@ function renderTeamPrune(report, days = DEFAULT_PRUNE_DAYS) {
|
|
|
497
532
|
function helpText() {
|
|
498
533
|
return [
|
|
499
534
|
'atris team - who does each job, with its tool and model, then active members and the rest',
|
|
535
|
+
'atris team --all - also list parked members in place',
|
|
500
536
|
'atris team presence - show who is awake and what they are doing',
|
|
501
537
|
'atris team prune - flag members with no recent activity; deletes nothing',
|
|
538
|
+
'atris team park <name> [--note "<reason>"] - hide a member from the team views; it still runs by name',
|
|
539
|
+
'atris team unpark <name> - bring a parked member back',
|
|
502
540
|
'',
|
|
503
|
-
'usage: atris team [roster|presence] [--json] [--html]',
|
|
541
|
+
'usage: atris team [roster|presence] [--all] [--json] [--html]',
|
|
504
542
|
'usage: atris team prune [--days N] [--json]',
|
|
543
|
+
'usage: atris team park <name> [--note "<reason>"] | atris team unpark <name>',
|
|
505
544
|
].join('\n');
|
|
506
545
|
}
|
|
507
546
|
|
|
547
|
+
const PARK_USAGE = 'usage: atris team park <name> [--note "<reason>"] | atris team unpark <name>';
|
|
548
|
+
|
|
549
|
+
function teamParkCommand(parked, rest, deps = {}) {
|
|
550
|
+
const write = deps.write || process.stdout.write.bind(process.stdout);
|
|
551
|
+
const error = deps.error || process.stderr.write.bind(process.stderr);
|
|
552
|
+
let name = '';
|
|
553
|
+
let note = '';
|
|
554
|
+
let bad = false;
|
|
555
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
556
|
+
const arg = rest[i];
|
|
557
|
+
if (parked && arg === '--note') { i += 1; note = rest[i] || ''; if (!note) bad = true; continue; }
|
|
558
|
+
if (parked && arg.startsWith('--note=')) { note = arg.slice('--note='.length); continue; }
|
|
559
|
+
if (arg.startsWith('-') || name) { bad = true; continue; }
|
|
560
|
+
name = arg;
|
|
561
|
+
}
|
|
562
|
+
if (bad || !name) {
|
|
563
|
+
error(`${PARK_USAGE}\n`);
|
|
564
|
+
return 2;
|
|
565
|
+
}
|
|
566
|
+
const root = deps.root || repoRoot(deps.cwd || process.cwd());
|
|
567
|
+
const result = setMemberParked(root, name, { parked, note, now: deps.now ? new Date(deps.now()) : new Date() });
|
|
568
|
+
if (!result.ok) {
|
|
569
|
+
error(`${result.error}\n`);
|
|
570
|
+
return 1;
|
|
571
|
+
}
|
|
572
|
+
write(`${result.message}\n`);
|
|
573
|
+
return 0;
|
|
574
|
+
}
|
|
575
|
+
|
|
508
576
|
function teamCommand(args = [], deps = {}) {
|
|
509
577
|
if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
|
510
578
|
(deps.write || process.stdout.write.bind(process.stdout))(`${helpText()}\n`);
|
|
@@ -531,18 +599,22 @@ function teamCommand(args = [], deps = {}) {
|
|
|
531
599
|
(deps.write || process.stdout.write.bind(process.stdout))(`${output}\n`);
|
|
532
600
|
return 0;
|
|
533
601
|
}
|
|
602
|
+
if (args[0] === 'park' || args[0] === 'unpark') {
|
|
603
|
+
return teamParkCommand(args[0] === 'park', args.slice(1), deps);
|
|
604
|
+
}
|
|
534
605
|
const rosterArgs = args.filter((arg) => arg !== 'roster');
|
|
535
|
-
const rosterFlags = new Set(['--json', '--html']);
|
|
606
|
+
const rosterFlags = new Set(['--json', '--html', '--all']);
|
|
536
607
|
if (args[0] !== 'presence' && rosterArgs.every((arg) => rosterFlags.has(arg))) {
|
|
537
608
|
const html = rosterArgs.includes('--html');
|
|
538
609
|
const json = rosterArgs.includes('--json');
|
|
610
|
+
const all = rosterArgs.includes('--all');
|
|
539
611
|
if (html && json) {
|
|
540
612
|
(deps.error || process.stderr.write.bind(process.stderr))('usage: atris team [--json] [--html] (not both)\n');
|
|
541
613
|
return 2;
|
|
542
614
|
}
|
|
543
615
|
const roster = deps.roster || collectTeamRoster(deps);
|
|
544
616
|
if (html) {
|
|
545
|
-
const outPath = writeTeamBoardHtml(roster, deps);
|
|
617
|
+
const outPath = writeTeamBoardHtml(roster, { ...deps, all });
|
|
546
618
|
(deps.write || process.stdout.write.bind(process.stdout))(`${outPath}\n`);
|
|
547
619
|
return 0;
|
|
548
620
|
}
|
|
@@ -554,12 +626,12 @@ function teamCommand(args = [], deps = {}) {
|
|
|
554
626
|
: readLineupSafe(deps.root || repoRoot(deps.cwd || process.cwd()), deps.lineupNow || new Date());
|
|
555
627
|
const output = json
|
|
556
628
|
? JSON.stringify(roster.map((entry) => ({ ...entry, lineup: memberLineup(lineup, entry.name) })), null, 2)
|
|
557
|
-
: renderTeamWithLineup(roster, lineup, deps);
|
|
629
|
+
: renderTeamWithLineup(roster, lineup, { ...deps, all });
|
|
558
630
|
(deps.write || process.stdout.write.bind(process.stdout))(`${output}\n`);
|
|
559
631
|
return 0;
|
|
560
632
|
}
|
|
561
633
|
if (args[0] !== 'presence' || args.some((arg, index) => index > 0 && arg !== '--json')) {
|
|
562
|
-
(deps.error || process.stderr.write.bind(process.stderr))('usage: atris team [roster|presence|prune] [--json] [--html]\n');
|
|
634
|
+
(deps.error || process.stderr.write.bind(process.stderr))('usage: atris team [roster|presence|prune|park|unpark] [--all] [--json] [--html]\n');
|
|
563
635
|
return 2;
|
|
564
636
|
}
|
|
565
637
|
const presence = deps.presence || collectTeamPresence(deps);
|
package/commands/worktree.js
CHANGED
|
@@ -6,6 +6,7 @@ const { spawnSync } = require('child_process');
|
|
|
6
6
|
const { hasFlag, readFlag } = require('../lib/arg-parser');
|
|
7
7
|
const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
|
|
8
8
|
const { isConductorArtifact } = require('../lib/conductor-artifacts');
|
|
9
|
+
const { fixMapDocs } = require('../lib/map-refs');
|
|
9
10
|
const close = require('./close');
|
|
10
11
|
|
|
11
12
|
const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
|
|
@@ -641,6 +642,7 @@ function shipHelp() {
|
|
|
641
642
|
console.log('');
|
|
642
643
|
console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
|
|
643
644
|
console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
|
|
645
|
+
console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
|
|
644
646
|
console.log(' unstaged regenerated adapter files are skipped unless staged first or named in --message');
|
|
645
647
|
console.log(' recommended flight verify: npm run test:fast && node --test <focused files>');
|
|
646
648
|
}
|
|
@@ -725,6 +727,35 @@ function runShipHealthCheck(workspaceRoot, options = {}) {
|
|
|
725
727
|
}
|
|
726
728
|
}
|
|
727
729
|
|
|
730
|
+
const MAP_REFRESH_COMMIT_MESSAGE = 'Refresh map references that moved';
|
|
731
|
+
const MAP_REFRESH_TRAILER = 'Co-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>';
|
|
732
|
+
|
|
733
|
+
// Rewrites map line refs whose code moved (the same fix `atris doc-health
|
|
734
|
+
// --fix-refs` applies), then commits only the rewritten map files. A human-only
|
|
735
|
+
// remainder or an error never blocks the ship; pass --no-map-refresh to skip.
|
|
736
|
+
function refreshMovedMapRefs(root, args = []) {
|
|
737
|
+
if (hasFlag(args, '--no-map-refresh')) return { skipped: true, refreshed: 0, left: 0 };
|
|
738
|
+
try {
|
|
739
|
+
const { changes, left } = fixMapDocs(root);
|
|
740
|
+
if (changes.length) {
|
|
741
|
+
const docs = [...new Set(changes.map(change => change.doc))];
|
|
742
|
+
runGit(['add', '--', ...docs], { cwd: root });
|
|
743
|
+
runGit(['commit', '-m', MAP_REFRESH_COMMIT_MESSAGE, '-m', MAP_REFRESH_TRAILER], { cwd: root });
|
|
744
|
+
console.log(`map: refreshed ${changes.length} moved references`);
|
|
745
|
+
} else {
|
|
746
|
+
console.log('map: references current');
|
|
747
|
+
}
|
|
748
|
+
if (left.length) {
|
|
749
|
+
console.log(`map: ${left.length} references need a human; run atris doc-health --fix-refs to see them`);
|
|
750
|
+
}
|
|
751
|
+
return { skipped: false, refreshed: changes.length, left: left.length };
|
|
752
|
+
} catch (error) {
|
|
753
|
+
const message = String((error && error.message) || error).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
754
|
+
console.log(`map: refresh skipped (${message}); the ship continues`);
|
|
755
|
+
return { skipped: false, refreshed: 0, left: 0, error };
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
728
759
|
function shipWorktree(args) {
|
|
729
760
|
if (hasFlag(args, '--help') || hasFlag(args, '-h') || args[0] === 'help') {
|
|
730
761
|
shipHelp();
|
|
@@ -816,6 +847,12 @@ function shipWorktree(args) {
|
|
|
816
847
|
if (!dryRun && !proofTreeMatches('head')) return 3;
|
|
817
848
|
}
|
|
818
849
|
|
|
850
|
+
if (dryRun) {
|
|
851
|
+
console.log('map: skipped (dry-run)');
|
|
852
|
+
} else {
|
|
853
|
+
refreshMovedMapRefs(root, args);
|
|
854
|
+
}
|
|
855
|
+
|
|
819
856
|
if (verify) {
|
|
820
857
|
const beforeVerify = dryRun ? [] : statusSnapshot(root);
|
|
821
858
|
console.log(`verify: ${verify}`);
|
|
@@ -1227,6 +1264,7 @@ function help() {
|
|
|
1227
1264
|
console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
|
|
1228
1265
|
console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
|
|
1229
1266
|
console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
|
|
1267
|
+
console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
|
|
1230
1268
|
console.log(' recommended verify: npm run test:fast && node --test <focused files>');
|
|
1231
1269
|
console.log(' atris worktree status');
|
|
1232
1270
|
console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
|
|
@@ -1270,6 +1308,7 @@ module.exports = {
|
|
|
1270
1308
|
parseAgentFlightName,
|
|
1271
1309
|
parseWorktrees,
|
|
1272
1310
|
normalizeTargetRef,
|
|
1311
|
+
refreshMovedMapRefs,
|
|
1273
1312
|
slugify,
|
|
1274
1313
|
taskTokens,
|
|
1275
1314
|
statusCounts,
|
package/commands/youtube.js
CHANGED
|
@@ -7,7 +7,7 @@ const os = require('os');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const https = require('https');
|
|
9
9
|
|
|
10
|
-
const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [youtube-url-or-playlist...] [haiku|atris-fast|
|
|
10
|
+
const YTNOTES_USAGE = 'usage: ytnotes <youtube-url> [youtube-url-or-playlist...] [auto|gemini|haiku|atris-fast|grok|codex|cursor]';
|
|
11
11
|
const YTNOTES_HINT = 'zero credits, local captions + a fast engine';
|
|
12
12
|
const NOTES_PLAYLIST_CAP = 10;
|
|
13
13
|
|
|
@@ -236,23 +236,31 @@ function captionHostAllowed(urlString) {
|
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}
|
|
239
|
+
// Caption tracks in the order to try them. Human subtitles first, then the
|
|
240
|
+
// auto track in the language actually spoken ("en-orig", "es-orig"). YouTube's
|
|
241
|
+
// machine-translated tracks (plain "en" on most English videos) are rate
|
|
242
|
+
// limited with HTTP 429, so they only come after every real track.
|
|
243
|
+
function captionTrackCandidates(info = {}) {
|
|
244
|
+
const candidates = [];
|
|
245
|
+
const add = (trackSets = {}, languages) => {
|
|
246
|
+
for (const language of languages) {
|
|
247
|
+
if (language === 'live_chat') continue;
|
|
248
|
+
const tracks = (trackSets[language] || []).filter((track) => track?.url);
|
|
249
|
+
const track = tracks.find((t) => ['json3', 'vtt', 'srv3', 'ttml'].includes(t.ext)) || tracks[0];
|
|
250
|
+
if (track && !candidates.some((c) => c.track === track)) candidates.push({ language, track });
|
|
251
251
|
}
|
|
252
|
-
return null;
|
|
253
252
|
};
|
|
253
|
+
const manual = info.subtitles || {};
|
|
254
|
+
const auto = info.automatic_captions || {};
|
|
255
|
+
add(manual, ['en', 'en-US', 'en-GB', ...Object.keys(manual).filter((l) => l.startsWith('en-'))]);
|
|
256
|
+
add(manual, Object.keys(manual));
|
|
257
|
+
add(auto, ['en-orig', ...Object.keys(auto).filter((l) => l.endsWith('-orig'))]);
|
|
258
|
+
add(auto, ['en', 'en-US', 'en-GB']);
|
|
259
|
+
return candidates;
|
|
260
|
+
}
|
|
254
261
|
|
|
255
|
-
|
|
262
|
+
function chooseCaptionTrack(info = {}) {
|
|
263
|
+
return captionTrackCandidates(info)[0] || null;
|
|
256
264
|
}
|
|
257
265
|
|
|
258
266
|
function formatTimestampFromMs(ms) {
|
|
@@ -478,12 +486,14 @@ function parseYtDlpInfoJson(result) {
|
|
|
478
486
|
}
|
|
479
487
|
|
|
480
488
|
function localCaptionNames(id) {
|
|
481
|
-
// scripts/det/ytnotes keeps these VTT names
|
|
489
|
+
// scripts/det/ytnotes keeps these VTT names (.local.vtt = transcribed on this
|
|
490
|
+
// machine when the video had no captions) plus leftover yt_<id>.clean.txt.
|
|
482
491
|
return [
|
|
483
492
|
`yt_${id}.en.vtt`,
|
|
484
493
|
`yt_${id}.en-orig.vtt`,
|
|
485
494
|
`yt_${id}.en-US.vtt`,
|
|
486
495
|
`yt_${id}.en-GB.vtt`,
|
|
496
|
+
`yt_${id}.local.vtt`,
|
|
487
497
|
`yt_${id}.clean.txt`,
|
|
488
498
|
];
|
|
489
499
|
}
|
|
@@ -521,14 +531,14 @@ function readLocalCaptionText({ url, id, workDir } = {}) {
|
|
|
521
531
|
|
|
522
532
|
async function loadCaptionRaw(info, youtubeUrl, deps = {}) {
|
|
523
533
|
const payload = info && typeof info === 'object' ? info : {};
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
534
|
+
const fetchText = deps.fetchCaptionText || fetchCaptionText;
|
|
535
|
+
// A blocked track (429) falls through to the next one instead of failing.
|
|
536
|
+
const candidates = captionTrackCandidates(payload).slice(0, 4);
|
|
537
|
+
for (const candidate of candidates) {
|
|
538
|
+
const raw = await fetchText(candidate.track.url);
|
|
539
|
+
if (String(raw || '').trim()) return { raw, language: candidate.language };
|
|
540
|
+
}
|
|
541
|
+
const selected = candidates[0];
|
|
532
542
|
const local = readLocalCaptionText({
|
|
533
543
|
url: youtubeUrl,
|
|
534
544
|
id: payload.id,
|
|
@@ -559,7 +569,7 @@ async function extractLocalTranscript(youtubeUrl, deps = {}) {
|
|
|
559
569
|
const result = runner('yt-dlp', ytDlpInfoArgs(youtubeUrl), {
|
|
560
570
|
encoding: 'utf8',
|
|
561
571
|
timeout: 20000,
|
|
562
|
-
maxBuffer:
|
|
572
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
563
573
|
});
|
|
564
574
|
const info = parseYtDlpInfoJson(result);
|
|
565
575
|
const loaded = await loadCaptionRaw(info, youtubeUrl, deps);
|
|
@@ -3697,7 +3707,7 @@ async function extractTeachSource(youtubeUrl, deps = {}) {
|
|
|
3697
3707
|
const result = runner('yt-dlp', ytDlpInfoArgs(youtubeUrl), {
|
|
3698
3708
|
encoding: 'utf8',
|
|
3699
3709
|
timeout: 20000,
|
|
3700
|
-
maxBuffer:
|
|
3710
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
3701
3711
|
});
|
|
3702
3712
|
const info = parseYtDlpInfoJson(result);
|
|
3703
3713
|
const loaded = await loadCaptionRaw(info, youtubeUrl, deps);
|
package/lib/engine-ask.js
CHANGED
|
@@ -64,7 +64,43 @@ function parseBoundedInteger(raw, flag, min, max) {
|
|
|
64
64
|
return value;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
// The ask job's roster line for this engine. Questions are not searches, so
|
|
68
|
+
// a bare ask rides the engine's own default model; only an `## ask` section
|
|
69
|
+
// that lists this engine as a live worker pins its model and effort. Layers
|
|
70
|
+
// resolve the way every job resolves: this session, this project, all
|
|
71
|
+
// projects. Never throws.
|
|
72
|
+
function askRosterPin(engine, root) {
|
|
73
|
+
try {
|
|
74
|
+
const {
|
|
75
|
+
ENGINE_JOBS,
|
|
76
|
+
ROSTER_LAYERS,
|
|
77
|
+
readEngineRegistry,
|
|
78
|
+
readRosterState,
|
|
79
|
+
rosterWorkerWalk,
|
|
80
|
+
} = require('./engine-registry');
|
|
81
|
+
const registry = readEngineRegistry(root, { persist: false });
|
|
82
|
+
const state = readRosterState(root, { registry });
|
|
83
|
+
for (const layer of ROSTER_LAYERS) {
|
|
84
|
+
const pick = state[layer] && state[layer].picks && state[layer].picks.ask;
|
|
85
|
+
if (!pick || typeof pick !== 'object') continue;
|
|
86
|
+
const role = ENGINE_JOBS[pick.like] || 'navigator';
|
|
87
|
+
const step = rosterWorkerWalk(pick, role, registry).find(
|
|
88
|
+
(entry) => !entry.skip && entry.engine && entry.engine.id === engine
|
|
89
|
+
);
|
|
90
|
+
if (!step) continue;
|
|
91
|
+
const pin = {};
|
|
92
|
+
for (const field of ['roster_model', 'roster_effort']) {
|
|
93
|
+
if (step.engine[field]) pin[field] = step.engine[field];
|
|
94
|
+
}
|
|
95
|
+
return pin;
|
|
96
|
+
}
|
|
97
|
+
return {};
|
|
98
|
+
} catch {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeAskJob(job, index, pinFor = null) {
|
|
68
104
|
if (!job || typeof job !== 'object' || Array.isArray(job)) {
|
|
69
105
|
throw new Error(`job ${index + 1} must be an object`);
|
|
70
106
|
}
|
|
@@ -78,10 +114,15 @@ function normalizeAskJob(job, index) {
|
|
|
78
114
|
if (Buffer.byteLength(prompt) > MAX_ASK_PROMPT_BYTES) {
|
|
79
115
|
throw new Error(`job ${index + 1} prompt must be ${MAX_ASK_PROMPT_BYTES} bytes or fewer`);
|
|
80
116
|
}
|
|
81
|
-
|
|
117
|
+
// No model named: the ask line's model and effort ride along when the
|
|
118
|
+
// roster has one for this engine. A named model means the roster does not
|
|
119
|
+
// decide.
|
|
120
|
+
const pin = !String(job.model || '').trim() && pinFor ? pinFor(engine) : {};
|
|
121
|
+
const model = resolveAskModel(engine, job.model || pin.roster_model || '');
|
|
122
|
+
const effort = askEffortArgs(engine, pin.roster_effort).length ? String(pin.roster_effort).trim().toLowerCase() : '';
|
|
82
123
|
const label = String(job.label || '').trim();
|
|
83
124
|
if (label.length > 80) throw new Error(`job ${index + 1} label must be 80 characters or fewer`);
|
|
84
|
-
return { engine, model, prompt, label };
|
|
125
|
+
return { engine, model, ...(effort ? { effort } : {}), prompt, label };
|
|
85
126
|
}
|
|
86
127
|
|
|
87
128
|
function labelAskJobs(jobs) {
|
|
@@ -166,14 +207,14 @@ function parseEngineAskArgs(args, { root = process.cwd(), readFile = fs.readFile
|
|
|
166
207
|
throw new Error(`could not read --jobs ${jobsFile}: ${error.message}`);
|
|
167
208
|
}
|
|
168
209
|
if (!Array.isArray(parsed)) throw new Error('--jobs must contain a json array');
|
|
169
|
-
jobs = parsed.map(normalizeAskJob);
|
|
210
|
+
jobs = parsed.map((job, index) => normalizeAskJob(job, index, (engine) => askRosterPin(engine, root)));
|
|
170
211
|
} else {
|
|
171
212
|
if (!commonPrompt || !requestedEngines.length) throw new Error(askUsage());
|
|
172
213
|
jobs = requestedEngines.map((requestedEngine, index) => normalizeAskJob({
|
|
173
214
|
engine: requestedEngine,
|
|
174
215
|
model: requestedModel,
|
|
175
216
|
prompt: commonPrompt,
|
|
176
|
-
}, index));
|
|
217
|
+
}, index, (engine) => askRosterPin(engine, root)));
|
|
177
218
|
}
|
|
178
219
|
|
|
179
220
|
if (!jobs.length) throw new Error('at least one engine ask job is required');
|
|
@@ -205,8 +246,21 @@ function resolveAskModel(engine, modelName = '') {
|
|
|
205
246
|
return RUNNER_PROFILE_DEFS[engine]?.model || DEFAULT_CLAUDE_RUNNER_MODEL;
|
|
206
247
|
}
|
|
207
248
|
|
|
249
|
+
// The effort flag as argv, from the same profile table the runners use
|
|
250
|
+
// ("--effort high", "-c model_reasoning_effort=high"). Empty when the engine
|
|
251
|
+
// takes no effort or not that word.
|
|
252
|
+
function askEffortArgs(engine, effort) {
|
|
253
|
+
const word = String(effort || '').trim().toLowerCase();
|
|
254
|
+
const profile = RUNNER_PROFILE_DEFS[engine];
|
|
255
|
+
if (!word || !profile || !profile.effortFlag || !(profile.efforts || []).includes(word)) return [];
|
|
256
|
+
const parts = profile.effortFlag.split(/\s+/).filter(Boolean);
|
|
257
|
+
const last = parts.pop();
|
|
258
|
+
return last.endsWith('=') ? [...parts, `${last}${word}`] : [...parts, last, word];
|
|
259
|
+
}
|
|
260
|
+
|
|
208
261
|
function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
|
|
209
262
|
timeoutMs = DEFAULT_ASK_TIMEOUT_MS,
|
|
263
|
+
effort = '',
|
|
210
264
|
} = {}) {
|
|
211
265
|
const engine = canonicalEngineName(engineName);
|
|
212
266
|
const profile = RUNNER_PROFILE_DEFS[engine];
|
|
@@ -214,6 +268,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
|
|
|
214
268
|
const model = resolveAskModel(engine, modelName);
|
|
215
269
|
assertAskModelSupported(engine, model);
|
|
216
270
|
const request = guardedPrompt(prompt);
|
|
271
|
+
const effortArgs = askEffortArgs(engine, effort);
|
|
217
272
|
|
|
218
273
|
if (engine === 'atris-fast' || engine === 'composer') {
|
|
219
274
|
return { engine, bin: profile.bin, args: ['--fast', '--cloud', '--print', request] };
|
|
@@ -225,6 +280,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
|
|
|
225
280
|
args: [
|
|
226
281
|
'-p', request,
|
|
227
282
|
'--model', model || profile.model || DEFAULT_CLAUDE_RUNNER_MODEL,
|
|
283
|
+
...effortArgs,
|
|
228
284
|
'--tools', 'Read,Glob,Grep,WebSearch,WebFetch',
|
|
229
285
|
'--permission-mode', 'plan',
|
|
230
286
|
'--no-session-persistence',
|
|
@@ -235,7 +291,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
|
|
|
235
291
|
return {
|
|
236
292
|
engine,
|
|
237
293
|
bin: profile.bin,
|
|
238
|
-
args: ['exec', ...(model ? ['-m', model] : []), '--sandbox', 'read-only', '--ephemeral', '--ignore-user-config', '--ignore-rules', '--color', 'never', request],
|
|
294
|
+
args: ['exec', ...(model ? ['-m', model] : []), ...effortArgs, '--sandbox', 'read-only', '--ephemeral', '--ignore-user-config', '--ignore-rules', '--color', 'never', request],
|
|
239
295
|
};
|
|
240
296
|
}
|
|
241
297
|
if (engine === 'cursor') {
|
|
@@ -252,7 +308,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
|
|
|
252
308
|
return {
|
|
253
309
|
engine,
|
|
254
310
|
bin: profile.bin,
|
|
255
|
-
args: ['--no-memory', '--no-subagents', '--permission-mode', 'plan', '--sandbox', 'read-only', ...(model ? ['--model', model] : []), '-p', request],
|
|
311
|
+
args: ['--no-memory', '--no-subagents', '--permission-mode', 'plan', '--sandbox', 'read-only', ...(model ? ['--model', model] : []), ...effortArgs, '-p', request],
|
|
256
312
|
};
|
|
257
313
|
}
|
|
258
314
|
if (engine === 'agy') {
|
|
@@ -269,6 +325,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
|
|
|
269
325
|
'--dangerously-skip-permissions',
|
|
270
326
|
'--print-timeout', `${Math.max(1, Math.ceil(timeoutMs / 1000))}s`,
|
|
271
327
|
...(model ? ['--model', model] : []),
|
|
328
|
+
...effortArgs,
|
|
272
329
|
'-p', request,
|
|
273
330
|
],
|
|
274
331
|
};
|
|
@@ -283,7 +340,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
|
|
|
283
340
|
if (engine === 'opencode') {
|
|
284
341
|
// `run` with a message is headless print mode; the built-in plan agent
|
|
285
342
|
// keeps the ask read-only (verified live 2026-08-21, ~7s per lookup).
|
|
286
|
-
return { engine, bin: profile.bin, args: ['run', '--agent', 'plan', ...(model ? ['-m', model] : []), request] };
|
|
343
|
+
return { engine, bin: profile.bin, args: ['run', '--agent', 'plan', ...(model ? ['-m', model] : []), ...effortArgs, request] };
|
|
287
344
|
}
|
|
288
345
|
throw new Error(`engine ask has no read-only command for ${engine}`);
|
|
289
346
|
}
|
|
@@ -480,7 +537,7 @@ async function runEngineAskJobs(jobs, {
|
|
|
480
537
|
const answers = new Array(jobs.length);
|
|
481
538
|
let nextIndex = 0;
|
|
482
539
|
const execute = executeAskJob || (async (job) => {
|
|
483
|
-
const invocation = buildReadOnlyEngineInvocation(job.engine, job.prompt, job.model, { timeoutMs });
|
|
540
|
+
const invocation = buildReadOnlyEngineInvocation(job.engine, job.prompt, job.model, { timeoutMs, effort: job.effort || '' });
|
|
484
541
|
return runAskProcess(invocation, { cwd: invocation.cwd || root, timeoutMs, signal, onOutputChunk });
|
|
485
542
|
});
|
|
486
543
|
const workerCount = Math.min(concurrency, jobs.length);
|
|
@@ -534,6 +591,51 @@ function recordEngineAskHealth(answers, root) {
|
|
|
534
591
|
}
|
|
535
592
|
}
|
|
536
593
|
|
|
594
|
+
// One run-record line per answer, job "ask": the worker as it ran, its
|
|
595
|
+
// seconds, how it ended, and tokens when printed. A cancelled ask is the
|
|
596
|
+
// operator's stop, not the engine's result, so it writes nothing.
|
|
597
|
+
function recordEngineAskRuns(answers, root, { timeoutMs = 0, startedAt = '' } = {}) {
|
|
598
|
+
let appendRosterRun;
|
|
599
|
+
let parseRunUsage;
|
|
600
|
+
let engineRunsView;
|
|
601
|
+
try {
|
|
602
|
+
({ appendRosterRun, parseRunUsage } = require('./roster-runs'));
|
|
603
|
+
({ engineRunsView } = require('./roster-models'));
|
|
604
|
+
} catch {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const maxSeconds = Math.round(Number(timeoutMs) / 1000) || 0;
|
|
608
|
+
for (const answer of answers) {
|
|
609
|
+
if (!answer || answer.cancelled || answer.reason === 'cancelled') continue;
|
|
610
|
+
let outcome = 'landed';
|
|
611
|
+
let detail = '';
|
|
612
|
+
if (answer.timed_out || answer.reason === 'timeout') {
|
|
613
|
+
outcome = 'stalled';
|
|
614
|
+
detail = maxSeconds >= 60 ? `stalled at ${Math.round(maxSeconds / 60)} min` : `stalled at ${maxSeconds}s`;
|
|
615
|
+
} else if (!answer.ok) {
|
|
616
|
+
const credit = engineFailureHealthStatus({ ...answer, status: 'errored' }) === 'credit_out';
|
|
617
|
+
outcome = credit ? 'credit out' : 'failed';
|
|
618
|
+
detail = credit ? 'the engine hit its usage limit' : compactFailure(answer);
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
appendRosterRun(root, {
|
|
622
|
+
at: startedAt || new Date(Date.now() - (Number(answer.duration_ms) || 0)).toISOString(),
|
|
623
|
+
job: 'ask',
|
|
624
|
+
engine: answer.engine,
|
|
625
|
+
model: answer.model || engineRunsView(answer.engine).model || '',
|
|
626
|
+
...(answer.effort ? { effort: answer.effort } : {}),
|
|
627
|
+
...(maxSeconds ? { max_seconds: maxSeconds } : {}),
|
|
628
|
+
seconds: Math.max(0, (Number(answer.duration_ms) || 0) / 1000),
|
|
629
|
+
outcome,
|
|
630
|
+
...(detail ? { detail } : {}),
|
|
631
|
+
...(answer.label ? { task: answer.label } : {}),
|
|
632
|
+
source: 'ask',
|
|
633
|
+
...parseRunUsage({ stdout: answer.stdout, stderr: answer.stderr }),
|
|
634
|
+
});
|
|
635
|
+
} catch { /* best effort */ }
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
537
639
|
function engineAskReceipt(answers, { concurrency, timeoutMs, at = new Date().toISOString() }) {
|
|
538
640
|
const receiptAnswers = answers.map((answer) => ({ ...answer, status: answerStatus(answer) }));
|
|
539
641
|
const answered = receiptAnswers.filter((answer) => answer.status === 'answered').length;
|
|
@@ -670,6 +772,7 @@ async function runEngineAskCommand(args, root = process.cwd(), deps = {}) {
|
|
|
670
772
|
onOutputChunk: (chunk, stream) => appendLiveLog(liveLogPath, chunk, stream),
|
|
671
773
|
});
|
|
672
774
|
recordEngineAskHealth(answers, root);
|
|
775
|
+
recordEngineAskRuns(answers, root, { timeoutMs: parsed.timeoutMs, startedAt });
|
|
673
776
|
const receipt = engineAskReceipt(answers, {
|
|
674
777
|
concurrency: parsed.concurrency,
|
|
675
778
|
timeoutMs: parsed.timeoutMs,
|