copilot-operator 0.1.34 → 0.1.36
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/dist/src/api/operator.service.js +30 -1
- package/dist/src/cli.js +11 -8
- package/dist/src/config/schema.js +6 -0
- package/dist/src/orchestrator/taskRunner.js +82 -10
- package/dist/src/plan/systemGuide.js +40 -0
- package/dist/src/transport/copilotTransport.js +107 -18
- package/dist/src/transport/locators.js +7 -2
- package/dist/src/transport/modelMatch.js +12 -1
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/867-d4f5bd8c5805bffb.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-879158e9e550eea7.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-630b663626cebe4a.js +1 -0
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +10 -10
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +10 -10
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +10 -10
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +10 -10
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +10 -10
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +10 -10
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +10 -10
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +10 -10
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +10 -10
- package/package.json +2 -1
- package/dist/web/_next/static/chunks/867-dfaaa8047cbf2fc2.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-54f8f1ab4cac47fa.js +0 -1
- package/dist/web/_next/static/chunks/app/layout-3efbb282292ccc38.js +0 -1
- /package/dist/web/_next/static/{pfa56tOBq6o-WBQZnyUn2 → rWkIIFdTKzD4fL0Pe-y-9}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{pfa56tOBq6o-WBQZnyUn2 → rWkIIFdTKzD4fL0Pe-y-9}/_ssgManifest.js +0 -0
|
@@ -1102,6 +1102,7 @@ let OperatorService = class OperatorService {
|
|
|
1102
1102
|
// The page is the source of the model names: Settings and the saved list follow it (see `ModelHooks`).
|
|
1103
1103
|
models: {
|
|
1104
1104
|
locate: async (name) => (await this.store.getModels())?.options.find((o) => sameModel(o.name, name))?.locator,
|
|
1105
|
+
askHelp: (ask) => this.askModelHelp(ask, controller.signal),
|
|
1105
1106
|
renamedDefault: async (which, from, to) => {
|
|
1106
1107
|
const now = (await this.settings.load()).copilot;
|
|
1107
1108
|
if (which === 'model' && (now.defaultModel ?? '').trim() === from)
|
|
@@ -1866,6 +1867,34 @@ let OperatorService = class OperatorService {
|
|
|
1866
1867
|
};
|
|
1867
1868
|
}
|
|
1868
1869
|
// --- approvals ------------------------------------------------------------------------
|
|
1870
|
+
/**
|
|
1871
|
+
* Asks the operator to choose the model in the Copilot window, when the runner could not (operator's
|
|
1872
|
+
* request, 2026-10-06). It waits on the approvals card like a held step, in every mode, until it is
|
|
1873
|
+
* answered or the run is stopped.
|
|
1874
|
+
*/
|
|
1875
|
+
askModelHelp(ask, signal) {
|
|
1876
|
+
return new Promise((resolveAnswer) => {
|
|
1877
|
+
if (signal.aborted) {
|
|
1878
|
+
resolveAnswer('stop');
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
const approval = {
|
|
1882
|
+
id: newId('a-'),
|
|
1883
|
+
sessionId: ask.sessionId,
|
|
1884
|
+
taskId: '',
|
|
1885
|
+
stepId: 0,
|
|
1886
|
+
description: ask.asked ? `Choose "${ask.asked}" in the Copilot window` : 'Choose the model in the Copilot window',
|
|
1887
|
+
createdAt: new Date().toISOString(),
|
|
1888
|
+
model: { asked: ask.asked, shown: ask.shown, why: ask.why, tries: ask.tries, ...(ask.manual ? { manual: true } : {}) },
|
|
1889
|
+
};
|
|
1890
|
+
this.waiting.set(approval.id, {
|
|
1891
|
+
approval,
|
|
1892
|
+
resolve: (d) => resolveAnswer(d.action === 'run' ? 'recheck' : d.action === 'skip' ? 'continue' : 'stop'),
|
|
1893
|
+
});
|
|
1894
|
+
this.bus.publish({ sessionId: ask.sessionId, type: 'approval-requested', level: 'warn',
|
|
1895
|
+
message: `choose "${ask.asked}" in the Copilot window, then answer on the card`, data: { approvalId: approval.id, model: approval.model } });
|
|
1896
|
+
});
|
|
1897
|
+
}
|
|
1869
1898
|
pendingApprovals(sessionId) {
|
|
1870
1899
|
return [...this.waiting.values()].map((w) => w.approval).filter((a) => !sessionId || a.sessionId === sessionId);
|
|
1871
1900
|
}
|
|
@@ -1952,7 +1981,7 @@ let OperatorService = class OperatorService {
|
|
|
1952
1981
|
if (w.approval.sessionId !== sessionId)
|
|
1953
1982
|
continue;
|
|
1954
1983
|
// A fetch waits for its own answer: "run the rest without asking" is not an answer to it.
|
|
1955
|
-
if (w.approval.network)
|
|
1984
|
+
if (w.approval.network || w.approval.model)
|
|
1956
1985
|
continue;
|
|
1957
1986
|
this.waiting.delete(id);
|
|
1958
1987
|
w.resolve({ action: 'run' });
|
package/dist/src/cli.js
CHANGED
|
@@ -195,7 +195,7 @@ program
|
|
|
195
195
|
replyTimeoutMs: 60_000,
|
|
196
196
|
signInTimeoutMs: 15 * 60_000,
|
|
197
197
|
humanWaitMs: 15 * 60_000,
|
|
198
|
-
onEvent: (e, d) => process.stdout.write(` ${e}${d && Object.keys(d).length ? ' ' + JSON.stringify(d) : ''}
|
|
198
|
+
onEvent: (e, d) => process.stdout.write(` ${e}${d && Object.keys(d).length ? ' ' + JSON.stringify(d) : ''}
|
|
199
199
|
`),
|
|
200
200
|
});
|
|
201
201
|
await transport.open();
|
|
@@ -383,6 +383,7 @@ program
|
|
|
383
383
|
.option('--url <url>', 'chat url', Url.chat)
|
|
384
384
|
.option('--json', 'print the raw list, for pasting into an issue')
|
|
385
385
|
.option('--set <name>', 'also switch the chat to this model, by its exact name')
|
|
386
|
+
.option('--steps', 'with --set: print each step of the choice with its time, and keep a picture of each in runs/_models')
|
|
386
387
|
.action(async (opts) => {
|
|
387
388
|
const transport = new CopilotTransport({
|
|
388
389
|
profileDir: opts.profile,
|
|
@@ -392,7 +393,9 @@ program
|
|
|
392
393
|
headless: false,
|
|
393
394
|
replyTimeoutMs: 60_000,
|
|
394
395
|
signInTimeoutMs: 900_000,
|
|
395
|
-
|
|
396
|
+
// The time of each step, to the millisecond: the pauses between them are what is being looked at.
|
|
397
|
+
onEvent: (e, d) => console.log(` ${new Date().toISOString().slice(11, 23)} [${e}]${d ? ' ' + JSON.stringify(d) : ''}`),
|
|
398
|
+
...(opts.steps ? { modelStepShots: true } : {}),
|
|
396
399
|
});
|
|
397
400
|
try {
|
|
398
401
|
await transport.open();
|
|
@@ -488,14 +491,14 @@ program
|
|
|
488
491
|
console.log('');
|
|
489
492
|
console.log(`task "${done?.title}": ${done?.status} after ${done?.iterations ?? 0} iteration(s)`);
|
|
490
493
|
if (done?.summary)
|
|
491
|
-
console.log(`
|
|
492
|
-
Summary:
|
|
494
|
+
console.log(`
|
|
495
|
+
Summary:
|
|
493
496
|
${done.summary}`);
|
|
494
497
|
if (done?.reason)
|
|
495
|
-
console.log(`
|
|
498
|
+
console.log(`
|
|
496
499
|
Reason: ${done.reason}`);
|
|
497
500
|
if (final?.chat)
|
|
498
|
-
console.log(`
|
|
501
|
+
console.log(`
|
|
499
502
|
chat: ${final.chat.name} — ${final.chat.url}`);
|
|
500
503
|
if (done?.runId)
|
|
501
504
|
console.log(`log: ${join(cfg.resolved.runsDir, done.runId, 'task-log.txt')}`);
|
|
@@ -515,8 +518,8 @@ program
|
|
|
515
518
|
return;
|
|
516
519
|
}
|
|
517
520
|
const c = withChat.chat;
|
|
518
|
-
console.log(`${c.name}
|
|
519
|
-
${c.url}
|
|
521
|
+
console.log(`${c.name}
|
|
522
|
+
${c.url}
|
|
520
523
|
session ${withChat.id}, ${withChat.tasks.length} task(s), created ${c.createdAt}`);
|
|
521
524
|
});
|
|
522
525
|
program.parseAsync(process.argv).catch((e) => {
|
|
@@ -97,6 +97,12 @@ export const RunConfigSchema = z.object({
|
|
|
97
97
|
* different from whatever did the work.
|
|
98
98
|
*/
|
|
99
99
|
defaultReviewModel: z.string().default(''),
|
|
100
|
+
/**
|
|
101
|
+
* Choose the model by hand (operator's workaround, 2026-10-06): at the start of a run, and of a review's
|
|
102
|
+
* conversation, the runner does not touch the picker; a card in the UI asks the operator to choose the
|
|
103
|
+
* model in the Copilot window and press "Ready". A conversation already on the model is not asked about.
|
|
104
|
+
*/
|
|
105
|
+
manualModel: z.boolean().default(false),
|
|
100
106
|
replyTimeoutSec: z.number().int().positive().default(900),
|
|
101
107
|
signInTimeoutSec: z.number().int().positive().default(900),
|
|
102
108
|
humanWaitSec: z.number().int().positive().default(900),
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { createHash } from 'node:crypto';
|
|
14
14
|
import { readFile, mkdir, writeFile, appendFile } from 'node:fs/promises';
|
|
15
15
|
import { join, resolve } from 'node:path';
|
|
16
|
+
import { buttonShows } from '../transport/modelMatch.js';
|
|
16
17
|
import { createTransport, isReplyTimeout } from '../transport/chatTransport.js';
|
|
17
18
|
import { buildChatName, chatCode, loadPointer, savePointer } from '../transport/chatSession.js';
|
|
18
19
|
import { parseReply, formatErrorMessage, findLikelyDamage, damageGuidance } from '../protocol/parser.js';
|
|
@@ -532,14 +533,44 @@ export function effectiveModels(session, cfg) {
|
|
|
532
533
|
async function applySessionModel(transport, session, bus, cfg, store, hooks) {
|
|
533
534
|
const { model: wanted, modelFrom } = effectiveModels(session, cfg);
|
|
534
535
|
const fromSettings = modelFrom === 'settings';
|
|
535
|
-
|
|
536
|
+
const manual = !!cfg.copilot.manualModel && !!hooks?.askHelp;
|
|
537
|
+
if (!wanted && !manual)
|
|
536
538
|
return {};
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
539
|
+
let result;
|
|
540
|
+
if (manual) {
|
|
541
|
+
/*
|
|
542
|
+
* Chosen by hand (operator's workaround, 2026-10-06): the picker is not touched. A conversation already
|
|
543
|
+
* on the model — the chat keeps its model — is not asked about; otherwise the card asks for it.
|
|
544
|
+
*/
|
|
545
|
+
const shown = await transport.currentModel().catch(() => null);
|
|
546
|
+
const hand = wanted && buttonShows(shown, wanted)
|
|
547
|
+
? { result: { ok: true, current: wanted, by: 'already' }, goOnAsItIs: false }
|
|
548
|
+
: await withOperatorHand(transport, wanted, { ok: false, current: shown, reason: 'Settings ask for the model to be chosen by hand' }, hooks, session.id, bus, true);
|
|
549
|
+
if (!wanted) {
|
|
550
|
+
const now = await transport.currentModel().catch(() => null);
|
|
551
|
+
if (hand.result.ok || hand.goOnAsItIs) {
|
|
552
|
+
await store?.updateSession(session.id, (s) => { s.modelInUse = now ?? undefined; }).catch(() => undefined);
|
|
553
|
+
return { current: now ?? undefined };
|
|
554
|
+
}
|
|
555
|
+
return { refused: 'The model was to be chosen by hand, and the operator stopped the run. Nothing was sent.' };
|
|
556
|
+
}
|
|
557
|
+
result = hand.result;
|
|
558
|
+
if (hand.goOnAsItIs) {
|
|
559
|
+
bus.publish({ sessionId: session.id, type: 'model-selected', level: 'warn', message: `model: ${result.current ?? 'unknown'} (chosen by hand; not "${wanted}")`, data: { asked: wanted, current: result.current, ok: false, by: 'hand' } });
|
|
560
|
+
return { current: result.current ?? undefined };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
const locator = await hooks?.locate?.(wanted).catch(() => undefined);
|
|
565
|
+
result = await transport.selectModel(wanted, { locator }).catch((e) => ({
|
|
566
|
+
ok: false,
|
|
567
|
+
current: null,
|
|
568
|
+
reason: e.message,
|
|
569
|
+
}));
|
|
570
|
+
}
|
|
571
|
+
const hand = await withOperatorHand(transport, wanted, result, hooks, session.id, bus, false);
|
|
572
|
+
result = hand.result;
|
|
573
|
+
const goOnAsItIs = hand.goOnAsItIs;
|
|
543
574
|
const renamed = await followPageModels('model', wanted, result, session, store, hooks, modelFrom);
|
|
544
575
|
if (renamed)
|
|
545
576
|
bus.publish({ sessionId: session.id, type: 'model-renamed', level: 'warn', message: renamed, data: { asked: wanted, chosen: result.matched } });
|
|
@@ -550,7 +581,7 @@ async function applySessionModel(transport, session, bus, cfg, store, hooks) {
|
|
|
550
581
|
message: result.ok
|
|
551
582
|
? `model: ${result.current ?? wanted} (${modelFrom === 'settings' ? 'from Settings' : modelFrom === 'session' ? "the session's own" : 'from the plan'})`
|
|
552
583
|
: `could not switch to "${wanted}" (${modelFrom === 'settings' ? 'the default model in Settings' : modelFrom === 'session' ? "the session's own" : 'from the plan'}): ${(result.reason ?? 'unknown reason').replace(/[.\s]+$/, '')}. Nothing is sent on ${result.current ?? 'the chat default'} instead.`,
|
|
553
|
-
data: { asked: wanted, from: modelFrom, fromSettings, current: result.current, ok: result.ok, by: result.by ?? null,
|
|
584
|
+
data: { asked: wanted, from: modelFrom, fromSettings, current: result.current, ok: result.ok, by: result.by ?? null, manual },
|
|
554
585
|
});
|
|
555
586
|
// Kept on the session, so each task's record says which model it ran on and why (see `runTask`).
|
|
556
587
|
await store
|
|
@@ -570,6 +601,8 @@ async function applySessionModel(transport, session, bus, cfg, store, hooks) {
|
|
|
570
601
|
* A model the operator chose and the chat is not on is not worked around: the run went on Auto with a
|
|
571
602
|
* warning nobody saw, and its work was then taken for the chosen model's (2026-10-05). Nothing goes out.
|
|
572
603
|
*/
|
|
604
|
+
if (!result.ok && goOnAsItIs)
|
|
605
|
+
return { current: result.current ?? undefined };
|
|
573
606
|
if (!result.ok) {
|
|
574
607
|
return {
|
|
575
608
|
current: result.current ?? undefined,
|
|
@@ -579,6 +612,35 @@ async function applySessionModel(transport, session, bus, cfg, store, hooks) {
|
|
|
579
612
|
}
|
|
580
613
|
return { current: result.current ?? undefined };
|
|
581
614
|
}
|
|
615
|
+
/**
|
|
616
|
+
* The operator's hand, when the runner could not put the chat on the model, or when Settings ask for it to
|
|
617
|
+
* be chosen by hand (operator's requests, 2026-10-06): the run waits on a card in the UI asking them to
|
|
618
|
+
* choose it in the Copilot window; "check and go on" reads the picker button again, and asks again while it
|
|
619
|
+
* still shows another model. With no model asked for, whatever the operator chose is taken.
|
|
620
|
+
*/
|
|
621
|
+
async function withOperatorHand(transport, wanted, first, hooks, sessionId, bus, manual) {
|
|
622
|
+
let result = first;
|
|
623
|
+
for (let tries = 1; !result.ok && hooks?.askHelp; tries += 1) {
|
|
624
|
+
const shown = await transport.currentModel().catch(() => null);
|
|
625
|
+
bus.publish({ sessionId, type: 'model-help-asked', level: 'warn',
|
|
626
|
+
message: manual
|
|
627
|
+
? `asking the operator to choose ${wanted ? `"${wanted}"` : 'the model'} in the Copilot window (Settings: by hand; picker shows "${shown ?? 'nothing'}")`
|
|
628
|
+
: `the chat could not be put on "${wanted}" by the runner; asking the operator to choose it in the Copilot window (picker shows "${shown ?? 'nothing'}")`,
|
|
629
|
+
data: { asked: wanted, shown, why: result.reason ?? '', tries, manual } });
|
|
630
|
+
const answer = await hooks.askHelp({ sessionId, asked: wanted, shown, why: result.reason ?? 'unknown reason', tries, ...(manual ? { manual: true } : {}) });
|
|
631
|
+
if (answer === 'stop')
|
|
632
|
+
return { result, goOnAsItIs: false };
|
|
633
|
+
if (answer === 'continue') {
|
|
634
|
+
bus.publish({ sessionId, type: 'model-help-continue', level: 'warn', message: `the operator chose to go on with the model the chat is on ("${shown ?? 'unknown'}")${wanted ? `, not "${wanted}"` : ''}`, data: { asked: wanted, shown } });
|
|
635
|
+
return { result: { ok: false, current: shown, reason: `the operator chose to go on with "${shown ?? 'unknown'}"` }, goOnAsItIs: true };
|
|
636
|
+
}
|
|
637
|
+
const now = await transport.currentModel().catch(() => null);
|
|
638
|
+
result = !wanted || buttonShows(now, wanted)
|
|
639
|
+
? { ok: true, current: wanted || now, by: 'hand' }
|
|
640
|
+
: { ok: false, current: now, reason: `the picker still shows "${now ?? 'nothing'}", not "${wanted}"` };
|
|
641
|
+
}
|
|
642
|
+
return { result, goOnAsItIs: false };
|
|
643
|
+
}
|
|
582
644
|
/** Runs one task to completion inside an already-open transport. */
|
|
583
645
|
export async function runTask(transport, session, task, deps) {
|
|
584
646
|
const { cfg, store, bus, authorizer, signal } = deps;
|
|
@@ -1756,8 +1818,18 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1756
1818
|
try {
|
|
1757
1819
|
await transport.newChat();
|
|
1758
1820
|
if (model) {
|
|
1759
|
-
|
|
1760
|
-
|
|
1821
|
+
let picked;
|
|
1822
|
+
if (cfg.copilot.manualModel && deps.models?.askHelp) {
|
|
1823
|
+
// By hand, as the work's model (see `applySessionModel`): the review's fresh conversation is asked about too.
|
|
1824
|
+
const shown = await transport.currentModel().catch(() => null);
|
|
1825
|
+
picked = buttonShows(shown, model)
|
|
1826
|
+
? { ok: true, current: model, by: 'already' }
|
|
1827
|
+
: (await withOperatorHand(transport, model, { ok: false, current: shown, reason: 'Settings ask for the model to be chosen by hand (the review)' }, deps.models, session.id, bus, true)).result;
|
|
1828
|
+
}
|
|
1829
|
+
else {
|
|
1830
|
+
const reviewLocator = await deps.models?.locate?.(model).catch(() => undefined);
|
|
1831
|
+
picked = await transport.selectModel(model, { locator: reviewLocator }).catch((e) => ({ ok: false, current: null, reason: e.message }));
|
|
1832
|
+
}
|
|
1761
1833
|
const renamed = await followPageModels('review', model, picked, session, store, deps.models, effectiveModels(session, cfg).reviewModelFrom);
|
|
1762
1834
|
if (renamed)
|
|
1763
1835
|
sink.event('model-renamed', { asked: model, chosen: picked.matched, review: true }, renamed, 'warn');
|
|
@@ -230,6 +230,38 @@ export const SYSTEM_GUIDE = [
|
|
|
230
230
|
doesEn: 'Ends the whole task here, aborted; its remaining steps are not run.',
|
|
231
231
|
doesBg: 'Прекратява цялата задача тук; останалите ѝ стъпки не се изпълняват.',
|
|
232
232
|
},
|
|
233
|
+
{
|
|
234
|
+
key: 'approval.modelChosen',
|
|
235
|
+
kind: 'button',
|
|
236
|
+
en: 'I chose it — check and continue',
|
|
237
|
+
bg: 'Избрах го — провери и продължи',
|
|
238
|
+
doesEn: 'On the card that appears when the bot could not choose the model: after you chose it in the Copilot window, the bot reads the picker and goes on, or asks again if it shows another model.',
|
|
239
|
+
doesBg: 'На картата, която се появява, когато ботът не успее да избере модела: след като го изберете в прозореца на Copilot, ботът проверява менюто и продължава, или пита пак, ако то показва друг модел.',
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
key: 'approval.manualReady',
|
|
243
|
+
kind: 'button',
|
|
244
|
+
en: 'Ready — check and continue',
|
|
245
|
+
bg: 'Готово — провери и продължи',
|
|
246
|
+
doesEn: 'On the card shown when Settings ask for the model to be chosen by hand: after you chose it in the Copilot window, the bot checks the picker and starts.',
|
|
247
|
+
doesBg: 'На картата при ръчен избор на модела (от Настройки): след като го изберете в прозореца на Copilot, ботът проверява менюто и започва.',
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
key: 'approval.modelAsItIs',
|
|
251
|
+
kind: 'button',
|
|
252
|
+
en: 'Continue on the model it is on',
|
|
253
|
+
bg: 'Продължи на текущия модел',
|
|
254
|
+
doesEn: 'The run goes on with whatever model the chat is on, and the record says so.',
|
|
255
|
+
doesBg: 'Изпълнението продължава с модела, на който е чатът, и записът го казва.',
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
key: 'approval.modelStop',
|
|
259
|
+
kind: 'button',
|
|
260
|
+
en: 'Stop',
|
|
261
|
+
bg: 'Спри',
|
|
262
|
+
doesEn: 'Nothing is sent: the session stops, its tasks stay queued.',
|
|
263
|
+
doesBg: 'Нищо не се изпраща: сесията спира, задачите ѝ остават на опашката.',
|
|
264
|
+
},
|
|
233
265
|
],
|
|
234
266
|
},
|
|
235
267
|
],
|
|
@@ -3004,6 +3036,14 @@ export const SYSTEM_GUIDE = [
|
|
|
3004
3036
|
doesEn: 'How many different approaches a "blocked" must list (2–20); with fewer the chat is asked for another.',
|
|
3005
3037
|
doesBg: 'Колко различни подхода трябва да изброи „blocked“ (2–20); с по-малко чатът е помолен за още един.',
|
|
3006
3038
|
},
|
|
3039
|
+
{
|
|
3040
|
+
key: 'def.manualModel',
|
|
3041
|
+
kind: 'field',
|
|
3042
|
+
en: 'Choose the model by hand at the start of each run',
|
|
3043
|
+
bg: 'Избирай модела ръчно в началото на всяко пускане',
|
|
3044
|
+
doesEn: 'The bot leaves the model picker alone and asks, on a card, for the model to be chosen in the Copilot window; a conversation already on the model is not asked about.',
|
|
3045
|
+
doesBg: 'Ботът не пипа менюто с моделите и моли, с карта, моделът да бъде избран в прозореца на Copilot; разговор, който вече е на модела, не се пита.',
|
|
3046
|
+
},
|
|
3007
3047
|
{
|
|
3008
3048
|
key: 'exec.maxReviewRounds',
|
|
3009
3049
|
kind: 'field',
|
|
@@ -940,31 +940,60 @@ Current URL: ${url}`);
|
|
|
940
940
|
const textOf = async (el) => (((await el.innerText().catch(() => '')) ?? '').split('\n')[0] ?? '').trim();
|
|
941
941
|
// A fixed pause after every opening and every press (see `ModelPicking`); the checks give it less.
|
|
942
942
|
const settle = () => this.p.waitForTimeout(this.opts.modelSettleMs ?? ModelPicking.settleMs);
|
|
943
|
-
|
|
944
|
-
const
|
|
943
|
+
const pause = this.opts.modelBeforePressMs ?? ModelPicking.beforePressMs;
|
|
944
|
+
const poll = this.opts.modelPollMs ?? ModelPicking.pollMs;
|
|
945
945
|
const appear = this.opts.modelAppearMs ?? ModelPicking.appearMs;
|
|
946
|
-
|
|
947
|
-
|
|
946
|
+
let shot = 0;
|
|
947
|
+
/** With `modelStepShots`, a picture and the menu's HTML at each step, numbered, beside the transport's files. */
|
|
948
|
+
const picture = async (label) => {
|
|
949
|
+
if (!this.opts.modelStepShots)
|
|
950
|
+
return;
|
|
951
|
+
shot += 1;
|
|
952
|
+
await this.captureMenu(`step-${String(shot).padStart(2, '0')}-${label.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40)}`, await this.readMenuRows().catch(() => [])).catch(() => undefined);
|
|
953
|
+
};
|
|
954
|
+
/*
|
|
955
|
+
* The operator's rhythm for every element of the picker (2026-10-06): look for it; while it is not
|
|
956
|
+
* there, wait two seconds and look again; once it is there, wait two seconds more, then press it.
|
|
957
|
+
* Each look and each pause is in the record with its time, so the pauses can be seen.
|
|
958
|
+
*/
|
|
959
|
+
const waitThenFind = async (label, find) => {
|
|
960
|
+
const deadline = Date.now() + appear;
|
|
961
|
+
let looks = 1;
|
|
962
|
+
let el = await find();
|
|
963
|
+
while (!el && Date.now() < deadline) {
|
|
964
|
+
this.emit('model-step', { step: 'not there yet, waiting', what: label, look: looks, waitMs: poll });
|
|
965
|
+
await this.p.waitForTimeout(poll);
|
|
966
|
+
looks += 1;
|
|
967
|
+
el = await find();
|
|
968
|
+
}
|
|
969
|
+
if (!el) {
|
|
970
|
+
this.emit('model-step', { step: 'never appeared', what: label, looks });
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
this.emit('model-step', { step: 'there, pausing before the press', what: label, look: looks, text: await textOf(el), pauseMs: pause });
|
|
974
|
+
await this.p.waitForTimeout(pause);
|
|
975
|
+
await picture(`before-${label}`);
|
|
976
|
+
return el;
|
|
977
|
+
};
|
|
978
|
+
const visible = async (el) => (el && (await el.isVisible().catch(() => false)) ? el : null);
|
|
948
979
|
const reach = async () => {
|
|
949
|
-
const button = await this.resolveModelButton();
|
|
980
|
+
const button = await waitThenFind('the picker button', async () => visible(await this.resolveModelButton(1_000)));
|
|
950
981
|
if (!button)
|
|
951
982
|
return { problem: 'This chat does not show a model picker.' };
|
|
952
|
-
await
|
|
953
|
-
await button.click();
|
|
983
|
+
await button.click({ timeout: 5_000 }).catch(() => undefined);
|
|
954
984
|
await settle();
|
|
955
985
|
if (!(await this.waitForPopup()))
|
|
956
986
|
return { problem: 'The model picker did not open.' };
|
|
957
987
|
this.emit('model-step', { step: 'picker opened' });
|
|
958
988
|
if (target.group) {
|
|
959
|
-
const group = await this.rowByXPath(target.group.xpath, target.group.name,
|
|
960
|
-
if (!group
|
|
989
|
+
const group = await waitThenFind(`the "${target.group.name}" group`, () => this.rowByXPath(target.group.xpath, target.group.name, 0));
|
|
990
|
+
if (!group)
|
|
961
991
|
return { problem: `the "${target.group.name}" row was not found by ${target.group.xpath}` };
|
|
962
|
-
this.emit('model-step', { step: 'group found', text: await textOf(group) });
|
|
963
992
|
/*
|
|
964
993
|
* Opened without the mouse first: a mouse click goes to coordinates, and the picker moves while it
|
|
965
994
|
* opens, so on the work machine a press meant for "GPT" (now second) opened "Claude" (now first)
|
|
966
995
|
* (2026-10-06). A click sent to the element itself cannot land on another row; then the keyboard
|
|
967
|
-
* (focus, ArrowRight opens a submenu); the mouse only last. Each time, its model must
|
|
996
|
+
* (focus, ArrowRight opens a submenu); the mouse only last. Each time, its model must appear.
|
|
968
997
|
*/
|
|
969
998
|
const ways = [
|
|
970
999
|
['click on the element', async () => { await group.evaluate((el) => el.click(), undefined, { timeout: 3_000 }); }],
|
|
@@ -973,26 +1002,75 @@ Current URL: ${url}`);
|
|
|
973
1002
|
['mouse click', async () => { await group.click({ timeout: 3_000 }); }],
|
|
974
1003
|
];
|
|
975
1004
|
for (const [how, act] of ways) {
|
|
976
|
-
await beforePress();
|
|
977
1005
|
await act().catch(() => undefined);
|
|
978
1006
|
await settle();
|
|
979
|
-
const row = await this.rowByXPath(target.xpath, target.model,
|
|
980
|
-
if (row
|
|
1007
|
+
const row = await waitThenFind(`"${target.model}"`, () => this.rowByXPath(target.xpath, target.model, 0));
|
|
1008
|
+
if (row) {
|
|
981
1009
|
this.emit('model-step', { step: 'group opened', how, model: await textOf(row) });
|
|
982
1010
|
return { row };
|
|
983
1011
|
}
|
|
984
1012
|
this.emit('model-step', { step: 'group not opened', how });
|
|
1013
|
+
await this.p.waitForTimeout(pause);
|
|
985
1014
|
}
|
|
986
1015
|
await this.captureMenu('select-failed', await this.readMenuRows().catch(() => [])).catch(() => undefined);
|
|
987
1016
|
return { problem: `"${target.model}" did not appear after opening "${target.group.name}" every way (${target.xpath}); the menu is kept as models-select-failed.html/.png` };
|
|
988
1017
|
}
|
|
989
|
-
const row = await this.rowByXPath(target.xpath, target.model,
|
|
990
|
-
if (!row
|
|
1018
|
+
const row = await waitThenFind(`"${target.model}"`, () => this.rowByXPath(target.xpath, target.model, 0));
|
|
1019
|
+
if (!row) {
|
|
991
1020
|
await this.captureMenu('select-failed', await this.readMenuRows().catch(() => [])).catch(() => undefined);
|
|
992
1021
|
return { problem: `"${target.model}" was not found by ${target.xpath}; the menu is kept as models-select-failed.html/.png` };
|
|
993
1022
|
}
|
|
994
1023
|
return { row };
|
|
995
1024
|
};
|
|
1025
|
+
/*
|
|
1026
|
+
* A model in a group, by the keyboard (operator's choice, 2026-10-06: the submenu failed when it was
|
|
1027
|
+
* expanded by click or hover). The picker opened, the group's row is focused, ArrowRight opens its
|
|
1028
|
+
* submenu and puts the focus in it, ArrowDown moves the focus until it is on the model — read from the
|
|
1029
|
+
* focused row's own text — and Enter chooses it. No hover, no coordinates.
|
|
1030
|
+
*/
|
|
1031
|
+
const byKeyboard = async (attempt) => {
|
|
1032
|
+
if (!target.group)
|
|
1033
|
+
return false;
|
|
1034
|
+
const button = await waitThenFind('the picker button', async () => visible(await this.resolveModelButton(1_000)));
|
|
1035
|
+
if (!button)
|
|
1036
|
+
return false;
|
|
1037
|
+
await button.click({ timeout: 5_000 }).catch(() => undefined);
|
|
1038
|
+
await settle();
|
|
1039
|
+
if (!(await this.waitForPopup()))
|
|
1040
|
+
return false;
|
|
1041
|
+
const group = await waitThenFind(`the "${target.group.name}" group`, () => this.rowByXPath(target.group.xpath, target.group.name, 0));
|
|
1042
|
+
if (!group) {
|
|
1043
|
+
await this.closeMenu();
|
|
1044
|
+
return false;
|
|
1045
|
+
}
|
|
1046
|
+
await group.focus({ timeout: 3_000 }).catch(() => undefined);
|
|
1047
|
+
await this.p.keyboard.press('ArrowRight');
|
|
1048
|
+
this.emit('model-step', { step: 'group opened by the keyboard (ArrowRight)', attempt });
|
|
1049
|
+
await settle();
|
|
1050
|
+
const focused = () => this.p.evaluate(() => {
|
|
1051
|
+
const el = document.activeElement;
|
|
1052
|
+
const row = el?.closest('[role="menuitemradio"],[role="menuitem"]');
|
|
1053
|
+
return { text: (row?.innerText ?? '').trim(), radio: !!el?.closest('[role="menuitemradio"]') };
|
|
1054
|
+
});
|
|
1055
|
+
for (let presses = 0; presses < 12; presses += 1) {
|
|
1056
|
+
const now = await focused();
|
|
1057
|
+
const line = now.text.split('\n')[0]?.trim() ?? '';
|
|
1058
|
+
if (now.radio && (sameModel(line, name) || sameModel(line, target.model) || containsModel(now.text, target.model))) {
|
|
1059
|
+
this.emit('model-step', { step: 'model reached by the keyboard, pausing before Enter', attempt, text: line, presses, pauseMs: pause });
|
|
1060
|
+
await this.p.waitForTimeout(pause);
|
|
1061
|
+
await picture('before-enter');
|
|
1062
|
+
await this.p.keyboard.press('Enter');
|
|
1063
|
+
await settle();
|
|
1064
|
+
return true;
|
|
1065
|
+
}
|
|
1066
|
+
await this.p.keyboard.press('ArrowDown');
|
|
1067
|
+
await this.p.waitForTimeout(400);
|
|
1068
|
+
}
|
|
1069
|
+
this.emit('model-step', { step: 'model not reached by the keyboard', attempt });
|
|
1070
|
+
await this.closeMenu();
|
|
1071
|
+
await settle();
|
|
1072
|
+
return false;
|
|
1073
|
+
};
|
|
996
1074
|
const checked = async (row) => (await row.getAttribute('aria-checked').catch(() => null)) === 'true';
|
|
997
1075
|
/*
|
|
998
1076
|
* The whole choice, tried again from a closed menu while the picker does not show the model as chosen,
|
|
@@ -1002,6 +1080,17 @@ Current URL: ${url}`);
|
|
|
1002
1080
|
let why = '';
|
|
1003
1081
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
1004
1082
|
this.emit('model-step', { step: 'attempt', attempt, of: attempts, model: name });
|
|
1083
|
+
// A model in a group: the keyboard first; the other ways only when it does not get there.
|
|
1084
|
+
if (target.group && (await byKeyboard(attempt))) {
|
|
1085
|
+
await this.closeMenu();
|
|
1086
|
+
await settle();
|
|
1087
|
+
const shownNow = await this.currentModel();
|
|
1088
|
+
if (buttonShows(shownNow, name)) {
|
|
1089
|
+
this.emit('model-selected', { model: name, buttonShows: shownNow, by: 'operator', attempt, seenOn: 'button', how: 'keyboard' });
|
|
1090
|
+
return { ok: true, current: name, by: 'operator' };
|
|
1091
|
+
}
|
|
1092
|
+
this.emit('model-step', { step: 'not shown on the button after Enter, trying the other ways', attempt, buttonShows: shownNow });
|
|
1093
|
+
}
|
|
1005
1094
|
const found = await reach();
|
|
1006
1095
|
if ('problem' in found) {
|
|
1007
1096
|
why = found.problem;
|
|
@@ -1017,14 +1106,14 @@ Current URL: ${url}`);
|
|
|
1017
1106
|
}
|
|
1018
1107
|
// On the element itself, for the same reason as the group; the mouse only when that does nothing.
|
|
1019
1108
|
this.emit('model-step', { step: 'model pressed', attempt, text: await textOf(found.row) });
|
|
1020
|
-
await beforePress();
|
|
1021
1109
|
await found.row.evaluate((el) => el.click(), undefined, { timeout: 3_000 }).catch(() => undefined);
|
|
1022
1110
|
await settle();
|
|
1023
1111
|
if ((await this.modelMenuOpen()) && !(await checked(found.row))) {
|
|
1024
|
-
await
|
|
1112
|
+
await this.p.waitForTimeout(pause);
|
|
1025
1113
|
await found.row.click({ timeout: 3_000 }).catch(() => undefined);
|
|
1026
1114
|
await settle();
|
|
1027
1115
|
}
|
|
1116
|
+
await picture('after-press');
|
|
1028
1117
|
await this.closeMenu();
|
|
1029
1118
|
await settle();
|
|
1030
1119
|
/*
|
|
@@ -167,10 +167,15 @@ export const OperatorModelLocators = {
|
|
|
167
167
|
*/
|
|
168
168
|
export const ModelPicking = {
|
|
169
169
|
attempts: 6,
|
|
170
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* The operator's rhythm (2026-10-06): an element of the picker is looked for; while it is not there the
|
|
172
|
+
* runner waits `pollMs` and looks again, up to `appearMs`; once it is there it waits `beforePressMs`
|
|
173
|
+
* more, then presses it.
|
|
174
|
+
*/
|
|
175
|
+
pollMs: 2_000,
|
|
171
176
|
beforePressMs: 2_000,
|
|
172
177
|
settleMs: 1_500,
|
|
173
|
-
appearMs:
|
|
178
|
+
appearMs: 20_000,
|
|
174
179
|
};
|
|
175
180
|
/** A text as an XPath string literal, whatever quotes it holds. */
|
|
176
181
|
export function xpathText(text) {
|
|
@@ -19,7 +19,18 @@ export function buttonShows(shown, asked) {
|
|
|
19
19
|
return false;
|
|
20
20
|
const s = normModel(shown);
|
|
21
21
|
const a = normModel(asked);
|
|
22
|
-
|
|
22
|
+
if (s.length === 0)
|
|
23
|
+
return false;
|
|
24
|
+
if (s === a)
|
|
25
|
+
return true;
|
|
26
|
+
/*
|
|
27
|
+
* The button drops the last word ("GPT-5.6 Sol Think deeper" reads "GPT-5.6 Sol Think"), no more: a
|
|
28
|
+
* shorter start — "GPT" alone — would agree with every model of the group, and a run would take the
|
|
29
|
+
* chat for set and choose nothing (2026-10-06).
|
|
30
|
+
*/
|
|
31
|
+
const sw = s.split(' ');
|
|
32
|
+
const aw = a.split(' ');
|
|
33
|
+
return sw.length >= aw.length - 1 && aw.length > 1 && a.startsWith(`${s} `);
|
|
23
34
|
}
|
|
24
35
|
const words = (name) => normModel(name).split(' ').filter(Boolean);
|
|
25
36
|
const isVersion = (w) => /^v?\d+(?:\.\d+)*$/.test(w);
|