@lincoln504/pi-research 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.mjs +449 -187
  3. package/dist/prompts/research-knowledge-relevance-triage.md +2 -0
  4. package/dist/prompts/research-knowledge-search-extractor.md +2 -0
  5. package/dist/prompts/researcher.md +2 -14
  6. package/dist/thread-worker.mjs +91 -59
  7. package/docs/AGENT-SKILL.md +4 -3
  8. package/docs/CHANGELOG.md +120 -0
  9. package/docs/CONFIGURATION.md +2 -2
  10. package/docs/SDK.md +1 -1
  11. package/package.json +1 -1
  12. package/skills/pi-research/SKILL.md +1 -1
  13. package/src/cli.ts +44 -3
  14. package/src/config.ts +36 -13
  15. package/src/constants.ts +23 -4
  16. package/src/core/llm/research-model-resolver.ts +38 -0
  17. package/src/core/planning-service.ts +67 -9
  18. package/src/index.ts +3 -1
  19. package/src/infrastructure/browser/browser-task-scheduler.ts +16 -9
  20. package/src/infrastructure/browser/config.ts +40 -2
  21. package/src/infrastructure/browser/thread-worker-browser.ts +69 -48
  22. package/src/infrastructure/browser/thread-worker-messaging.ts +65 -4
  23. package/src/infrastructure/browser/thread-worker.mjs +91 -59
  24. package/src/infrastructure/browser/thread-worker.ts +13 -5
  25. package/src/infrastructure/embedding/embedding-server.ts +54 -0
  26. package/src/knowledge/embedder-init.ts +3 -2
  27. package/src/knowledge/embedder.ts +11 -3
  28. package/src/knowledge/index.ts +36 -10
  29. package/src/knowledge/store.ts +85 -5
  30. package/src/observers/research-observer-impl.ts +14 -6
  31. package/src/orchestration/deep-research-orchestrator.ts +3 -1
  32. package/src/orchestration/quick-research-orchestrator.ts +29 -10
  33. package/src/orchestration/research-synthesis-service.ts +32 -0
  34. package/src/orchestration/researcher-executor.ts +31 -16
  35. package/src/orchestration/researcher.ts +2 -2
  36. package/src/orchestration/session-state.ts +35 -4
  37. package/src/prompts/research-knowledge-relevance-triage.md +2 -0
  38. package/src/prompts/research-knowledge-search-extractor.md +2 -0
  39. package/src/prompts/researcher.md +2 -14
  40. package/src/sdk.ts +16 -0
  41. package/src/skill-install/skill-installer.ts +5 -1
  42. package/src/stackexchange/index.ts +3 -0
  43. package/src/stackexchange/rest-client.ts +7 -12
  44. package/src/tools/index.ts +2 -1
  45. package/src/tools/research-knowledge-search.ts +37 -4
  46. package/src/tools/research-tool-definition.ts +54 -12
  47. package/src/tools/scrape.ts +8 -1
  48. package/src/tools/security.ts +13 -10
  49. package/src/utils/coverage-digest.ts +1 -1
  50. package/src/utils/host-config.ts +12 -1
  51. package/src/utils/log-utils.ts +15 -2
  52. package/src/utils/metrics-summary.ts +21 -1
  53. package/src/utils/tool-usage-tracker.ts +6 -3
  54. package/src/web-research/browser-search.ts +11 -4
  55. package/src/web-research/web-scraper.ts +36 -16
  56. package/src/youtube/index.ts +11 -1
  57. package/src/youtube/potoken.ts +8 -1
  58. package/src/youtube/transcript-client.ts +56 -5
@@ -23,7 +23,7 @@ import {
23
23
  import {
24
24
  setWorkerId,
25
25
  initBrowser,
26
- getContext,
26
+ acquireTaskContext,
27
27
  resetBrowser,
28
28
  cleanupBrowser,
29
29
  taskStarted,
@@ -107,23 +107,28 @@ async function runTask(data: TaskData | undefined): Promise<TaskResult> {
107
107
  // Registered around the whole browser-touching lifetime of this task so
108
108
  // resetBrowser() (below) can tell whether a sibling task dispatched to this
109
109
  // same worker process (WORKER_CONCURRENCY > 1) is still using the shared
110
- // browser/context before tearing it down.
110
+ // browser before tearing it down.
111
111
  taskStarted();
112
+ // Task-owned, not module-owned: acquireTaskContext() below hands back a
113
+ // FRESH BrowserContext for this task alone, closed in the finally below —
114
+ // never shared with a concurrent or later sibling task.
115
+ let taskContext: any;
112
116
  try {
113
117
  await initBrowser();
114
118
  const initMs = Date.now() - startTime;
119
+ taskContext = await acquireTaskContext();
115
120
 
116
121
  let result: any;
117
122
  if (type === 'search') {
118
123
  if (!query) throw new Error('Search task requires a query');
119
- const searchResult = await executeSearchTask(getContext(), query, abortController.signal);
124
+ const searchResult = await executeSearchTask(taskContext, query, abortController.signal);
120
125
  result = { results: searchResult.results, duration: Date.now() - startTime, jitter: searchResult.jitter };
121
126
  } else if (type === 'scrape') {
122
127
  if (!url) throw new Error('Scrape task requires a URL');
123
- const scrapeResult = await executeScrapeTask(getContext(), url, abortController.signal);
128
+ const scrapeResult = await executeScrapeTask(taskContext, url, abortController.signal);
124
129
  result = { ...scrapeResult, duration: Date.now() - startTime };
125
130
  } else if (type === 'healthcheck') {
126
- const healthResult = await executeHealthCheck(getContext(), initMs, abortController.signal);
131
+ const healthResult = await executeHealthCheck(taskContext, initMs, abortController.signal);
127
132
  result = { ...healthResult, duration: Date.now() - startTime };
128
133
  } else {
129
134
  result = { error: 'Unknown task type', duration: Date.now() - startTime };
@@ -156,6 +161,9 @@ async function runTask(data: TaskData | undefined): Promise<TaskResult> {
156
161
  };
157
162
  } finally {
158
163
  if (taskTimer !== undefined) clearTimeout(taskTimer);
164
+ if (taskContext) {
165
+ Promise.resolve(taskContext.close()).catch(() => { /* task is already done; a close failure here is not actionable */ });
166
+ }
159
167
  taskFinished();
160
168
  }
161
169
  }
@@ -80,6 +80,11 @@ class SerialQueue {
80
80
  private readonly hardDeadlineMs: number;
81
81
  private readonly onPoison?: (err: Error) => void;
82
82
  private poisoned: Error | null = null;
83
+ // The currently-executing task's real work promise (not the soft-timeout race) —
84
+ // set only while a native call is actually in flight. Lets a caller that needs
85
+ // to dispose the underlying pipeline (EmbeddingServer.shutdown) wait for true
86
+ // idleness instead of racing disposal against a still-running inference.
87
+ private currentWork: Promise<unknown> | null = null;
83
88
 
84
89
  constructor(
85
90
  maxDepth = 200,
@@ -132,6 +137,7 @@ class SerialQueue {
132
137
  let hardTimer: ReturnType<typeof setTimeout> | undefined;
133
138
  try {
134
139
  work = fn();
140
+ this.currentWork = work;
135
141
  // Arm the hard-deadline watchdog from the moment the real GPU work starts, so
136
142
  // the cap is an honest wall-clock deadline measured from work-start rather than
137
143
  // (soft-timeout + hardDeadlineMs). A single inference still unsettled after
@@ -162,6 +168,7 @@ class SerialQueue {
162
168
  } finally {
163
169
  settled = true;
164
170
  if (hardTimer !== undefined) clearTimeout(hardTimer);
171
+ if (this.currentWork === work) this.currentWork = null;
165
172
  }
166
173
  }
167
174
  };
@@ -170,6 +177,43 @@ class SerialQueue {
170
177
  });
171
178
  }
172
179
 
180
+ /**
181
+ * Resolve once no native call is in flight AND no queued task is still due
182
+ * to run. Used by EmbeddingServer.shutdown() to wait out a still-running
183
+ * inference before disposing the pipeline — without this, disposal could
184
+ * run concurrently with a legitimately slow (not hung) embed/embedMany call
185
+ * on the same re-entrant-locked GPU context, the exact native-segfault
186
+ * class this queue exists to prevent. No timeout: a promise that truly
187
+ * never settles is already an accepted, pre-existing leak-to-process-exit
188
+ * scenario elsewhere in this class (see the poison path's
189
+ * disposeEmbedder=false handling) — better to leak than risk concurrent
190
+ * native access.
191
+ *
192
+ * Checking only `currentWork` is NOT enough with a second task already
193
+ * queued: a task's `run()` clears `currentWork` to null in its `finally`
194
+ * (once its native call has settled), and `pump()` only claims it again for
195
+ * the next task on a LATER microtask tick, once `run()` for the current
196
+ * task has fully unwound and `await entry.run()` resumes. If a caller's own
197
+ * check happens to land in that gap, `currentWork` reads as null even
198
+ * though a second native call is about to start (or a queue in the process
199
+ * of unwinding to its next task hasn't reached it yet) — exactly the window
200
+ * disposal must not run in. `running` stays true for the queue's entire
201
+ * drain, across every task in `tasks`, not just the current one, so it has
202
+ * no equivalent gap and closes this one.
203
+ */
204
+ async waitForIdle(): Promise<void> {
205
+ while (this.currentWork || this.running) {
206
+ if (this.currentWork) {
207
+ await this.currentWork.catch(() => {});
208
+ } else {
209
+ // Nothing executing right now, but pump() is still mid-drain (about
210
+ // to claim the next queued task, or unwinding) — yield a microtask
211
+ // so it can advance instead of reporting idle into this exact gap.
212
+ await Promise.resolve();
213
+ }
214
+ }
215
+ }
216
+
173
217
  private poison(err: Error): void {
174
218
  if (this.poisoned) return;
175
219
  this.poisoned = err;
@@ -414,6 +458,16 @@ export class EmbeddingServer implements IEmbedder {
414
458
  // is enough for another process to re-elect; the leaked native resources die with
415
459
  // eventual process exit.
416
460
  if (disposeEmbedder) {
461
+ // Wait for the SerialQueue to go idle BEFORE disposing. This shutdown() path
462
+ // also runs on ordinary leadership loss — not just the poison path above — and
463
+ // a legitimately slow (not hung) embed/embedMany can still be mid-flight on
464
+ // this same GPU context. Embedder.dispose()'s own drainActiveEmbeddings only
465
+ // waits 5s, an order of magnitude under the queue's own 120s soft timeout, so
466
+ // without this a normal large batch could have its pipeline disposed out from
467
+ // under it — the same concurrent-native-call hazard the queue exists to
468
+ // prevent, just triggered by disposal racing a live call instead of two calls
469
+ // racing each other.
470
+ await this.queue.waitForIdle();
417
471
  await this.embedder.dispose().catch((err) => {
418
472
  if (this.isShuttingDown) {
419
473
  logger.debug('[EmbeddingServer] Error disposing embedder during shutdown (expected):', err);
@@ -417,7 +417,8 @@ export async function handleWebGPUWarmupError(
417
417
  gpuLockHeld: boolean,
418
418
  model: string,
419
419
  initializationTimeoutMs: number,
420
- useCache: boolean
420
+ useCache: boolean,
421
+ poolingMode: 'mean' | 'cls' | 'last_token' = 'mean'
421
422
  ): Promise<{ success: boolean; pipeline?: FeatureExtractionPipeline; error?: Error; dummy?: any }> {
422
423
  markWebGpuFallback();
423
424
 
@@ -451,7 +452,7 @@ export async function handleWebGPUWarmupError(
451
452
  logger.info('[embedder] CPU pipeline loaded successfully');
452
453
 
453
454
  // Warmup CPU pipeline
454
- const { dummy, success, error: cpuWarmupErr } = await warmupPipeline(cpuPipeline, 'mean', useCache);
455
+ const { dummy, success, error: cpuWarmupErr } = await warmupPipeline(cpuPipeline, poolingMode, useCache);
455
456
  if (!success || cpuWarmupErr) {
456
457
  logger.error('[embedder] CPU fallback pipeline warmup failed:', cpuWarmupErr);
457
458
  if (cpuPipeline) {
@@ -331,7 +331,8 @@ export class Embedder {
331
331
  this.gpuLockHeld,
332
332
  this.model,
333
333
  this.initializationTimeoutMs,
334
- this.useCache
334
+ this.useCache,
335
+ this.poolingMode
335
336
  );
336
337
  if (!result.success) {
337
338
  throw result.error;
@@ -404,9 +405,16 @@ export class Embedder {
404
405
  // the VECTOR — the full text is still stored verbatim and covered by FTS — but the
405
406
  // truncation is otherwise silent, so surface it: a spike here means chunks are
406
407
  // arriving larger than the embed cap and vector recall on their tail is degraded.
408
+ let keep = maxChars;
409
+ // slice() counts UTF-16 code units, so the cut can land between the halves of
410
+ // a surrogate pair (emoji, rare CJK) — same guard as truncateWithMarker
411
+ // (utils/text-utils.ts): a lone high surrogate is invalid text some
412
+ // tokenizers/providers choke on. Back off one unit to restore well-formed text.
413
+ const lastKeptUnit = text.charCodeAt(keep - 1);
414
+ if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
407
415
  metrics.increment('embedder_truncations_total', 1, { model: this.model });
408
- logger.debug(`[embedder] Truncated input for embedding: ${text.length} -> ${maxChars} chars (model=${this.model})`);
409
- return text.slice(0, maxChars);
416
+ logger.debug(`[embedder] Truncated input for embedding: ${text.length} -> ${keep} chars (model=${this.model})`);
417
+ return text.slice(0, keep);
410
418
  }
411
419
  return text;
412
420
  }
@@ -1,4 +1,5 @@
1
1
  import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
2
3
  import { KnowledgeStore } from './store.ts';
3
4
  import { WriterQueue } from './writer-queue.ts';
4
5
  import { Chunker } from './chunker.ts';
@@ -108,19 +109,44 @@ export async function createKnowledgeStoreComponents(
108
109
  }
109
110
 
110
111
  /**
111
- * Force delete the entire knowledge store directory.
112
- * Used for system-level clearing or recovery from corruption.
112
+ * Force delete the ACTIVE table's own directory, to recover from a corrupted
113
+ * LanceDB manifest. Scoped to that one table, not the whole dbDir: dbDir is a
114
+ * single unified store shared by every workspace and the global scope (rows
115
+ * are scoped by a `workspace` column, not by separate directories), and it
116
+ * also holds `_backup_*`/`_migration_*` sibling directories — the deliberate,
117
+ * user-recoverable safety net the 'backup' migration strategy creates. A
118
+ * directory-wide wipe here previously destroyed every other project's data
119
+ * and every backup the instant ANY workspace's session hit a corrupted
120
+ * manifest, on the one code path whose entire purpose is recovering from
121
+ * exactly that kind of damage.
113
122
  */
114
123
  export async function forceDeleteKnowledgeStore(config?: Config, workspace?: string): Promise<void> {
115
124
  const dbDir = getDbDir(config, workspace);
116
- if (fs.existsSync(dbDir)) {
117
- try {
118
- fs.rmSync(dbDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
119
- fs.mkdirSync(dbDir, { recursive: true });
120
- logger.info(`[knowledge] Knowledge store at ${dbDir} forcefully deleted and recreated.`);
121
- } catch (err) {
122
- logger.error(`[knowledge] Failed to forcefully delete directory ${dbDir}:`, err);
123
- throw err;
125
+ if (!fs.existsSync(dbDir)) return;
126
+
127
+ // Mirrors KnowledgeStore's own manifest read (store.ts): the active table
128
+ // name defaults to 'knowledge' but can point elsewhere after a persisted
129
+ // rename failure. A missing or unreadable manifest is not itself a reason to
130
+ // widen the blast radius, so it falls back to the default rather than
131
+ // escalating to a dbDir-wide delete.
132
+ let tableName = 'knowledge';
133
+ const manifestPath = path.join(dbDir, 'store-manifest.json');
134
+ try {
135
+ if (fs.existsSync(manifestPath)) {
136
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { activeTableName?: string };
137
+ if (manifest.activeTableName) tableName = manifest.activeTableName;
124
138
  }
139
+ } catch (err) {
140
+ logger.warn(`[knowledge] Failed to read manifest before forced delete, defaulting to table '${tableName}':`, err);
141
+ }
142
+
143
+ const tableDir = path.join(dbDir, `${tableName}.lance`);
144
+ if (!fs.existsSync(tableDir)) return;
145
+ try {
146
+ fs.rmSync(tableDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
147
+ logger.info(`[knowledge] Corrupted table directory ${tableDir} forcefully deleted; will be recreated on next init.`);
148
+ } catch (err) {
149
+ logger.error(`[knowledge] Failed to forcefully delete table directory ${tableDir}:`, err);
150
+ throw err;
125
151
  }
126
152
  }
@@ -502,12 +502,92 @@ export class KnowledgeStore implements IKnowledgeStore {
502
502
  // If canonical name was already something else and it still exists,
503
503
  // we might need to handle it. But we just dropped it above.
504
504
  await fsPromises.rename(tempDir, canonicalDir);
505
-
505
+
506
506
  this.tableName = canonicalName;
507
- await this.saveManifest();
508
-
509
- // Reopen the canonical table so this.table reflects the renamed path
510
- this.table = await this.db.openTable(this.tableName);
507
+ if (await this.saveManifest()) {
508
+ // Reopen the canonical table so this.table reflects the renamed path
509
+ this.table = await this.db.openTable(this.tableName);
510
+ } else {
511
+ // The directory rename to the canonical name already happened on disk,
512
+ // but the manifest still says tempTableName (persisted successfully
513
+ // above, before the old table was dropped). Left as-is, the next
514
+ // process start would read that stale name, find no such directory
515
+ // (renamed away), and silently CREATE A NEW EMPTY TABLE under it —
516
+ // stranding every re-embedded document in the now-unreferenced
517
+ // canonical directory with no recovery path (pruneOrphanedMigrationDirs
518
+ // doesn't recognize a bare 'knowledge.lance' as orphaned migration
519
+ // material). Revert the rename so the manifest (still correctly
520
+ // pointing at tempTableName, from the pre-drop save above) and the
521
+ // on-disk layout agree again — the data stays fully intact and
522
+ // accessible under its temp name either way. Deliberately NOT thrown:
523
+ // the migration itself fully succeeded and the in-memory handle is
524
+ // fine, so surfacing this as a migration failure would trip the
525
+ // caller's re-embed-failed fallback to the 'backup' strategy, which
526
+ // would needlessly rename this perfectly good, fully re-embedded data
527
+ // out of the way and start a fresh empty table over one manifest
528
+ // write hiccup.
529
+ logger.error('[store] Failed to persist canonical table name after rename — reverting to keep manifest and disk in sync.');
530
+ try {
531
+ await fsPromises.rename(canonicalDir, tempDir);
532
+ this.tableName = tempTableName;
533
+ this.table = await this.db.openTable(this.tableName);
534
+ logger.info(`[store] Migrated data remains safe and accessible as ${tempTableName}; canonical rename deferred to a future successful manifest save.`);
535
+ } catch (revertErr) {
536
+ // The revert itself failed too. fs.rename is atomic on a single
537
+ // filesystem — a failed rename never leaves data half-moved — so
538
+ // the data is still fully at canonicalDir, not tempDir. The outer
539
+ // catch below was written for a DIFFERENT case (the first rename
540
+ // never happening at all) and would wrongly point the manifest at
541
+ // tempTableName, whose directory no longer exists on disk. Instead,
542
+ // try to persist a manifest against the name that actually matches
543
+ // disk right now, so the mismatch this whole branch exists to avoid
544
+ // doesn't reappear one level deeper.
545
+ logger.error(`[store] Revert rename also failed; data remains on disk at ${canonicalDir}: ${revertErr}`);
546
+ // Everything from here on is wrapped in its OWN try/catch and never
547
+ // rethrows: a third compounding failure (e.g. openTable() itself
548
+ // throwing, on the same underlying disk fault that already broke
549
+ // two renames) must not escape to the outer catch (renameErr)
550
+ // either — that one assumes data is still at tempDir, which by
551
+ // this point is doubly false.
552
+ // The manifest repair is deliberately NOT gated behind the table
553
+ // reopen: saveManifest depends only on writeFile/rename, not on an
554
+ // open table, so a reopen-only failure (a LanceDB-level fault on an
555
+ // otherwise-writable filesystem) must not skip a repair the code can
556
+ // still make — a skipped repair leaves the persisted manifest
557
+ // pointing at the nonexistent temp name, which is the next-restart
558
+ // empty-table hazard this whole branch exists to prevent.
559
+ this.tableName = canonicalName;
560
+ let reopened = false;
561
+ try {
562
+ this.table = await this.db.openTable(this.tableName);
563
+ reopened = true;
564
+ } catch (openErr) {
565
+ // A third compounding failure: even reopening the table at its
566
+ // actual on-disk location failed. This process has no working
567
+ // table handle for it, but the data itself should still be
568
+ // physically intact at canonicalDir (only the rename and its
569
+ // revert have run, and neither drops data — they only move a
570
+ // directory or fail outright). The manifest repair below still
571
+ // proceeds, so a future restart can find the data even though
572
+ // this process cannot serve it.
573
+ logger.error(`[store] CRITICAL: could not reopen the table at '${canonicalName}' after a failed rename revert (${openErr}). Data should still be intact on disk at ${canonicalDir}, but this process has no working table handle for it. Attempting the manifest repair anyway so a restart can find the data; verify ${canonicalDir} exists if problems persist.`);
574
+ }
575
+ try {
576
+ if (await this.saveManifest()) {
577
+ logger.warn(`[store] Recovered: manifest now points at '${canonicalName}', matching the data's actual on-disk location${reopened ? '' : ' (this process still has no working table handle — restart to serve it)'}.`);
578
+ } else {
579
+ // Both the revert and this recovery manifest save failed. The
580
+ // on-disk manifest (last persisted pointing at tempTableName,
581
+ // before the old table was dropped) now disagrees with disk.
582
+ // Fail loud rather than let a future restart silently create
583
+ // an empty table under the stale name.
584
+ logger.error(`[store] CRITICAL: could not persist a manifest matching disk after a failed rename revert. Data is intact on disk at ${canonicalDir} (table '${canonicalName}'), but the persisted manifest is stale and points at '${tempTableName}', which no longer exists. If this process exits before a future successful manifest save, the next start will not find the data automatically — manually repair the manifest to point at '${canonicalName}' before restarting.`);
585
+ }
586
+ } catch (manifestErr) {
587
+ logger.error(`[store] CRITICAL: manifest repair threw after a failed rename revert (${manifestErr}). Data is intact on disk at ${canonicalDir} (table '${canonicalName}'), but the persisted manifest is stale and points at '${tempTableName}'. Manually repair the manifest to point at '${canonicalName}' before restarting.`);
588
+ }
589
+ }
590
+ }
511
591
  } catch (renameErr) {
512
592
  // If rename fails (cross-device, permissions, etc.) we keep the temp name
513
593
  // but CRITICALLY we now persist it in the manifest so future sessions
@@ -43,6 +43,14 @@ export interface ObserverContext {
43
43
  */
44
44
  renderImmediate?: () => void;
45
45
  researchComplexity: number;
46
+ /**
47
+ * Whether prompt caching is active for the run's resolved model (see
48
+ * isPromptCachingActiveForModel). With caching active the effective
49
+ * per-researcher scrape-batch maximum is one higher, and progress units
50
+ * must be computed relative to that same effective count the tracker
51
+ * enforces — not the base configured value.
52
+ */
53
+ cachingActive?: boolean;
46
54
  }
47
55
 
48
56
  export interface ObserverState {
@@ -94,7 +102,7 @@ export function createResearchObserver(
94
102
  activateSlice(panelState, state.quickSliceLabel);
95
103
  updateSliceStatus(panelState, state.quickSliceLabel, 'starting...', debouncedRefresh);
96
104
 
97
- const units = getUnitsPerResearcher();
105
+ const units = getUnitsPerResearcher(undefined, ctx.cachingActive);
98
106
  panelState.progress = { expected: units, made: 0 };
99
107
  }
100
108
  // Quick research: researcher is running → steering is acceptable
@@ -152,7 +160,7 @@ export function createResearchObserver(
152
160
  onPlanningSuccess: (plan) => {
153
161
  updateSliceStatus(panelState, 'coord', 'ready', debouncedRefresh);
154
162
  completeSlice(panelState, 'coord');
155
- const unitsPerResearcher = getUnitsPerResearcher();
163
+ const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
156
164
  const count = plan.researchers?.length || 0;
157
165
  const units = (count * unitsPerResearcher) + LEAD_EVAL_UNITS;
158
166
  panelState.progress = { expected: units, made: 0 };
@@ -319,7 +327,7 @@ export function createResearchObserver(
319
327
 
320
328
  onResearcherProgress: (id, status, tokens, cost) => {
321
329
  const sliceId = id === 'quick' ? state.quickSliceLabel : id;
322
- const unitsPerResearcher = getUnitsPerResearcher();
330
+ const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
323
331
 
324
332
  if (status !== undefined) {
325
333
  if (status.startsWith('done:')) {
@@ -350,7 +358,7 @@ export function createResearchObserver(
350
358
  onResearcherComplete: (id, _report) => {
351
359
  const sliceId = id === 'quick' ? state.quickSliceLabel : id;
352
360
  if (panelState.progress) {
353
- const unitsPerResearcher = getUnitsPerResearcher();
361
+ const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
354
362
  const current = progressCredits.get(id) ?? 0;
355
363
  const remaining = unitsPerResearcher - current;
356
364
  if (remaining > 0) {
@@ -365,7 +373,7 @@ export function createResearchObserver(
365
373
  onResearcherFailure: (id, _error) => {
366
374
  const sliceId = id === 'quick' ? state.quickSliceLabel : id;
367
375
  if (panelState.progress) {
368
- const unitsPerResearcher = getUnitsPerResearcher();
376
+ const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
369
377
  const current = progressCredits.get(id) ?? 0;
370
378
  const remaining = unitsPerResearcher - current;
371
379
  if (remaining > 0) {
@@ -430,7 +438,7 @@ export function createResearchObserver(
430
438
  } else {
431
439
  // Delegation: prepare for new round's search → researchers → steering acceptable
432
440
  if (plan?.researchers && plan.researchers.length > 0 && panelState.progress) {
433
- const unitsPerResearcher = getUnitsPerResearcher();
441
+ const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
434
442
  panelState.progress.expected += (plan.researchers.length * unitsPerResearcher) + LEAD_EVAL_UNITS;
435
443
  }
436
444
  panelState.steeringAcceptable = true;
@@ -517,7 +517,9 @@ export class DeepResearchOrchestrator {
517
517
  // Guarded so a one-round budget cannot announce the same round twice.
518
518
  if (this.currentRound > lastAnnouncedRound) {
519
519
  observer?.onRoundStart?.(this.currentRound);
520
- lastAnnouncedRound = this.currentRound;
520
+ // No assignment to lastAnnouncedRound here: this is the last place in the
521
+ // function that reads it — final synthesis proceeds unconditionally right
522
+ // after, nothing downstream checks it again.
521
523
  }
522
524
  // The round this synthesis actually happens at, not the budget. They coincide on
523
525
  // the round-cap exit but not on the wait-exhaustion one, which breaks out early —
@@ -19,6 +19,7 @@ import { getConfig, type Config } from '../config.ts';
19
19
  import { createResearcherSession } from './researcher.ts';
20
20
  import { ensureAssistantResponse, parseCitations } from '../utils/text-utils.ts';
21
21
  import { getMaxScrapeBatches, resolveExcludedTools } from '../constants.ts';
22
+ import { resolveResearchModel, isPromptCachingActiveForModel } from '../core/llm/research-model-resolver.ts';
22
23
  import type { ResearchObserver } from '../core/interfaces/observer-interfaces.ts';
23
24
  import { HeadlessObserver, makeSafeObserver, type HeadlessObserverOptions } from './headless-observer.ts';
24
25
  import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
@@ -133,7 +134,15 @@ export class QuickResearchOrchestrator {
133
134
  }
134
135
 
135
136
  const researcherPromptTemplate = loadPrompt('researcher');
136
- const maxScrapeBatches = getMaxScrapeBatches(this.config);
137
+ // Resolve the model here (same deterministic inputs createResearcherSession
138
+ // below will resolve again) purely to learn whether prompt caching will be
139
+ // active, so the batch count told to the model in the prompt below matches
140
+ // the limit ToolUsageTracker actually enforces — resolveResearchModel is a
141
+ // pure, cheap lookup over already-loaded registries, safe to call twice.
142
+ const cachingActiveForBudget = isPromptCachingActiveForModel(
143
+ resolveResearchModel({ modelRegistry: ctx.modelRegistry, config: this.config, hostModel: model, cwd: ctx.cwd })
144
+ );
145
+ const maxScrapeBatches = getMaxScrapeBatches(this.config, cachingActiveForBudget);
137
146
  const maxScrapeBatchesDisplay = maxScrapeBatches > 99 ? 'unlimited' : maxScrapeBatches.toString();
138
147
 
139
148
  const quickEvidenceSection =
@@ -162,19 +171,29 @@ export class QuickResearchOrchestrator {
162
171
  }
163
172
  evidenceLines.push(quickEvidenceSection);
164
173
 
174
+ // The system prompt carries only run-invariant instructions; the goal,
175
+ // knowledge-store results, evidence, and steering go in the initial USER
176
+ // message below. A byte-identical system prompt + tool set is the cacheable
177
+ // prefix providers key on, so back-to-back quick runs (and every follow-up
178
+ // turn within this session) reuse it via prompt-cache reads.
165
179
  const prompt = injectCurrentDate(researcherPromptTemplate, 'researcher')
166
- // Function replacers: insert `$`-bearing dynamic content (scraped evidence,
167
- // store descriptions, the user query) literally, not as a substitution pattern.
168
- .replace('{{goal}}', () => query + steeringSection)
169
- .replace('{{store_section}}', () => storeSection)
170
- .replace('{{evidence_section}}', () => evidenceLines.join('\n\n'))
171
- .replace('{{coordination_section}}', '')
172
180
  .replace('{{extra_tool_guidelines}}', '- `search`: Perform broad web searches (Round 1 only).')
173
181
  // Quick research has no research lead and no next round — see
174
182
  // RESEARCHER_DIGEST_SECTION. Its report IS the deliverable.
175
- .replace('{{digest_section}}', '');
183
+ .replace('{{digest_section}}', '')
184
+ .trim();
176
185
 
177
- logger.debug(`[QuickOrchestrator] System Prompt:\n${prompt}`);
186
+ // `$`-bearing dynamic content (the user query, store descriptions, steering)
187
+ // is plain string concatenation here — no String.replace substitution risk.
188
+ const userMessage = [
189
+ `Goal: ${query}`,
190
+ storeSection.trim(),
191
+ evidenceLines.join('\n\n').trim(),
192
+ steeringSection.trim(),
193
+ 'Perform your research and submit your full report now.',
194
+ ].filter(Boolean).join('\n\n');
195
+
196
+ logger.debug(`[QuickOrchestrator] System Prompt:\n${prompt}\n\nUser Message:\n${userMessage}`);
178
197
 
179
198
  let lastSeenSearchCount = 0;
180
199
  const { session, resolvedModel } = await createResearcherSession({
@@ -287,7 +306,7 @@ export class QuickResearchOrchestrator {
287
306
  // whether the race resolves via session.prompt, timeout, or abort.
288
307
  let abortCleanup: (() => void) | undefined;
289
308
  try {
290
- const promptPromise = session.prompt(query);
309
+ const promptPromise = session.prompt(userMessage);
291
310
  promptPromise.catch((err: Error) => logger.debug(`[QuickOrchestrator] Background session prompt rejection: ${err.message}`));
292
311
  await Promise.race([
293
312
  promptPromise,
@@ -34,6 +34,22 @@ const NO_SOURCES_NOTE =
34
34
  const UNVERIFIED_LINKS_NOTE =
35
35
  '_None of the links above could be verified: no page fetch succeeded this session and no citation could be matched to retrieved content. They may be inaccurate or invented — check each one before relying on it._';
36
36
 
37
+ // Below this many verified sources, a completed report is WEAKLY grounded: it will
38
+ // still read as a fully sourced document, but almost none of its claims can trace to
39
+ // retrieved content — exactly the shape of a run where scrapes mostly failed (bot
40
+ // blocks, resource starvation) or the topic had little real coverage, and the model
41
+ // filled the gaps from its own weights. Observed in production: a depth-1 run whose
42
+ // report cited ONE page shipped with no caveat and fabricated most of its specifics;
43
+ // the calling agent had to detect the degradation itself. The note gives readers
44
+ // (human or agent) the machine-visible signal that was missing.
45
+ const WEAK_GROUNDING_MIN_SOURCES = 3;
46
+
47
+ function weakGroundingNote(count: number): string {
48
+ return `_Grounding notice: only ${count} source${count === 1 ? '' : 's'} could be verified for this report. ` +
49
+ 'Claims not attributable to the cited source' + (count === 1 ? '' : 's') +
50
+ ' above should be treated as unverified — cross-check anything load-bearing before relying on it._';
51
+ }
52
+
37
53
 
38
54
  /**
39
55
  * Redact inline `https?://` tokens in synthesis prose whose URL was NOT retrieved
@@ -382,6 +398,16 @@ export class ResearchSynthesisService implements IService {
382
398
  // fabricated source can still reach the user; inline URLs also violate the
383
399
  // required format. Verified inline references (in `trustedUrls`) are kept.
384
400
  body = redactUnverifiedProseUrls(body, trustedUrls);
401
+ if (globalCitations.length < WEAK_GROUNDING_MIN_SOURCES) {
402
+ // The notice goes BEFORE the CITED LINKS header, never after it: anything
403
+ // trailing the last citation entry is absorbed into that entry's
404
+ // Description by parseCitations, and quick mode re-parses this exact
405
+ // string to build knowledge-store entries — a notice placed after the
406
+ // list polluted the stored description of the final source on precisely
407
+ // the degraded runs the notice exists to flag.
408
+ logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} verified source(s) — appending grounding notice.`);
409
+ return `${body}\n\n${weakGroundingNote(globalCitations.length)}\n\n${verifiedLinksSection}`;
410
+ }
385
411
  return `${body}\n\n${verifiedLinksSection}`;
386
412
  }
387
413
  logger.warn('[ResearchSynthesisService] Synthesis missing CITED LINKS - appending verified version');
@@ -393,6 +419,12 @@ export class ResearchSynthesisService implements IService {
393
419
  const validIds = new Set(globalCitations.map((c) => c.id));
394
420
  let prose = rewriteCitationMarkers(synthesis.trim(), new Map(), (n) => !validIds.has(n));
395
421
  prose = redactUnverifiedProseUrls(prose, trustedUrls);
422
+ if (globalCitations.length < WEAK_GROUNDING_MIN_SOURCES) {
423
+ // Before the CITED LINKS header for the same parseCitations-absorption
424
+ // reason as the replace path above.
425
+ logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} verified source(s) — appending grounding notice.`);
426
+ return `${prose}\n\n${weakGroundingNote(globalCitations.length)}\n\n${verifiedLinksSection}`;
427
+ }
396
428
  return `${prose}\n\n${verifiedLinksSection}`;
397
429
  }
398
430
 
@@ -180,6 +180,20 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
180
180
  const researcherExecutionStartMs = Date.now();
181
181
  const deliveredSteeringIds = new Set<string>();
182
182
 
183
+ // The system prompt is deliberately invariant across researchers, rounds, AND
184
+ // retry attempts: everything per-researcher (goal, knowledge-store results,
185
+ // initial evidence, sibling coordination, steering) is delivered in the initial
186
+ // USER message instead. A byte-identical system prompt + tool set is the
187
+ // cacheable prefix providers key on, so every researcher after the first gets a
188
+ // prompt-cache read on that prefix rather than a fresh write. Built once, out
189
+ // of the retry loop, because nothing in it varies per attempt.
190
+ const researcherSystemPrompt = injectCurrentDate(researcherPromptTemplate, 'researcher')
191
+ .replace('{{extra_tool_guidelines}}', '')
192
+ // Function replacer so `$`-bearing content is inserted literally, not as a
193
+ // String.replace substitution pattern.
194
+ .replace('{{digest_section}}', () => RESEARCHER_DIGEST_SECTION)
195
+ .trim();
196
+
183
197
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
184
198
  // Launch boundary: never build a session for a run that is already cancelled
185
199
  // or fast-stopped. The abort sentinel keeps the wrapper's clean-cancel
@@ -221,30 +235,31 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
221
235
  }
222
236
  }
223
237
 
224
- // Generate prompt for this attempt, incorporating ALL steering messages delivered so far
238
+ // Build this attempt's initial USER message: the researcher's assignment plus
239
+ // every per-researcher section that used to live in the system prompt (goal,
240
+ // knowledge-store results, initial evidence, sibling coordination), and ALL
241
+ // steering messages delivered so far. Rebuilt per attempt so a retry picks up
242
+ // steering that arrived between attempts; the system prompt above never changes.
225
243
  const allSteering = getSteeringMessages(sessionId);
226
244
  let steeringSection = '';
227
245
  if (allSteering.length > 0) {
228
- steeringSection = '\n\n### ADDITIONAL USER GUIDANCE (Mandatory directional rules for your research)\n' +
246
+ steeringSection = '### ADDITIONAL USER GUIDANCE (Mandatory directional rules for your research)\n' +
229
247
  allSteering.map(m => {
230
248
  deliveredSteeringIds.add(m.id);
231
249
  return `- ${m.text}`;
232
250
  }).join('\n');
233
251
  }
234
252
 
235
- const prompt = injectCurrentDate(researcherPromptTemplate, 'researcher')
236
- // Function replacers so a `$`-bearing dynamic value (scraped evidence, store
237
- // descriptions, or an LLM-authored goal containing $&, $$, $1, …) is inserted
238
- // literally instead of being interpreted as a String.replace substitution pattern.
239
- .replace('{{goal}}', () => researcherConfig.goal)
240
- .replace('{{store_section}}', () => storeSection)
241
- .replace('{{evidence_section}}', () => evidenceSection)
242
- .replace('{{coordination_section}}', () => previousQueriesSection)
243
- .replace('{{extra_tool_guidelines}}', '')
244
- .replace('{{digest_section}}', () => RESEARCHER_DIGEST_SECTION)
245
- .trim() + steeringSection;
253
+ const userMessage = [
254
+ `Topic: ${researcherConfig.name}\nGoal: ${researcherConfig.goal}`,
255
+ storeSection.trim(),
256
+ evidenceSection.trim(),
257
+ previousQueriesSection.trim(),
258
+ steeringSection,
259
+ 'Perform your research and submit your full report now.',
260
+ ].filter(Boolean).join('\n\n');
246
261
 
247
- logger.debug(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} System Prompt:\n${prompt}`);
262
+ logger.debug(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} System Prompt:\n${researcherSystemPrompt}\n\nUser Message:\n${userMessage}`);
248
263
 
249
264
  const workerExclude = ['search'];
250
265
  const mergedExclude = [...new Set([...workerExclude, ...(options.excludeTools || [])])];
@@ -275,7 +290,7 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
275
290
  cwd: ctx.cwd,
276
291
  ctxModel: model,
277
292
  modelRegistry: ctx.modelRegistry,
278
- systemPrompt: prompt,
293
+ systemPrompt: researcherSystemPrompt,
279
294
  extensionCtx: ctx,
280
295
  excludeTools: mergedExclude,
281
296
  researcherId: id,
@@ -434,7 +449,7 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
434
449
 
435
450
  let abortCleanup: (() => void) | undefined;
436
451
  try {
437
- const promptPromise = session.prompt(`Topic: ${researcherConfig.name}\nGoal: ${researcherConfig.goal}\n\nPerform your research and submit your full report now.`);
452
+ const promptPromise = session.prompt(userMessage);
438
453
  promptPromise.catch((err: Error) => logger.debug(`[ResearcherExecutor] Background session prompt rejection: ${err.message}`));
439
454
  await Promise.race([
440
455
  promptPromise,