@mjasnikovs/pi-task 0.38.32 → 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 -2
- package/dist/config/option-picker.d.ts +39 -11
- package/dist/config/option-picker.js +52 -12
- package/dist/config/reasoning.d.ts +10 -7
- package/dist/config/reasoning.js +19 -32
- package/dist/config/register.d.ts +74 -40
- package/dist/config/register.js +212 -128
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -214,8 +214,8 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
|
|
|
214
214
|
| **command timeout** | 15 min | Wall-clock ceiling on a **single** tool execution. Local models routinely run a command that never returns (a hung build, a dev server, a check with no timeout) and the run wedges until you abort by hand — pi's bash tool has an optional timeout with no default, so this is the missing one. One knob, two surfaces: in the main session the overrun call is cancelled (killing the tool's whole process tree) plus a reminder turn; in the verify/fix gate children the child is killed and re-spawned with a hint, halving the ceiling on repeat hangs. Choices: 5/10/15/30 min or **off** — off unguards both surfaces, gates included. |
|
|
215
215
|
| **stuck reply retry** | 10 min | Inactivity ceiling on the **model stream**. A hung or silently-dropped stream throws nothing at all, so neither the connection-error retry (it needs a reported error) nor the **command timeout** (tool calls only) nor the dead-backend stall guard (a reachable endpoint reads as proof of life) can see it — an mx5 run lost ~2.9h to three of them while the model server stayed healthy. Measured as time since the **last stream event of any kind**, so a slow model emitting one token every 30s is never touched, and it pauses while a tool runs. On expiry the main session aborts the turn (through the same channel the command watchdog uses) and posts a resume reminder; a child is killed and routed into the existing connection-error retry. Choices: 5/10/20/30 min or **off**. Keep it generous on local backends — prompt processing on a large context legitimately emits nothing for minutes. |
|
|
216
216
|
| **yolo mode** | off | **Unattended runs.** Wherever pi-task would stop and ask, it takes the option already marked RECOMMENDED, stamps the artifact `(YOLO)` so an audit can tell a machine decided, and shows no prompt at all — clarify/grill answers, the verify-FAIL picker (auto-**Accept**, recorded as a yolo debt), and the final-gate picker (autofix while the budget lasts, then leave the run FAILED). A question with no recommendation is **skipped**, never invented. For throwaway/test projects nobody is watching; a real run should decide these itself. |
|
|
217
|
-
| **
|
|
218
|
-
| **
|
|
217
|
+
| **profile** | default | How much the helper sessions think, in one word, for every step at once. Local models differ sharply here: some break without reasoning, some waste minutes with it, and some cannot do it at all. **default** uses the per-step table pi-task has measured, **on** and **off** force one answer everywhere and ignore that table, and **custom** is whatever the step rows say — changing any of them switches this to custom. A step on **inherit** passes no flag at all, so it uses whatever thinking level pi itself is set to, which is what every step did before this setting existed. |
|
|
218
|
+
| **steps: …** | all `inherit` | One row per group of steps, carrying BOTH dials: the model those children run on and the level they think at, shown as `level · model`. Enter walks a two-step picker — model first, then level — and **the level step offers only what that model declares, opening on the one that will actually run**. That is the whole point of the merge: pi silently CLAMPS a level a model cannot do (a level you set can be erased, and an `off` can be clamped back up to `medium`), so instead of discovering that later you watch the cursor land on the level you are really getting. Models are offered from `pi.modelRegistry.getAvailable()` and stored as the canonical `provider/id` that pi's own `--model` takes. **inherit** on the model half emits no flag, so an all-inherit table is byte-identical to a build without this feature; that is the shipped default, because which models exist is a property of your machine and nothing here can be measured for you. A stored model this machine cannot resolve is never erased (you may have set it on another machine): the flag is dropped, the step runs on pi's default, and a startup hint names the step. Two need care — a provider registered by a host **extension** needs that extension enabled under **ext: …** or those children exit 1; and **implementation** is not free, because it is *your* session moved for the turn and moved back, and a model switch re-bills the whole prompt as a cache miss, twice per task. |
|
|
219
219
|
| **debug logs** | events | How much of a run is written to `.pi-tasks/*-debug.log`. **`events`** keeps decisions and guard actions — which phase ran, why a worker was retried, what the git-state guard restored, what a write-capable child changed on disk, why a gate returned FAIL — a few lines per task. **`full`** adds every line the child model emitted and every tool result; that's ~85% of the bytes (a real 247 KB `verify-debug.log` is 1315 lines, 521 of them tool dumps) and is what you want while actively debugging. **`off`** writes nothing. Nothing in pi-task ever reads these files back, so the setting cannot change how a run behaves — only whether you can explain it afterwards, and a log not written can't be recovered later. |
|
|
220
220
|
| **watch: …** | all on | One toggle per tool in the live session, deciding whether **command timeout** applies to it. The list is discovered from `pi.getAllTools()` when the menu opens — built-ins first, then each extension's tools with the owning entry-point path in the description — so nothing is typed by hand and an uninstalled tool just stops being listed. Turn one **off** only for a tool that already owns a longer bounded, cancellable contract of its own (the guard exists because pi's `bash` has an optional timeout with *no* default — that reasoning doesn't transfer to a tool that has one). Two things to know before you do: a genuine hang in an unwatched tool is caught by nothing, since **stuck reply retry** is paused for the whole time any tool runs; and an unwatched tool is still killed as collateral if a *watched* sibling in the same turn overruns, because pi runs sibling tool calls concurrently and the abort ends the whole turn. Stored as exemptions, so the default and every tool pi-task has never seen stay guarded. |
|
|
221
221
|
| **ext: …** | all off | One toggle per installed host `pi` extension, loading it into every child session by explicit path. Children otherwise run with extensions off, so a provider registered by an extension (e.g. `pi-lmstudio`) doesn't exist in them and they can't resolve the default model. Children also inherit the extension's tools and hooks, so only enable ones you trust. The list is strictly additive (discovery stays off), and an entry whose file is gone is skipped at spawn time, never fatal. |
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The two-step picker a /task-config step row opens on Enter.
|
|
3
3
|
*
|
|
4
4
|
* WHY A SUBMENU AND NOT `ctx.ui.select`
|
|
5
5
|
* -------------------------------------
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* in place of the list and delegates input to it. And its `done(v)` calls
|
|
10
10
|
* `onChange(id, v)` with exactly the returned value while `done(undefined)`
|
|
11
11
|
* writes nothing — the same one-value contract every cycling row uses, so the
|
|
12
|
-
* panel's dispatch needs no branch and a
|
|
13
|
-
*
|
|
12
|
+
* panel's dispatch needs no branch and a pair chosen out of two long lists costs
|
|
13
|
+
* ONE write.
|
|
14
14
|
*
|
|
15
15
|
* WHY NOT A BARE `SelectList`
|
|
16
16
|
* ---------------------------
|
|
@@ -26,17 +26,45 @@ import { Container } from '@earendil-works/pi-tui';
|
|
|
26
26
|
import type { SelectItem } from '@earendil-works/pi-tui';
|
|
27
27
|
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
28
28
|
type Theme = ExtensionCommandContext['ui']['theme'];
|
|
29
|
+
/** What the second stage offers, once the first has been answered. */
|
|
30
|
+
export interface PairStage {
|
|
31
|
+
options: readonly SelectItem[];
|
|
32
|
+
/**
|
|
33
|
+
* The option to open on — the value that will ACTUALLY run.
|
|
34
|
+
*
|
|
35
|
+
* Supplied by the caller rather than derived here, because deciding it is
|
|
36
|
+
* the clamp, and the clamp belongs to the config layer that owns the model's
|
|
37
|
+
* declared ladder. It is what makes the auto-switch visible: pick a model
|
|
38
|
+
* that cannot do `medium` and this stage opens on `off`.
|
|
39
|
+
*/
|
|
40
|
+
preselect: string;
|
|
41
|
+
}
|
|
42
|
+
export interface PairOptions {
|
|
43
|
+
first: readonly SelectItem[];
|
|
44
|
+
second: (firstValue: string) => PairStage;
|
|
45
|
+
/** Which half of the stored value stage one opens on. */
|
|
46
|
+
firstOf: (value: string) => string;
|
|
47
|
+
/** How the two answers become the one string the row stores. */
|
|
48
|
+
join: (first: string, second: string) => string;
|
|
49
|
+
}
|
|
29
50
|
/**
|
|
30
|
-
*
|
|
51
|
+
* Model, then thinking level, as one choice.
|
|
52
|
+
*
|
|
53
|
+
* Two stages rather than one flat list of every legal pair: the pair space is
|
|
54
|
+
* models × levels, and the common edit is "change the model, keep the level" —
|
|
55
|
+
* a flat list would make that re-pick both every time.
|
|
31
56
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
57
|
+
* Escape at EITHER stage cancels the whole thing. Going back one step would be
|
|
58
|
+
* friendlier, but a TUI select owns up, down, enter and escape and has no fifth
|
|
59
|
+
* key to spare, and a half-answered pair must never reach `done`.
|
|
35
60
|
*/
|
|
36
|
-
export declare class
|
|
37
|
-
private readonly
|
|
38
|
-
private readonly
|
|
39
|
-
|
|
61
|
+
export declare class PairPicker extends Container {
|
|
62
|
+
private readonly options;
|
|
63
|
+
private readonly theme;
|
|
64
|
+
private readonly done;
|
|
65
|
+
private stage;
|
|
66
|
+
constructor(options: PairOptions, currentValue: string, theme: Theme, done: (value?: string) => void);
|
|
67
|
+
private openSecond;
|
|
40
68
|
handleInput(data: string): void;
|
|
41
69
|
}
|
|
42
70
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The two-step picker a /task-config step row opens on Enter.
|
|
3
3
|
*
|
|
4
4
|
* WHY A SUBMENU AND NOT `ctx.ui.select`
|
|
5
5
|
* -------------------------------------
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* in place of the list and delegates input to it. And its `done(v)` calls
|
|
10
10
|
* `onChange(id, v)` with exactly the returned value while `done(undefined)`
|
|
11
11
|
* writes nothing — the same one-value contract every cycling row uses, so the
|
|
12
|
-
* panel's dispatch needs no branch and a
|
|
13
|
-
*
|
|
12
|
+
* panel's dispatch needs no branch and a pair chosen out of two long lists costs
|
|
13
|
+
* ONE write.
|
|
14
14
|
*
|
|
15
15
|
* WHY NOT A BARE `SelectList`
|
|
16
16
|
* ---------------------------
|
|
@@ -35,23 +35,29 @@ function selectTheme(theme) {
|
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
|
-
* A filterable
|
|
38
|
+
* A filterable list with a text filter above it.
|
|
39
39
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* Split out because both stages are one of these and the routing is the part
|
|
41
|
+
* that is easy to get wrong: `SelectList` owns four keys and drops the rest, so
|
|
42
|
+
* everything else has to be handed to the `Input` and the filter re-applied.
|
|
43
43
|
*/
|
|
44
|
-
|
|
44
|
+
class FilterList extends Container {
|
|
45
|
+
onPick;
|
|
46
|
+
onCancel;
|
|
45
47
|
input = new Input();
|
|
46
48
|
list;
|
|
47
|
-
constructor(options,
|
|
49
|
+
constructor(options, preselect, theme, onPick, onCancel) {
|
|
48
50
|
super();
|
|
51
|
+
this.onPick = onPick;
|
|
52
|
+
this.onCancel = onCancel;
|
|
49
53
|
this.list = new SelectList([...options], PICKER_VISIBLE, selectTheme(theme));
|
|
50
|
-
|
|
54
|
+
// A `preselect` matching no option is normal, not an error: a row can
|
|
55
|
+
// hold a spec the catalog no longer offers, and it must still open.
|
|
56
|
+
const at = options.findIndex(o => o.value === preselect);
|
|
51
57
|
if (at !== -1)
|
|
52
58
|
this.list.setSelectedIndex(at);
|
|
53
|
-
this.list.onSelect = item =>
|
|
54
|
-
this.list.onCancel = () =>
|
|
59
|
+
this.list.onSelect = item => this.onPick(item.value);
|
|
60
|
+
this.list.onCancel = () => this.onCancel();
|
|
55
61
|
this.addChild(this.input);
|
|
56
62
|
this.addChild(this.list);
|
|
57
63
|
}
|
|
@@ -71,3 +77,37 @@ export class OptionPicker extends Container {
|
|
|
71
77
|
this.list.setFilter(this.input.getValue());
|
|
72
78
|
}
|
|
73
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Model, then thinking level, as one choice.
|
|
82
|
+
*
|
|
83
|
+
* Two stages rather than one flat list of every legal pair: the pair space is
|
|
84
|
+
* models × levels, and the common edit is "change the model, keep the level" —
|
|
85
|
+
* a flat list would make that re-pick both every time.
|
|
86
|
+
*
|
|
87
|
+
* Escape at EITHER stage cancels the whole thing. Going back one step would be
|
|
88
|
+
* friendlier, but a TUI select owns up, down, enter and escape and has no fifth
|
|
89
|
+
* key to spare, and a half-answered pair must never reach `done`.
|
|
90
|
+
*/
|
|
91
|
+
export class PairPicker extends Container {
|
|
92
|
+
options;
|
|
93
|
+
theme;
|
|
94
|
+
done;
|
|
95
|
+
stage;
|
|
96
|
+
constructor(options, currentValue, theme, done) {
|
|
97
|
+
super();
|
|
98
|
+
this.options = options;
|
|
99
|
+
this.theme = theme;
|
|
100
|
+
this.done = done;
|
|
101
|
+
this.stage = new FilterList(options.first, options.firstOf(currentValue), theme, first => this.openSecond(first), () => done(undefined));
|
|
102
|
+
this.addChild(this.stage);
|
|
103
|
+
}
|
|
104
|
+
openSecond(first) {
|
|
105
|
+
const { options: second, preselect } = this.options.second(first);
|
|
106
|
+
this.stage = new FilterList(second, preselect, this.theme, level => this.done(this.options.join(first, level)), () => this.done(undefined));
|
|
107
|
+
this.clear();
|
|
108
|
+
this.addChild(this.stage);
|
|
109
|
+
}
|
|
110
|
+
handleInput(data) {
|
|
111
|
+
this.stage.handleInput(data);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -104,13 +104,16 @@ export declare function effectiveReasoning(cfg: PiTaskConfig): Record<ChildGroup
|
|
|
104
104
|
* version before this feature existed.
|
|
105
105
|
*/
|
|
106
106
|
export declare function thinkingArgs(setting: GroupSetting): string[];
|
|
107
|
-
/** One honest sentence per group, for the /task-config rows. */
|
|
108
|
-
export declare const REASONING_GROUP_HELP: Readonly<Record<ChildGroup, string>>;
|
|
109
107
|
/**
|
|
110
|
-
* One honest sentence per
|
|
108
|
+
* One honest sentence per step, for the merged /task-config rows.
|
|
111
109
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
110
|
+
* ONE table, not the model help and the reasoning help concatenated. The panel
|
|
111
|
+
* sizes every frame from its tallest description (`settingsBodyHeight`), so
|
|
112
|
+
* gluing two paragraphs together for `implementation` would permanently add
|
|
113
|
+
* about six rows to the whole menu — for a row most users never open.
|
|
114
|
+
*
|
|
115
|
+
* What survives the merge is the part that is not guessable: which children the
|
|
116
|
+
* step actually spawns, what was MEASURED about its thinking level, and the one
|
|
117
|
+
* cost that is paid per turn rather than per change.
|
|
115
118
|
*/
|
|
116
|
-
export declare const
|
|
119
|
+
export declare const STEP_GROUP_HELP: Readonly<Record<ChildGroup, string>>;
|
package/dist/config/reasoning.js
CHANGED
|
@@ -105,12 +105,23 @@ export function effectiveReasoning(cfg) {
|
|
|
105
105
|
export function thinkingArgs(setting) {
|
|
106
106
|
return setting === 'inherit' ? [] : ['--thinking', setting];
|
|
107
107
|
}
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
/**
|
|
109
|
+
* One honest sentence per step, for the merged /task-config rows.
|
|
110
|
+
*
|
|
111
|
+
* ONE table, not the model help and the reasoning help concatenated. The panel
|
|
112
|
+
* sizes every frame from its tallest description (`settingsBodyHeight`), so
|
|
113
|
+
* gluing two paragraphs together for `implementation` would permanently add
|
|
114
|
+
* about six rows to the whole menu — for a row most users never open.
|
|
115
|
+
*
|
|
116
|
+
* What survives the merge is the part that is not guessable: which children the
|
|
117
|
+
* step actually spawns, what was MEASURED about its thinking level, and the one
|
|
118
|
+
* cost that is paid per turn rather than per change.
|
|
119
|
+
*/
|
|
120
|
+
export const STEP_GROUP_HELP = {
|
|
121
|
+
research: 'The pi-worker subagent tool, and the fallback for any research worker below. '
|
|
122
|
+
+ 'Long read-only loops, where a cheap fast model pays off.',
|
|
112
123
|
'research:files': 'Research worker 1 of 4: maps which files the task will touch. Read-heavy. '
|
|
113
|
-
+ 'Measured: the two arms tie, so it runs without
|
|
124
|
+
+ 'Measured: the two thinking arms tie, so it runs without.',
|
|
114
125
|
'research:apis': 'Research worker 2 of 4: the symbols and signatures the task must call. '
|
|
115
126
|
+ 'Read-heavy, docs- and search-capable.',
|
|
116
127
|
'research:context': 'Research worker 3 of 4: how the project is put together. One of the two that '
|
|
@@ -125,31 +136,7 @@ export const REASONING_GROUP_HELP = {
|
|
|
125
136
|
gate: 'The checks that run after code is written: verify, enforce, lint-fix, autofix.',
|
|
126
137
|
extraction: 'The small no-tools children that pull one answer out of a fetched page or '
|
|
127
138
|
+ 'a docs chunk.',
|
|
128
|
-
implementation: 'The main session turn that
|
|
129
|
-
+
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* One honest sentence per model row.
|
|
133
|
-
*
|
|
134
|
-
* They are NOT the reasoning help reworded. A model cell answers a different
|
|
135
|
-
* question — which machine does this work — and one of them costs money every
|
|
136
|
-
* turn rather than once per change, which is a thing the row has to say.
|
|
137
|
-
*/
|
|
138
|
-
export const MODEL_GROUP_HELP = {
|
|
139
|
-
research: 'The pi-worker subagent tool, and the fallback for any research worker below. '
|
|
140
|
-
+ 'Long read-only loops: a cheap fast model pays off here.',
|
|
141
|
-
'research:files': 'Research worker 1 of 4: maps which files the task will touch.',
|
|
142
|
-
'research:apis': 'Research worker 2 of 4: the symbols and signatures the task must call.',
|
|
143
|
-
'research:context': 'Research worker 3 of 4: how the project is put together.',
|
|
144
|
-
'research:tooling': 'Research worker 4 of 4: the commands that build, test and run it.',
|
|
145
|
-
phase: 'Refining your request, generating and answering the clarifying questions, '
|
|
146
|
-
+ 'writing the spec, and critiquing it.',
|
|
147
|
-
planning: "/task-auto's planners: splitting a design document into tasks.",
|
|
148
|
-
plan: "/task-plan's interactive question-and-answer children.",
|
|
149
|
-
gate: 'The checks that run after code is written: verify, enforce, lint-fix, autofix.',
|
|
150
|
-
extraction: 'The small no-tools children that pull one answer out of a page or a docs chunk.',
|
|
151
|
-
implementation: 'The main session turn that writes the code — YOUR session, switched for the turn and '
|
|
152
|
-
+ 'switched back. Unlike every row above, this one is not free: a model switch re-bills '
|
|
153
|
-
+ 'the whole prompt as a cache miss, twice per task. Leave it on inherit unless you '
|
|
154
|
-
+ 'want a different model than the one you are reading this in.'
|
|
139
|
+
implementation: 'The main session turn that writes the code — YOUR session, moved for the turn and '
|
|
140
|
+
+ 'moved back. Unlike every step above, the model half here is not free: a switch '
|
|
141
|
+
+ 're-bills the whole prompt as a cache miss, twice per task.'
|
|
155
142
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { SettingsList } from '@earendil-works/pi-tui';
|
|
3
|
-
import type { Component
|
|
3
|
+
import type { Component } from '@earendil-works/pi-tui';
|
|
4
4
|
import { type ReasoningModelFacts } from '../shared/reasoning-capability.js';
|
|
5
|
+
import { type PairOptions } from './option-picker.js';
|
|
5
6
|
import { type PiTaskConfig } from './config.js';
|
|
6
7
|
import { type InstalledExtension } from './extension-list.js';
|
|
7
8
|
import { type GuardableTool } from './tool-list.js';
|
|
@@ -55,7 +56,7 @@ declare class BorderedBox implements Component {
|
|
|
55
56
|
export interface ConfigItem {
|
|
56
57
|
/**
|
|
57
58
|
* The row's id. A `keyof PiTaskConfig` for a fixed setting; a prefixed
|
|
58
|
-
* string (`
|
|
59
|
+
* string (`step:`, `tool:`, `ext:`) for a DISCOVERED one.
|
|
59
60
|
*
|
|
60
61
|
* It is `string`, not `keyof PiTaskConfig`, and that is what lets the three
|
|
61
62
|
* dynamic families BE rows instead of bypassing them — so the round-trip
|
|
@@ -84,7 +85,7 @@ export interface ConfigItem {
|
|
|
84
85
|
* `values` stays the STATIC complete vocabulary, because that is what the
|
|
85
86
|
* round-trip property in config-items.test.ts quantifies over.
|
|
86
87
|
*/
|
|
87
|
-
|
|
88
|
+
picker?: (cfg: PiTaskConfig) => PairOptions;
|
|
88
89
|
/** What the panel shows for the current value. */
|
|
89
90
|
format: (cfg: PiTaskConfig) => string;
|
|
90
91
|
/** Write the chosen label back. A value it does not recognise is ignored. */
|
|
@@ -104,7 +105,7 @@ export interface ConfigItem {
|
|
|
104
105
|
* exemptions from it) end up separated by rows that have nothing to do with
|
|
105
106
|
* them. The headers are inert rows: no `values`, so Enter does nothing on them.
|
|
106
107
|
*/
|
|
107
|
-
export type Section = 'session' | 'checks' | 'research' | '
|
|
108
|
+
export type Section = 'session' | 'checks' | 'research' | 'profile' | 'reasoning' | 'timeouts' | 'unattended' | 'logging' | 'extensions';
|
|
108
109
|
/** Section order, and the label each header renders. */
|
|
109
110
|
export declare const SECTIONS: ReadonlyArray<{
|
|
110
111
|
key: Section;
|
|
@@ -138,7 +139,7 @@ export declare function toolItems(tools: readonly GuardableTool[]): ConfigItem[]
|
|
|
138
139
|
/** Apply a per-tool watchdog toggle to the exemption list (idempotent both ways). */
|
|
139
140
|
export declare function applyToolToggle(exempt: readonly string[], toolName: string, watched: boolean): string[];
|
|
140
141
|
/**
|
|
141
|
-
* The label for one
|
|
142
|
+
* The label for one step row.
|
|
142
143
|
*
|
|
143
144
|
* A group whose name carries a colon is a CHILD of the group before the colon —
|
|
144
145
|
* `research:files` is one of the four workers `research` fans out to. Rendered
|
|
@@ -147,14 +148,18 @@ export declare function applyToolToggle(exempt: readonly string[], toolName: str
|
|
|
147
148
|
* parent is also a row.
|
|
148
149
|
*
|
|
149
150
|
* So a child is drawn as a tree branch under its parent and loses the repeated
|
|
150
|
-
* `
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
151
|
+
* `research:` prefix, the same text at the head of four consecutive lines. `└─`
|
|
152
|
+
* on the last child, `├─` on the rest, decided from the group's position in
|
|
153
|
+
* {@link CHILD_GROUPS} rather than a hand-kept list — adding a fifth worker
|
|
154
|
+
* moves the corner on its own.
|
|
155
|
+
*
|
|
156
|
+
* A parentless group is its own bare name. It carried a `think: ` prefix while
|
|
157
|
+
* there were two families to tell apart; with one row per step there is nothing
|
|
158
|
+
* to disambiguate, and the prefix was the widest thing in the column.
|
|
154
159
|
*
|
|
155
160
|
* Leading spaces survive: SettingsList pads the label right, never trims it.
|
|
156
161
|
*/
|
|
157
|
-
export declare function
|
|
162
|
+
export declare function stepRowLabel(group: ChildGroup): string;
|
|
158
163
|
/**
|
|
159
164
|
* What this machine can offer a model row, and what each offer can DO.
|
|
160
165
|
*
|
|
@@ -173,26 +178,6 @@ export interface ModelCatalog {
|
|
|
173
178
|
}
|
|
174
179
|
/** No registry reachable. Every row still renders; nothing narrows. */
|
|
175
180
|
export declare const EMPTY_CATALOG: ModelCatalog;
|
|
176
|
-
export declare function modelItems(catalog: ModelCatalog): ConfigItem[];
|
|
177
|
-
/**
|
|
178
|
-
* Write a group's model, and RE-CLAMP its thinking cell in the same write.
|
|
179
|
-
*
|
|
180
|
-
* Narrowing the picker does nothing about a level already stored from before the
|
|
181
|
-
* model was chosen: on its own it would freeze a lie into a cell it has just
|
|
182
|
-
* made unconfigurable. `syncRows` re-renders every row, so the user sees the
|
|
183
|
-
* level move.
|
|
184
|
-
*
|
|
185
|
-
* The clamp reads `resolveReasoning`, NOT `cfg.reasoningLevels[group]`. In mode
|
|
186
|
-
* `on` or `off` the stored table is ignored entirely, so a user in `on` who
|
|
187
|
-
* picks a non-reasoning model has a stored cell that says nothing and an
|
|
188
|
-
* effective `medium` the model will erase — and comparing the stored cell would
|
|
189
|
-
* see no clamp and stay silent about a real lie.
|
|
190
|
-
*
|
|
191
|
-
* Guarded on the clamp actually MOVING something, because `applyReasoningLevel`
|
|
192
|
-
* flips the whole table to `custom`, and picking a fully-capable model must not
|
|
193
|
-
* do that as a side effect.
|
|
194
|
-
*/
|
|
195
|
-
export declare function applyGroupModel(cfg: PiTaskConfig, group: ChildGroup, chosen: string, catalog: ModelCatalog): void;
|
|
196
181
|
/**
|
|
197
182
|
* The levels a row may offer, given the model that row's group will run on.
|
|
198
183
|
*
|
|
@@ -202,7 +187,56 @@ export declare function applyGroupModel(cfg: PiTaskConfig, group: ChildGroup, ch
|
|
|
202
187
|
* offer them. A model declaring `xhigh` must not smuggle it in here.
|
|
203
188
|
*/
|
|
204
189
|
export declare function offeredLevels(facts: ReasoningModelFacts | undefined): GroupSetting[];
|
|
205
|
-
|
|
190
|
+
/**
|
|
191
|
+
* `level · provider/id`, the one string a step row shows and accepts.
|
|
192
|
+
*
|
|
193
|
+
* THE LEVEL COMES FIRST, and that is a display decision with teeth.
|
|
194
|
+
* `SettingsList` truncates a value from the RIGHT, so whatever is last is what
|
|
195
|
+
* silently disappears — and a real local model id (`local/Qwen3.8-27B-UD-Q4_K_XL
|
|
196
|
+
* .gguf`) is wide enough to consume the whole column on its own. Level-first
|
|
197
|
+
* means the half that falls off is the one still identifiable from its head, and
|
|
198
|
+
* the levels line up as a column you can read down.
|
|
199
|
+
*/
|
|
200
|
+
export declare function formatStepValue(spec: string, level: GroupSetting): string;
|
|
201
|
+
/**
|
|
202
|
+
* The two halves back out, or `undefined` for anything not of that shape.
|
|
203
|
+
*
|
|
204
|
+
* Split on the FIRST separator, because the level leads and is a closed set,
|
|
205
|
+
* while a `provider/id` could conceivably contain one.
|
|
206
|
+
*/
|
|
207
|
+
export declare function parseStepValue(value: string): {
|
|
208
|
+
spec: string;
|
|
209
|
+
level: GroupSetting;
|
|
210
|
+
} | undefined;
|
|
211
|
+
/**
|
|
212
|
+
* One row per step, carrying BOTH dials.
|
|
213
|
+
*
|
|
214
|
+
* They were two parallel blocks of eleven, and the coupling between them was
|
|
215
|
+
* invisible: choosing a model re-clamps that step's thinking level, but the row
|
|
216
|
+
* that moved was eleven rows away from the row you touched. One row shows the
|
|
217
|
+
* pair, and the two-step picker shows the clamp happening.
|
|
218
|
+
*
|
|
219
|
+
* `values` is the LEGAL cross product — every model against only the levels that
|
|
220
|
+
* model declares. Nothing renders it: the picker offers two short lists, and the
|
|
221
|
+
* round-trip property in config-items.test.ts is its only reader. Building it
|
|
222
|
+
* from `offeredLevels` rather than the full ladder is what makes the property
|
|
223
|
+
* true, because a pair the model cannot honour would be clamped by `apply` and
|
|
224
|
+
* would not round-trip.
|
|
225
|
+
*/
|
|
226
|
+
export declare function stepItems(catalog?: ModelCatalog): ConfigItem[];
|
|
227
|
+
/**
|
|
228
|
+
* Write both halves of a step row, atomically.
|
|
229
|
+
*
|
|
230
|
+
* Atomically matters for the round-trip property, which starts from a FRESH
|
|
231
|
+
* config every iteration: a value that wrote only one half would leave the other
|
|
232
|
+
* at its default and render as something else.
|
|
233
|
+
*
|
|
234
|
+
* The level is re-clamped even though the picker only ever offers legal pairs.
|
|
235
|
+
* The picker is not the only door — `values` is built when the panel opens, and
|
|
236
|
+
* a registry that moved underneath it would otherwise let an unhonourable level
|
|
237
|
+
* through.
|
|
238
|
+
*/
|
|
239
|
+
export declare function applyStepValue(cfg: PiTaskConfig, group: ChildGroup, chosen: string, catalog: ModelCatalog): void;
|
|
206
240
|
/**
|
|
207
241
|
* Apply one group row's new value.
|
|
208
242
|
*
|
|
@@ -247,13 +281,13 @@ export type PanelItem = {
|
|
|
247
281
|
* {@link createSettingsPanel} is where a theme exists, so it is where these
|
|
248
282
|
* options become a component.
|
|
249
283
|
*/
|
|
250
|
-
|
|
284
|
+
pickerOptions?: () => PairOptions;
|
|
251
285
|
/**
|
|
252
286
|
* What the headless one-line rendering calls this row, when `label` reads
|
|
253
287
|
* only in the panel. A tree branch means nothing on a line of `|`-joined
|
|
254
|
-
* rows: `├─ files` there names no parent, where `
|
|
255
|
-
* does. Set by the
|
|
256
|
-
*
|
|
288
|
+
* rows: `├─ files` there names no parent, where `step: research:files`
|
|
289
|
+
* does. Set by the step rows; every other row leaves it off and its `label`
|
|
290
|
+
* is used.
|
|
257
291
|
*/
|
|
258
292
|
headlessLabel?: string;
|
|
259
293
|
};
|
|
@@ -267,10 +301,10 @@ export declare function createSettingsPanel(items: PanelItem[], theme: Theme,
|
|
|
267
301
|
* Called with the row's id, its new value, and the LIST ITSELF.
|
|
268
302
|
*
|
|
269
303
|
* The list is handed back because some rows change what OTHER rows display:
|
|
270
|
-
* flipping `
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
304
|
+
* flipping `profile` to off means every `step:` row now runs at off, and a
|
|
305
|
+
* row's `currentValue` is a snapshot taken when the panel was built. Without
|
|
306
|
+
* a way to write the others back, the menu would show `profile off` beside
|
|
307
|
+
* rows still claiming `inherit`.
|
|
274
308
|
*/
|
|
275
309
|
onChange: (id: string, newValue: string, list: SettingsList) => void, onCancel: () => void): BorderedBox;
|
|
276
310
|
/**
|
|
@@ -294,7 +328,7 @@ export declare function panelItems(cfg: PiTaskConfig, installed: InstalledExtens
|
|
|
294
328
|
*
|
|
295
329
|
* A row's `currentValue` in the live list is a snapshot taken when the panel was
|
|
296
330
|
* built, and rows describe each other: cycling `reasoning` to `off` changes what
|
|
297
|
-
* every `
|
|
331
|
+
* every `step:` row runs at, and setting one step row flips the profile, which
|
|
298
332
|
* changes all the others.
|
|
299
333
|
*
|
|
300
334
|
* This runs after ANY change, over EVERY row. Re-reading a `format` costs
|
package/dist/config/register.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { getKeybindings, SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
|
|
2
2
|
import { clampToModel, supportedThinkingLevels } from '../shared/reasoning-capability.js';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { MODEL_INHERIT, splitSpec } from './group-models.js';
|
|
4
|
+
import { PairPicker } from './option-picker.js';
|
|
5
5
|
import { registerBridgeCommand } from '../remote/bridge.js';
|
|
6
6
|
import { readPkgVersion } from '../shared/pkg-version.js';
|
|
7
7
|
import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
|
|
8
8
|
import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
|
|
9
9
|
import { listInstalledExtensions } from './extension-list.js';
|
|
10
10
|
import { listGuardableTools } from './tool-list.js';
|
|
11
|
-
import { CHILD_GROUPS, REASONING_MODES, sanitizeReasoningMode,
|
|
11
|
+
import { CHILD_GROUPS, REASONING_MODES, sanitizeReasoningMode, STEP_GROUP_HELP, REASONING_SETTINGS, effectiveReasoning, resolveReasoning } from './reasoning.js';
|
|
12
12
|
// Version in the title so a bug report or screenshot says which build it came
|
|
13
13
|
// from without anyone having to go look it up.
|
|
14
14
|
const CONFIG_TITLE = `pi-task ${readPkgVersion()} settings`;
|
|
@@ -76,12 +76,20 @@ export const SECTIONS = [
|
|
|
76
76
|
{ key: 'session', title: 'session' },
|
|
77
77
|
{ key: 'checks', title: 'after each task' },
|
|
78
78
|
{ key: 'research', title: 'research' },
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
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' },
|
|
85
93
|
{ key: 'unattended', title: 'unattended' },
|
|
86
94
|
{ key: 'logging', title: 'logging' },
|
|
87
95
|
{ key: 'extensions', title: 'child extensions' },
|
|
@@ -229,15 +237,17 @@ export const ITEMS = [
|
|
|
229
237
|
+ 'For throwaway projects you are not watching'),
|
|
230
238
|
{
|
|
231
239
|
id: 'reasoningMode',
|
|
232
|
-
section
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
+ '
|
|
239
|
-
+ '
|
|
240
|
-
+ '
|
|
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',
|
|
241
251
|
values: [...REASONING_MODES],
|
|
242
252
|
format: cfg => String(cfg.reasoningMode),
|
|
243
253
|
apply: (cfg, chosen) => {
|
|
@@ -340,9 +350,9 @@ export function applyToolToggle(exempt, toolName, watched) {
|
|
|
340
350
|
* though the custom table underneath is untouched, which is the honest
|
|
341
351
|
* answer to "what will my next child do".
|
|
342
352
|
*/
|
|
343
|
-
const
|
|
353
|
+
const STEP_ID_PREFIX = 'step:';
|
|
344
354
|
/**
|
|
345
|
-
* The label for one
|
|
355
|
+
* The label for one step row.
|
|
346
356
|
*
|
|
347
357
|
* A group whose name carries a colon is a CHILD of the group before the colon —
|
|
348
358
|
* `research:files` is one of the four workers `research` fans out to. Rendered
|
|
@@ -351,88 +361,27 @@ const REASON_ID_PREFIX = 'reason:';
|
|
|
351
361
|
* parent is also a row.
|
|
352
362
|
*
|
|
353
363
|
* So a child is drawn as a tree branch under its parent and loses the repeated
|
|
354
|
-
* `
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
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.
|
|
358
372
|
*
|
|
359
373
|
* Leading spaces survive: SettingsList pads the label right, never trims it.
|
|
360
374
|
*/
|
|
361
|
-
export function
|
|
375
|
+
export function stepRowLabel(group) {
|
|
362
376
|
const colon = group.indexOf(':');
|
|
363
377
|
if (colon < 0)
|
|
364
|
-
return
|
|
378
|
+
return group;
|
|
365
379
|
const parent = group.slice(0, colon);
|
|
366
380
|
const nextIsSibling = CHILD_GROUPS[CHILD_GROUPS.indexOf(group) + 1]?.startsWith(`${parent}:`);
|
|
367
381
|
return ` ${nextIsSibling ? '├─' : '└─'} ${group.slice(colon + 1)}`;
|
|
368
382
|
}
|
|
369
|
-
const MODEL_ID_PREFIX = 'model:';
|
|
370
383
|
/** No registry reachable. Every row still renders; nothing narrows. */
|
|
371
384
|
export const EMPTY_CATALOG = { specs: [], facts: () => undefined };
|
|
372
|
-
export function modelItems(catalog) {
|
|
373
|
-
return CHILD_GROUPS.map(group => ({
|
|
374
|
-
id: MODEL_ID_PREFIX + group,
|
|
375
|
-
section: 'models',
|
|
376
|
-
label: reasoningRowLabel(group).replace('think: ', 'model: '),
|
|
377
|
-
headlessLabel: `model: ${group}`,
|
|
378
|
-
description: MODEL_GROUP_HELP[group],
|
|
379
|
-
// The COMPLETE accepted vocabulary, which is what the round-trip
|
|
380
|
-
// property quantifies over. With nothing discovered it is `['inherit']`:
|
|
381
|
-
// length 1, still selectable, still not a header. No degenerate case.
|
|
382
|
-
values: [MODEL_INHERIT, ...catalog.specs],
|
|
383
|
-
submenu: () => [
|
|
384
|
-
{ value: MODEL_INHERIT, label: MODEL_INHERIT, description: "pi's own default" },
|
|
385
|
-
...catalog.specs.map(spec => ({
|
|
386
|
-
value: spec,
|
|
387
|
-
label: spec,
|
|
388
|
-
...(catalog.note?.(spec) === undefined ? {} : { description: catalog.note(spec) })
|
|
389
|
-
}))
|
|
390
|
-
],
|
|
391
|
-
// VERBATIM, even when absent from `values` — the vanished-model case,
|
|
392
|
-
// where the row must still say what the config holds so the hint that
|
|
393
|
-
// names it has something to point at.
|
|
394
|
-
format: cfg => cfg.groupModels[group],
|
|
395
|
-
apply: (cfg, chosen) => applyGroupModel(cfg, group, chosen, catalog)
|
|
396
|
-
}));
|
|
397
|
-
}
|
|
398
|
-
/**
|
|
399
|
-
* Write a group's model, and RE-CLAMP its thinking cell in the same write.
|
|
400
|
-
*
|
|
401
|
-
* Narrowing the picker does nothing about a level already stored from before the
|
|
402
|
-
* model was chosen: on its own it would freeze a lie into a cell it has just
|
|
403
|
-
* made unconfigurable. `syncRows` re-renders every row, so the user sees the
|
|
404
|
-
* level move.
|
|
405
|
-
*
|
|
406
|
-
* The clamp reads `resolveReasoning`, NOT `cfg.reasoningLevels[group]`. In mode
|
|
407
|
-
* `on` or `off` the stored table is ignored entirely, so a user in `on` who
|
|
408
|
-
* picks a non-reasoning model has a stored cell that says nothing and an
|
|
409
|
-
* effective `medium` the model will erase — and comparing the stored cell would
|
|
410
|
-
* see no clamp and stay silent about a real lie.
|
|
411
|
-
*
|
|
412
|
-
* Guarded on the clamp actually MOVING something, because `applyReasoningLevel`
|
|
413
|
-
* flips the whole table to `custom`, and picking a fully-capable model must not
|
|
414
|
-
* do that as a side effect.
|
|
415
|
-
*/
|
|
416
|
-
export function applyGroupModel(cfg, group, chosen, catalog) {
|
|
417
|
-
// MEMBERSHIP, not just shape. A stored spec naming a vanished model must
|
|
418
|
-
// survive — that is the sanitizer's job, and `format` still renders it — but
|
|
419
|
-
// it may only ever ARRIVE here from the picker, which offers exactly these.
|
|
420
|
-
// Without this, the panel could write a spec this machine cannot resolve.
|
|
421
|
-
if (!isModelSpec(chosen))
|
|
422
|
-
return;
|
|
423
|
-
if (chosen !== MODEL_INHERIT && !catalog.specs.includes(chosen))
|
|
424
|
-
return;
|
|
425
|
-
cfg.groupModels = { ...cfg.groupModels, [group]: chosen };
|
|
426
|
-
const facts = catalog.facts(chosen);
|
|
427
|
-
if (facts === undefined)
|
|
428
|
-
return;
|
|
429
|
-
const wanted = resolveReasoning(group, cfg);
|
|
430
|
-
if (wanted === 'inherit')
|
|
431
|
-
return;
|
|
432
|
-
const clamped = clampToModel(facts, wanted);
|
|
433
|
-
if (clamped !== wanted)
|
|
434
|
-
applyReasoningLevel(cfg, group, clamped);
|
|
435
|
-
}
|
|
436
385
|
/**
|
|
437
386
|
* The levels a row may offer, given the model that row's group will run on.
|
|
438
387
|
*
|
|
@@ -447,30 +396,171 @@ export function offeredLevels(facts) {
|
|
|
447
396
|
const supported = supportedThinkingLevels(facts);
|
|
448
397
|
return REASONING_SETTINGS.filter(s => s === 'inherit' || supported.includes(s));
|
|
449
398
|
}
|
|
450
|
-
|
|
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];
|
|
451
447
|
return CHILD_GROUPS.map(group => ({
|
|
452
|
-
id:
|
|
448
|
+
id: STEP_ID_PREFIX + group,
|
|
453
449
|
section: 'reasoning',
|
|
454
|
-
label:
|
|
455
|
-
headlessLabel: `
|
|
456
|
-
description:
|
|
457
|
-
values:
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
format: cfg => resolveReasoning(group, cfg),
|
|
471
|
-
apply: (cfg, chosen) => applyReasoningLevel(cfg, group, chosen)
|
|
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)
|
|
472
466
|
}));
|
|
473
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
|
+
}
|
|
474
564
|
/**
|
|
475
565
|
* Apply one group row's new value.
|
|
476
566
|
*
|
|
@@ -621,10 +711,10 @@ export function createSettingsPanel(items, theme,
|
|
|
621
711
|
* Called with the row's id, its new value, and the LIST ITSELF.
|
|
622
712
|
*
|
|
623
713
|
* The list is handed back because some rows change what OTHER rows display:
|
|
624
|
-
* flipping `
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
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`.
|
|
628
718
|
*/
|
|
629
719
|
onChange, onCancel) {
|
|
630
720
|
const headerLabels = new Set(items.filter(isSectionRow).map(i => i.label));
|
|
@@ -639,11 +729,11 @@ onChange, onCancel) {
|
|
|
639
729
|
* crosses a section boundary.
|
|
640
730
|
*/
|
|
641
731
|
let submenuOpen = false;
|
|
642
|
-
const settingItems = items.map(({
|
|
732
|
+
const settingItems = items.map(({ pickerOptions, ...row }) => pickerOptions === undefined ? row : ({
|
|
643
733
|
...row,
|
|
644
734
|
submenu: (currentValue, done) => {
|
|
645
735
|
submenuOpen = true;
|
|
646
|
-
return new
|
|
736
|
+
return new PairPicker(pickerOptions(), currentValue, theme, v => {
|
|
647
737
|
submenuOpen = false;
|
|
648
738
|
done(v);
|
|
649
739
|
});
|
|
@@ -651,7 +741,7 @@ onChange, onCancel) {
|
|
|
651
741
|
}));
|
|
652
742
|
const list = new SettingsList(settingItems, MAX_VISIBLE, makeTheme(theme, label => headerLabels.has(label.trimEnd())), (id, newValue) => onChange(id, newValue, list), onCancel);
|
|
653
743
|
return new BorderedBox(new SkipInertRows(list, items.map(i => !isSectionRow(i)
|
|
654
|
-
&& ((i.values?.length ?? 0) > 0 || 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));
|
|
655
745
|
}
|
|
656
746
|
/**
|
|
657
747
|
* Every settings row for this session, fixed and DISCOVERED, in menu order.
|
|
@@ -671,13 +761,7 @@ catalog = EMPTY_CATALOG) {
|
|
|
671
761
|
// The discovered rows carry a section like every other row — the per-tool
|
|
672
762
|
// watchdog exemptions under `timeouts` (they are exemptions FROM that
|
|
673
763
|
// timeout), and the per-extension toggles under their own heading.
|
|
674
|
-
return [
|
|
675
|
-
...ITEMS,
|
|
676
|
-
...modelItems(catalog),
|
|
677
|
-
...reasoningItems(catalog),
|
|
678
|
-
...toolItems(tools),
|
|
679
|
-
...extensionItems(installed)
|
|
680
|
-
];
|
|
764
|
+
return [...ITEMS, ...stepItems(catalog), ...toolItems(tools), ...extensionItems(installed)];
|
|
681
765
|
}
|
|
682
766
|
/** Render `rows` for the current config, grouped under their section headers. */
|
|
683
767
|
export function renderRows(cfg, rows) {
|
|
@@ -691,7 +775,7 @@ export function renderRows(cfg, rows) {
|
|
|
691
775
|
description: i.description,
|
|
692
776
|
currentValue: i.format(cfg),
|
|
693
777
|
values: i.values ?? ['on', 'off'],
|
|
694
|
-
...(i.
|
|
778
|
+
...(i.picker === undefined ? {} : { pickerOptions: () => i.picker(cfg) }),
|
|
695
779
|
...(i.headlessLabel === undefined ? {} : { headlessLabel: i.headlessLabel })
|
|
696
780
|
}));
|
|
697
781
|
// An empty section prints no header. `extensions` has no fixed rows at
|
|
@@ -713,7 +797,7 @@ export function panelItems(cfg, installed, tools = [], catalog = EMPTY_CATALOG)
|
|
|
713
797
|
*
|
|
714
798
|
* A row's `currentValue` in the live list is a snapshot taken when the panel was
|
|
715
799
|
* built, and rows describe each other: cycling `reasoning` to `off` changes what
|
|
716
|
-
* every `
|
|
800
|
+
* every `step:` row runs at, and setting one step row flips the profile, which
|
|
717
801
|
* changes all the others.
|
|
718
802
|
*
|
|
719
803
|
* This runs after ANY change, over EVERY row. Re-reading a `format` costs
|
|
@@ -725,7 +809,7 @@ export function syncRows(cfg, rows, list) {
|
|
|
725
809
|
list.updateValue(row.id, row.format(cfg));
|
|
726
810
|
}
|
|
727
811
|
/**
|
|
728
|
-
* The
|
|
812
|
+
* The step rows' model offer list, read live when the menu opens.
|
|
729
813
|
*
|
|
730
814
|
* `getAvailable()`, never `getAll()`: an unauthed model would spawn a child that
|
|
731
815
|
* exits 1 on every phase of that group, and offering it would be offering a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|