@mjasnikovs/pi-task 0.38.31 → 0.39.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 +2 -1
- package/dist/config/config.d.ts +16 -2
- package/dist/config/config.js +7 -2
- package/dist/config/group-args.d.ts +52 -0
- package/dist/config/group-args.js +110 -0
- package/dist/config/group-models.d.ts +88 -0
- package/dist/config/group-models.js +117 -0
- package/dist/config/groups.d.ts +76 -0
- package/dist/config/groups.js +110 -0
- package/dist/config/option-picker.d.ts +70 -0
- package/dist/config/option-picker.js +113 -0
- package/dist/config/reasoning.d.ts +26 -64
- package/dist/config/reasoning.js +31 -115
- package/dist/config/register.d.ts +144 -24
- package/dist/config/register.js +345 -56
- package/dist/index.js +2 -0
- package/dist/remote/push.js +1 -7
- package/dist/shared/data-home.d.ts +8 -0
- package/dist/shared/data-home.js +14 -0
- package/dist/shared/model-endpoint.d.ts +53 -0
- package/dist/shared/model-endpoint.js +98 -2
- package/dist/shared/reasoning-capability.d.ts +25 -5
- package/dist/shared/reasoning-capability.js +18 -9
- package/dist/task/child-runner.d.ts +19 -16
- package/dist/task/child-runner.js +64 -36
- package/dist/task/context-usage.d.ts +46 -0
- package/dist/task/context-usage.js +41 -0
- package/dist/task/gate-child.d.ts +15 -4
- package/dist/task/gate-child.js +2 -2
- package/dist/task/gate-deps.js +7 -2
- package/dist/task/implementation-hold.d.ts +118 -0
- package/dist/task/implementation-hold.js +165 -0
- package/dist/task/model-hold-stash.d.ts +43 -0
- package/dist/task/model-hold-stash.js +70 -0
- package/dist/task/orchestrator.d.ts +18 -5
- package/dist/task/orchestrator.js +36 -4
- package/dist/task/phases.js +2 -2
- package/dist/task/research-worker.d.ts +2 -2
- package/dist/task/research-worker.js +1 -1
- package/dist/workers/docs-core.js +2 -2
- package/dist/workers/docs-lookup.d.ts +4 -3
- package/dist/workers/docs-lookup.js +1 -1
- package/dist/workers/fetch-core.js +2 -2
- package/dist/workers/focused-extractor.d.ts +4 -3
- package/dist/workers/focused-extractor.js +5 -4
- package/dist/workers/index.js +2 -0
- package/dist/workers/model-warning.d.ts +69 -0
- package/dist/workers/model-warning.js +113 -0
- package/dist/workers/pi-worker-core.d.ts +7 -7
- package/dist/workers/pi-worker-core.js +4 -3
- package/dist/workers/pi-worker-docs.js +2 -2
- package/dist/workers/pi-worker.js +4 -4
- package/dist/workers/reasoning-warning.d.ts +17 -9
- package/dist/workers/reasoning-warning.js +69 -22
- package/package.json +1 -1
- package/dist/config/reasoning-args.d.ts +0 -23
- package/dist/config/reasoning-args.js +0 -28
- package/dist/task/implementation-thinking.d.ts +0 -56
- package/dist/task/implementation-thinking.js +0 -32
package/dist/config/register.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { getKeybindings, SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
|
|
2
|
+
import { clampToModel, supportedThinkingLevels } from '../shared/reasoning-capability.js';
|
|
3
|
+
import { MODEL_INHERIT, splitSpec } from './group-models.js';
|
|
4
|
+
import { PairPicker } from './option-picker.js';
|
|
2
5
|
import { registerBridgeCommand } from '../remote/bridge.js';
|
|
3
6
|
import { readPkgVersion } from '../shared/pkg-version.js';
|
|
4
7
|
import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
|
|
5
8
|
import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
|
|
6
9
|
import { listInstalledExtensions } from './extension-list.js';
|
|
7
10
|
import { listGuardableTools } from './tool-list.js';
|
|
8
|
-
import {
|
|
11
|
+
import { CHILD_GROUPS, REASONING_MODES, sanitizeReasoningMode, STEP_GROUP_HELP, REASONING_SETTINGS, effectiveReasoning, resolveReasoning } from './reasoning.js';
|
|
9
12
|
// Version in the title so a bug report or screenshot says which build it came
|
|
10
13
|
// from without anyone having to go look it up.
|
|
11
14
|
const CONFIG_TITLE = `pi-task ${readPkgVersion()} settings`;
|
|
@@ -73,7 +76,20 @@ export const SECTIONS = [
|
|
|
73
76
|
{ key: 'session', title: 'session' },
|
|
74
77
|
{ key: 'checks', title: 'after each task' },
|
|
75
78
|
{ key: 'research', title: 'research' },
|
|
76
|
-
|
|
79
|
+
// The global override gets its own heading, so it cannot be mistaken for a
|
|
80
|
+
// twelfth step. It is one row, and `renderRows` drops an empty section, so
|
|
81
|
+
// this costs a header and a blank line and nothing else.
|
|
82
|
+
//
|
|
83
|
+
// Both titles are SHORT on purpose. `SettingsList` sizes its label column
|
|
84
|
+
// from the widest item it holds — headers included — so a long heading is
|
|
85
|
+
// taken straight out of every row's value column, and the first thing to
|
|
86
|
+
// fall off the right is the thinking level.
|
|
87
|
+
{ key: 'profile', title: 'profile' },
|
|
88
|
+
// ONE block for both dials. They shipped as two parallel blocks of eleven,
|
|
89
|
+
// which put a step's two settings eleven rows apart and hid the coupling
|
|
90
|
+
// between them: choosing a model re-clamps that step's thinking level, and
|
|
91
|
+
// nobody could see it happen.
|
|
92
|
+
{ key: 'reasoning', title: 'steps' },
|
|
77
93
|
{ key: 'unattended', title: 'unattended' },
|
|
78
94
|
{ key: 'logging', title: 'logging' },
|
|
79
95
|
{ key: 'extensions', title: 'child extensions' },
|
|
@@ -83,6 +99,16 @@ export const SECTIONS = [
|
|
|
83
99
|
];
|
|
84
100
|
/** Marks a header row, so onChange can ignore one and tests can find them. */
|
|
85
101
|
export const SECTION_ID_PREFIX = 'section:';
|
|
102
|
+
/**
|
|
103
|
+
* Is this row scenery rather than a setting?
|
|
104
|
+
*
|
|
105
|
+
* Reads the ID, not `values`. Those two agreed only while every real row cycled
|
|
106
|
+
* a list: a picker row has a `submenu` and may carry `values` purely for the
|
|
107
|
+
* round-trip contract, so "no values" stopped meaning "not a row". Both
|
|
108
|
+
* `sectionHeader` and `sectionGap` already stamp the prefix, so this is exact
|
|
109
|
+
* rather than a heuristic, and all three consumers ask the same question.
|
|
110
|
+
*/
|
|
111
|
+
export const isSectionRow = (item) => item.id.startsWith(SECTION_ID_PREFIX);
|
|
86
112
|
/**
|
|
87
113
|
* An inert titled row. No `values` ⇒ SettingsList's Enter handler no-ops on it,
|
|
88
114
|
* and {@link SkipInertRows} steps the cursor straight over it.
|
|
@@ -211,15 +237,17 @@ export const ITEMS = [
|
|
|
211
237
|
+ 'For throwaway projects you are not watching'),
|
|
212
238
|
{
|
|
213
239
|
id: 'reasoningMode',
|
|
214
|
-
section
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
+ '
|
|
221
|
-
+ '
|
|
222
|
-
+ '
|
|
240
|
+
// Its OWN section, above the per-step block it governs. Sitting inside
|
|
241
|
+
// that block it read as a twelfth step called `reasoning` — a global
|
|
242
|
+
// override disguised as one more per-step row.
|
|
243
|
+
section: 'profile',
|
|
244
|
+
label: 'profile',
|
|
245
|
+
description: 'How much every step below thinks, in one word. "default" uses the per-step '
|
|
246
|
+
+ 'table pi-task has measured, "on" and "off" force one answer everywhere and '
|
|
247
|
+
+ 'IGNORE the rows below, and "custom" is whatever those rows say. They always '
|
|
248
|
+
+ 'show what each step actually runs at, so changing one switches this to '
|
|
249
|
+
+ 'custom. A step left on "inherit" uses whatever thinking level pi itself is '
|
|
250
|
+
+ 'set to, which is what every step did before this setting existed',
|
|
223
251
|
values: [...REASONING_MODES],
|
|
224
252
|
format: cfg => String(cfg.reasoningMode),
|
|
225
253
|
apply: (cfg, chosen) => {
|
|
@@ -322,9 +350,9 @@ export function applyToolToggle(exempt, toolName, watched) {
|
|
|
322
350
|
* though the custom table underneath is untouched, which is the honest
|
|
323
351
|
* answer to "what will my next child do".
|
|
324
352
|
*/
|
|
325
|
-
const
|
|
353
|
+
const STEP_ID_PREFIX = 'step:';
|
|
326
354
|
/**
|
|
327
|
-
* The label for one
|
|
355
|
+
* The label for one step row.
|
|
328
356
|
*
|
|
329
357
|
* A group whose name carries a colon is a CHILD of the group before the colon —
|
|
330
358
|
* `research:files` is one of the four workers `research` fans out to. Rendered
|
|
@@ -333,37 +361,206 @@ const REASON_ID_PREFIX = 'reason:';
|
|
|
333
361
|
* parent is also a row.
|
|
334
362
|
*
|
|
335
363
|
* So a child is drawn as a tree branch under its parent and loses the repeated
|
|
336
|
-
* `
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
364
|
+
* `research:` prefix, the same text at the head of four consecutive lines. `└─`
|
|
365
|
+
* on the last child, `├─` on the rest, decided from the group's position in
|
|
366
|
+
* {@link CHILD_GROUPS} rather than a hand-kept list — adding a fifth worker
|
|
367
|
+
* moves the corner on its own.
|
|
368
|
+
*
|
|
369
|
+
* A parentless group is its own bare name. It carried a `think: ` prefix while
|
|
370
|
+
* there were two families to tell apart; with one row per step there is nothing
|
|
371
|
+
* to disambiguate, and the prefix was the widest thing in the column.
|
|
340
372
|
*
|
|
341
373
|
* Leading spaces survive: SettingsList pads the label right, never trims it.
|
|
342
374
|
*/
|
|
343
|
-
export function
|
|
375
|
+
export function stepRowLabel(group) {
|
|
344
376
|
const colon = group.indexOf(':');
|
|
345
377
|
if (colon < 0)
|
|
346
|
-
return
|
|
378
|
+
return group;
|
|
347
379
|
const parent = group.slice(0, colon);
|
|
348
|
-
const nextIsSibling =
|
|
380
|
+
const nextIsSibling = CHILD_GROUPS[CHILD_GROUPS.indexOf(group) + 1]?.startsWith(`${parent}:`);
|
|
349
381
|
return ` ${nextIsSibling ? '├─' : '└─'} ${group.slice(colon + 1)}`;
|
|
350
382
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
383
|
+
/** No registry reachable. Every row still renders; nothing narrows. */
|
|
384
|
+
export const EMPTY_CATALOG = { specs: [], facts: () => undefined };
|
|
385
|
+
/**
|
|
386
|
+
* The levels a row may offer, given the model that row's group will run on.
|
|
387
|
+
*
|
|
388
|
+
* The INTERSECTION with `REASONING_SETTINGS`, not `supportedThinkingLevels`
|
|
389
|
+
* directly: that returns the whole ladder including `xhigh` and `max`, which
|
|
390
|
+
* this menu excludes on purpose (see reasoning.ts) because pi's own UI may not
|
|
391
|
+
* offer them. A model declaring `xhigh` must not smuggle it in here.
|
|
392
|
+
*/
|
|
393
|
+
export function offeredLevels(facts) {
|
|
394
|
+
if (facts === undefined)
|
|
395
|
+
return [...REASONING_SETTINGS];
|
|
396
|
+
const supported = supportedThinkingLevels(facts);
|
|
397
|
+
return REASONING_SETTINGS.filter(s => s === 'inherit' || supported.includes(s));
|
|
398
|
+
}
|
|
399
|
+
/** The separator between a step row's two halves. */
|
|
400
|
+
const PAIR_SEP = ' \u00b7 ';
|
|
401
|
+
/**
|
|
402
|
+
* `level · provider/id`, the one string a step row shows and accepts.
|
|
403
|
+
*
|
|
404
|
+
* THE LEVEL COMES FIRST, and that is a display decision with teeth.
|
|
405
|
+
* `SettingsList` truncates a value from the RIGHT, so whatever is last is what
|
|
406
|
+
* silently disappears — and a real local model id (`local/Qwen3.8-27B-UD-Q4_K_XL
|
|
407
|
+
* .gguf`) is wide enough to consume the whole column on its own. Level-first
|
|
408
|
+
* means the half that falls off is the one still identifiable from its head, and
|
|
409
|
+
* the levels line up as a column you can read down.
|
|
410
|
+
*/
|
|
411
|
+
export function formatStepValue(spec, level) {
|
|
412
|
+
return `${level}${PAIR_SEP}${spec}`;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* The two halves back out, or `undefined` for anything not of that shape.
|
|
416
|
+
*
|
|
417
|
+
* Split on the FIRST separator, because the level leads and is a closed set,
|
|
418
|
+
* while a `provider/id` could conceivably contain one.
|
|
419
|
+
*/
|
|
420
|
+
export function parseStepValue(value) {
|
|
421
|
+
const at = value.indexOf(PAIR_SEP);
|
|
422
|
+
if (at <= 0)
|
|
423
|
+
return undefined;
|
|
424
|
+
const level = value.slice(0, at);
|
|
425
|
+
const spec = value.slice(at + PAIR_SEP.length);
|
|
426
|
+
if (spec === '' || !REASONING_SETTINGS.includes(level))
|
|
427
|
+
return undefined;
|
|
428
|
+
return { spec, level: level };
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* One row per step, carrying BOTH dials.
|
|
432
|
+
*
|
|
433
|
+
* They were two parallel blocks of eleven, and the coupling between them was
|
|
434
|
+
* invisible: choosing a model re-clamps that step's thinking level, but the row
|
|
435
|
+
* that moved was eleven rows away from the row you touched. One row shows the
|
|
436
|
+
* pair, and the two-step picker shows the clamp happening.
|
|
437
|
+
*
|
|
438
|
+
* `values` is the LEGAL cross product — every model against only the levels that
|
|
439
|
+
* model declares. Nothing renders it: the picker offers two short lists, and the
|
|
440
|
+
* round-trip property in config-items.test.ts is its only reader. Building it
|
|
441
|
+
* from `offeredLevels` rather than the full ladder is what makes the property
|
|
442
|
+
* true, because a pair the model cannot honour would be clamped by `apply` and
|
|
443
|
+
* would not round-trip.
|
|
444
|
+
*/
|
|
445
|
+
export function stepItems(catalog = EMPTY_CATALOG) {
|
|
446
|
+
const specs = [MODEL_INHERIT, ...catalog.specs];
|
|
447
|
+
return CHILD_GROUPS.map(group => ({
|
|
448
|
+
id: STEP_ID_PREFIX + group,
|
|
354
449
|
section: 'reasoning',
|
|
355
|
-
label:
|
|
356
|
-
headlessLabel: `
|
|
357
|
-
description:
|
|
358
|
-
values:
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
450
|
+
label: stepRowLabel(group),
|
|
451
|
+
headlessLabel: `step: ${group}`,
|
|
452
|
+
description: STEP_GROUP_HELP[group],
|
|
453
|
+
values: specs.flatMap(spec => offeredLevels(catalog.facts(spec)).map(level => formatStepValue(spec, level))),
|
|
454
|
+
// Built at ENTER-time from the LIVE draft, not when the rows were made:
|
|
455
|
+
// stage two narrows to the model chosen in stage one, and the user can
|
|
456
|
+
// have changed another row since the panel opened.
|
|
457
|
+
picker: cfg => stepPicker(group, cfg, catalog),
|
|
458
|
+
// The model half VERBATIM even when the catalog no longer offers it — the
|
|
459
|
+
// vanished-model case, where the row is the only place the user can see
|
|
460
|
+
// what their config actually holds. The thinking half is the EFFECTIVE
|
|
461
|
+
// level, not the stored cell: in mode default/on/off the stored table is
|
|
462
|
+
// not what runs, and a row showing a value the run does not use is worse
|
|
463
|
+
// than no row.
|
|
464
|
+
format: cfg => formatStepValue(cfg.groupModels[group], resolveReasoning(group, cfg)),
|
|
465
|
+
apply: (cfg, chosen) => applyStepValue(cfg, group, chosen, catalog)
|
|
365
466
|
}));
|
|
366
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* The two lists behind one step row.
|
|
470
|
+
*
|
|
471
|
+
* Stage two is where the coupling becomes visible. It offers only the levels the
|
|
472
|
+
* chosen model declares, and it OPENS on the level that will actually run — the
|
|
473
|
+
* current one when that model can honour it, otherwise the clamp, with the
|
|
474
|
+
* reason written beside it. Picking a model that cannot think is therefore not a
|
|
475
|
+
* silent downgrade discovered later; it is the option the cursor is already on.
|
|
476
|
+
*/
|
|
477
|
+
function stepPicker(group, cfg, catalog) {
|
|
478
|
+
const held = cfg.groupModels[group];
|
|
479
|
+
// The row's CURRENT model leads stage one when the catalog cannot offer it.
|
|
480
|
+
// Without this, opening the row to nudge only the level would silently
|
|
481
|
+
// rewrite the model to `inherit`: `FilterList` falls back to index 0 when
|
|
482
|
+
// the preselect matches nothing, and the two dials are one row now, so there
|
|
483
|
+
// is no way to touch the level without confirming a model. That would erase
|
|
484
|
+
// a spec set on the user's other machine — the one thing the loader, the
|
|
485
|
+
// format function and the startup hint all go out of their way to preserve.
|
|
486
|
+
const missing = held !== MODEL_INHERIT && !catalog.specs.includes(held);
|
|
487
|
+
return {
|
|
488
|
+
first: [
|
|
489
|
+
...(missing ?
|
|
490
|
+
[{ value: held, label: held, description: 'not available here — kept as-is' }]
|
|
491
|
+
: []),
|
|
492
|
+
{ value: MODEL_INHERIT, label: MODEL_INHERIT, description: "pi's own default" },
|
|
493
|
+
...catalog.specs.map(spec => ({
|
|
494
|
+
value: spec,
|
|
495
|
+
label: spec,
|
|
496
|
+
...(catalog.note?.(spec) === undefined ? {} : { description: catalog.note(spec) })
|
|
497
|
+
}))
|
|
498
|
+
],
|
|
499
|
+
second: spec => {
|
|
500
|
+
const facts = catalog.facts(spec);
|
|
501
|
+
const offered = offeredLevels(facts);
|
|
502
|
+
const wanted = resolveReasoning(group, cfg);
|
|
503
|
+
const clamped = facts === undefined || wanted === 'inherit' ?
|
|
504
|
+
wanted
|
|
505
|
+
: clampToModel(facts, wanted);
|
|
506
|
+
// Back inside the menu's own vocabulary. `clampToModel` walks UP
|
|
507
|
+
// first and knows the whole ladder, so a model declaring `xhigh`
|
|
508
|
+
// can land on a level `offeredLevels` deliberately excludes — and
|
|
509
|
+
// then stage two would open on `inherit` with the explanation
|
|
510
|
+
// attached to no row at all.
|
|
511
|
+
const runs = offered.includes(clamped) ? clamped : (offered.at(-1) ?? 'inherit');
|
|
512
|
+
return {
|
|
513
|
+
options: offered.map(level => ({
|
|
514
|
+
value: level,
|
|
515
|
+
label: level,
|
|
516
|
+
...(level === runs && runs !== wanted ?
|
|
517
|
+
{ description: `${spec} cannot do ${wanted}` }
|
|
518
|
+
: {})
|
|
519
|
+
})),
|
|
520
|
+
preselect: runs
|
|
521
|
+
};
|
|
522
|
+
},
|
|
523
|
+
firstOf: value => parseStepValue(value)?.spec ?? value,
|
|
524
|
+
join: (spec, level) => formatStepValue(spec, level)
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Write both halves of a step row, atomically.
|
|
529
|
+
*
|
|
530
|
+
* Atomically matters for the round-trip property, which starts from a FRESH
|
|
531
|
+
* config every iteration: a value that wrote only one half would leave the other
|
|
532
|
+
* at its default and render as something else.
|
|
533
|
+
*
|
|
534
|
+
* The level is re-clamped even though the picker only ever offers legal pairs.
|
|
535
|
+
* The picker is not the only door — `values` is built when the panel opens, and
|
|
536
|
+
* a registry that moved underneath it would otherwise let an unhonourable level
|
|
537
|
+
* through.
|
|
538
|
+
*/
|
|
539
|
+
export function applyStepValue(cfg, group, chosen, catalog) {
|
|
540
|
+
const pair = parseStepValue(chosen);
|
|
541
|
+
if (pair === undefined)
|
|
542
|
+
return;
|
|
543
|
+
// MEMBERSHIP, not just shape: the panel may only ever write what the picker
|
|
544
|
+
// showed. That is `inherit`, the catalog's own specs, and — when the catalog
|
|
545
|
+
// cannot offer it — the spec this cell ALREADY holds, which stage one keeps
|
|
546
|
+
// at its head precisely so the level can be changed without discarding it.
|
|
547
|
+
// Re-writing the value that is already there is not a new unresolvable spec.
|
|
548
|
+
if (pair.spec !== MODEL_INHERIT
|
|
549
|
+
&& pair.spec !== cfg.groupModels[group]
|
|
550
|
+
&& !catalog.specs.includes(pair.spec)) {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
cfg.groupModels = { ...cfg.groupModels, [group]: pair.spec };
|
|
554
|
+
const facts = catalog.facts(pair.spec);
|
|
555
|
+
const level = facts === undefined || pair.level === 'inherit' ?
|
|
556
|
+
pair.level
|
|
557
|
+
: clampToModel(facts, pair.level);
|
|
558
|
+
// Only when it MOVES something: `applyReasoningLevel` flips the whole table
|
|
559
|
+
// to `custom`, and picking a pair the config already runs must not do that
|
|
560
|
+
// as a side effect.
|
|
561
|
+
if (level !== resolveReasoning(group, cfg))
|
|
562
|
+
applyReasoningLevel(cfg, group, level);
|
|
563
|
+
}
|
|
367
564
|
/**
|
|
368
565
|
* Apply one group row's new value.
|
|
369
566
|
*
|
|
@@ -441,18 +638,29 @@ const DOWN_KEY = '\x1b[B';
|
|
|
441
638
|
*
|
|
442
639
|
* It drives the list through its own public `handleInput` — pressing the very
|
|
443
640
|
* key the user pressed, N times — rather than reaching for the private
|
|
444
|
-
* `selectedIndex`.
|
|
445
|
-
*
|
|
641
|
+
* `selectedIndex`. With search off, up and down are the only two things that
|
|
642
|
+
* move that index — EXCEPT while a submenu is open, when `SettingsList` forwards
|
|
643
|
+
* everything to the submenu and returns without moving it at all. So a picker
|
|
644
|
+
* suspends this entirely; see `suspended`.
|
|
446
645
|
*/
|
|
447
646
|
class SkipInertRows {
|
|
448
647
|
list;
|
|
449
648
|
selectable;
|
|
649
|
+
suspended;
|
|
450
650
|
index = 0;
|
|
451
651
|
constructor(list,
|
|
452
652
|
/** True where a row can be selected, in the list's own order. */
|
|
453
|
-
selectable
|
|
653
|
+
selectable,
|
|
654
|
+
/**
|
|
655
|
+
* True while a picker is open. The list then owns every key, so this
|
|
656
|
+
* must not intercept — see the flag's own comment in
|
|
657
|
+
* {@link createSettingsPanel}. Defaulted so the constructor's own
|
|
658
|
+
* opening walk, and every existing test, are unaffected.
|
|
659
|
+
*/
|
|
660
|
+
suspended = () => false) {
|
|
454
661
|
this.list = list;
|
|
455
662
|
this.selectable = selectable;
|
|
663
|
+
this.suspended = suspended;
|
|
456
664
|
// The first row is a header, so the panel would open on it. Only
|
|
457
665
|
// synthesise the keypress if it is actually bound to "down" — feeding
|
|
458
666
|
// a key the list ignores would move the mirror and not the cursor.
|
|
@@ -471,9 +679,10 @@ class SkipInertRows {
|
|
|
471
679
|
}
|
|
472
680
|
handleInput(data) {
|
|
473
681
|
const kb = getKeybindings();
|
|
474
|
-
const step =
|
|
475
|
-
: kb.matches(data, 'tui.select.
|
|
476
|
-
:
|
|
682
|
+
const step = this.suspended() ? 0
|
|
683
|
+
: kb.matches(data, 'tui.select.down') ? 1
|
|
684
|
+
: kb.matches(data, 'tui.select.up') ? -1
|
|
685
|
+
: 0;
|
|
477
686
|
if (step === 0) {
|
|
478
687
|
this.list.handleInput(data);
|
|
479
688
|
return;
|
|
@@ -502,16 +711,37 @@ export function createSettingsPanel(items, theme,
|
|
|
502
711
|
* Called with the row's id, its new value, and the LIST ITSELF.
|
|
503
712
|
*
|
|
504
713
|
* The list is handed back because some rows change what OTHER rows display:
|
|
505
|
-
* flipping `
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
714
|
+
* flipping `profile` to off means every `step:` row now runs at off, and a
|
|
715
|
+
* row's `currentValue` is a snapshot taken when the panel was built. Without
|
|
716
|
+
* a way to write the others back, the menu would show `profile off` beside
|
|
717
|
+
* rows still claiming `inherit`.
|
|
509
718
|
*/
|
|
510
719
|
onChange, onCancel) {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
720
|
+
const headerLabels = new Set(items.filter(isSectionRow).map(i => i.label));
|
|
721
|
+
/**
|
|
722
|
+
* True while a picker is open, so SkipInertRows stops intercepting arrows.
|
|
723
|
+
*
|
|
724
|
+
* `SettingsList.handleInput` delegates to an open submenu and RETURNS, so
|
|
725
|
+
* `selectedIndex` never moves while one is up. SkipInertRows keeps its own
|
|
726
|
+
* mirror of that index and replays the key once per row it skips — so
|
|
727
|
+
* without this flag, one arrow press inside a picker moves the mirror off
|
|
728
|
+
* the real cursor AND arrives in the picker two or three times when the walk
|
|
729
|
+
* crosses a section boundary.
|
|
730
|
+
*/
|
|
731
|
+
let submenuOpen = false;
|
|
732
|
+
const settingItems = items.map(({ pickerOptions, ...row }) => pickerOptions === undefined ? row : ({
|
|
733
|
+
...row,
|
|
734
|
+
submenu: (currentValue, done) => {
|
|
735
|
+
submenuOpen = true;
|
|
736
|
+
return new PairPicker(pickerOptions(), currentValue, theme, v => {
|
|
737
|
+
submenuOpen = false;
|
|
738
|
+
done(v);
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}));
|
|
742
|
+
const list = new SettingsList(settingItems, MAX_VISIBLE, makeTheme(theme, label => headerLabels.has(label.trimEnd())), (id, newValue) => onChange(id, newValue, list), onCancel);
|
|
743
|
+
return new BorderedBox(new SkipInertRows(list, items.map(i => !isSectionRow(i)
|
|
744
|
+
&& ((i.values?.length ?? 0) > 0 || i.pickerOptions !== undefined)), () => submenuOpen), CONFIG_TITLE, s => theme.fg('borderMuted', s), s => theme.fg('accent', theme.bold(s)), settingsBodyHeight(items.map(i => i.description), MAX_VISIBLE, OVERLAY_WIDTH - 8));
|
|
515
745
|
}
|
|
516
746
|
/**
|
|
517
747
|
* Every settings row for this session, fixed and DISCOVERED, in menu order.
|
|
@@ -524,11 +754,14 @@ onChange, onCancel) {
|
|
|
524
754
|
* Fixed rows come before discovered ones within a section, so a freshly
|
|
525
755
|
* installed extension appends rather than reshuffling the menu.
|
|
526
756
|
*/
|
|
527
|
-
export function configRows(installed, tools = []
|
|
757
|
+
export function configRows(installed, tools = [],
|
|
758
|
+
// A third positional with a default, exactly like `tools`, so every existing
|
|
759
|
+
// test stays deterministic and no test has to know a registry exists.
|
|
760
|
+
catalog = EMPTY_CATALOG) {
|
|
528
761
|
// The discovered rows carry a section like every other row — the per-tool
|
|
529
762
|
// watchdog exemptions under `timeouts` (they are exemptions FROM that
|
|
530
763
|
// timeout), and the per-extension toggles under their own heading.
|
|
531
|
-
return [...ITEMS, ...
|
|
764
|
+
return [...ITEMS, ...stepItems(catalog), ...toolItems(tools), ...extensionItems(installed)];
|
|
532
765
|
}
|
|
533
766
|
/** Render `rows` for the current config, grouped under their section headers. */
|
|
534
767
|
export function renderRows(cfg, rows) {
|
|
@@ -542,6 +775,7 @@ export function renderRows(cfg, rows) {
|
|
|
542
775
|
description: i.description,
|
|
543
776
|
currentValue: i.format(cfg),
|
|
544
777
|
values: i.values ?? ['on', 'off'],
|
|
778
|
+
...(i.picker === undefined ? {} : { pickerOptions: () => i.picker(cfg) }),
|
|
545
779
|
...(i.headlessLabel === undefined ? {} : { headlessLabel: i.headlessLabel })
|
|
546
780
|
}));
|
|
547
781
|
// An empty section prints no header. `extensions` has no fixed rows at
|
|
@@ -555,15 +789,15 @@ export function renderRows(cfg, rows) {
|
|
|
555
789
|
return out;
|
|
556
790
|
}
|
|
557
791
|
/** The full settings row list for the current config, in menu order. */
|
|
558
|
-
export function panelItems(cfg, installed, tools = []) {
|
|
559
|
-
return renderRows(cfg, configRows(installed, tools));
|
|
792
|
+
export function panelItems(cfg, installed, tools = [], catalog = EMPTY_CATALOG) {
|
|
793
|
+
return renderRows(cfg, configRows(installed, tools, catalog));
|
|
560
794
|
}
|
|
561
795
|
/**
|
|
562
796
|
* Re-ask every row what it now displays, and write the answers back.
|
|
563
797
|
*
|
|
564
798
|
* A row's `currentValue` in the live list is a snapshot taken when the panel was
|
|
565
799
|
* built, and rows describe each other: cycling `reasoning` to `off` changes what
|
|
566
|
-
* every `
|
|
800
|
+
* every `step:` row runs at, and setting one step row flips the profile, which
|
|
567
801
|
* changes all the others.
|
|
568
802
|
*
|
|
569
803
|
* This runs after ANY change, over EVERY row. Re-reading a `format` costs
|
|
@@ -574,6 +808,59 @@ export function syncRows(cfg, rows, list) {
|
|
|
574
808
|
for (const row of rows)
|
|
575
809
|
list.updateValue(row.id, row.format(cfg));
|
|
576
810
|
}
|
|
811
|
+
/**
|
|
812
|
+
* The step rows' model offer list, read live when the menu opens.
|
|
813
|
+
*
|
|
814
|
+
* `getAvailable()`, never `getAll()`: an unauthed model would spawn a child that
|
|
815
|
+
* exits 1 on every phase of that group, and offering it would be offering a
|
|
816
|
+
* config that cannot work.
|
|
817
|
+
*
|
|
818
|
+
* A provider registered by a host EXTENSION is offered with a note rather than
|
|
819
|
+
* hidden. Children run `--no-extensions`, which disables discovery only — pi
|
|
820
|
+
* still loads every explicit `-e` path, and `childBaseArgs` injects one per
|
|
821
|
+
* whitelisted extension. So such a model works in a child exactly when its
|
|
822
|
+
* extension is whitelisted, and we cannot tell which extension that is:
|
|
823
|
+
* `getRegisteredProviderIds()` gives ids, and the `{name, config, extensionPath}`
|
|
824
|
+
* triples live in the runner's internal state, drained at bind. Choosing it with
|
|
825
|
+
* the wrong whitelist fails LOUDLY — the child sees no such provider, so pi's
|
|
826
|
+
* resolver reports "not found" and exits 1 — which is why a note is enough.
|
|
827
|
+
*/
|
|
828
|
+
function liveCatalog(ctx) {
|
|
829
|
+
// Same contract as the tool enumeration above, for the same reason: the
|
|
830
|
+
// model runtime is not guaranteed usable at the moment a command runs, and a
|
|
831
|
+
// registry that cannot answer must cost the model rows, never the menu.
|
|
832
|
+
// Every row below still renders; `EMPTY_CATALOG` offers only `inherit` and
|
|
833
|
+
// narrows nothing, which is exactly the pre-feature panel.
|
|
834
|
+
let registry;
|
|
835
|
+
let available;
|
|
836
|
+
let fromExtension;
|
|
837
|
+
try {
|
|
838
|
+
registry = ctx.modelRegistry;
|
|
839
|
+
available = registry.getAvailable();
|
|
840
|
+
fromExtension = new Set(registry.getRegisteredProviderIds());
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
return EMPTY_CATALOG;
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
specs: available.map(m => `${m.provider}/${m.id}`),
|
|
847
|
+
note: spec => {
|
|
848
|
+
const parts = splitSpec(spec);
|
|
849
|
+
return parts && fromExtension.has(parts.provider) ?
|
|
850
|
+
'provider comes from an extension — whitelist it under child extensions, '
|
|
851
|
+
+ "or this group's children exit 1"
|
|
852
|
+
: undefined;
|
|
853
|
+
},
|
|
854
|
+
facts: spec => {
|
|
855
|
+
// `inherit` means the session's own model, which is what a child
|
|
856
|
+
// resolves today. Its facts are what the thinking row must narrow to.
|
|
857
|
+
if (spec === MODEL_INHERIT)
|
|
858
|
+
return ctx.model;
|
|
859
|
+
const parts = splitSpec(spec);
|
|
860
|
+
return parts ? registry.find(parts.provider, parts.id) : undefined;
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
}
|
|
577
864
|
async function handleTaskConfig(_args, ctx, getTools = () => []) {
|
|
578
865
|
const cfg = {
|
|
579
866
|
...getConfig(),
|
|
@@ -582,7 +869,8 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
|
|
|
582
869
|
// Copied for the same reason as the two arrays above: the panel mutates
|
|
583
870
|
// its own draft, and sharing the live object would apply half-made
|
|
584
871
|
// choices to running children before the user finished choosing.
|
|
585
|
-
reasoningLevels: { ...getConfig().reasoningLevels }
|
|
872
|
+
reasoningLevels: { ...getConfig().reasoningLevels },
|
|
873
|
+
groupModels: { ...getConfig().groupModels }
|
|
586
874
|
};
|
|
587
875
|
// Enumerated live at open so an installed extension appears and an
|
|
588
876
|
// uninstalled one vanishes without pi-task doing any bookkeeping. A failed
|
|
@@ -592,17 +880,18 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
|
|
|
592
880
|
// one: getAllTools() throws until the extension runtime is initialized, so
|
|
593
881
|
// it can only be read here, when the menu opens, never at registration.
|
|
594
882
|
const tools = getTools();
|
|
883
|
+
const catalog = liveCatalog(ctx);
|
|
595
884
|
if (ctx.mode !== 'tui') {
|
|
596
885
|
// Built from panelItems and reading the SAME `format` the panel does,
|
|
597
886
|
// so the two renderings cannot disagree about what a setting says. A
|
|
598
887
|
// second walk of the same tables is the one place nobody would notice
|
|
599
888
|
// them drifting, because a headless run has no panel to compare against.
|
|
600
|
-
const lines = panelItems(cfg, installed, tools)
|
|
889
|
+
const lines = panelItems(cfg, installed, tools, catalog)
|
|
601
890
|
// The blank rows between sections are there to give the TUI air.
|
|
602
891
|
// One line of `|`-joined text has none to give, and an empty label
|
|
603
892
|
// would print as a stray `[]`.
|
|
604
893
|
.filter(i => i.label !== '')
|
|
605
|
-
.map(i => i
|
|
894
|
+
.map(i => isSectionRow(i) ?
|
|
606
895
|
`[${i.label.trim()}]`
|
|
607
896
|
: `${(i.headlessLabel ?? i.label).padEnd(22)} ${i.currentValue}`);
|
|
608
897
|
ctx.ui.notify(lines.join(' | '), 'info');
|
|
@@ -610,7 +899,7 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
|
|
|
610
899
|
}
|
|
611
900
|
// Built ONCE and shared by the renderer, the dispatch and the refresh, so
|
|
612
901
|
// all three necessarily agree about which rows exist.
|
|
613
|
-
const rows = configRows(installed, tools);
|
|
902
|
+
const rows = configRows(installed, tools, catalog);
|
|
614
903
|
await ctx.ui.custom((_tui, theme, _kb, done) => createSettingsPanel(renderRows(cfg, rows), theme, (id, newValue, list) => {
|
|
615
904
|
// Every row parses its own value. There is no generic
|
|
616
905
|
// fallback and no prefix ladder, so a row that forgets to
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { registerRemote } from './remote/register.js';
|
|
|
7
7
|
import { registerCommandWatchdog } from './task/command-watchdog.js';
|
|
8
8
|
import { registerStreamWatchdog } from './task/stream-watchdog.js';
|
|
9
9
|
import { registerImplementationGuards } from './task/implementation-guards.js';
|
|
10
|
+
import { registerModelHoldRestore } from './task/model-hold-stash.js';
|
|
10
11
|
export default function (pi) {
|
|
11
12
|
registerConfig(pi);
|
|
12
13
|
registerTask(pi);
|
|
@@ -17,4 +18,5 @@ export default function (pi) {
|
|
|
17
18
|
registerCommandWatchdog(pi);
|
|
18
19
|
registerStreamWatchdog(pi);
|
|
19
20
|
registerImplementationGuards(pi);
|
|
21
|
+
registerModelHoldRestore(pi);
|
|
20
22
|
}
|
package/dist/remote/push.js
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import webpush from 'web-push';
|
|
5
|
-
|
|
6
|
-
* (workers/docs-core.ts), but rooted at data-home rather than cache-home: what
|
|
7
|
-
* lives under here is not reconstructible by re-fetching. */
|
|
8
|
-
function dataHome() {
|
|
9
|
-
return process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), '.local', 'share');
|
|
10
|
-
}
|
|
4
|
+
import { dataHome } from '../shared/data-home.js';
|
|
11
5
|
/** Where the VAPID keypair is persisted. The browser subscribes with this
|
|
12
6
|
* public key as its applicationServerKey (see ui-script.ts), so the pair has to
|
|
13
7
|
* outlive the process that created it. */
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The XDG data-home base. Same env-or-homedir shape the docs cache uses
|
|
3
|
+
* (workers/docs-core.ts), but rooted at data-home rather than cache-home: what
|
|
4
|
+
* lives under here is not reconstructible by re-fetching.
|
|
5
|
+
*/
|
|
6
|
+
export declare function dataHome(): string;
|
|
7
|
+
/** A file under this machine's pi-task state directory. */
|
|
8
|
+
export declare function stateFile(name: string): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as os from 'node:os';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* The XDG data-home base. Same env-or-homedir shape the docs cache uses
|
|
5
|
+
* (workers/docs-core.ts), but rooted at data-home rather than cache-home: what
|
|
6
|
+
* lives under here is not reconstructible by re-fetching.
|
|
7
|
+
*/
|
|
8
|
+
export function dataHome() {
|
|
9
|
+
return process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), '.local', 'share');
|
|
10
|
+
}
|
|
11
|
+
/** A file under this machine's pi-task state directory. */
|
|
12
|
+
export function stateFile(name) {
|
|
13
|
+
return path.join(dataHome(), 'pi-task', name);
|
|
14
|
+
}
|