@mjasnikovs/pi-task 0.38.22 → 0.38.24

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 (42) hide show
  1. package/README.md +2 -2
  2. package/dist/config/reasoning-args.d.ts +12 -1
  3. package/dist/config/reasoning-args.js +5 -2
  4. package/dist/config/reasoning.d.ts +60 -2
  5. package/dist/config/reasoning.js +344 -37
  6. package/dist/config/register.d.ts +76 -32
  7. package/dist/config/register.js +124 -82
  8. package/dist/shared/reasoning-capability.d.ts +2 -5
  9. package/dist/shared/reasoning-capability.js +31 -4
  10. package/dist/task/auto-orchestrator.d.ts +2 -0
  11. package/dist/task/auto-orchestrator.js +28 -41
  12. package/dist/task/child-runner.d.ts +89 -24
  13. package/dist/task/child-runner.js +67 -46
  14. package/dist/task/orchestrator.d.ts +14 -20
  15. package/dist/task/orchestrator.js +12 -9
  16. package/dist/task/phases.d.ts +16 -23
  17. package/dist/task/phases.js +47 -452
  18. package/dist/task/question-dialog.d.ts +56 -0
  19. package/dist/task/question-dialog.js +53 -0
  20. package/dist/task/research-worker.d.ts +180 -0
  21. package/dist/task/research-worker.js +432 -0
  22. package/dist/workers/brave-warning.js +4 -30
  23. package/dist/workers/docs-core.d.ts +8 -4
  24. package/dist/workers/docs-core.js +30 -21
  25. package/dist/workers/docs-lookup.d.ts +72 -0
  26. package/dist/workers/docs-lookup.js +53 -0
  27. package/dist/workers/docs-project.d.ts +9 -0
  28. package/dist/workers/docs-project.js +15 -0
  29. package/dist/workers/pi-worker-core.d.ts +87 -1
  30. package/dist/workers/pi-worker-core.js +3 -7
  31. package/dist/workers/pi-worker-docs.js +27 -31
  32. package/dist/workers/reasoning-warning.d.ts +10 -16
  33. package/dist/workers/reasoning-warning.js +25 -57
  34. package/dist/workers/session-hint.d.ts +37 -0
  35. package/dist/workers/session-hint.js +82 -0
  36. package/dist/workers/worker-failure.d.ts +34 -0
  37. package/dist/workers/worker-failure.js +27 -16
  38. package/dist/workers/worker-kill.d.ts +84 -0
  39. package/dist/workers/worker-kill.js +124 -0
  40. package/package.json +1 -1
  41. package/dist/task/reasoning-groups.d.ts +0 -36
  42. package/dist/task/reasoning-groups.js +0 -36
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The docs TAIL — concatenate the chunks, extract against them, verify the
3
+ * citation, format the answer — written once, with the corpus as a row.
4
+ *
5
+ * WHY. This sequence existed three times: the project-source arm of
6
+ * `pi-worker-docs`, its package arm, and `docsFocused` in `docs-core`. The fetch
7
+ * channel proves the shape is avoidable — `fetchFocused` is one core and
8
+ * `pi-worker-fetch`'s `run` is 60 lines, while the docs registration was 293 for
9
+ * the same job over two corpora.
10
+ *
11
+ * The copies had drifted, in the place hand-flattening always drifts: the
12
+ * package arm's ERROR path dropped `autoInstallPin`, which both of its sibling
13
+ * paths keep — so a package that was auto-installed and then failed to re-resolve
14
+ * lost the `versionSource`/`declaredRange` provenance the last defect in this
15
+ * area was about.
16
+ *
17
+ * A CORPUS is what genuinely varies: the prompt, the header the answer is
18
+ * introduced by, and what an abort of it is called. Everything a corpus does NOT
19
+ * vary — where the content comes from, the version banner, the type-only
20
+ * detector, the details bag — stays with its caller, because those differ in kind
21
+ * and not in value.
22
+ */
23
+ import type { SpawnFn } from '../shared/child-process.js';
24
+ import { type FocusedAnswer, type FocusedFailure } from './focused-extractor.js';
25
+ /** The two corpora a docs lookup can read. A third (`page`) already has a prompt. */
26
+ export type DocsCorpusId = 'package' | 'project';
27
+ export interface DocsCorpus {
28
+ id: DocsCorpusId;
29
+ /** The extraction prompt for this corpus, over the concatenated content. */
30
+ buildPrompt: (query: string, content: string) => string;
31
+ /** The line the formatted answer is introduced by. */
32
+ header: string;
33
+ /** What an abort of this corpus's lookup is called, in the failure text. */
34
+ abortedMessage: string;
35
+ }
36
+ export interface DocsLookupInput {
37
+ corpus: DocsCorpus;
38
+ /** The retrieved chunks, in retrieval order. */
39
+ chunks: ReadonlyArray<{
40
+ content: string;
41
+ }>;
42
+ query: string;
43
+ cwd: string;
44
+ signal?: AbortSignal;
45
+ spawn?: SpawnFn;
46
+ /**
47
+ * The `extraction` group's `--thinking` fragment. Resolved by the CALLER so
48
+ * this module — like the extractor it wraps — never reads ambient config.
49
+ */
50
+ thinking: readonly string[];
51
+ }
52
+ export type DocsLookup = {
53
+ kind: 'answer';
54
+ /** The formatted answer: header, answer, and the verified excerpt. */
55
+ body: string;
56
+ /** Exactly what was prompted with, and what the citation was verified against. */
57
+ content: string;
58
+ extraction: FocusedAnswer;
59
+ /** Undefined when there was no excerpt to check. */
60
+ excerptVerified?: boolean;
61
+ } | {
62
+ kind: 'failed';
63
+ extraction: FocusedFailure;
64
+ };
65
+ /**
66
+ * Run one docs lookup over already-retrieved chunks.
67
+ *
68
+ * The citation is verified against exactly the text that was prompted with — the
69
+ * concatenation, not a superset. (`fetch` is the one site that verifies against a
70
+ * superset; see `FocusedRequest.verifyAgainst`.)
71
+ */
72
+ export declare function docsLookup(input: DocsLookupInput): Promise<DocsLookup>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The docs TAIL — concatenate the chunks, extract against them, verify the
3
+ * citation, format the answer — written once, with the corpus as a row.
4
+ *
5
+ * WHY. This sequence existed three times: the project-source arm of
6
+ * `pi-worker-docs`, its package arm, and `docsFocused` in `docs-core`. The fetch
7
+ * channel proves the shape is avoidable — `fetchFocused` is one core and
8
+ * `pi-worker-fetch`'s `run` is 60 lines, while the docs registration was 293 for
9
+ * the same job over two corpora.
10
+ *
11
+ * The copies had drifted, in the place hand-flattening always drifts: the
12
+ * package arm's ERROR path dropped `autoInstallPin`, which both of its sibling
13
+ * paths keep — so a package that was auto-installed and then failed to re-resolve
14
+ * lost the `versionSource`/`declaredRange` provenance the last defect in this
15
+ * area was about.
16
+ *
17
+ * A CORPUS is what genuinely varies: the prompt, the header the answer is
18
+ * introduced by, and what an abort of it is called. Everything a corpus does NOT
19
+ * vary — where the content comes from, the version banner, the type-only
20
+ * detector, the details bag — stays with its caller, because those differ in kind
21
+ * and not in value.
22
+ */
23
+ import { formatResultText } from '../shared/child-output.js';
24
+ import { runFocusedExtraction } from './focused-extractor.js';
25
+ /**
26
+ * Run one docs lookup over already-retrieved chunks.
27
+ *
28
+ * The citation is verified against exactly the text that was prompted with — the
29
+ * concatenation, not a superset. (`fetch` is the one site that verifies against a
30
+ * superset; see `FocusedRequest.verifyAgainst`.)
31
+ */
32
+ export async function docsLookup(input) {
33
+ const content = input.chunks.map(c => c.content).join('\n\n');
34
+ const extraction = await runFocusedExtraction({
35
+ prompt: input.corpus.buildPrompt(input.query, content),
36
+ verifyAgainst: content,
37
+ cwd: input.cwd,
38
+ signal: input.signal,
39
+ spawn: input.spawn,
40
+ thinking: input.thinking,
41
+ abortedMessage: input.corpus.abortedMessage
42
+ });
43
+ if (!extraction.ok)
44
+ return { kind: 'failed', extraction };
45
+ const excerptVerified = extraction.excerptVerified;
46
+ return {
47
+ kind: 'answer',
48
+ body: formatResultText(input.corpus.header, extraction, excerptVerified),
49
+ content,
50
+ extraction,
51
+ excerptVerified
52
+ };
53
+ }
@@ -1,6 +1,7 @@
1
1
  import type { CacheHandle } from './docs-cache.js';
2
2
  import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
3
3
  import type { RetrievedChunk } from './docs-retrieve.js';
4
+ import type { DocsCorpus } from './docs-lookup.js';
4
5
  export declare function getProjectName(cwd: string): string;
5
6
  export declare function cwdKey(cwd: string): string;
6
7
  /**
@@ -50,3 +51,11 @@ export declare function projectDocsRaw(cache: CacheHandle, cwd: string, query: s
50
51
  /** How to enumerate the project's sources. See getProjectFiles. */
51
52
  listFiles?: (cwd: string) => string[]): ProjectDocsRawResult;
52
53
  export declare function buildProjectPrompt(projectName: string, query: string, content: string): string;
54
+ /**
55
+ * The PROJECT corpus row: the current repo's own indexed `.ts`/`.tsx` source.
56
+ *
57
+ * Named after the project so a reader of the answer can tell a project-source
58
+ * citation from a package one at a glance — they are read and cited the same way
59
+ * and mean very different things.
60
+ */
61
+ export declare function projectCorpus(projectName: string): DocsCorpus;
@@ -208,3 +208,18 @@ export function buildProjectPrompt(projectName, query, content) {
208
208
  content
209
209
  });
210
210
  }
211
+ /**
212
+ * The PROJECT corpus row: the current repo's own indexed `.ts`/`.tsx` source.
213
+ *
214
+ * Named after the project so a reader of the answer can tell a project-source
215
+ * citation from a package one at a glance — they are read and cited the same way
216
+ * and mean very different things.
217
+ */
218
+ export function projectCorpus(projectName) {
219
+ return {
220
+ id: 'project',
221
+ buildPrompt: (query, content) => buildProjectPrompt(projectName, query, content),
222
+ header: `Per ${projectName} (project source):`,
223
+ abortedMessage: 'Project docs lookup aborted.'
224
+ };
225
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ContextSnapshot, type LoopHit, type SpawnFn } from '../shared/child-process.js';
2
+ import { RESTART_ORDER } from './worker-kill.js';
2
3
  /**
3
4
  * Tool calls that can GROUND an APIS claim — i.e. return content a signature or
4
5
  * command could be cited from. `pi-worker-docs` (the primary), `read` and `grep`
@@ -246,7 +247,7 @@ export interface RunWorkerInput {
246
247
  * Why an attempt was thrown away. One value per restart branch in runWorker, so
247
248
  * a log line naming the reason points at exactly one piece of code.
248
249
  */
249
- export type WorkerRestartReason = 'loop' | 'command-timeout' | 'stream-stall' | 'worker-timeout' | 'connection-error' | 'leaked-tool-call';
250
+ export type WorkerRestartReason = (typeof RESTART_ORDER)[number];
250
251
  /** One DISCARDED attempt: its cause and the wall clock it consumed and lost. */
251
252
  export interface WorkerRestart {
252
253
  /** 1-based number of the attempt being discarded (the 1st restart ends attempt 1). */
@@ -411,4 +412,89 @@ export interface RunWorkerResult {
411
412
  * itself, or a caller asking for 10s would silently get 30.
412
413
  */
413
414
  export declare function commandCeilingForAttempt(baseMs: number, priorHangs: number): number;
415
+ /**
416
+ * Everything the restart ladder reads about one finished attempt, plus the
417
+ * budgets it draws on. Assembled once per attempt so the rules below can be
418
+ * module-level data instead of six `if` blocks welded into `runWorker`'s closure.
419
+ */
420
+ interface RestartState {
421
+ loopHit?: LoopHit;
422
+ commandKill?: CommandKill;
423
+ streamStalled?: {
424
+ idleMs: number;
425
+ };
426
+ timedOut: boolean;
427
+ modelError?: string;
428
+ leaked: string | null;
429
+ /** The cap this attempt actually died against — the SCALE arm moves it. */
430
+ effectiveCapMs: number;
431
+ /** The child's tool string, which decides whether its edits can persist. */
432
+ tools: string;
433
+ restartBudgetSpent: number;
434
+ connRetries: number;
435
+ connectionRetries: number;
436
+ leakRetries: number;
437
+ }
438
+ /** What a rule does to the budgets when it fires. */
439
+ interface RestartCounters {
440
+ /** Consume one of the shared loop/timeout/connection restarts. */
441
+ shared?: boolean;
442
+ /** Consume one of the leaked-tool-call retries (a separate budget). */
443
+ leak?: boolean;
444
+ /** Count a WATCHDOG kill specifically — drives the command-ceiling halving. */
445
+ hang?: boolean;
446
+ /** Count a CONNECTION restart specifically — drives the backoff schedule. */
447
+ connection?: boolean;
448
+ }
449
+ /**
450
+ * One restartable failure: how to spot it, what to tell the fresh child, which
451
+ * budget it spends, and how long to wait first.
452
+ */
453
+ interface RestartRule {
454
+ reason: WorkerRestartReason;
455
+ /**
456
+ * Does this rule apply to the attempt, and is its budget unspent? Returns
457
+ * the restart's detail line, or null to fall through to the next rule.
458
+ *
459
+ * Detection and budget are ONE test on purpose. An out-of-budget failure must
460
+ * fall through to the return path, not stop the ladder — a loop kill with the
461
+ * shared budget spent still has to let the plain-abort return happen.
462
+ */
463
+ detect: (s: RestartState) => {
464
+ detail: string;
465
+ } | null;
466
+ /**
467
+ * The corrective preamble prepended to the next attempt's prompt. Omitted by
468
+ * `connection-error` alone: nothing the model did caused a dropped socket, so
469
+ * there is nothing to correct — and any hint already in flight from an
470
+ * earlier restart must survive the retry rather than be cleared by it.
471
+ */
472
+ hint?: (s: RestartState) => string;
473
+ counters: RestartCounters;
474
+ /** Backoff before re-spawning, in ms. Only the connection rule waits. */
475
+ backoffMs?: (s: RestartState) => number;
476
+ }
477
+ /**
478
+ * The restart ladder, in precedence order. FIRST MATCH WINS.
479
+ *
480
+ * Read the `!loopHit` guards as "a loop kill outranks me even when it has no
481
+ * budget left". They are not redundant with row order: when a loop is detected
482
+ * but the shared budget is spent, row 1 declines, and without those guards row 2
483
+ * or 4 would then restart the same runaway child under a hint that does not
484
+ * describe why it died.
485
+ *
486
+ * The whole ritual — check the budget, set the hint, spend the counters, record
487
+ * and announce the discarded attempt, sleep, re-spawn — belongs to the loop in
488
+ * `runWorker`, so a new failure mode is one row here and cannot be added without
489
+ * becoming visible in `restarts`.
490
+ */
491
+ export declare const RESTART_RULES: readonly RestartRule[];
492
+ /** What the command watchdog recorded when it killed an attempt. */
493
+ interface CommandKill {
494
+ toolName: string;
495
+ timeoutMs: number;
496
+ /** The command line itself, when the tool carried one — quoted into the hint
497
+ * so the fresh child knows which call it must not repeat unbounded. */
498
+ detail?: string;
499
+ }
414
500
  export declare function runWorker(input: RunWorkerInput): Promise<RunWorkerResult>;
@@ -10,6 +10,7 @@ import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../s
10
10
  import { discoverModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
11
11
  import { streamStallHint } from '../shared/stream-watchdog.js';
12
12
  import { classifyWorkerFailure } from './worker-failure.js';
13
+ import { CARRY_FORWARD_IDS } from './worker-kill.js';
13
14
  // `--mode json` makes pi emit structured events as they happen instead of
14
15
  // buffering the assistant text and flushing on exit. That matters for the
15
16
  // wait/work timing split: in text mode the first stdout chunk only arrives at
@@ -89,12 +90,7 @@ const CARRY_FORWARD_LIMIT = 24_000;
89
90
  * first is by definition the same call repeated, the second is malformed
90
91
  * protocol text, and replaying either would feed the failure back to itself.
91
92
  */
92
- const CARRY_FORWARD_REASONS = new Set([
93
- 'worker-timeout',
94
- 'command-timeout',
95
- 'stream-stall',
96
- 'connection-error'
97
- ]);
93
+ const CARRY_FORWARD_REASONS = CARRY_FORWARD_IDS;
98
94
  /**
99
95
  * Does this partial output carry ANSWER CONTENT, or is it the model clearing its
100
96
  * throat?
@@ -276,7 +272,7 @@ export function commandCeilingForAttempt(baseMs, priorHangs) {
276
272
  * `runWorker`, so a new failure mode is one row here and cannot be added without
277
273
  * becoming visible in `restarts`.
278
274
  */
279
- const RESTART_RULES = [
275
+ export const RESTART_RULES = [
280
276
  {
281
277
  // A loop-kill gets the same restart-with-hint treatment every other phase
282
278
  // already gets (runPhaseChild) — name the offending call so the
@@ -3,15 +3,15 @@ import { Type } from '@sinclair/typebox';
3
3
  import { Text } from '@earendil-works/pi-tui';
4
4
  import { openCache as defaultOpenCache } from './docs-cache.js';
5
5
  import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
6
- import { formatResultText } from '../shared/child-output.js';
7
- import { docsRaw, packageHeader, buildPrompt, buildVersionBanner } from './docs-core.js';
6
+ import { docsLookup } from './docs-lookup.js';
7
+ import { projectCorpus } from './docs-project.js';
8
+ import { docsRaw, packageCorpus, buildVersionBanner } from './docs-core.js';
8
9
  import { formatNpmVersionSection } from './npm-version.js';
9
- import { runFocusedExtraction } from './focused-extractor.js';
10
10
  import { childFailureReason, makeWorkerTool, workerAnswer, workerUnavailable } from './shared.js';
11
11
  import { isTypeOnlyAnswer } from '../task/type-only-answer.js';
12
12
  import { logDocsAnswer } from './typeonly-log.js';
13
13
  import { normalizeQuery } from './research-cache.js';
14
- import { projectDocsRaw, buildProjectPrompt } from './docs-project.js';
14
+ import { projectDocsRaw } from './docs-project.js';
15
15
  import { projectDocsBudget, projectDocsBudgetExhausted } from '../task/research-fanout-budget.js';
16
16
  import { isAbstention } from './abstention.js';
17
17
  import { groupThinkingArgs } from '../config/reasoning-args.js';
@@ -128,20 +128,18 @@ export function registerPiWorkerDocs(pi, internals = {}) {
128
128
  // It was DEAD in production (pi runs under node) and BYPASSED under bun test
129
129
  // (internals.spawn is always injected), i.e. untested, unreachable, and wrong.
130
130
  const spawn = internals.spawn ?? defaultSpawn;
131
- // Both paths below run the SAME extraction against this call's cwd/signal/spawn
132
- // and verify the citation against exactly the content they prompted with; only
133
- // the prompt body and the abort wording differ. (fetch is the site that verifies
134
- // against a superset see FocusedRequest.verifyAgainst.)
135
- const extract = (prompt, content, abortedMessage) => runFocusedExtraction({
136
- prompt,
137
- verifyAgainst: content,
131
+ // Both arms below run the SAME tail concatenate, extract, verify,
132
+ // format through `docsLookup`; only the CORPUS differs. The
133
+ // `extraction` group's level is resolved here so neither the lookup
134
+ // nor the extractor reads ambient config.
135
+ const lookup = (corpus, chunks) => docsLookup({
136
+ corpus,
137
+ chunks,
138
+ query: params.query,
138
139
  cwd: ctx.cwd,
139
140
  signal,
140
141
  spawn,
141
- // The `extraction` group's level. Resolved at the call site so the
142
- // extractor itself never reads ambient config.
143
- thinking: groupThinkingArgs('extraction'),
144
- abortedMessage
142
+ thinking: groupThinkingArgs('extraction')
145
143
  });
146
144
  // ── Project source lookup ───────────────────────────────────────
147
145
  if (params.module === '.') {
@@ -182,12 +180,10 @@ export function registerPiWorkerDocs(pi, internals = {}) {
182
180
  indexedFiles: filesIngested,
183
181
  indexingMs
184
182
  };
185
- const concatenated = chunks.map(c => c.content).join('\n\n');
186
- const extraction = await extract(buildProjectPrompt(projectName, params.query, concatenated), concatenated, 'Project docs lookup aborted.');
187
- if (!extraction.ok)
188
- return docsFailureResult(extraction, baseDetails, '');
189
- const verified = extraction.excerptVerified;
190
- const text = formatResultText(`Per ${projectName} (project source):`, extraction, verified);
183
+ const r = await lookup(projectCorpus(projectName), chunks);
184
+ if (r.kind === 'failed')
185
+ return docsFailureResult(r.extraction, baseDetails, '');
186
+ const { extraction, excerptVerified: verified, body: text } = r;
191
187
  // SAME instrumentation channel as the package path below, extended to the
192
188
  // project-source branch because that branch is the MAJORITY of what
193
189
  // worker:apis asks — 13 of 17 docs calls in run 15's fatal task, 7 of 12 in
@@ -243,14 +239,16 @@ export function registerPiWorkerDocs(pi, internals = {}) {
243
239
  hitCache: rawResult.hitCache,
244
240
  cacheError: rawResult.cacheError,
245
241
  autoInstalled: rawResult.autoInstalled,
242
+ // BUG FIX. Both sibling arms carry the pin and this one dropped
243
+ // it, so a package that WAS auto-installed and then failed to
244
+ // re-resolve lost its `versionSource`/`declaredRange` — the
245
+ // provenance the last defect in this area was about. `docsRaw`
246
+ // sets `autoInstallPin` on three of its five error returns.
247
+ ...pinDetails(rawResult.autoInstallPin),
246
248
  ...npmDetails
247
249
  };
248
250
  return workerUnavailable(npmHeader + rawResult.message, details, 'docs-error');
249
251
  }
250
- if (rawResult.kind === 'not_installed') {
251
- return workerUnavailable(npmHeader
252
- + `Package "${rawResult.pkg}" is not installed and auto-install failed.`, { resolveError: 'not_installed', ...npmDetails }, 'not-installed');
253
- }
254
252
  if (rawResult.kind === 'no_chunks') {
255
253
  const banner = buildVersionBanner(rawResult.autoInstallPin, rawResult.pkg.name, rawResult.pkg.version, ctx.cwd);
256
254
  // The package resolved and genuinely ships nothing to read — an
@@ -280,13 +278,11 @@ export function registerPiWorkerDocs(pi, internals = {}) {
280
278
  ...pinDetails(rawResult.autoInstallPin),
281
279
  ...npmDetails
282
280
  };
283
- const concatenated = chunks.map(c => c.content).join('\n\n');
284
- const extraction = await extract(buildPrompt(pkg, params.query, concatenated), concatenated, 'Docs lookup aborted.');
285
- if (!extraction.ok) {
286
- return docsFailureResult(extraction, baseDetails, versionBanner + npmHeader);
281
+ const r = await lookup(packageCorpus(pkg), chunks);
282
+ if (r.kind === 'failed') {
283
+ return docsFailureResult(r.extraction, baseDetails, versionBanner + npmHeader);
287
284
  }
288
- const verified = extraction.excerptVerified;
289
- const body = formatResultText(packageHeader(pkg), extraction, verified);
285
+ const { extraction, excerptVerified: verified, body, content: concatenated } = r;
290
286
  // F-2: a TYPE-ONLY answer is the dangerous failure. "unclear from this package"
291
287
  // is honest and already escalates; a signature is a well-formed, confident,
292
288
  // on-topic answer that names the very parameter asked about, so the worker
@@ -20,22 +20,9 @@
20
20
  * model and nothing renders at all.
21
21
  */
22
22
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
23
- import { type PiTaskConfig } from '../config/config.js';
24
- import { REASONING_GROUPS, resolveReasoning } from '../config/reasoning.js';
23
+ import { type GroupSetting, type ReasoningGroup } from '../config/reasoning.js';
25
24
  import { type ReasoningMismatch } from '../shared/reasoning-capability.js';
26
- /** Every group's current setting, in the shape `reasoningMismatches` wants. */
27
- export type GroupSettings = Array<{
28
- group: (typeof REASONING_GROUPS)[number];
29
- setting: ReturnType<typeof resolveReasoning>;
30
- }>;
31
- /**
32
- * Read every group's effective setting from a config.
33
- *
34
- * Takes the config rather than calling `getConfig()` so the caller decides where
35
- * it comes from. A test that has to mutate the live singleton to drive this is a
36
- * test whose result depends on whatever the developer had saved before it ran.
37
- */
38
- export declare function settingsFrom(cfg: PiTaskConfig): GroupSettings;
25
+ import { type ChatTemplateCaps } from '../shared/model-endpoint.js';
39
26
  /**
40
27
  * The warning line for a set of mismatches.
41
28
  *
@@ -61,4 +48,11 @@ export declare function registerReasoningWarning(pi: ExtensionAPI,
61
48
  * `session_start` so a /task-config change since the last session counts.
62
49
  * Injected by tests, which must not depend on the developer's saved config.
63
50
  */
64
- readSettings?: () => GroupSettings): void;
51
+ readSettings?: () => Readonly<Record<ReasoningGroup, GroupSetting>>,
52
+ /**
53
+ * The server-side chat-template probe. Injected so the REFINE path — the
54
+ * only half of this hint that talks to a network — is drivable at all; with
55
+ * the real probe it is reachable only from a model entry carrying a
56
+ * `baseUrl`, which no test model has.
57
+ */
58
+ probe?: (baseUrl: string) => Promise<ChatTemplateCaps | null>): void;
@@ -20,20 +20,11 @@
20
20
  * model and nothing renders at all.
21
21
  */
22
22
  import { getConfig } from '../config/config.js';
23
- import { REASONING_GROUPS, resolveReasoning } from '../config/reasoning.js';
23
+ import { effectiveReasoning } from '../config/reasoning.js';
24
24
  import { reasoningMismatches } from '../shared/reasoning-capability.js';
25
25
  import { probeChatTemplateCaps } from '../shared/model-endpoint.js';
26
+ import { registerSessionHint } from './session-hint.js';
26
27
  const WIDGET_KEY = 'pi-task-reasoning-warning';
27
- /**
28
- * Read every group's effective setting from a config.
29
- *
30
- * Takes the config rather than calling `getConfig()` so the caller decides where
31
- * it comes from. A test that has to mutate the live singleton to drive this is a
32
- * test whose result depends on whatever the developer had saved before it ran.
33
- */
34
- export function settingsFrom(cfg) {
35
- return REASONING_GROUPS.map(group => ({ group, setting: resolveReasoning(group, cfg) }));
36
- }
37
28
  /**
38
29
  * The warning line for a set of mismatches.
39
30
  *
@@ -83,60 +74,37 @@ export function registerReasoningWarning(pi,
83
74
  * `session_start` so a /task-config change since the last session counts.
84
75
  * Injected by tests, which must not depend on the developer's saved config.
85
76
  */
86
- readSettings = () => settingsFrom(getConfig())) {
87
- pi.on('session_start', (_event, ctx) => {
88
- // Terminal-only hint: needs an interactive TUI to render and to catch the
89
- // keystroke that dismisses it.
90
- if (ctx.mode !== 'tui')
91
- return;
77
+ readSettings = () => effectiveReasoning(getConfig()),
78
+ /**
79
+ * The server-side chat-template probe. Injected so the REFINE path the
80
+ * only half of this hint that talks to a network — is drivable at all; with
81
+ * the real probe it is reachable only from a model entry carrying a
82
+ * `baseUrl`, which no test model has.
83
+ */
84
+ probe = probeChatTemplateCaps) {
85
+ registerSessionHint(pi, WIDGET_KEY, ctx => {
92
86
  const model = ctx.model;
93
87
  const mismatches = reasoningMismatches(model, readSettings());
94
88
  if (mismatches.length === 0)
95
- return;
89
+ return null;
96
90
  const base = formatReasoningWarning(model?.name ?? model?.id ?? 'unknown', mismatches);
97
91
  if (base === null)
98
- return;
99
- let unsubscribe = null;
100
- let cleared = false;
101
- const clear = () => {
102
- cleared = true;
103
- try {
104
- ctx.ui.setWidget(WIDGET_KEY, undefined);
105
- }
106
- catch {
107
- /* stale ctx after a session switch — nothing to clear */
108
- }
109
- unsubscribe?.();
110
- unsubscribe = null;
111
- };
112
- const render = (text) => {
113
- try {
114
- ctx.ui.setWidget(WIDGET_KEY, [ctx.ui.theme.fg('warning', text)]);
115
- return true;
116
- }
117
- catch {
118
- return false;
119
- }
120
- };
121
- if (!render(base))
122
- return;
123
- unsubscribe = ctx.ui.onTerminalInput(() => {
124
- clear();
125
- return undefined;
126
- });
92
+ return null;
127
93
  // Fire-and-forget: the server probe only ever REFINES the cause line, so
128
94
  // it must not delay the warning or be able to prevent it. A 2s budget and
129
95
  // a swallowed failure mean a non-llama.cpp backend costs nothing.
130
- if (model?.baseUrl) {
131
- void probeChatTemplateCaps(model.baseUrl)
132
- .then(caps => {
133
- if (cleared || caps === null)
134
- return;
135
- const extra = formatCapabilityConflict(caps.supportsReasoningEffort, model.reasoning);
136
- if (extra)
137
- render(base + extra);
96
+ const baseUrl = model?.baseUrl;
97
+ if (model === undefined || baseUrl === undefined || baseUrl === '')
98
+ return { text: base };
99
+ const declares = model.reasoning;
100
+ return {
101
+ text: base,
102
+ refine: probe(baseUrl).then(caps => {
103
+ if (caps === null)
104
+ return null;
105
+ const extra = formatCapabilityConflict(caps.supportsReasoningEffort, declares);
106
+ return extra === null ? null : base + extra;
138
107
  })
139
- .catch(() => { });
140
- }
108
+ };
141
109
  });
142
110
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * A one-line startup hint in the TUI, and the whole widget lifetime around it.
3
+ *
4
+ * WHY IT IS ONE MODULE. Two hints exist (brave-warning, reasoning-warning) and
5
+ * both had written the same ritual out: the `session_start` subscription, the
6
+ * TUI gate, the `setWidget` in a try/catch, the `onTerminalInput` that clears on
7
+ * the first keystroke, the unsubscribe, and the swallow for a stale ctx — down
8
+ * to a byte-identical comment. Two adapters is a real seam, so the ritual lives
9
+ * here once and each hint supplies only its `compose`.
10
+ *
11
+ * The REFINE half is why this is not just deduplication. A hint may learn
12
+ * something after it has painted (the reasoning hint probes the model's server),
13
+ * and the rule that a refinement must never repaint a widget the user already
14
+ * dismissed lived in one closure variable in one of the two files. It is now a
15
+ * property of this module, asserted once.
16
+ */
17
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
18
+ export interface SessionHint {
19
+ /** The line to paint now. */
20
+ text: string;
21
+ /**
22
+ * A later refinement of that line, if this hint has one. Resolving to null —
23
+ * or rejecting — leaves the first line standing, so a refinement can never
24
+ * remove a warning it was only meant to sharpen. It is fire-and-forget: it
25
+ * cannot delay or prevent the first paint, and it is dropped if the user has
26
+ * already cleared the hint.
27
+ */
28
+ refine?: Promise<string | null>;
29
+ }
30
+ /**
31
+ * Register one startup hint.
32
+ *
33
+ * `compose` is the seam: it runs at `session_start` inside the TUI gate and
34
+ * returns the hint, or null to say nothing at all. Everything it returns is
35
+ * text; nothing about widgets, keystrokes or teardown reaches it.
36
+ */
37
+ export declare function registerSessionHint(pi: ExtensionAPI, key: string, compose: (ctx: ExtensionContext) => SessionHint | null): void;