@mjasnikovs/pi-task 0.38.18 → 0.38.20
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 +1 -0
- package/dist/config/config.d.ts +19 -0
- package/dist/config/config.js +10 -2
- package/dist/config/reasoning-args.d.ts +10 -0
- package/dist/config/reasoning-args.js +23 -0
- package/dist/config/reasoning.d.ts +160 -0
- package/dist/config/reasoning.js +530 -0
- package/dist/config/register.d.ts +72 -2
- package/dist/config/register.js +182 -33
- package/dist/shared/child-process.d.ts +19 -0
- package/dist/shared/child-process.js +9 -10
- package/dist/shared/model-endpoint.d.ts +34 -0
- package/dist/shared/model-endpoint.js +36 -0
- package/dist/shared/reasoning-capability.d.ts +86 -0
- package/dist/shared/reasoning-capability.js +82 -0
- package/dist/task/child-runner.d.ts +36 -26
- package/dist/task/child-runner.js +63 -6
- package/dist/task/child-status.d.ts +11 -3
- package/dist/task/child-status.js +14 -3
- package/dist/task/context-usage.d.ts +1 -1
- package/dist/task/context-usage.js +1 -1
- package/dist/task/decompose-fidelity.d.ts +21 -8
- package/dist/task/decompose-fidelity.js +98 -17
- package/dist/task/gate-child.d.ts +10 -0
- package/dist/task/gate-child.js +5 -1
- package/dist/task/gate-deps.js +4 -0
- package/dist/task/implementation-thinking.d.ts +54 -0
- package/dist/task/implementation-thinking.js +33 -0
- package/dist/task/orchestrator.d.ts +7 -0
- package/dist/task/orchestrator.js +46 -14
- package/dist/task/phases.js +47 -24
- package/dist/task/prompts.d.ts +0 -23
- package/dist/task/prompts.js +0 -25
- package/dist/task/reasoning-groups.d.ts +36 -0
- package/dist/task/reasoning-groups.js +36 -0
- package/dist/task/spec-validation.d.ts +28 -0
- package/dist/task/spec-validation.js +44 -0
- package/dist/task/stall-detector.d.ts +5 -0
- package/dist/task/stall-detector.js +5 -0
- package/dist/task/title-label.js +2 -2
- package/dist/workers/docs-core.js +4 -0
- package/dist/workers/fetch-core.js +4 -0
- package/dist/workers/focused-extractor.d.ts +12 -1
- package/dist/workers/focused-extractor.js +6 -2
- package/dist/workers/index.js +2 -0
- package/dist/workers/pi-worker-core.d.ts +33 -4
- package/dist/workers/pi-worker-core.js +28 -7
- package/dist/workers/pi-worker-docs.js +4 -0
- package/dist/workers/pi-worker.js +11 -1
- package/dist/workers/reasoning-warning.d.ts +64 -0
- package/dist/workers/reasoning-warning.js +142 -0
- package/package.json +4 -4
package/dist/config/register.js
CHANGED
|
@@ -5,6 +5,7 @@ import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../w
|
|
|
5
5
|
import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
|
|
6
6
|
import { listInstalledExtensions } from './extension-list.js';
|
|
7
7
|
import { listGuardableTools } from './tool-list.js';
|
|
8
|
+
import { REASONING_GROUPS, REASONING_MODES, sanitizeReasoningMode, REASONING_GROUP_HELP, REASONING_SETTINGS, resolveReasoning } from './reasoning.js';
|
|
8
9
|
// Version in the title so a bug report or screenshot says which build it came
|
|
9
10
|
// from without anyone having to go look it up.
|
|
10
11
|
const CONFIG_TITLE = `pi-task ${readPkgVersion()} settings`;
|
|
@@ -67,14 +68,37 @@ class BorderedBox {
|
|
|
67
68
|
this.child.handleInput(data);
|
|
68
69
|
}
|
|
69
70
|
}
|
|
71
|
+
/** Section order, and the label each header renders. */
|
|
72
|
+
export const SECTIONS = [
|
|
73
|
+
{ key: 'session', title: 'session' },
|
|
74
|
+
{ key: 'checks', title: 'after each task' },
|
|
75
|
+
{ key: 'research', title: 'research' },
|
|
76
|
+
{ key: 'reasoning', title: 'reasoning' },
|
|
77
|
+
{ key: 'timeouts', title: 'timeouts' },
|
|
78
|
+
{ key: 'unattended', title: 'unattended' },
|
|
79
|
+
{ key: 'logging', title: 'logging' },
|
|
80
|
+
{ key: 'extensions', title: 'child extensions' }
|
|
81
|
+
];
|
|
82
|
+
/** Marks a header row, so onChange can ignore one and tests can find them. */
|
|
83
|
+
export const SECTION_ID_PREFIX = 'section:';
|
|
84
|
+
/** An inert titled row. No `values` ⇒ SettingsList's Enter handler no-ops on it. */
|
|
85
|
+
function sectionHeader(title) {
|
|
86
|
+
return {
|
|
87
|
+
id: SECTION_ID_PREFIX + title,
|
|
88
|
+
label: `── ${title} ──`,
|
|
89
|
+
description: '',
|
|
90
|
+
currentValue: ''
|
|
91
|
+
};
|
|
92
|
+
}
|
|
70
93
|
/**
|
|
71
94
|
* The shared pair for a boolean setting: shown as on/off, stored as a boolean.
|
|
72
95
|
* Every non-enum row uses this, so a boolean cannot be given a bespoke parser by
|
|
73
96
|
* accident.
|
|
74
97
|
*/
|
|
75
|
-
function booleanItem(id, label, description) {
|
|
98
|
+
function booleanItem(section, id, label, description) {
|
|
76
99
|
return {
|
|
77
100
|
id,
|
|
101
|
+
section,
|
|
78
102
|
label,
|
|
79
103
|
description,
|
|
80
104
|
format: cfg => (cfg[id] ? 'on' : 'off'),
|
|
@@ -92,27 +116,28 @@ function booleanItem(id, label, description) {
|
|
|
92
116
|
* either of the two ladders this replaced.
|
|
93
117
|
*/
|
|
94
118
|
export const ITEMS = [
|
|
95
|
-
booleanItem('remote', 'remote control', 'Serve the task UI on your local network so you can follow and steer a run from '
|
|
119
|
+
booleanItem('session', 'remote', 'remote control', 'Serve the task UI on your local network so you can follow and steer a run from '
|
|
96
120
|
+ 'your phone. Prints a QR code to scan when it starts'),
|
|
97
|
-
booleanItem('autoCommit', 'auto-commit', 'Make a git commit before and after every sub-task, so each step is a checkpoint '
|
|
121
|
+
booleanItem('checks', 'autoCommit', 'auto-commit', 'Make a git commit before and after every sub-task, so each step is a checkpoint '
|
|
98
122
|
+ 'you can read back or roll back to'),
|
|
99
|
-
booleanItem('verifyWork', 'verify work', 'When a task says it is done, actually run the checks its spec asks for and report '
|
|
123
|
+
booleanItem('checks', 'verifyWork', 'verify work', 'When a task says it is done, actually run the checks its spec asks for and report '
|
|
100
124
|
+ "PASS or FAIL instead of taking the model's word for it. This is also what lets "
|
|
101
125
|
+ '"enforce guidelines" fix things safely. /task waits for the work to finish'),
|
|
102
|
-
booleanItem('enforceGuidelines', 'enforce guidelines', 'Check what each task committed against your AGENTS.md / CLAUDE.md rules. With '
|
|
126
|
+
booleanItem('checks', 'enforceGuidelines', 'enforce guidelines', 'Check what each task committed against your AGENTS.md / CLAUDE.md rules. With '
|
|
103
127
|
+ '"verify work" on it also fixes what it finds, undoing any fix that breaks the '
|
|
104
128
|
+ 'checks; on its own it only reports. /task waits for the work to finish'),
|
|
105
|
-
booleanItem('orientation', 'project tour', 'Show the research workers the shape of the project first — package manifest, '
|
|
129
|
+
booleanItem('research', 'orientation', 'project tour', 'Show the research workers the shape of the project first — package manifest, '
|
|
106
130
|
+ 'types, schema — so they spend their steps on the question instead of on finding '
|
|
107
131
|
+ 'their way around'),
|
|
108
|
-
booleanItem('parallelResearchWorkers', 'parallel research', 'Run the 4 research workers at once instead of one after another. Only faster if '
|
|
132
|
+
booleanItem('research', 'parallelResearchWorkers', 'parallel research', 'Run the 4 research workers at once instead of one after another. Only faster if '
|
|
109
133
|
+ 'your model backend can answer several requests at the same time — on a single '
|
|
110
134
|
+ 'local GPU it is measurably slower, so leave it off there'),
|
|
111
|
-
booleanItem('researchCache', 'research cache', 'Remember docs and web pages for the length of one run, so later tasks reuse what '
|
|
135
|
+
booleanItem('research', 'researchCache', 'research cache', 'Remember docs and web pages for the length of one run, so later tasks reuse what '
|
|
112
136
|
+ 'the first one already fetched instead of downloading it again. Only external '
|
|
113
137
|
+ 'sources, only successful fetches, and it is dropped when the run ends'),
|
|
114
138
|
{
|
|
115
139
|
id: 'searchProvider',
|
|
140
|
+
section: 'research',
|
|
116
141
|
label: 'search engine',
|
|
117
142
|
description: 'Which engine backs web search. Exa and DuckDuckGo work with no setup; Brave needs '
|
|
118
143
|
+ 'a BRAVE_SEARCH_API_KEY in your environment',
|
|
@@ -127,6 +152,7 @@ export const ITEMS = [
|
|
|
127
152
|
},
|
|
128
153
|
{
|
|
129
154
|
id: 'requestTimeoutMs',
|
|
155
|
+
section: 'timeouts',
|
|
130
156
|
label: 'command timeout',
|
|
131
157
|
description: 'Give up on any single command that runs this long, and tell the model to set its '
|
|
132
158
|
+ 'own timeout next time. Stops a run from waiting forever on a dev server or a '
|
|
@@ -144,6 +170,7 @@ export const ITEMS = [
|
|
|
144
170
|
},
|
|
145
171
|
{
|
|
146
172
|
id: 'streamInactivityMs',
|
|
173
|
+
section: 'timeouts',
|
|
147
174
|
label: 'stuck reply retry',
|
|
148
175
|
description: 'Give up on a model reply that has sent nothing for this long and ask again. A '
|
|
149
176
|
+ 'dropped connection looks exactly like a model thinking hard and reports no '
|
|
@@ -160,12 +187,30 @@ export const ITEMS = [
|
|
|
160
187
|
cfg.streamInactivityMs = opt.ms;
|
|
161
188
|
}
|
|
162
189
|
},
|
|
163
|
-
booleanItem('yoloMode', 'yolo mode', 'Stop asking you anything: every question takes the option pi recommends, a failed '
|
|
190
|
+
booleanItem('unattended', 'yoloMode', 'yolo mode', 'Stop asking you anything: every question takes the option pi recommends, a failed '
|
|
164
191
|
+ 'check is accepted and written down as debt, and a failed final check is retried '
|
|
165
192
|
+ 'until the budget runs out. Each auto-answer is marked (YOLO) in the task file. '
|
|
166
193
|
+ 'For throwaway projects you are not watching'),
|
|
194
|
+
{
|
|
195
|
+
id: 'reasoningMode',
|
|
196
|
+
section: 'reasoning',
|
|
197
|
+
label: 'reasoning',
|
|
198
|
+
description: 'How much the helper sessions think before answering. "default" uses the '
|
|
199
|
+
+ 'per-step table pi-task has measured, "on" and "off" force one answer '
|
|
200
|
+
+ 'everywhere, and "custom" is whatever you set in the "think:" rows below. '
|
|
201
|
+
+ 'Those rows always show what each step actually runs at, and changing one '
|
|
202
|
+
+ 'switches this to custom. A step left on "inherit" uses whatever thinking '
|
|
203
|
+
+ 'level pi itself is set to, which is what every step did before this setting '
|
|
204
|
+
+ 'existed',
|
|
205
|
+
values: [...REASONING_MODES],
|
|
206
|
+
format: cfg => String(cfg.reasoningMode),
|
|
207
|
+
apply: (cfg, chosen) => {
|
|
208
|
+
cfg.reasoningMode = sanitizeReasoningMode(chosen);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
167
211
|
{
|
|
168
212
|
id: 'debugLogs',
|
|
213
|
+
section: 'logging',
|
|
169
214
|
label: 'debug logs',
|
|
170
215
|
description: 'How much of a run gets written to .pi-tasks/*-debug.log. "events" keeps the '
|
|
171
216
|
+ 'decisions and the guard actions — what a checking step changed, why something '
|
|
@@ -233,6 +278,70 @@ export function applyToolToggle(exempt, toolName, watched) {
|
|
|
233
278
|
const rest = exempt.filter(n => n !== toolName);
|
|
234
279
|
return watched ? rest : [...rest, toolName];
|
|
235
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* One /task-config row per reasoning group, so a group's thinking level can be
|
|
283
|
+
* set without hand-editing config.json.
|
|
284
|
+
*
|
|
285
|
+
* SHOWN IN EVERY MODE, not only `custom`. Two reasons, and the second is the
|
|
286
|
+
* real one:
|
|
287
|
+
* - `SettingsList` fixes the overlay's body height from the descriptions it was
|
|
288
|
+
* constructed with (see createSettingsPanel), so rows that appear and vanish
|
|
289
|
+
* would leave the box sized for the wrong list.
|
|
290
|
+
* - The value displayed is what the group ACTUALLY runs at — resolveReasoning,
|
|
291
|
+
* not the stored custom table. That makes the measured `default` table
|
|
292
|
+
* readable from the menu instead of hidden in a source file, which is the
|
|
293
|
+
* whole point of having measured it.
|
|
294
|
+
*/
|
|
295
|
+
const REASON_ID_PREFIX = 'reason:';
|
|
296
|
+
export function reasoningItems(cfg) {
|
|
297
|
+
return REASONING_GROUPS.map(group => ({
|
|
298
|
+
id: REASON_ID_PREFIX + group,
|
|
299
|
+
label: `think: ${group}`,
|
|
300
|
+
description: REASONING_GROUP_HELP[group],
|
|
301
|
+
// The EFFECTIVE level, not cfg.reasoningLevels[group]: in default/on/off
|
|
302
|
+
// the stored table is not what runs, and a row that shows a value the
|
|
303
|
+
// run does not use is worse than no row.
|
|
304
|
+
currentValue: resolveReasoning(group, cfg),
|
|
305
|
+
values: [...REASONING_SETTINGS]
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Apply one group row's new value.
|
|
310
|
+
*
|
|
311
|
+
* Setting any group necessarily means "custom" — there is nowhere else to store
|
|
312
|
+
* a per-group choice. The seeding step is what stops that from being a trap: on
|
|
313
|
+
* the way out of `default`/`on`/`off` every OTHER group is first pinned to the
|
|
314
|
+
* level it was already running at, so changing one row changes one row. Without
|
|
315
|
+
* it, nudging `research` while in `off` would silently return the other six to
|
|
316
|
+
* whatever the stored table happened to hold.
|
|
317
|
+
*/
|
|
318
|
+
/**
|
|
319
|
+
* Write every `think:` row's displayed value back from the config.
|
|
320
|
+
*
|
|
321
|
+
* Called after ANY change, not just a reasoning one, because the mode row and
|
|
322
|
+
* the seven group rows are one control split across eight lines: cycling
|
|
323
|
+
* `reasoning` to `off` changes what all seven of them run at, and cycling one
|
|
324
|
+
* group row flips the mode, which changes the other six. A row showing a level
|
|
325
|
+
* the run will not use is worse than no row.
|
|
326
|
+
*/
|
|
327
|
+
export function refreshReasoningRows(cfg, list) {
|
|
328
|
+
for (const group of REASONING_GROUPS) {
|
|
329
|
+
list.updateValue(REASON_ID_PREFIX + group, resolveReasoning(group, cfg));
|
|
330
|
+
}
|
|
331
|
+
list.updateValue('reasoningMode', cfg.reasoningMode);
|
|
332
|
+
}
|
|
333
|
+
export function applyReasoningLevel(cfg, group, chosen) {
|
|
334
|
+
if (!REASONING_SETTINGS.includes(chosen))
|
|
335
|
+
return;
|
|
336
|
+
if (cfg.reasoningMode !== 'custom') {
|
|
337
|
+
const seeded = {};
|
|
338
|
+
for (const g of REASONING_GROUPS)
|
|
339
|
+
seeded[g] = resolveReasoning(g, cfg);
|
|
340
|
+
cfg.reasoningLevels = seeded;
|
|
341
|
+
cfg.reasoningMode = 'custom';
|
|
342
|
+
}
|
|
343
|
+
cfg.reasoningLevels = { ...cfg.reasoningLevels, [group]: chosen };
|
|
344
|
+
}
|
|
236
345
|
/** Overlay width; the list gets `- 4` of it, the description `- 4` again. */
|
|
237
346
|
const OVERLAY_WIDTH = 68;
|
|
238
347
|
/** Settings rows shown at once before the list scrolls. */
|
|
@@ -272,29 +381,59 @@ function makeTheme(theme) {
|
|
|
272
381
|
* exact component the overlay shows can be rendered to a string in a test or a
|
|
273
382
|
* preview script, rather than only being inspectable by opening the TUI.
|
|
274
383
|
*/
|
|
275
|
-
export function createSettingsPanel(items, theme,
|
|
276
|
-
|
|
384
|
+
export function createSettingsPanel(items, theme,
|
|
385
|
+
/**
|
|
386
|
+
* Called with the row's id, its new value, and the LIST ITSELF.
|
|
387
|
+
*
|
|
388
|
+
* The list is handed back because some rows change what OTHER rows display:
|
|
389
|
+
* flipping `reasoning` to off means all seven `think:` rows now run at off,
|
|
390
|
+
* and a row's `currentValue` is a snapshot taken when the panel was built.
|
|
391
|
+
* Without a way to write the others back, the menu shows `reasoning off`
|
|
392
|
+
* beside seven rows still claiming `inherit` — which is what it did.
|
|
393
|
+
*/
|
|
394
|
+
onChange, onCancel) {
|
|
395
|
+
const list = new SettingsList(items, MAX_VISIBLE, makeTheme(theme), (id, newValue) => onChange(id, newValue, list), onCancel);
|
|
277
396
|
return new BorderedBox(list, 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));
|
|
278
397
|
}
|
|
279
398
|
/** The full settings row list for the current config, in menu order. */
|
|
280
399
|
export function panelItems(cfg, installed, tools = []) {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
400
|
+
// The discovered rows belong to a section too — the per-tool watchdog
|
|
401
|
+
// exemptions under `timeouts` (they are exemptions FROM that timeout), and
|
|
402
|
+
// the per-extension toggles under their own heading.
|
|
403
|
+
const extra = {
|
|
404
|
+
reasoning: reasoningItems(cfg),
|
|
405
|
+
timeouts: toolItems(tools, cfg.commandTimeoutExemptTools),
|
|
406
|
+
extensions: extensionItems(installed, cfg.extensionWhitelist)
|
|
407
|
+
};
|
|
408
|
+
const out = [];
|
|
409
|
+
for (const { key, title } of SECTIONS) {
|
|
410
|
+
const rows = [
|
|
411
|
+
...ITEMS.filter(i => i.section === key).map(({ id, label, description, values, format }) => ({
|
|
412
|
+
id: id,
|
|
413
|
+
label,
|
|
414
|
+
description,
|
|
415
|
+
currentValue: format(cfg),
|
|
416
|
+
values: values ?? ['on', 'off']
|
|
417
|
+
})),
|
|
418
|
+
...(extra[key] ?? [])
|
|
419
|
+
];
|
|
420
|
+
// An empty section prints no header. `extensions` has no fixed rows at
|
|
421
|
+
// all, so with nothing installed the heading would otherwise sit alone.
|
|
422
|
+
if (rows.length === 0)
|
|
423
|
+
continue;
|
|
424
|
+
out.push(sectionHeader(title), ...rows);
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
292
427
|
}
|
|
293
428
|
async function handleTaskConfig(_args, ctx, getTools = () => []) {
|
|
294
429
|
const cfg = {
|
|
295
430
|
...getConfig(),
|
|
296
431
|
extensionWhitelist: [...getConfig().extensionWhitelist],
|
|
297
|
-
commandTimeoutExemptTools: [...getConfig().commandTimeoutExemptTools]
|
|
432
|
+
commandTimeoutExemptTools: [...getConfig().commandTimeoutExemptTools],
|
|
433
|
+
// Copied for the same reason as the two arrays above: the panel mutates
|
|
434
|
+
// its own draft, and sharing the live object would apply half-made
|
|
435
|
+
// choices to running children before the user finished choosing.
|
|
436
|
+
reasoningLevels: { ...getConfig().reasoningLevels }
|
|
298
437
|
};
|
|
299
438
|
// Enumerated live at open so an installed extension appears and an
|
|
300
439
|
// uninstalled one vanishes without pi-task doing any bookkeeping. A failed
|
|
@@ -307,22 +446,26 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
|
|
|
307
446
|
if (ctx.mode !== 'tui') {
|
|
308
447
|
// Reads the SAME `format` the panel does, so the two renderings cannot
|
|
309
448
|
// disagree about what a setting currently says.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const state = cfg.extensionWhitelist.includes(e.path) ? 'on' : 'off';
|
|
317
|
-
lines.push(`${('ext: ' + e.label).padEnd(22)} ${state}`);
|
|
318
|
-
}
|
|
449
|
+
// Built from panelItems, not a second hand-written walk of the same
|
|
450
|
+
// tables: the two renderings used to be able to disagree about what a
|
|
451
|
+
// setting said, and a headless run is the one place nobody would notice.
|
|
452
|
+
const lines = panelItems(cfg, installed, tools).map(i => i.values === undefined ?
|
|
453
|
+
`[${i.label.replace(/─/g, '').trim()}]`
|
|
454
|
+
: `${i.label.padEnd(22)} ${i.currentValue}`);
|
|
319
455
|
ctx.ui.notify(lines.join(' | '), 'info');
|
|
320
456
|
return;
|
|
321
457
|
}
|
|
322
|
-
await ctx.ui.custom((_tui, theme, _kb, done) => createSettingsPanel(panelItems(cfg, installed, tools), theme, (id, newValue) => {
|
|
458
|
+
await ctx.ui.custom((_tui, theme, _kb, done) => createSettingsPanel(panelItems(cfg, installed, tools), theme, (id, newValue, list) => {
|
|
459
|
+
// Header rows carry no `values`, so SettingsList never
|
|
460
|
+
// cycles them and this can only be a real setting.
|
|
461
|
+
if (id.startsWith(SECTION_ID_PREFIX))
|
|
462
|
+
return;
|
|
323
463
|
if (id.startsWith(EXT_ID_PREFIX)) {
|
|
324
464
|
cfg.extensionWhitelist = applyExtensionToggle(cfg.extensionWhitelist, id.slice(EXT_ID_PREFIX.length), newValue === 'on');
|
|
325
465
|
}
|
|
466
|
+
else if (id.startsWith(REASON_ID_PREFIX)) {
|
|
467
|
+
applyReasoningLevel(cfg, id.slice(REASON_ID_PREFIX.length), newValue);
|
|
468
|
+
}
|
|
326
469
|
else if (id.startsWith(TOOL_ID_PREFIX)) {
|
|
327
470
|
cfg.commandTimeoutExemptTools = applyToolToggle(cfg.commandTimeoutExemptTools, id.slice(TOOL_ID_PREFIX.length), newValue === 'on');
|
|
328
471
|
}
|
|
@@ -334,6 +477,12 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
|
|
|
334
477
|
// until someone noticed.
|
|
335
478
|
ITEMS.find(item => item.id === id)?.apply(cfg, newValue);
|
|
336
479
|
}
|
|
480
|
+
// The reasoning rows describe each other, so they are
|
|
481
|
+
// re-read from the config after every change — including
|
|
482
|
+
// changes to unrelated rows, which costs nothing and means
|
|
483
|
+
// there is no list of "changes that need a refresh" to keep
|
|
484
|
+
// correct.
|
|
485
|
+
refreshReasoningRows(cfg, list);
|
|
337
486
|
saveConfig(cfg).catch(() => { });
|
|
338
487
|
}, () => done(undefined)), { overlay: true, overlayOptions: { width: OVERLAY_WIDTH } });
|
|
339
488
|
}
|
|
@@ -115,6 +115,25 @@ export interface RunChildJsonEventsOptions {
|
|
|
115
115
|
mode: 'json-events';
|
|
116
116
|
onLine?: (line: string) => void;
|
|
117
117
|
onContextUsage?: (snapshot: ContextSnapshot) => void;
|
|
118
|
+
/**
|
|
119
|
+
* The child's context window in tokens, supplied BY THE CALLER — pi's event
|
|
120
|
+
* stream does not carry one (GitHub issue #16).
|
|
121
|
+
*
|
|
122
|
+
* Verified against the published tarballs of @earendil-works/pi-coding-agent
|
|
123
|
+
* and @earendil-works/pi-agent-core at 0.80.2 and 0.84.2, and against pi's
|
|
124
|
+
* own docs/json.md: the wire union is session / agent_* / turn_* / message_*
|
|
125
|
+
* / tool_execution_* / queue_update / compaction_* / auto_retry_*, the
|
|
126
|
+
* session header is {type,version,id,timestamp,cwd,parentSession}, and no
|
|
127
|
+
* member of either carries a window or even a model id. `contextUsage`
|
|
128
|
+
* exists ONLY as the in-process `ctx.getContextUsage()` extension API, which
|
|
129
|
+
* a `--mode json` child never speaks back to its parent.
|
|
130
|
+
*
|
|
131
|
+
* The parent therefore has to say. Children are spawned without `-m`
|
|
132
|
+
* (CHILD_BASE_ARGS), so they resolve the same default model the parent runs
|
|
133
|
+
* and the parent session's window is the honest answer. 0 / omitted keeps
|
|
134
|
+
* the old behaviour: report the token count with no window.
|
|
135
|
+
*/
|
|
136
|
+
contextWindow?: number;
|
|
118
137
|
onToolCall?: (call: ToolCall) => LoopHit | null;
|
|
119
138
|
/**
|
|
120
139
|
* Fires when a tool call finishes, carrying its RESULT (mx5 run 10 item 6: the
|
|
@@ -88,15 +88,12 @@ export class JsonEventSink {
|
|
|
88
88
|
handleEvent(evt) {
|
|
89
89
|
const opts = this.opts;
|
|
90
90
|
const t = typeof evt.type === 'string' ? evt.type : '';
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
91
|
+
// `message_end` is the ONLY context readout pi gives a `--mode json`
|
|
92
|
+
// child's parent. There used to be a `context_usage` branch above this
|
|
93
|
+
// one that was preferred over it; no released pi has ever emitted such
|
|
94
|
+
// an event (see `contextWindow` on RunChildJsonEventsOptions), so it was
|
|
95
|
+
// unreachable, and its presence is what made the zero window below look
|
|
96
|
+
// like a harmless fallback rather than the only path. Issue #16.
|
|
100
97
|
if (t === 'message_end' && opts.onContextUsage) {
|
|
101
98
|
const msg = evt.message;
|
|
102
99
|
if (msg?.role === 'assistant') {
|
|
@@ -107,7 +104,9 @@ export class JsonEventSink {
|
|
|
107
104
|
+ Number(usage.cacheWrite ?? 0)
|
|
108
105
|
+ Number(usage.output ?? 0);
|
|
109
106
|
if (tokens > 0) {
|
|
110
|
-
|
|
107
|
+
const cw = Math.max(0, Number(opts.contextWindow ?? 0));
|
|
108
|
+
const percent = cw > 0 ? Math.min(100, (tokens / cw) * 100) : 0;
|
|
109
|
+
opts.onContextUsage({ tokens, contextWindow: cw, percent });
|
|
111
110
|
}
|
|
112
111
|
}
|
|
113
112
|
}
|
|
@@ -6,3 +6,37 @@ export declare function discoverModelEndpoints(agentDir?: string): string[];
|
|
|
6
6
|
* timeout. An empty list is true: nothing to probe means never kill.
|
|
7
7
|
*/
|
|
8
8
|
export declare function probeModelEndpoints(urls: string[], timeoutMs?: number): Promise<boolean>;
|
|
9
|
+
/**
|
|
10
|
+
* What a llama.cpp server's own chat template can actually do about reasoning,
|
|
11
|
+
* as reported by `GET /props`.
|
|
12
|
+
*
|
|
13
|
+
* This is the only source of truth that does NOT come from models.json. It
|
|
14
|
+
* answers the one question the host-side clamp cannot: *is models.json lying
|
|
15
|
+
* about the server?* — the case that matters being pi's built-in llama.cpp
|
|
16
|
+
* provider, which hardcodes `reasoning: false`, so anyone who reached their
|
|
17
|
+
* server through `/login llama.cpp` rather than a hand-written provider entry
|
|
18
|
+
* has a dead knob and nothing to tell them so.
|
|
19
|
+
*/
|
|
20
|
+
export interface ChatTemplateCaps {
|
|
21
|
+
/** The template reads `reasoning_effort` — i.e. levels, not just on/off. */
|
|
22
|
+
supportsReasoningEffort: boolean;
|
|
23
|
+
/** The template reads `preserve_thinking` / `preserve_reasoning`. */
|
|
24
|
+
supportsPreserveReasoning: boolean;
|
|
25
|
+
/** The template mentions `enable_thinking` at all — i.e. thinking can be switched. */
|
|
26
|
+
mentionsEnableThinking: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Probe one base URL for its chat-template capabilities, or `null` for "no
|
|
30
|
+
* answer worth having" — not a llama.cpp server, unreachable, or a body in a
|
|
31
|
+
* shape this does not recognise.
|
|
32
|
+
*
|
|
33
|
+
* `null` is a first-class result, not an error: every non-llama.cpp backend
|
|
34
|
+
* returns it, and the caller must degrade to the models.json view rather than
|
|
35
|
+
* warn about a server it could not read. Never throws.
|
|
36
|
+
*
|
|
37
|
+
* The LEADING SLASH in `/props` is load-bearing. A configured baseUrl normally
|
|
38
|
+
* ends in `/v1` (llama-server's OpenAI-compatible prefix) while `/props` lives at
|
|
39
|
+
* the server root, so a relative `'props'` would resolve to `/v1/props` and 404 —
|
|
40
|
+
* which this would report as `null`, i.e. as a silent loss of the better signal.
|
|
41
|
+
*/
|
|
42
|
+
export declare function probeChatTemplateCaps(baseUrl: string, timeoutMs?: number): Promise<ChatTemplateCaps | null>;
|
|
@@ -54,3 +54,39 @@ export async function probeModelEndpoints(urls, timeoutMs = 5_000) {
|
|
|
54
54
|
}));
|
|
55
55
|
return results.some(Boolean);
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Probe one base URL for its chat-template capabilities, or `null` for "no
|
|
59
|
+
* answer worth having" — not a llama.cpp server, unreachable, or a body in a
|
|
60
|
+
* shape this does not recognise.
|
|
61
|
+
*
|
|
62
|
+
* `null` is a first-class result, not an error: every non-llama.cpp backend
|
|
63
|
+
* returns it, and the caller must degrade to the models.json view rather than
|
|
64
|
+
* warn about a server it could not read. Never throws.
|
|
65
|
+
*
|
|
66
|
+
* The LEADING SLASH in `/props` is load-bearing. A configured baseUrl normally
|
|
67
|
+
* ends in `/v1` (llama-server's OpenAI-compatible prefix) while `/props` lives at
|
|
68
|
+
* the server root, so a relative `'props'` would resolve to `/v1/props` and 404 —
|
|
69
|
+
* which this would report as `null`, i.e. as a silent loss of the better signal.
|
|
70
|
+
*/
|
|
71
|
+
export async function probeChatTemplateCaps(baseUrl, timeoutMs = 2_000) {
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(new URL('/props', baseUrl), {
|
|
74
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok)
|
|
77
|
+
return null;
|
|
78
|
+
const body = (await res.json());
|
|
79
|
+
const caps = body.chat_template_caps;
|
|
80
|
+
if (typeof caps !== 'object' || caps === null)
|
|
81
|
+
return null;
|
|
82
|
+
const template = typeof body.chat_template === 'string' ? body.chat_template : '';
|
|
83
|
+
return {
|
|
84
|
+
supportsReasoningEffort: caps.supports_reasoning_effort === true,
|
|
85
|
+
supportsPreserveReasoning: caps.supports_preserve_reasoning === true,
|
|
86
|
+
mentionsEnableThinking: template.includes('enable_thinking')
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What pi will ACTUALLY send, given a model and a requested thinking level.
|
|
3
|
+
*
|
|
4
|
+
* WHY A LOCAL COPY OF PI'S CLAMP
|
|
5
|
+
* ------------------------------
|
|
6
|
+
* pi never reports that it ignored or downgraded a level. Measured live against
|
|
7
|
+
* this machine's llama-server, with a proxy capturing the request body:
|
|
8
|
+
*
|
|
9
|
+
* 1. a model with `reasoning: false` + `--thinking medium`
|
|
10
|
+
* → the body carries NO reasoning field at all. No error, no warning.
|
|
11
|
+
* 2. `thinkingLevelMap: {off: null, ...}` + `--thinking off`
|
|
12
|
+
* → silently clamped UP to `medium`. Thinking stays on.
|
|
13
|
+
* 3. `--thinking low` where `low: null`
|
|
14
|
+
* → silently clamped to `medium`.
|
|
15
|
+
*
|
|
16
|
+
* All three are the same arithmetic, and it is pure: `getSupportedThinkingLevels`
|
|
17
|
+
* / `clampThinkingLevel` in @earendil-works/pi-ai's models module. Reproducing it
|
|
18
|
+
* lets one predicate — `clampToModel(m, wanted) !== wanted` — catch all three
|
|
19
|
+
* host-side, before a single request is sent.
|
|
20
|
+
*
|
|
21
|
+
* Reimplemented rather than imported because `@earendil-works/pi-ai` is neither a
|
|
22
|
+
* dependency nor a peerDependency of pi-task: it is present only because
|
|
23
|
+
* pi-coding-agent hoists it, so importing it would take a hard dependency on a
|
|
24
|
+
* transitive package to get twenty lines of arithmetic. SOURCE OF TRUTH is that
|
|
25
|
+
* module; `reasoning-capability.test.ts` is where a change upstream shows up.
|
|
26
|
+
*/
|
|
27
|
+
import type { ReasoningGroup, GroupSetting } from '../config/reasoning.js';
|
|
28
|
+
/**
|
|
29
|
+
* pi's own level ladder, in order. The order is the whole algorithm: an
|
|
30
|
+
* unsupported level is resolved by walking UP first, then down.
|
|
31
|
+
*/
|
|
32
|
+
export declare const THINKING_LADDER: readonly ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
33
|
+
export type LadderLevel = (typeof THINKING_LADDER)[number];
|
|
34
|
+
/**
|
|
35
|
+
* The two fields of pi's `Model` that decide reasoning behaviour. Named
|
|
36
|
+
* separately so the pure functions below can be tested with object literals
|
|
37
|
+
* instead of a whole model registry.
|
|
38
|
+
*/
|
|
39
|
+
export interface ReasoningModelFacts {
|
|
40
|
+
reasoning: boolean;
|
|
41
|
+
thinkingLevelMap?: Partial<Record<string, string | null>>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The levels this model will actually honour.
|
|
45
|
+
*
|
|
46
|
+
* Two rules that are easy to get backwards:
|
|
47
|
+
* - `reasoning: false` collapses everything to `['off']`. That is failure mode
|
|
48
|
+
* 1: the knob is not rejected, it is erased.
|
|
49
|
+
* - a MISSING map entry means "supported" for the standard levels but
|
|
50
|
+
* "unsupported" for `xhigh` / `max`, which are opt-in and must be declared.
|
|
51
|
+
* This is why config/reasoning.ts does not offer those two: a model with no
|
|
52
|
+
* map at all would receive the raw string, and Qwen3.8's chat template
|
|
53
|
+
* answers an unknown effort with HTTP 500 rather than a clamp.
|
|
54
|
+
*/
|
|
55
|
+
export declare function supportedThinkingLevels(model: ReasoningModelFacts): LadderLevel[];
|
|
56
|
+
/**
|
|
57
|
+
* The level pi will use in place of the one asked for. Equal to the input when
|
|
58
|
+
* the model supports it — which is what makes the inequality a mismatch test.
|
|
59
|
+
*/
|
|
60
|
+
export declare function clampToModel(model: ReasoningModelFacts, level: LadderLevel): LadderLevel;
|
|
61
|
+
/** One group whose configured setting the connected model will not honour. */
|
|
62
|
+
export interface ReasoningMismatch {
|
|
63
|
+
group: ReasoningGroup;
|
|
64
|
+
/** What /task-config says. Never `inherit` — an inherited group asks for nothing. */
|
|
65
|
+
wanted: LadderLevel;
|
|
66
|
+
/** What pi will send instead. */
|
|
67
|
+
actual: LadderLevel;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Every group whose setting the model will silently change.
|
|
71
|
+
*
|
|
72
|
+
* `inherit` groups are skipped entirely, and that is what keeps a default
|
|
73
|
+
* install permanently quiet: with the shipped all-`inherit` table this returns
|
|
74
|
+
* an empty array for every model, including one with no reasoning at all.
|
|
75
|
+
*
|
|
76
|
+
* It reports mismatches in BOTH directions, which is wider than "warn when
|
|
77
|
+
* reasoning is on but unsupported". The failure actually captured on this
|
|
78
|
+
* machine is the mirror of that — `off` clamped UP to `medium`, so a user who
|
|
79
|
+
* turned thinking off still pays for it — and it is the same comparison. Warning
|
|
80
|
+
* about one direction while staying silent about the other would ship this
|
|
81
|
+
* feature with its own measured failure mode unreported.
|
|
82
|
+
*/
|
|
83
|
+
export declare function reasoningMismatches(model: ReasoningModelFacts | undefined, settings: ReadonlyArray<{
|
|
84
|
+
group: ReasoningGroup;
|
|
85
|
+
setting: GroupSetting;
|
|
86
|
+
}>): ReasoningMismatch[];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi's own level ladder, in order. The order is the whole algorithm: an
|
|
3
|
+
* unsupported level is resolved by walking UP first, then down.
|
|
4
|
+
*/
|
|
5
|
+
export const THINKING_LADDER = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
6
|
+
/**
|
|
7
|
+
* The levels this model will actually honour.
|
|
8
|
+
*
|
|
9
|
+
* Two rules that are easy to get backwards:
|
|
10
|
+
* - `reasoning: false` collapses everything to `['off']`. That is failure mode
|
|
11
|
+
* 1: the knob is not rejected, it is erased.
|
|
12
|
+
* - a MISSING map entry means "supported" for the standard levels but
|
|
13
|
+
* "unsupported" for `xhigh` / `max`, which are opt-in and must be declared.
|
|
14
|
+
* This is why config/reasoning.ts does not offer those two: a model with no
|
|
15
|
+
* map at all would receive the raw string, and Qwen3.8's chat template
|
|
16
|
+
* answers an unknown effort with HTTP 500 rather than a clamp.
|
|
17
|
+
*/
|
|
18
|
+
export function supportedThinkingLevels(model) {
|
|
19
|
+
if (!model.reasoning)
|
|
20
|
+
return ['off'];
|
|
21
|
+
return THINKING_LADDER.filter(level => {
|
|
22
|
+
const mapped = model.thinkingLevelMap?.[level];
|
|
23
|
+
if (mapped === null)
|
|
24
|
+
return false;
|
|
25
|
+
if (level === 'xhigh' || level === 'max')
|
|
26
|
+
return mapped !== undefined;
|
|
27
|
+
return true;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The level pi will use in place of the one asked for. Equal to the input when
|
|
32
|
+
* the model supports it — which is what makes the inequality a mismatch test.
|
|
33
|
+
*/
|
|
34
|
+
export function clampToModel(model, level) {
|
|
35
|
+
const available = supportedThinkingLevels(model);
|
|
36
|
+
if (available.includes(level))
|
|
37
|
+
return level;
|
|
38
|
+
const requested = THINKING_LADDER.indexOf(level);
|
|
39
|
+
if (requested === -1)
|
|
40
|
+
return available[0] ?? 'off';
|
|
41
|
+
for (let i = requested; i < THINKING_LADDER.length; i++) {
|
|
42
|
+
const candidate = THINKING_LADDER[i];
|
|
43
|
+
if (available.includes(candidate))
|
|
44
|
+
return candidate;
|
|
45
|
+
}
|
|
46
|
+
for (let i = requested - 1; i >= 0; i--) {
|
|
47
|
+
const candidate = THINKING_LADDER[i];
|
|
48
|
+
if (available.includes(candidate))
|
|
49
|
+
return candidate;
|
|
50
|
+
}
|
|
51
|
+
return available[0] ?? 'off';
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Every group whose setting the model will silently change.
|
|
55
|
+
*
|
|
56
|
+
* `inherit` groups are skipped entirely, and that is what keeps a default
|
|
57
|
+
* install permanently quiet: with the shipped all-`inherit` table this returns
|
|
58
|
+
* an empty array for every model, including one with no reasoning at all.
|
|
59
|
+
*
|
|
60
|
+
* It reports mismatches in BOTH directions, which is wider than "warn when
|
|
61
|
+
* reasoning is on but unsupported". The failure actually captured on this
|
|
62
|
+
* machine is the mirror of that — `off` clamped UP to `medium`, so a user who
|
|
63
|
+
* turned thinking off still pays for it — and it is the same comparison. Warning
|
|
64
|
+
* about one direction while staying silent about the other would ship this
|
|
65
|
+
* feature with its own measured failure mode unreported.
|
|
66
|
+
*/
|
|
67
|
+
export function reasoningMismatches(model, settings) {
|
|
68
|
+
// No model resolved yet (session still starting, or none selected): say
|
|
69
|
+
// nothing. A warning naming no model is noise, not information.
|
|
70
|
+
if (!model)
|
|
71
|
+
return [];
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const { group, setting } of settings) {
|
|
74
|
+
if (setting === 'inherit')
|
|
75
|
+
continue;
|
|
76
|
+
const wanted = setting;
|
|
77
|
+
const actual = clampToModel(model, wanted);
|
|
78
|
+
if (actual !== wanted)
|
|
79
|
+
out.push({ group, wanted, actual });
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|