@mjasnikovs/pi-task 0.38.23 → 0.38.25

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 (48) 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 +47 -6
  5. package/dist/config/reasoning.js +84 -9
  6. package/dist/config/register.d.ts +50 -26
  7. package/dist/config/register.js +96 -80
  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/gate-child.js +11 -11
  15. package/dist/task/orchestrator.d.ts +14 -20
  16. package/dist/task/orchestrator.js +12 -9
  17. package/dist/task/phases.d.ts +0 -23
  18. package/dist/task/phases.js +48 -464
  19. package/dist/task/question-dialog.d.ts +56 -0
  20. package/dist/task/question-dialog.js +53 -0
  21. package/dist/task/research-fanout-budget.d.ts +20 -0
  22. package/dist/task/research-fanout-budget.js +29 -0
  23. package/dist/task/research-worker.d.ts +183 -0
  24. package/dist/task/research-worker.js +429 -0
  25. package/dist/workers/brave-warning.js +4 -30
  26. package/dist/workers/docs-core.d.ts +8 -4
  27. package/dist/workers/docs-core.js +30 -21
  28. package/dist/workers/docs-lookup.d.ts +72 -0
  29. package/dist/workers/docs-lookup.js +53 -0
  30. package/dist/workers/docs-project.d.ts +9 -0
  31. package/dist/workers/docs-project.js +15 -0
  32. package/dist/workers/pi-worker-core.d.ts +112 -109
  33. package/dist/workers/pi-worker-core.js +33 -48
  34. package/dist/workers/pi-worker-docs.js +27 -31
  35. package/dist/workers/pi-worker.js +6 -0
  36. package/dist/workers/reasoning-warning.d.ts +10 -16
  37. package/dist/workers/reasoning-warning.js +25 -57
  38. package/dist/workers/session-hint.d.ts +37 -0
  39. package/dist/workers/session-hint.js +82 -0
  40. package/dist/workers/worker-failure.d.ts +34 -0
  41. package/dist/workers/worker-failure.js +27 -16
  42. package/dist/workers/worker-kill.d.ts +84 -0
  43. package/dist/workers/worker-kill.js +124 -0
  44. package/dist/workers/worker-profiles.d.ts +314 -0
  45. package/dist/workers/worker-profiles.js +220 -0
  46. package/package.json +1 -1
  47. package/dist/task/reasoning-groups.d.ts +0 -36
  48. package/dist/task/reasoning-groups.js +0 -36
@@ -0,0 +1,429 @@
1
+ /**
2
+ * ONE research worker, cache-skip to persist.
3
+ *
4
+ * WHY IT IS A MODULE. This was a 228-line closure inside `phaseResearch` over
5
+ * eleven locals, and inside it live the three RETRY GATES and their precedence:
6
+ * the EMPTY-SECTION gate (the only one that can fail the phase), the
7
+ * ZERO-RETRIEVAL gate and the SILENT gate (both of which discard a failed retry
8
+ * and ship the original). Getting that order wrong is how a run either dies on a
9
+ * legitimately empty section or ships one written from memory.
10
+ *
11
+ * The cost was in the TESTS. Reaching the gates meant a temp dir, a real task
12
+ * file, and a fake spawn routed on prose lifted out of `prompts.ts` — plus, for
13
+ * attempt-1-vs-attempt-2, a second sentence lifted out of a module-private
14
+ * preamble constant. In a codebase whose whole workflow is re-wording prompts and
15
+ * measuring what changed, that means a reworded preamble silently stops the gate
16
+ * tests from testing the gate. Behind this interface a test scripts
17
+ * `runWorker(label, attempt)` and states the `RunWorkerResult` fields a gate
18
+ * reads.
19
+ *
20
+ * THE INVARIANT, stated once: the empty gate runs FIRST and is the only one that
21
+ * can throw; the other two keep the original when their retry does not improve a
22
+ * named measure; and `confirmedEmpty` suppresses the silent gate, because that
23
+ * retry already asked "you wrote nothing" and got the same answer.
24
+ */
25
+ import { classifyWorkerFailure } from '../workers/worker-failure.js';
26
+ import { classifyContextSilence, countBullets } from './context-silence.js';
27
+ /**
28
+ * Task-file heading under which a research worker's validated output is cached.
29
+ * A resumed research phase reads these to skip workers that already succeeded,
30
+ * instead of re-running all four from scratch when one of them fails — the
31
+ * expensive case (e.g. 3 healthy workers thrown away because the 4th looped).
32
+ */
33
+ export function researchWorkerCacheHeading(section) {
34
+ return `research worker ${section}`;
35
+ }
36
+ /**
37
+ * Classify a research worker's result so the phase can react per-worker instead
38
+ * of treating every failure the same. Two distinct failure shapes:
39
+ *
40
+ * - 'runaway' (loop-kill OR per-worker wall-clock timeout): the worker explored
41
+ * too long and was killed *after* burning its MAX_LOOP_RESTARTS restarts. It
42
+ * did real work and left partial text; the other three workers are unaffected.
43
+ * Failing the whole task here would throw away every already-good worker AND
44
+ * abort the entire auto-run over the weakest section — and because the loop is
45
+ * deterministic, a resume just re-loops and re-fails. So this DEGRADES: keep
46
+ * the partial answer (marked), cache it, move on. A loop-kill is a SIGTERM
47
+ * (exit 143) OR a clean exit 0 with truncated text, so loopHit/timedOut — not
48
+ * exitCode — are the reliable signal and are checked first.
49
+ *
50
+ * - 'fatal' (non-zero exit that isn't a loop-kill, a provider error behind an
51
+ * empty answer, or a leaked never-executed tool call): the output is
52
+ * untrustworthy in a way partial text can't paper over (broken env, model
53
+ * disconnect, wrong tool-call dialect). These still throw — degrading them
54
+ * would launder a real breakage into a plausible-looking section.
55
+ *
56
+ * - 'empty' (clean exit 0, no provider error, no loop/timeout — the model simply
57
+ * wrote nothing): NOT a failure. On an extremely simple task ("create a folder
58
+ * with an index.html in it") three of the four workers have genuinely nothing
59
+ * to report, and each worker prompt tells the model to emit ONLY what this task
60
+ * touches and to drop everything else — so silence is the CORRECT answer and
61
+ * was killing the whole task at research (issue #10). Measured live on the
62
+ * issue's own prompt (30 reps/worker, local Qwen3.6-27B): every APIS answer was
63
+ * semantically "there is nothing here", and 2/30 were literally zero bytes on a
64
+ * clean exit — the other 28 survived only because the model happened to wrap the
65
+ * same non-answer in a parenthetical, which is model style, not signal. The
66
+ * caller retries once and then accepts an explicit empty section; what stays
67
+ * fatal is silence WITH a reported cause, which is the masked-disconnect case
68
+ * this branch was written for and which `modelError` now names outright.
69
+ *
70
+ * Returns null when the result is trustworthy.
71
+ */
72
+ export function classifyResearchWorker(name, result) {
73
+ // What KILLED the child, if anything — classified once, in the ladder that
74
+ // owns the precedence (workers/worker-failure.ts), because every kill path
75
+ // also sets `aborted` and a non-zero exit. This switch says only what each
76
+ // cause means to RESEARCH; being exhaustive, a new cause is a compile error
77
+ // here instead of falling through to the generic "exit N".
78
+ const failure = classifyWorkerFailure(result);
79
+ if (failure) {
80
+ switch (failure.kind) {
81
+ case 'loop': {
82
+ const argsStr = JSON.stringify(failure.hit.call.args);
83
+ return {
84
+ kind: 'runaway',
85
+ reason: `stuck in a loop — called ${failure.hit.call.name}(${argsStr}) `
86
+ + `×${failure.hit.count} in the last ${failure.hit.windowSize} calls `
87
+ + `and still looped after restarts`
88
+ };
89
+ }
90
+ case 'worker-timeout':
91
+ return { kind: 'runaway', reason: 'timed out after restarts' };
92
+ case 'command-timeout':
93
+ return {
94
+ kind: 'runaway',
95
+ reason: `ran a \`${failure.toolName}\` command that never returned and was killed `
96
+ + 'after restarts'
97
+ };
98
+ case 'stream-stall':
99
+ return {
100
+ kind: 'runaway',
101
+ reason: `model stream went silent for ${failure.idleMs}ms after restarts`
102
+ };
103
+ case 'stalled':
104
+ return {
105
+ kind: 'fatal',
106
+ error: new Error(`Research ${name} worker: model server unreachable — the child produced no `
107
+ + 'output and the model endpoint did not respond')
108
+ };
109
+ case 'leaked-tool-call':
110
+ return {
111
+ kind: 'fatal',
112
+ error: new Error(`Research ${name} worker wrote a tool call as text instead of invoking it `
113
+ + `(${failure.text.trim()}) — it never ran`)
114
+ };
115
+ case 'aborted':
116
+ case 'exit':
117
+ return {
118
+ kind: 'fatal',
119
+ error: new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`)
120
+ };
121
+ }
122
+ }
123
+ if (result.text.trim().length === 0) {
124
+ // NOTHING CAME BACK — two different events wear the same face, and the whole
125
+ // point of this branch is to tell them apart:
126
+ //
127
+ // FAILED, cause reported: pi delivers a failed turn as an empty assistant
128
+ // message with stopReason "error" and exit 0, so the real cause used to be
129
+ // discarded and reported as the useless "produced no output". Name it.
130
+ // FAILED, child never spoke: no stdout at all means the child died before it
131
+ // could run (unresolvable provider, missing key, bad argv) — it never
132
+ // answered, so it cannot have answered "nothing".
133
+ // EMPTY: a child that streamed, exited 0, reported no error, and wrote no
134
+ // answer. The worker ran and the model had nothing to say — a real answer
135
+ // on a task that touches nothing, not a failure.
136
+ if (result.modelError) {
137
+ return {
138
+ kind: 'fatal',
139
+ error: new Error(`Research ${name} worker: model error — ${result.modelError.slice(0, 200)}`)
140
+ };
141
+ }
142
+ if (!result.sawOutput) {
143
+ return {
144
+ kind: 'fatal',
145
+ error: new Error(`Research ${name} worker produced no output — the child never wrote a `
146
+ + 'single byte, so it died before it could answer'
147
+ + (result.stderr ? `: ${result.stderr.slice(-300)}` : ''))
148
+ };
149
+ }
150
+ return { kind: 'empty' };
151
+ }
152
+ return null;
153
+ }
154
+ /**
155
+ * Build a degraded section body for a runaway worker: a one-line marker naming
156
+ * the failure (so downstream phases and a human reading the task file know this
157
+ * section is incomplete) followed by whatever partial answer the worker streamed
158
+ * before it was killed. The marker is always present even when there is no
159
+ * partial text, so an empty degrade is never mistaken for a real finding.
160
+ */
161
+ export function degradedSectionBody(name, reason, partial) {
162
+ const marker = `(degraded: research ${name} worker ${reason}; this section may be incomplete)`;
163
+ const body = partial.trim();
164
+ return body.length > 0 ? `${marker}\n\n${body}` : marker;
165
+ }
166
+ /**
167
+ * The body written for a research section the worker confirmed has no entries.
168
+ *
169
+ * Three states have to stay distinguishable to anyone — human or later phase —
170
+ * reading a research section, so each carries its own marker:
171
+ * `(none — …)` the worker RAN and answered "nothing applies" (this)
172
+ * `(degraded: …)` the worker was killed mid-answer, text may be partial
173
+ * (degradedSectionBody)
174
+ * section absent the worker never got that far — the phase threw
175
+ *
176
+ * Naming the worker inside the marker keeps it true after assembly, where the
177
+ * section headings are all that separate the four workers' output.
178
+ */
179
+ export function emptySectionBody(name) {
180
+ return `(none — the ${name} worker ran and reported no entries for this task)`;
181
+ }
182
+ /**
183
+ * A worker answer that IS the word "nothing" and carries no other content:
184
+ * `(none)`, `N/A`, `- none`, `(no content)`, `(no entries)`. Live workers write
185
+ * these often on a task that touches nothing (measured on the issue's prompt:
186
+ * `(no content)`, `(no response)`, a bare `(none)` from the gate's own retry),
187
+ * and each one means exactly what an empty answer means — so they are recorded
188
+ * with the same marker rather than passed through in whatever shape the model
189
+ * happened to pick. Deliberately NARROW: it matches only a lone token, never
190
+ * prose like "(no APIs to list — this task creates a plain HTML file …)", which
191
+ * carries a reason worth keeping.
192
+ */
193
+ const BARE_NONE_ANSWER = /^[-*\s]*\(?\s*(?:none|n\/?a|nothing|no (?:content|entries|response|items|results))\s*\.?\s*\)?\s*$/i;
194
+ export function isBareNoneAnswer(text) {
195
+ return BARE_NONE_ANSWER.test(text.trim());
196
+ }
197
+ /**
198
+ * Prepended on the ONE retry the empty-section gate triggers. A zero-byte answer is
199
+ * ambiguous — a crashed worker looks exactly like a worker with nothing to say — so
200
+ * the retry's only job is to remove the ambiguity: answer properly, or say "(none)"
201
+ * in as many words.
202
+ *
203
+ * It must NOT turn into an invitation to skip the work: `(none)` is offered only
204
+ * behind an explicit "after you have looked" condition, because this retry also
205
+ * fires on a normal project where the first attempt died for an unrelated reason,
206
+ * and an easy opt-out there would silence real research.
207
+ *
208
+ * MEASUREMENT OPEN. The recovery path's QUALITY on a real repo is being measured
209
+ * (scripts live under /home/edgars/tmp/issue10: first FILES answer faulted to
210
+ * empty, every other child live, against an uninterrupted control). First rep on
211
+ * an earlier wording did NOT take the `(none)` exit but drifted into writing code
212
+ * instead of listing paths — the deliverable-not-inputs failure the base prompt
213
+ * already forbids below this preamble. Blast radius is bounded: the gate fires
214
+ * only on a run that would otherwise have FAILED outright, so a mediocre recovered
215
+ * section is strictly better than the dead task it replaces — but if the drift
216
+ * reproduces, this preamble must restate the section's output contract, not just
217
+ * demand an answer.
218
+ */
219
+ const EMPTY_SECTION_PREAMBLE = 'STOP. Your previous attempt returned an EMPTY answer — zero characters. An empty '
220
+ + 'response cannot be accepted, because it is indistinguishable from a worker that '
221
+ + 'crashed before it wrote anything. Answer again now, and do the research properly '
222
+ + 'this time: look first, then write what you found, in the required format. Only if '
223
+ + 'you have looked and there is genuinely nothing to report — the task touches no '
224
+ + 'existing file, needs no external symbol, or the project has no such tooling — write '
225
+ + 'exactly `(none)` and nothing else. Do not answer `(none)` to avoid the work, and '
226
+ + 'never answer with silence.';
227
+ // One worker, cache-skip to persist: on a resume, a worker whose cached
228
+ // output is already on disk is skipped — so when one worker fails and the
229
+ // phase is re-run, the others don't burn minutes regenerating work that was
230
+ // already good. Each worker is validated inline (not in a second pass), so
231
+ // only trustworthy text is ever cached.
232
+ //
233
+ // A fatal failure (crash/empty/leak) still throws — the already-cached
234
+ // workers survive for the resume. A runaway (loop/timeout) degrades to its
235
+ // partial output instead, so one weak worker can't abort a whole auto-run;
236
+ // the degraded section is cached too, so a resume doesn't re-loop it.
237
+ export async function runResearchWorker(spec, run, prior = []) {
238
+ const cacheHeading = researchWorkerCacheHeading(spec.section);
239
+ const cached = await run.readCached(cacheHeading);
240
+ if (cached.trim().length > 0) {
241
+ run.logDebug?.(`${spec.label}: cached — skipping re-run`);
242
+ run.onDone();
243
+ return { name: spec.section, text: cached.trim() };
244
+ }
245
+ run.logDebug?.(`${spec.label}: start`);
246
+ const basePrompt = typeof spec.prompt === 'function' ? spec.prompt(prior) : spec.prompt;
247
+ const runOnce = (extraPreamble) => run.record(spec.label, run.runWorker(spec.label, {
248
+ prompt: extraPreamble ? `${extraPreamble}\n\n${basePrompt}` : basePrompt,
249
+ cwd: run.cwd,
250
+ signal: run.signal,
251
+ spawn: run.spawn,
252
+ // ONE CELL PER WORKER since 2026-08-28. They used to share
253
+ // the `research` cell on the grounds that they are the same
254
+ // job over four questions; the run logs disagree. All 40.7
255
+ // wasted research minutes in mx5-n were restarts in
256
+ // `tooling` and `context`, and `files`/`apis` never
257
+ // restarted — so the level that pays for one pair is being
258
+ // paid for the other. THE FOUR CELLS DO NOT SHIP IDENTICAL:
259
+ // `research:files` is `off` on a measured tie while the
260
+ // other three are `medium`, so this line changes what the
261
+ // FILES worker runs at for every default-mode user. The
262
+ // evidence is on each cell in reasoning.ts.
263
+ thinking: run.thinkingFor(spec.label),
264
+ ...(spec.tools ? { tools: spec.tools } : {}),
265
+ ...(spec.extensions ? { extensions: spec.extensions } : {}),
266
+ // The three 5B lever spreads that used to sit here are the
267
+ // `research` row of WORKER_PROFILES (workers/worker-profiles.ts).
268
+ // Two facts still come from here, and only these two: which of
269
+ // the four workers is docs-capable (only it can be scaled), and
270
+ // the phase's FROZEN lever reader, so every worker in one run
271
+ // sees the same arm.
272
+ profile: 'research',
273
+ policyInputs: {
274
+ ...(spec.fanoutBounded ? { fanoutBounded: true } : {}),
275
+ env: run.leverEnv
276
+ },
277
+ // One line per DISCARDED attempt. The `done` line below reports
278
+ // the final attempt only, so a worker that timed out twice at
279
+ // 240s and then answered used to log exactly like a clean one —
280
+ // 8 minutes of burned compute recoverable only by subtracting
281
+ // its own wait+work from the start/done timestamps.
282
+ onCarryForward: ci => {
283
+ run.logDebug?.(`${spec.label}: CARRY-FORWARD injected into attempt ${ci.attempt}`
284
+ + ` (${ci.chars} chars onto a ${ci.promptCharsBefore}-char prompt)`);
285
+ },
286
+ onRestart: rs => {
287
+ run.logDebug?.(`${spec.label}: RESTART (attempt ${rs.attempt} discarded)`
288
+ + ` reason=${rs.reason} wall=${rs.wallMs}ms`
289
+ + ` wait=${rs.waitMs}ms work=${rs.workMs}ms`
290
+ + (rs.detail ? ` — ${rs.detail}` : ''));
291
+ run.onChildOutput?.(`${spec.label}: restart (${rs.reason})`);
292
+ },
293
+ onLine: line => {
294
+ // The one 'stream' site in this file: raw research-worker
295
+ // output. Every other logDebug here records a decision.
296
+ // onChildOutput drives the widget and is not gated.
297
+ run.logDebug?.(`${spec.label}: ${line}`, 'stream');
298
+ run.onChildOutput?.(`${spec.label}: ${line}`);
299
+ }
300
+ }));
301
+ let r = await runOnce();
302
+ // EMPTY-SECTION GATE (issue #10). A worker that returns zero bytes on a clean run
303
+ // used to fail the whole task ("Research APIS worker produced no output"), which is
304
+ // exactly what an extremely simple task provokes: with nothing on disk to survey and
305
+ // no external symbol in play, silence is the correct answer and the run died on it.
306
+ // Retry ONCE — silence is genuinely ambiguous, and a worker that crashed before
307
+ // writing deserves a second attempt — then accept an explicitly empty section. A
308
+ // provider error behind the silence is classified fatal below and never reaches here.
309
+ let confirmedEmpty = false;
310
+ if (classifyResearchWorker(spec.section, r)?.kind === 'empty') {
311
+ run.logDebug?.(`${spec.label}: EMPTY answer on a clean exit — retrying once before`
312
+ + ' accepting the section as having no entries');
313
+ run.onChildOutput?.(`${spec.label}: empty — retrying`);
314
+ const retry = await runOnce(EMPTY_SECTION_PREAMBLE);
315
+ if (retry.text.trim().length > 0) {
316
+ run.logDebug?.(`${spec.label}: retry answered (len=${retry.text.trim().length})`
317
+ + ' — replacing the empty section');
318
+ r = retry;
319
+ }
320
+ else {
321
+ confirmedEmpty = classifyResearchWorker(spec.section, retry)?.kind === 'empty';
322
+ run.logDebug?.(`${spec.label}: retry STILL empty — `
323
+ + (confirmedEmpty ?
324
+ 'the worker ran twice and reported no entries; recording the'
325
+ + ' section as empty (NOT a failure)'
326
+ : 'and this attempt did not run cleanly — failing the phase'));
327
+ if (!confirmedEmpty)
328
+ r = retry;
329
+ }
330
+ }
331
+ // ZERO-RETRIEVAL GATE — a deterministic handle, not another instruction. A non-empty
332
+ // section produced with no grounding-retrieval call was written from memory; retry ONCE
333
+ // with a forced retrieval-first pass and keep the retry only if it actually retrieved.
334
+ if (spec.zeroRetrievalRetry && r.groundingRetrievalCount === 0 && r.text.trim().length > 0) {
335
+ run.logDebug?.(`${spec.label}: ZERO grounding-retrieval on a non-empty section`
336
+ + ' — every symbol is unverified memory; re-running once with a forced'
337
+ + ' retrieval-first pass');
338
+ run.onChildOutput?.(`${spec.label}: zero-retrieval — retrying with forced retrieval`);
339
+ const retry = await runOnce(spec.zeroRetrievalRetry);
340
+ if (retry.groundingRetrievalCount > 0 && retry.text.trim().length > 0) {
341
+ run.logDebug?.(`${spec.label}: retry grounded (${retry.groundingRetrievalCount} retrieval`
342
+ + ' calls) — replacing the memory-written section');
343
+ r = retry;
344
+ }
345
+ else {
346
+ run.logDebug?.(`${spec.label}: retry STILL zero-retrieval`
347
+ + ` (calls=${retry.groundingRetrievalCount}, len=${retry.text.trim().length})`
348
+ + ' — keeping the original (no regression, entry count preserved)');
349
+ }
350
+ }
351
+ // SILENT-RETRY GATE — a deterministic handle over the section body, not another
352
+ // instruction. A section that parses to ZERO bullets from a loop-degrade banner or a
353
+ // hallucinated non-bullet fragment (classifyContextSilence → genuineLoss) dropped
354
+ // context that was there to surface; retry ONCE with a forced-emit preamble and keep
355
+ // the retry only if it produces bullets. A legitimately-empty section (an honest
356
+ // "nothing to surface") and a fatal failure are BOTH left alone — the former is not a
357
+ // loss, the latter throws below and must stay a loud failure, not a silent retry.
358
+ const silentBodyOf = (res) => {
359
+ const f = classifyResearchWorker(spec.section, res);
360
+ if (f?.kind === 'fatal')
361
+ return null;
362
+ return f?.kind === 'runaway' ?
363
+ degradedSectionBody(spec.section, f.reason, res.text)
364
+ : res.text.trim();
365
+ };
366
+ // `confirmedEmpty` already spent a retry on exactly this ("you wrote nothing"), and
367
+ // the worker answered "nothing applies" a second time — re-asking here would just
368
+ // burn a third child for the same answer.
369
+ if (spec.retryIfSilent && !confirmedEmpty) {
370
+ const body = silentBodyOf(r);
371
+ const verdict = body === null ? null : classifyContextSilence(body);
372
+ if (verdict?.silent && verdict.genuineLoss) {
373
+ run.logDebug?.(`${spec.label}: silent-retry first-silent cause=${verdict.cause}`
374
+ + ` — zero bullets, re-running once with a forced-emit preamble`);
375
+ run.onChildOutput?.(`${spec.label}: silent — retrying`);
376
+ const retry = await runOnce(spec.retryIfSilent);
377
+ const retryBody = silentBodyOf(retry);
378
+ const retryBullets = retryBody === null ? 0 : countBullets(retryBody);
379
+ if (retryBullets > 0) {
380
+ run.logDebug?.(`${spec.label}: silent-retry recovered bullets=${retryBullets}`
381
+ + ' — replacing the silent section');
382
+ r = retry;
383
+ }
384
+ else {
385
+ run.logDebug?.(`${spec.label}: silent-retry still-silent`
386
+ + ` (bullets=${retryBullets}) — keeping the original`);
387
+ }
388
+ }
389
+ }
390
+ run.logDebug?.(`${spec.label}: done exit=${r.exitCode} wait=${r.waitMs}ms work=${r.workMs}ms`
391
+ // attempts/total are the pair that makes wait+work honest: they are
392
+ // the FINAL attempt's split, and only `total` sees the discarded ones.
393
+ + ` attempts=${r.attempts} total=${r.totalWallMs}ms`
394
+ + (r.restarts.length > 0 ?
395
+ ` restarts=[${r.restarts.map(x => x.reason).join(',')}]`
396
+ : '')
397
+ // Attribution for the RESCUE arm: a run with zero restarts was
398
+ // never killed (the progress deadline did it), while a run that
399
+ // restarted and salvaged was killed but kept its work. Without
400
+ // this the two are indistinguishable in the logs, and "0
401
+ // timeouts" cannot be traced to the half that earned it.
402
+ + (r.salvagedFromDiscardedAttempt ? ' salvaged=1' : '')
403
+ + (r.stderr ? ` stderr=${r.stderr.slice(0, 300)}` : '')
404
+ + (r.leakedToolCall ? ` leaked=${r.leakedToolCall.trim().slice(0, 80)}` : ''));
405
+ run.onDone();
406
+ const failure = classifyResearchWorker(spec.section, r);
407
+ if (failure?.kind === 'fatal')
408
+ throw failure.error;
409
+ // A worker that answers "nothing applies" is recorded the same way whether it
410
+ // said so with zero bytes (confirmedEmpty) or with a bare "(none)"/"N/A" — the
411
+ // two are the same answer, and only the marker makes either one distinguishable
412
+ // from a worker that never answered at all.
413
+ const rawText = failure?.kind === 'runaway' ? degradedSectionBody(spec.section, failure.reason, r.text)
414
+ : confirmedEmpty || isBareNoneAnswer(r.text) ? emptySectionBody(spec.section)
415
+ : r.text.trim();
416
+ if (failure?.kind === 'runaway') {
417
+ run.logDebug?.(`${spec.label}: degraded — ${failure.reason}`);
418
+ }
419
+ if (!confirmedEmpty && isBareNoneAnswer(r.text)) {
420
+ run.logDebug?.(`${spec.label}: answered "${r.text.trim().slice(0, 40)}" — recording it as`
421
+ + ' an empty section (the worker ran and found nothing)');
422
+ }
423
+ // Post-check the worker's own output before it is persisted, so the cache a
424
+ // resume reads back is already gated. A degraded partial goes through it too —
425
+ // a truncated section can still carry a laundered claim.
426
+ const sectionText = spec.postProcess ? spec.postProcess(rawText) : rawText;
427
+ await run.persistSection(cacheHeading, sectionText);
428
+ return { name: spec.section, text: sectionText };
429
+ }
@@ -5,6 +5,7 @@
5
5
  * clears itself on the first interaction (any keystroke).
6
6
  */
7
7
  import { getConfig } from '../config/config.js';
8
+ import { registerSessionHint } from './session-hint.js';
8
9
  const WIDGET_KEY = 'pi-task-brave-warning';
9
10
  const WARNING = '⚠ pi-task: search provider is Brave but BRAVE_SEARCH_API_KEY is not set — web search '
10
11
  + 'is disabled. Get a free key at https://api.search.brave.com/app/keys or switch '
@@ -14,34 +15,7 @@ function hasBraveKey() {
14
15
  return Boolean(process.env.BRAVE_SEARCH_API_KEY ?? process.env.BRAVE_API_KEY);
15
16
  }
16
17
  export function registerBraveKeyWarning(pi) {
17
- pi.on('session_start', (_event, ctx) => {
18
- // Terminal-only hint: needs an interactive TUI to render and to catch the
19
- // keystroke that dismisses it. Only the brave provider can be misconfigured;
20
- // skip whenever another provider is selected or a key is already present.
21
- if (ctx.mode !== 'tui' || getConfig().searchProvider !== 'brave' || hasBraveKey())
22
- return;
23
- let unsubscribe = null;
24
- const clear = () => {
25
- try {
26
- ctx.ui.setWidget(WIDGET_KEY, undefined);
27
- }
28
- catch {
29
- /* stale ctx after a session switch — nothing to clear */
30
- }
31
- unsubscribe?.();
32
- unsubscribe = null;
33
- };
34
- try {
35
- ctx.ui.setWidget(WIDGET_KEY, [ctx.ui.theme.fg('warning', WARNING)]);
36
- }
37
- catch {
38
- return;
39
- }
40
- // Disappear on any interaction — the first raw keystroke clears it.
41
- // Returning undefined leaves the input untouched (we only observe it).
42
- unsubscribe = ctx.ui.onTerminalInput(() => {
43
- clear();
44
- return undefined;
45
- });
46
- });
18
+ // Only the brave provider can be misconfigured; say nothing whenever another
19
+ // provider is selected or a key is already present.
20
+ registerSessionHint(pi, WIDGET_KEY, () => getConfig().searchProvider !== 'brave' || hasBraveKey() ? null : { text: WARNING });
47
21
  }
@@ -4,6 +4,7 @@ import { resolvePackage as defaultResolvePackage, type ResolvedPackage } from '.
4
4
  import { retrieveChunks as defaultRetrieveChunks, type RetrievedChunk } from './docs-retrieve.js';
5
5
  import { npmVersionLookup as defaultNpmVersionLookup, type NpmVersionInfo } from './npm-version.js';
6
6
  import { type SpawnFn } from '../shared/child-process.js';
7
+ import { type DocsCorpus } from './docs-lookup.js';
7
8
  import { type ExcerptVerification } from '../shared/child-output.js';
8
9
  /**
9
10
  * Provenance of an auto-installed package's version, so the answer can state
@@ -36,10 +37,6 @@ export type DocsRawResult = {
36
37
  autoInstalled?: boolean;
37
38
  autoInstallPin?: AutoInstallPin;
38
39
  npmVersion?: NpmVersionInfo | null;
39
- } | {
40
- kind: 'not_installed';
41
- pkg: string;
42
- npmVersion?: NpmVersionInfo | null;
43
40
  } | {
44
41
  kind: 'no_chunks';
45
42
  pkg: ResolvedPackage;
@@ -254,4 +251,11 @@ export declare function buildPrompt(pkg: ResolvedPackage, query: string, content
254
251
  * this call compile, with three of the five fields existing only for that.
255
252
  */
256
253
  /** The header for an npm package answer. */
254
+ /**
255
+ * The PACKAGE corpus row: an npm package's `.d.ts` + README chunks.
256
+ *
257
+ * A function of the resolved package rather than a constant, because both the
258
+ * prompt and the header name the exact `name@version` that was read.
259
+ */
260
+ export declare function packageCorpus(pkg: ResolvedPackage): DocsCorpus;
257
261
  export declare function packageHeader(pkg: ResolvedPackage): string;
@@ -8,7 +8,7 @@ import { resolvePackage as defaultResolvePackage, ResolveError, isDtsFile, resol
8
8
  import { retrieveChunks as defaultRetrieveChunks, PACKAGE_RETRIEVE_LIMIT, RETRIEVE_CONTENT_BUDGET } from './docs-retrieve.js';
9
9
  import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
10
10
  import { runChild } from '../shared/child-process.js';
11
- import { runFocusedExtraction } from './focused-extractor.js';
11
+ import { docsLookup } from './docs-lookup.js';
12
12
  import { buildExtractionPrompt } from './abstention.js';
13
13
  import { groupThinkingArgs } from '../config/reasoning-args.js';
14
14
  const DEFAULT_LIMIT = PACKAGE_RETRIEVE_LIMIT;
@@ -398,7 +398,7 @@ export async function docsRaw(input) {
398
398
  docsRawCached(cache, pkg, input.query, ensureIndexed, retrieveChunks, autoInstalled)
399
399
  : docsRawUncached(pkg, cacheError ?? 'unknown cache error', autoInstalled);
400
400
  result.npmVersion = await npmVersionPromise;
401
- if (autoInstallPin && result.kind !== 'not_installed')
401
+ if (autoInstallPin)
402
402
  result.autoInstallPin = autoInstallPin;
403
403
  return result;
404
404
  }
@@ -547,27 +547,22 @@ export async function docsFocused(input) {
547
547
  if (rawResult.kind === 'error') {
548
548
  throw new Error(rawResult.message);
549
549
  }
550
- if (rawResult.kind === 'not_installed') {
551
- throw new Error(`Package "${rawResult.pkg}" is not installed`);
552
- }
553
550
  if (rawResult.kind === 'no_chunks') {
554
551
  throw new Error(`Package ${rawResult.pkg.name}@${rawResult.pkg.version} has no .d.ts files or README.`);
555
552
  }
556
553
  const { pkg, chunks, hitCache, indexingMs } = rawResult;
557
- const concatenated = chunks.map(c => c.content).join('\n\n');
558
- const extraction = await runFocusedExtraction({
559
- prompt: buildPrompt(pkg, input.query, concatenated),
560
- // Exactly what went into the prompt — this path prompts with the whole concatenation,
561
- // so the verify target and the prompt content are the same text.
562
- verifyAgainst: concatenated,
554
+ const r = await docsLookup({
555
+ corpus: packageCorpus(pkg),
556
+ chunks,
557
+ query: input.query,
563
558
  cwd: input.cwd,
564
559
  signal: input.signal,
565
560
  spawn,
566
- // The `extraction` group's level. Resolved at the call site so the
567
- // extractor itself never reads ambient config.
568
- thinking: groupThinkingArgs('extraction'),
569
- abortedMessage: 'Docs lookup aborted.'
561
+ // The `extraction` group's level. Resolved at the call site so neither
562
+ // the lookup nor the extractor reads ambient config.
563
+ thinking: groupThinkingArgs('extraction')
570
564
  });
565
+ const extraction = r.extraction;
571
566
  const base = {
572
567
  pkg,
573
568
  version: pkg.version,
@@ -583,13 +578,13 @@ export async function docsFocused(input) {
583
578
  // A failed child yields NO answer. The caller (phaseAutoAnswer) gates on `answer` being
584
579
  // non-empty, so an empty one keeps a dead child's output out of the spec entirely;
585
580
  // `failure` carries the reason for anyone who wants to report it.
586
- if (!extraction.ok)
587
- return { answer: '', failure: extraction.failure, ...base };
581
+ if (r.kind === 'failed')
582
+ return { answer: '', failure: r.extraction.failure, ...base };
588
583
  return {
589
- answer: extraction.answer,
590
- excerpt: extraction.excerpt,
591
- excerptVerified: extraction.excerptVerified,
592
- excerptCheck: extraction.excerptCheck,
584
+ answer: r.extraction.answer,
585
+ excerpt: r.extraction.excerpt,
586
+ excerptVerified: r.extraction.excerptVerified,
587
+ excerptCheck: r.extraction.excerptCheck,
593
588
  ...base
594
589
  };
595
590
  }
@@ -613,6 +608,20 @@ export function buildPrompt(pkg, query, content) {
613
608
  * this call compile, with three of the five fields existing only for that.
614
609
  */
615
610
  /** The header for an npm package answer. */
611
+ /**
612
+ * The PACKAGE corpus row: an npm package's `.d.ts` + README chunks.
613
+ *
614
+ * A function of the resolved package rather than a constant, because both the
615
+ * prompt and the header name the exact `name@version` that was read.
616
+ */
617
+ export function packageCorpus(pkg) {
618
+ return {
619
+ id: 'package',
620
+ buildPrompt: (query, content) => buildPrompt(pkg, query, content),
621
+ header: packageHeader(pkg),
622
+ abortedMessage: 'Docs lookup aborted.'
623
+ };
624
+ }
616
625
  export function packageHeader(pkg) {
617
626
  return `Per ${pkg.name}@${pkg.version}:`;
618
627
  }