@mjasnikovs/pi-task 0.38.19 → 0.38.21

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.
Files changed (44) hide show
  1. package/README.md +1 -0
  2. package/dist/config/config.d.ts +19 -0
  3. package/dist/config/config.js +10 -2
  4. package/dist/config/reasoning-args.d.ts +10 -0
  5. package/dist/config/reasoning-args.js +23 -0
  6. package/dist/config/reasoning.d.ts +160 -0
  7. package/dist/config/reasoning.js +530 -0
  8. package/dist/config/register.d.ts +72 -2
  9. package/dist/config/register.js +291 -38
  10. package/dist/shared/model-endpoint.d.ts +34 -0
  11. package/dist/shared/model-endpoint.js +36 -0
  12. package/dist/shared/reasoning-capability.d.ts +86 -0
  13. package/dist/shared/reasoning-capability.js +82 -0
  14. package/dist/task/child-runner.d.ts +19 -26
  15. package/dist/task/child-runner.js +48 -6
  16. package/dist/task/decompose-fidelity.d.ts +21 -8
  17. package/dist/task/decompose-fidelity.js +98 -17
  18. package/dist/task/gate-child.d.ts +10 -0
  19. package/dist/task/gate-child.js +1 -0
  20. package/dist/task/gate-deps.js +4 -0
  21. package/dist/task/implementation-thinking.d.ts +54 -0
  22. package/dist/task/implementation-thinking.js +33 -0
  23. package/dist/task/orchestrator.d.ts +7 -0
  24. package/dist/task/orchestrator.js +42 -14
  25. package/dist/task/phases.js +47 -24
  26. package/dist/task/prompts.d.ts +0 -23
  27. package/dist/task/prompts.js +0 -25
  28. package/dist/task/reasoning-groups.d.ts +36 -0
  29. package/dist/task/reasoning-groups.js +36 -0
  30. package/dist/task/spec-validation.d.ts +28 -0
  31. package/dist/task/spec-validation.js +44 -0
  32. package/dist/task/title-label.js +2 -2
  33. package/dist/workers/docs-core.js +4 -0
  34. package/dist/workers/fetch-core.js +4 -0
  35. package/dist/workers/focused-extractor.d.ts +12 -1
  36. package/dist/workers/focused-extractor.js +6 -2
  37. package/dist/workers/index.js +2 -0
  38. package/dist/workers/pi-worker-core.d.ts +22 -0
  39. package/dist/workers/pi-worker-core.js +24 -6
  40. package/dist/workers/pi-worker-docs.js +4 -0
  41. package/dist/workers/pi-worker.js +11 -1
  42. package/dist/workers/reasoning-warning.d.ts +64 -0
  43. package/dist/workers/reasoning-warning.js +142 -0
  44. package/package.json +1 -1
@@ -1,10 +1,11 @@
1
- import { SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
1
+ import { getKeybindings, SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
2
  import { registerBridgeCommand } from '../remote/bridge.js';
3
3
  import { readPkgVersion } from '../shared/pkg-version.js';
4
4
  import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
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,58 @@ 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: 'unattended', title: 'unattended' },
78
+ { key: 'logging', title: 'logging' },
79
+ { key: 'extensions', title: 'child extensions' },
80
+ // Last on purpose. It is the longest block (a fixed timeout plus one row
81
+ // per live tool, so it grows with the host) and the least often changed —
82
+ // in front of `unattended` it pushed every short section off the screen.
83
+ { key: 'timeouts', title: 'timeouts' }
84
+ ];
85
+ /** Marks a header row, so onChange can ignore one and tests can find them. */
86
+ export const SECTION_ID_PREFIX = 'section:';
87
+ /**
88
+ * An inert titled row. No `values` ⇒ SettingsList's Enter handler no-ops on it,
89
+ * and {@link SkipInertRows} steps the cursor straight over it.
90
+ *
91
+ * Upper case, and styled muted by {@link makeTheme}, because the dashed
92
+ * lower-case form it replaces was the same case, colour and weight as the
93
+ * setting labels underneath it — eight headings that read as nine more rows.
94
+ */
95
+ function sectionHeader(title) {
96
+ return {
97
+ id: SECTION_ID_PREFIX + title,
98
+ label: title.toUpperCase(),
99
+ description: '',
100
+ currentValue: ''
101
+ };
102
+ }
103
+ /**
104
+ * A blank row between two sections.
105
+ *
106
+ * `SettingsList` renders exactly one line per item, so the only way to put air
107
+ * above a heading is to hand it an empty row. It carries the header prefix so
108
+ * everything that already treats a header as scenery — the inert check, the
109
+ * cursor skip, the headless rendering — covers it with no second rule.
110
+ */
111
+ function sectionGap(title) {
112
+ return { id: `${SECTION_ID_PREFIX}gap:${title}`, label: '', description: '', currentValue: '' };
113
+ }
70
114
  /**
71
115
  * The shared pair for a boolean setting: shown as on/off, stored as a boolean.
72
116
  * Every non-enum row uses this, so a boolean cannot be given a bespoke parser by
73
117
  * accident.
74
118
  */
75
- function booleanItem(id, label, description) {
119
+ function booleanItem(section, id, label, description) {
76
120
  return {
77
121
  id,
122
+ section,
78
123
  label,
79
124
  description,
80
125
  format: cfg => (cfg[id] ? 'on' : 'off'),
@@ -92,27 +137,28 @@ function booleanItem(id, label, description) {
92
137
  * either of the two ladders this replaced.
93
138
  */
94
139
  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 '
140
+ booleanItem('session', 'remote', 'remote control', 'Serve the task UI on your local network so you can follow and steer a run from '
96
141
  + '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 '
142
+ booleanItem('checks', 'autoCommit', 'auto-commit', 'Make a git commit before and after every sub-task, so each step is a checkpoint '
98
143
  + '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 '
144
+ booleanItem('checks', 'verifyWork', 'verify work', 'When a task says it is done, actually run the checks its spec asks for and report '
100
145
  + "PASS or FAIL instead of taking the model's word for it. This is also what lets "
101
146
  + '"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 '
147
+ booleanItem('checks', 'enforceGuidelines', 'enforce guidelines', 'Check what each task committed against your AGENTS.md / CLAUDE.md rules. With '
103
148
  + '"verify work" on it also fixes what it finds, undoing any fix that breaks the '
104
149
  + '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, '
150
+ booleanItem('research', 'orientation', 'project tour', 'Show the research workers the shape of the project first — package manifest, '
106
151
  + 'types, schema — so they spend their steps on the question instead of on finding '
107
152
  + 'their way around'),
108
- booleanItem('parallelResearchWorkers', 'parallel research', 'Run the 4 research workers at once instead of one after another. Only faster if '
153
+ booleanItem('research', 'parallelResearchWorkers', 'parallel research', 'Run the 4 research workers at once instead of one after another. Only faster if '
109
154
  + 'your model backend can answer several requests at the same time — on a single '
110
155
  + '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 '
156
+ booleanItem('research', 'researchCache', 'research cache', 'Remember docs and web pages for the length of one run, so later tasks reuse what '
112
157
  + 'the first one already fetched instead of downloading it again. Only external '
113
158
  + 'sources, only successful fetches, and it is dropped when the run ends'),
114
159
  {
115
160
  id: 'searchProvider',
161
+ section: 'research',
116
162
  label: 'search engine',
117
163
  description: 'Which engine backs web search. Exa and DuckDuckGo work with no setup; Brave needs '
118
164
  + 'a BRAVE_SEARCH_API_KEY in your environment',
@@ -127,6 +173,7 @@ export const ITEMS = [
127
173
  },
128
174
  {
129
175
  id: 'requestTimeoutMs',
176
+ section: 'timeouts',
130
177
  label: 'command timeout',
131
178
  description: 'Give up on any single command that runs this long, and tell the model to set its '
132
179
  + 'own timeout next time. Stops a run from waiting forever on a dev server or a '
@@ -144,6 +191,7 @@ export const ITEMS = [
144
191
  },
145
192
  {
146
193
  id: 'streamInactivityMs',
194
+ section: 'timeouts',
147
195
  label: 'stuck reply retry',
148
196
  description: 'Give up on a model reply that has sent nothing for this long and ask again. A '
149
197
  + 'dropped connection looks exactly like a model thinking hard and reports no '
@@ -160,12 +208,30 @@ export const ITEMS = [
160
208
  cfg.streamInactivityMs = opt.ms;
161
209
  }
162
210
  },
163
- booleanItem('yoloMode', 'yolo mode', 'Stop asking you anything: every question takes the option pi recommends, a failed '
211
+ booleanItem('unattended', 'yoloMode', 'yolo mode', 'Stop asking you anything: every question takes the option pi recommends, a failed '
164
212
  + 'check is accepted and written down as debt, and a failed final check is retried '
165
213
  + 'until the budget runs out. Each auto-answer is marked (YOLO) in the task file. '
166
214
  + 'For throwaway projects you are not watching'),
215
+ {
216
+ id: 'reasoningMode',
217
+ section: 'reasoning',
218
+ label: 'reasoning',
219
+ description: 'How much the helper sessions think before answering. "default" uses the '
220
+ + 'per-step table pi-task has measured, "on" and "off" force one answer '
221
+ + 'everywhere, and "custom" is whatever you set in the "think:" rows below. '
222
+ + 'Those rows always show what each step actually runs at, and changing one '
223
+ + 'switches this to custom. A step left on "inherit" uses whatever thinking '
224
+ + 'level pi itself is set to, which is what every step did before this setting '
225
+ + 'existed',
226
+ values: [...REASONING_MODES],
227
+ format: cfg => String(cfg.reasoningMode),
228
+ apply: (cfg, chosen) => {
229
+ cfg.reasoningMode = sanitizeReasoningMode(chosen);
230
+ }
231
+ },
167
232
  {
168
233
  id: 'debugLogs',
234
+ section: 'logging',
169
235
  label: 'debug logs',
170
236
  description: 'How much of a run gets written to .pi-tasks/*-debug.log. "events" keeps the '
171
237
  + 'decisions and the guard actions — what a checking step changed, why something '
@@ -233,10 +299,74 @@ export function applyToolToggle(exempt, toolName, watched) {
233
299
  const rest = exempt.filter(n => n !== toolName);
234
300
  return watched ? rest : [...rest, toolName];
235
301
  }
302
+ /**
303
+ * One /task-config row per reasoning group, so a group's thinking level can be
304
+ * set without hand-editing config.json.
305
+ *
306
+ * SHOWN IN EVERY MODE, not only `custom`. Two reasons, and the second is the
307
+ * real one:
308
+ * - `SettingsList` fixes the overlay's body height from the descriptions it was
309
+ * constructed with (see createSettingsPanel), so rows that appear and vanish
310
+ * would leave the box sized for the wrong list.
311
+ * - The value displayed is what the group ACTUALLY runs at — resolveReasoning,
312
+ * not the stored custom table. That makes the measured `default` table
313
+ * readable from the menu instead of hidden in a source file, which is the
314
+ * whole point of having measured it.
315
+ */
316
+ const REASON_ID_PREFIX = 'reason:';
317
+ export function reasoningItems(cfg) {
318
+ return REASONING_GROUPS.map(group => ({
319
+ id: REASON_ID_PREFIX + group,
320
+ label: `think: ${group}`,
321
+ description: REASONING_GROUP_HELP[group],
322
+ // The EFFECTIVE level, not cfg.reasoningLevels[group]: in default/on/off
323
+ // the stored table is not what runs, and a row that shows a value the
324
+ // run does not use is worse than no row.
325
+ currentValue: resolveReasoning(group, cfg),
326
+ values: [...REASONING_SETTINGS]
327
+ }));
328
+ }
329
+ /**
330
+ * Apply one group row's new value.
331
+ *
332
+ * Setting any group necessarily means "custom" — there is nowhere else to store
333
+ * a per-group choice. The seeding step is what stops that from being a trap: on
334
+ * the way out of `default`/`on`/`off` every OTHER group is first pinned to the
335
+ * level it was already running at, so changing one row changes one row. Without
336
+ * it, nudging `research` while in `off` would silently return the other six to
337
+ * whatever the stored table happened to hold.
338
+ */
339
+ /**
340
+ * Write every `think:` row's displayed value back from the config.
341
+ *
342
+ * Called after ANY change, not just a reasoning one, because the mode row and
343
+ * the seven group rows are one control split across eight lines: cycling
344
+ * `reasoning` to `off` changes what all seven of them run at, and cycling one
345
+ * group row flips the mode, which changes the other six. A row showing a level
346
+ * the run will not use is worse than no row.
347
+ */
348
+ export function refreshReasoningRows(cfg, list) {
349
+ for (const group of REASONING_GROUPS) {
350
+ list.updateValue(REASON_ID_PREFIX + group, resolveReasoning(group, cfg));
351
+ }
352
+ list.updateValue('reasoningMode', cfg.reasoningMode);
353
+ }
354
+ export function applyReasoningLevel(cfg, group, chosen) {
355
+ if (!REASONING_SETTINGS.includes(chosen))
356
+ return;
357
+ if (cfg.reasoningMode !== 'custom') {
358
+ const seeded = {};
359
+ for (const g of REASONING_GROUPS)
360
+ seeded[g] = resolveReasoning(g, cfg);
361
+ cfg.reasoningLevels = seeded;
362
+ cfg.reasoningMode = 'custom';
363
+ }
364
+ cfg.reasoningLevels = { ...cfg.reasoningLevels, [group]: chosen };
365
+ }
236
366
  /** Overlay width; the list gets `- 4` of it, the description `- 4` again. */
237
367
  const OVERLAY_WIDTH = 68;
238
368
  /** Settings rows shown at once before the list scrolls. */
239
- const MAX_VISIBLE = 9;
369
+ const MAX_VISIBLE = 11;
240
370
  /**
241
371
  * Tallest body the settings list can render, so {@link BorderedBox} can pad
242
372
  * every frame to it and hold the border still. Mirrors SettingsList's own
@@ -247,9 +377,19 @@ export function settingsBodyHeight(descriptions, maxVisible, wrapWidth) {
247
377
  const tallestDescription = Math.max(0, ...descriptions.map(d => wrapTextWithAnsi(d, wrapWidth).length));
248
378
  return 1 + maxVisible + 1 + 1 + tallestDescription + 1 + 1;
249
379
  }
250
- function makeTheme(theme) {
380
+ function makeTheme(theme, isHeader) {
251
381
  return {
252
- label: (text, selected) => selected ? theme.fg('accent', theme.bold(text)) : theme.fg('text', text),
382
+ label: (text, selected) => {
383
+ // Headers are scenery, so they are rendered quieter than the rows
384
+ // they title rather than louder. `isHeader` is asked by text
385
+ // because SettingsListTheme only ever sees the padded label —
386
+ // matching on the text is what keeps the styling in one place
387
+ // instead of pre-colouring the string back in panelItems, which
388
+ // has no theme to colour it with.
389
+ if (isHeader(text))
390
+ return theme.fg('muted', theme.bold(text));
391
+ return selected ? theme.fg('accent', theme.bold(text)) : theme.fg('text', text);
392
+ },
253
393
  // A filled/hollow dot makes the on/off column scannable at a glance
254
394
  // without reading a word on every row. Enum values (an engine name, a
255
395
  // duration) are real content, so they stay readable rather than muted.
@@ -267,34 +407,132 @@ function makeTheme(theme) {
267
407
  hint: text => theme.fg('dim', text)
268
408
  };
269
409
  }
410
+ /** The arrow key SettingsList moves down on, under the default bindings. */
411
+ const DOWN_KEY = '\x1b[B';
412
+ /**
413
+ * Moves the cursor over the section headers and the blank rows between them.
414
+ *
415
+ * Those rows are decoration: they carry no `values`, so Enter already does
416
+ * nothing on them. Without this they were still stops on the way down — with a
417
+ * heading AND a blank line per section that is sixteen dead keypresses in a
418
+ * thirty-row menu, and the panel opens with the cursor parked on a heading that
419
+ * has no description to show.
420
+ *
421
+ * It drives the list through its own public `handleInput` — pressing the very
422
+ * key the user pressed, N times — rather than reaching for the private
423
+ * `selectedIndex`. The mirror it keeps cannot drift: with search off and no
424
+ * submenus, up and down are the only two things that move that index.
425
+ */
426
+ class SkipInertRows {
427
+ list;
428
+ selectable;
429
+ index = 0;
430
+ constructor(list,
431
+ /** True where a row can be selected, in the list's own order. */
432
+ selectable) {
433
+ this.list = list;
434
+ this.selectable = selectable;
435
+ // The first row is a header, so the panel would open on it. Only
436
+ // synthesise the keypress if it is actually bound to "down" — feeding
437
+ // a key the list ignores would move the mirror and not the cursor.
438
+ if (!getKeybindings().matches(DOWN_KEY, 'tui.select.down'))
439
+ return;
440
+ while (this.index < selectable.length && !selectable[this.index]) {
441
+ this.list.handleInput(DOWN_KEY);
442
+ this.index++;
443
+ }
444
+ }
445
+ render(width) {
446
+ return this.list.render(width);
447
+ }
448
+ invalidate() {
449
+ this.list.invalidate();
450
+ }
451
+ handleInput(data) {
452
+ const kb = getKeybindings();
453
+ const step = kb.matches(data, 'tui.select.down') ? 1
454
+ : kb.matches(data, 'tui.select.up') ? -1
455
+ : 0;
456
+ if (step === 0) {
457
+ this.list.handleInput(data);
458
+ return;
459
+ }
460
+ const n = this.selectable.length;
461
+ let target = this.index;
462
+ for (let moved = 1; moved <= n; moved++) {
463
+ target = (target + step + n) % n;
464
+ if (this.selectable[target]) {
465
+ for (let i = 0; i < moved; i++)
466
+ this.list.handleInput(data);
467
+ this.index = target;
468
+ return;
469
+ }
470
+ }
471
+ // Every row is scenery. Nothing to select, so nothing to move.
472
+ }
473
+ }
270
474
  /**
271
475
  * Builds the framed settings panel. Split out of the command handler so the
272
476
  * exact component the overlay shows can be rendered to a string in a test or a
273
477
  * preview script, rather than only being inspectable by opening the TUI.
274
478
  */
275
- export function createSettingsPanel(items, theme, onChange, onCancel) {
276
- const list = new SettingsList(items, MAX_VISIBLE, makeTheme(theme), onChange, onCancel);
277
- 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));
479
+ export function createSettingsPanel(items, theme,
480
+ /**
481
+ * Called with the row's id, its new value, and the LIST ITSELF.
482
+ *
483
+ * The list is handed back because some rows change what OTHER rows display:
484
+ * flipping `reasoning` to off means all seven `think:` rows now run at off,
485
+ * and a row's `currentValue` is a snapshot taken when the panel was built.
486
+ * Without a way to write the others back, the menu shows `reasoning off`
487
+ * beside seven rows still claiming `inherit` — which is what it did.
488
+ */
489
+ onChange, onCancel) {
490
+ // A row with no `values` is a header or the blank line above one.
491
+ const headerLabels = new Set(items.filter(i => i.values === undefined).map(i => i.label));
492
+ const list = new SettingsList(items, MAX_VISIBLE, makeTheme(theme, label => headerLabels.has(label.trimEnd())), (id, newValue) => onChange(id, newValue, list), onCancel);
493
+ return new BorderedBox(new SkipInertRows(list, items.map(i => (i.values?.length ?? 0) > 0)), 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
494
  }
279
495
  /** The full settings row list for the current config, in menu order. */
280
496
  export function panelItems(cfg, installed, tools = []) {
281
- return [
282
- ...ITEMS.map(({ id, label, description, values, format }) => ({
283
- id: id,
284
- label,
285
- description,
286
- currentValue: format(cfg),
287
- values: values ?? ['on', 'off']
288
- })),
289
- ...toolItems(tools, cfg.commandTimeoutExemptTools),
290
- ...extensionItems(installed, cfg.extensionWhitelist)
291
- ];
497
+ // The discovered rows belong to a section too — the per-tool watchdog
498
+ // exemptions under `timeouts` (they are exemptions FROM that timeout), and
499
+ // the per-extension toggles under their own heading.
500
+ const extra = {
501
+ reasoning: reasoningItems(cfg),
502
+ timeouts: toolItems(tools, cfg.commandTimeoutExemptTools),
503
+ extensions: extensionItems(installed, cfg.extensionWhitelist)
504
+ };
505
+ const out = [];
506
+ for (const { key, title } of SECTIONS) {
507
+ const rows = [
508
+ ...ITEMS.filter(i => i.section === key).map(({ id, label, description, values, format }) => ({
509
+ id: id,
510
+ label,
511
+ description,
512
+ currentValue: format(cfg),
513
+ values: values ?? ['on', 'off']
514
+ })),
515
+ ...(extra[key] ?? [])
516
+ ];
517
+ // An empty section prints no header. `extensions` has no fixed rows at
518
+ // all, so with nothing installed the heading would otherwise sit alone.
519
+ if (rows.length === 0)
520
+ continue;
521
+ if (out.length > 0)
522
+ out.push(sectionGap(title));
523
+ out.push(sectionHeader(title), ...rows);
524
+ }
525
+ return out;
292
526
  }
293
527
  async function handleTaskConfig(_args, ctx, getTools = () => []) {
294
528
  const cfg = {
295
529
  ...getConfig(),
296
530
  extensionWhitelist: [...getConfig().extensionWhitelist],
297
- commandTimeoutExemptTools: [...getConfig().commandTimeoutExemptTools]
531
+ commandTimeoutExemptTools: [...getConfig().commandTimeoutExemptTools],
532
+ // Copied for the same reason as the two arrays above: the panel mutates
533
+ // its own draft, and sharing the live object would apply half-made
534
+ // choices to running children before the user finished choosing.
535
+ reasoningLevels: { ...getConfig().reasoningLevels }
298
536
  };
299
537
  // Enumerated live at open so an installed extension appears and an
300
538
  // uninstalled one vanishes without pi-task doing any bookkeeping. A failed
@@ -307,22 +545,31 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
307
545
  if (ctx.mode !== 'tui') {
308
546
  // Reads the SAME `format` the panel does, so the two renderings cannot
309
547
  // disagree about what a setting currently says.
310
- const lines = ITEMS.map(({ label, format }) => `${label.padEnd(22)} ${format(cfg)}`);
311
- for (const t of tools) {
312
- const state = cfg.commandTimeoutExemptTools.includes(t.name) ? 'off' : 'on';
313
- lines.push(`${('watch: ' + t.name).padEnd(22)} ${state}`);
314
- }
315
- for (const e of installed) {
316
- const state = cfg.extensionWhitelist.includes(e.path) ? 'on' : 'off';
317
- lines.push(`${('ext: ' + e.label).padEnd(22)} ${state}`);
318
- }
548
+ // Built from panelItems, not a second hand-written walk of the same
549
+ // tables: the two renderings used to be able to disagree about what a
550
+ // setting said, and a headless run is the one place nobody would notice.
551
+ const lines = panelItems(cfg, installed, tools)
552
+ // The blank rows between sections are there to give the TUI air.
553
+ // One line of `|`-joined text has none to give, and an empty label
554
+ // would print as a stray `[]`.
555
+ .filter(i => i.label !== '')
556
+ .map(i => i.values === undefined ?
557
+ `[${i.label.trim()}]`
558
+ : `${i.label.padEnd(22)} ${i.currentValue}`);
319
559
  ctx.ui.notify(lines.join(' | '), 'info');
320
560
  return;
321
561
  }
322
- await ctx.ui.custom((_tui, theme, _kb, done) => createSettingsPanel(panelItems(cfg, installed, tools), theme, (id, newValue) => {
562
+ await ctx.ui.custom((_tui, theme, _kb, done) => createSettingsPanel(panelItems(cfg, installed, tools), theme, (id, newValue, list) => {
563
+ // Header rows carry no `values`, so SettingsList never
564
+ // cycles them and this can only be a real setting.
565
+ if (id.startsWith(SECTION_ID_PREFIX))
566
+ return;
323
567
  if (id.startsWith(EXT_ID_PREFIX)) {
324
568
  cfg.extensionWhitelist = applyExtensionToggle(cfg.extensionWhitelist, id.slice(EXT_ID_PREFIX.length), newValue === 'on');
325
569
  }
570
+ else if (id.startsWith(REASON_ID_PREFIX)) {
571
+ applyReasoningLevel(cfg, id.slice(REASON_ID_PREFIX.length), newValue);
572
+ }
326
573
  else if (id.startsWith(TOOL_ID_PREFIX)) {
327
574
  cfg.commandTimeoutExemptTools = applyToolToggle(cfg.commandTimeoutExemptTools, id.slice(TOOL_ID_PREFIX.length), newValue === 'on');
328
575
  }
@@ -334,6 +581,12 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
334
581
  // until someone noticed.
335
582
  ITEMS.find(item => item.id === id)?.apply(cfg, newValue);
336
583
  }
584
+ // The reasoning rows describe each other, so they are
585
+ // re-read from the config after every change — including
586
+ // changes to unrelated rows, which costs nothing and means
587
+ // there is no list of "changes that need a refresh" to keep
588
+ // correct.
589
+ refreshReasoningRows(cfg, list);
337
590
  saveConfig(cfg).catch(() => { });
338
591
  }, () => done(undefined)), { overlay: true, overlayOptions: { width: OVERLAY_WIDTH } });
339
592
  }
@@ -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[];