@lincoln504/pi-research 1.1.0 → 1.2.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 (82) hide show
  1. package/README.md +4 -4
  2. package/dist/cli.mjs +1404 -600
  3. package/dist/thread-worker.mjs +40 -11
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +19 -9
  6. package/docs/CHANGELOG.md +107 -1
  7. package/docs/CONFIGURATION.md +10 -8
  8. package/docs/KNOWLEDGE-STORE.md +5 -4
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +63 -5
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/build.cjs +23 -5
  14. package/scripts/cleanup.cjs +50 -10
  15. package/scripts/prepare.cjs +15 -5
  16. package/scripts/setup.cjs +24 -4
  17. package/skills/pi-research/README.md +9 -2
  18. package/skills/pi-research/SKILL.md +8 -5
  19. package/skills/pi-research/references/configuration.md +7 -3
  20. package/skills/pi-research/scripts/run.mjs +24 -2
  21. package/src/cli.ts +161 -21
  22. package/src/config.ts +56 -15
  23. package/src/constants.ts +31 -0
  24. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  25. package/src/core/interfaces/state-manager-interfaces.ts +1 -1
  26. package/src/core/llm/model-registry-factory.ts +40 -3
  27. package/src/core/llm/research-model-resolver.ts +2 -2
  28. package/src/core/pi-version.ts +136 -0
  29. package/src/core/service-registry.ts +117 -8
  30. package/src/index.ts +11 -18
  31. package/src/infrastructure/browser/browser-client.ts +16 -4
  32. package/src/infrastructure/browser/browser-error-utils.ts +12 -1
  33. package/src/infrastructure/browser/browser-server.ts +17 -5
  34. package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
  35. package/src/infrastructure/browser/cleanup-utils.ts +45 -7
  36. package/src/infrastructure/browser/client-agent.ts +4 -3
  37. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  38. package/src/infrastructure/browser/scheduler-factory.ts +26 -1
  39. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  40. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  41. package/src/infrastructure/browser/thread-worker.mjs +40 -11
  42. package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
  43. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  44. package/src/infrastructure/embedding/embedding-factory.ts +28 -11
  45. package/src/infrastructure/embedding/embedding-server.ts +65 -4
  46. package/src/infrastructure/file-lock-service.ts +223 -26
  47. package/src/infrastructure/process-lifecycle-service.ts +108 -31
  48. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  49. package/src/infrastructure/state/state-manager-service.ts +1 -1
  50. package/src/infrastructure/state/state-manager.ts +19 -20
  51. package/src/infrastructure/types/state-types.ts +9 -0
  52. package/src/knowledge/embedder-init.ts +1 -1
  53. package/src/knowledge/embedder.ts +52 -3
  54. package/src/knowledge/index.ts +1 -1
  55. package/src/knowledge/store.ts +35 -9
  56. package/src/knowledge/webgpu-viability.ts +9 -4
  57. package/src/knowledge/writer-queue.ts +88 -46
  58. package/src/orchestration/deep-research-orchestrator.ts +13 -2
  59. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  60. package/src/orchestration/research-orchestration-service.ts +2 -4
  61. package/src/orchestration/research-synthesis-service.ts +34 -92
  62. package/src/security/cisa-kev.ts +10 -3
  63. package/src/security/github-advisories.ts +18 -7
  64. package/src/security/nvd-client.ts +17 -6
  65. package/src/security/osv-client.ts +14 -4
  66. package/src/skill-install/skill-installer.ts +135 -9
  67. package/src/stackexchange/rest-client.ts +6 -2
  68. package/src/tools/research-knowledge-search.ts +23 -2
  69. package/src/tools/search.ts +6 -1
  70. package/src/utils/atomic-replace.ts +101 -0
  71. package/src/utils/circuit-breaker.ts +20 -0
  72. package/src/utils/citation-utils.ts +106 -8
  73. package/src/utils/http-body.ts +176 -0
  74. package/src/utils/llm-usage.ts +37 -0
  75. package/src/utils/metrics-summary.ts +25 -2
  76. package/src/utils/research-export.ts +9 -2
  77. package/src/web-research/browser-search.ts +50 -4
  78. package/src/web-research/retry-utils.ts +13 -1
  79. package/src/web-research/search.ts +20 -5
  80. package/src/web-research/types.ts +17 -4
  81. package/src/web-research/web-scraper.ts +7 -50
  82. package/src/youtube/transcript-client.ts +5 -1
@@ -79,6 +79,15 @@ export const SingletonStateSchema = Type.Object({
79
79
  // in-process embedder instead. Optional for entries written by older
80
80
  // processes, which are tolerated as matching.
81
81
  model: Type.Optional(Type.String()),
82
+ // Shared secret a follower must present on every embed request. Mirrors
83
+ // browserServer.authSecret: the server listens on loopback, but loopback is
84
+ // not an authorization boundary — any local process could otherwise enqueue
85
+ // embedding work, and because the serial queue steps the leader down when
86
+ // poisoned, sustained abuse denies the knowledge store rather than merely
87
+ // burning GPU. Optional so an entry written by an older leader still parses;
88
+ // a client that finds none simply sends no header, and such a leader does not
89
+ // check one either.
90
+ authSecret: Type.Optional(Type.String()),
82
91
  })),
83
92
  });
84
93
 
@@ -252,7 +252,7 @@ export async function purgeModelCache(model: string): Promise<void> {
252
252
  logger.warn(`[embedder] Refusing to purge unsafe model-cache path for model id ${JSON.stringify(model)}`);
253
253
  return;
254
254
  }
255
- await rm(modelDir, { recursive: true, force: true });
255
+ await rm(modelDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
256
256
  logger.warn(`[embedder] Purged corrupt model cache at ${modelDir}`);
257
257
  } catch (err) {
258
258
  logger.warn('[embedder] Failed to purge model cache:', err);
@@ -68,6 +68,14 @@ export class Embedder {
68
68
  private idleTimer: NodeJS.Timeout | null = null;
69
69
  private readonly IDLE_TIMEOUT_MS = 60 * 1000;
70
70
  private activeEmbeddings = 0;
71
+ /**
72
+ * Why the in-flight dispose was started. An IDLE dispose is a memory-reclaim
73
+ * pause that a later embed is expected to undo, so initialize() waits it out and
74
+ * re-initializes. A TERMINAL dispose (shutdown, config change, explicit
75
+ * disposal) must never be undone — re-initializing there would resurrect the
76
+ * ONNX pipeline during teardown. Only meaningful while state === 'disposing'.
77
+ */
78
+ private disposeReason: 'idle' | 'terminal' = 'terminal';
71
79
 
72
80
  constructor(options: EmbedderOptions) {
73
81
  this.model = options.model;
@@ -113,7 +121,7 @@ export class Embedder {
113
121
  }
114
122
  if (this.state === 'ready') {
115
123
  logger.info(`[embedder] Idle timeout reached (${this.IDLE_TIMEOUT_MS}ms), releasing GPU memory...`);
116
- this.dispose().catch(err => logger.warn('[embedder] Failed to dispose on idle:', err));
124
+ this.dispose('idle').catch(err => logger.warn('[embedder] Failed to dispose on idle:', err));
117
125
  }
118
126
  }, this.IDLE_TIMEOUT_MS);
119
127
  if (this.idleTimer) safeUnref(this.idleTimer);
@@ -129,7 +137,36 @@ export class Embedder {
129
137
  async initialize(): Promise<void> {
130
138
  if (this.state === 'ready') return;
131
139
  if (this.state === 'disposing') {
132
- throw new Error('Cannot initialize while disposing');
140
+ // An IDLE dispose is a pause, not an end: the pipeline was released purely
141
+ // to reclaim GPU/host memory and the next embed is meant to bring it back.
142
+ // Throwing here lost that race — the idle timer fires, dispose() flips the
143
+ // state, and a document that arrives during the (up to ~5s) teardown got
144
+ // "Cannot initialize while disposing", which nothing classifies as
145
+ // transient, so the writer queue dropped it with no retry. Wait the dispose
146
+ // out and re-initialize instead.
147
+ if (this.disposeReason === 'idle') {
148
+ logger.debug('[embedder] initialize() raced an idle dispose — waiting for it to finish, then re-initializing.');
149
+ await this.disposePromise?.catch(() => { /* a failed idle dispose must not block the revive */ });
150
+ // Re-check: another caller may have revived (or terminally disposed) the
151
+ // embedder while we waited.
152
+ if ((this.state as EmbedderState) === 'ready') return;
153
+ // A terminal dispose landed as a DOWNGRADE onto the idle dispose we just
154
+ // awaited: state is 'idle', but reviving is forbidden — pre-fix this fell
155
+ // through to a full model load during process teardown. (A LATER, fresh
156
+ // initialize()/embed() after the completed dispose never enters this
157
+ // branch and stays revivable — that re-use is pinned behaviour.) The cast
158
+ // defeats control-flow narrowing, which cannot see the awaited dispose
159
+ // mutating the field — same idiom as the state re-checks around it.
160
+ if ((this.disposeReason as 'idle' | 'terminal') === 'terminal') {
161
+ throw new Error('Cannot initialize while disposing');
162
+ }
163
+ if ((this.state as EmbedderState) === 'disposing') {
164
+ throw new Error('Cannot initialize while disposing');
165
+ }
166
+ } else {
167
+ // Terminal dispose (shutdown / config change): must NOT be revived.
168
+ throw new Error('Cannot initialize while disposing');
169
+ }
133
170
  }
134
171
 
135
172
  if (this.initializingPromise) {
@@ -529,14 +566,26 @@ export class Embedder {
529
566
  }
530
567
  }
531
568
 
532
- async dispose(): Promise<void> {
569
+ /**
570
+ * @param reason `'idle'` for the memory-reclaim pause driven by the idle timer —
571
+ * a later embed is allowed to revive the embedder. Anything else is terminal
572
+ * (shutdown, config change, explicit disposal) and must never be revived, so
573
+ * the default is deliberately the safe one: callers that do not opt in get
574
+ * terminal semantics.
575
+ */
576
+ async dispose(reason: 'idle' | 'terminal' = 'terminal'): Promise<void> {
533
577
  if (this.state === 'idle') return;
534
578
  this.stopIdleTimer();
535
579
 
536
580
  if (this.state === 'disposing' && this.disposePromise) {
581
+ // A terminal dispose landing on top of an in-flight idle dispose must
582
+ // downgrade the reason, so a concurrent initialize() cannot revive what is
583
+ // now a shutdown. Never the reverse.
584
+ if (reason === 'terminal') this.disposeReason = 'terminal';
537
585
  return this.disposePromise;
538
586
  }
539
587
 
588
+ this.disposeReason = reason;
540
589
  this.state = 'disposing';
541
590
  this.disposePromise = (async () => {
542
591
  // Wait for all active embeddings to complete
@@ -115,7 +115,7 @@ export async function forceDeleteKnowledgeStore(config?: Config, workspace?: str
115
115
  const dbDir = getDbDir(config, workspace);
116
116
  if (fs.existsSync(dbDir)) {
117
117
  try {
118
- fs.rmSync(dbDir, { recursive: true, force: true });
118
+ fs.rmSync(dbDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
119
119
  fs.mkdirSync(dbDir, { recursive: true });
120
120
  logger.info(`[knowledge] Knowledge store at ${dbDir} forcefully deleted and recreated.`);
121
121
  } catch (err) {
@@ -721,12 +721,28 @@ export class KnowledgeStore implements IKnowledgeStore {
721
721
  }
722
722
  }
723
723
 
724
- async deleteByUrlAndType(url: string, ingestionType: string): Promise<void> {
725
- // Coordinate with close() — see deleteByUrl.
726
- return this.trackOperation('deleteByUrlAndType', undefined, () => this.deleteByUrlAndTypeInner(url, ingestionType));
724
+ /**
725
+ * @param olderThan When set, only rows with `timestamp < olderThan` are removed.
726
+ * This is what lets a refresh write the replacement chunks FIRST and prune the
727
+ * superseded ones after, instead of deleting first and losing the entry
728
+ * outright if the insert then fails. `timestamp` is used as the discriminator
729
+ * because it is a real (indexed) column — `contentHash` lives inside the
730
+ * JSON-encoded `metadata` blob and cannot be filtered on.
731
+ */
732
+ async deleteByUrlAndType(url: string, ingestionType: string, olderThan?: number): Promise<void> {
733
+ // Coordinate with close() — see deleteByUrl. The closing-skip is surfaced
734
+ // rather than silently resolving: a prune skipped because the store is
735
+ // closing (a post-run background ingest racing a Ctrl+C close) leaves
736
+ // duplicates exactly like a failed prune does, but resolved cleanly, so the
737
+ // writer-queue's failed-prune accounting never saw its most common cause.
738
+ if (this.isClosing) {
739
+ logger.warn(`[store] Prune of ${ingestionType} chunks for ${url} skipped — store is closing; superseded duplicates remain until the next refresh.`);
740
+ metrics.increment('knowledge_store_delete_total', 1, { operation: 'by_url_and_type', status: 'skipped_closing' });
741
+ }
742
+ return this.trackOperation('deleteByUrlAndType', undefined, () => this.deleteByUrlAndTypeInner(url, ingestionType, olderThan));
727
743
  }
728
744
 
729
- private async deleteByUrlAndTypeInner(url: string, ingestionType: string): Promise<void> {
745
+ private async deleteByUrlAndTypeInner(url: string, ingestionType: string, olderThan?: number): Promise<void> {
730
746
  let retryCount = 0;
731
747
  const MAX_RETRIES = 5;
732
748
  const BASE_DELAY = 100;
@@ -738,11 +754,15 @@ export class KnowledgeStore implements IKnowledgeStore {
738
754
  try {
739
755
  const escapedUrl = url.replace(/'/g, "''");
740
756
  const escapedType = ingestionType.replace(/'/g, "''");
741
- await table.delete(`url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND (${scopeFilter})`);
757
+ // Integer-coerced so the predicate can never carry anything but digits.
758
+ const ageClause = Number.isFinite(olderThan as number)
759
+ ? ` AND timestamp < ${Math.floor(olderThan as number)}`
760
+ : '';
761
+ await table.delete(`url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND (${scopeFilter})${ageClause}`);
742
762
  const duration = Date.now() - startTime;
743
763
  metrics.observe('knowledge_store_delete_duration_ms', duration, { operation: 'by_url_and_type' });
744
764
  metrics.increment('knowledge_store_delete_total', 1, { operation: 'by_url_and_type', status: 'success' });
745
- logger.log(`[store] Deleted ${ingestionType} chunks for ${url} (scoped)`);
765
+ logger.log(`[store] Deleted ${ingestionType} chunks for ${url} (scoped)${ageClause ? ' older than the replacement' : ''}`);
746
766
  return;
747
767
  } catch (err) {
748
768
  if (isLanceCommitConflict(err)) {
@@ -842,10 +862,14 @@ export class KnowledgeStore implements IKnowledgeStore {
842
862
  const startTime = Date.now();
843
863
  const escapedUrl = url.replace(/'/g, "''");
844
864
 
865
+ // limit > 1 deliberately: after a failed prune two generations of this URL can
866
+ // coexist, and LanceDB's fragment-scan order typically returns the OLDEST row
867
+ // first — limit(1) would serve the superseded content (and feed its older
868
+ // timestamp to the serve-age gate) until the next successful ingest.
845
869
  const results = await table
846
870
  .query()
847
871
  .where(`url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`)
848
- .limit(1)
872
+ .limit(16)
849
873
  .toArray();
850
874
 
851
875
  const duration = Date.now() - startTime;
@@ -856,6 +880,8 @@ export class KnowledgeStore implements IKnowledgeStore {
856
880
  metrics.increment('knowledge_store_cache_hits_total', 1, { status: 'miss' });
857
881
  return null;
858
882
  }
883
+ // Newest generation wins, never row order.
884
+ results.sort((a: any, b: any) => Number(b.timestamp) - Number(a.timestamp));
859
885
 
860
886
  const r = results[0];
861
887
  // Read-time freshness. The stored timestamp (ms) is otherwise discarded here;
@@ -1081,7 +1107,7 @@ export class KnowledgeStore implements IKnowledgeStore {
1081
1107
  for (const name of migrationDirs) {
1082
1108
  const fullPath = path.join(this.options.dbDir, name);
1083
1109
  logger.info(`[store] Removing orphaned migration temp directory: ${name}`);
1084
- await fsPromises.rm(fullPath, { recursive: true, force: true });
1110
+ await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
1085
1111
  }
1086
1112
 
1087
1113
  // `_backup_` dirs are intentional, user-recoverable snapshots from the
@@ -1094,7 +1120,7 @@ export class KnowledgeStore implements IKnowledgeStore {
1094
1120
  for (const name of backupDirs.slice(0, -1)) {
1095
1121
  const fullPath = path.join(this.options.dbDir, name);
1096
1122
  logger.info(`[store] Removing superseded backup directory: ${name}`);
1097
- await fsPromises.rm(fullPath, { recursive: true, force: true });
1123
+ await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
1098
1124
  }
1099
1125
  }
1100
1126
  } catch (err) {
@@ -150,10 +150,13 @@ function runProbe(model: string, cacheDir: string, timeoutMs: number): Promise<b
150
150
  }, timeoutMs);
151
151
  if (typeof timer.unref === 'function') timer.unref();
152
152
 
153
- let stdout = '';
154
- let stderr = '';
155
- child.stdout?.on('data', (d) => { stdout += String(d); });
156
- child.stderr?.on('data', (d) => { stderr += String(d); });
153
+ // Buffered and decoded once at 'close': per-chunk String(d) mangles a
154
+ // multi-byte character straddling a pipe-chunk boundary (the PROBE_OK
155
+ // sentinel is ASCII, but the logged stderr detail line is not guaranteed to be).
156
+ const stdoutChunks: Buffer[] = [];
157
+ const stderrChunks: Buffer[] = [];
158
+ child.stdout?.on('data', (d) => { stdoutChunks.push(Buffer.from(d)); });
159
+ child.stderr?.on('data', (d) => { stderrChunks.push(Buffer.from(d)); });
157
160
 
158
161
  child.on('error', (err) => {
159
162
  logger.warn('[embedder] WebGPU probe process error — defaulting to CPU:', err);
@@ -161,6 +164,8 @@ function runProbe(model: string, cacheDir: string, timeoutMs: number): Promise<b
161
164
  });
162
165
 
163
166
  child.on('close', (code, signal) => {
167
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8');
168
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
164
169
  // Viability is the sentinel, not the exit code (teardown may abort post-success).
165
170
  if (stdout.includes('PROBE_OK')) {
166
171
  finish(true);
@@ -16,7 +16,7 @@ interface WriterQueueOptions {
16
16
  store: {
17
17
  isStoreClosed?: () => boolean;
18
18
  findByUrl: (url: string) => Promise<StoreDocument[]>;
19
- deleteByUrlAndType: (url: string, type: string) => Promise<void>;
19
+ deleteByUrlAndType: (url: string, type: string, olderThan?: number) => Promise<void>;
20
20
  addDocuments: (docs: StoreDocument[]) => Promise<void>;
21
21
  };
22
22
  chunker: Chunker | null;
@@ -73,50 +73,58 @@ export class WriterQueue implements IWriterQueue {
73
73
  if (this.processing) return;
74
74
  this.processing = true;
75
75
 
76
- while (this.queue.length > 0) {
77
- const item = this.queue.shift()!;
78
- try {
79
- // FIX (#2): Chain per-URL to serialize concurrent ingests of the same URL.
80
- // process() already runs sequentially, so we chain only for the race case
81
- // where two enqueue() calls trigger concurrent process() calls.
82
- const urlKey = normalizeUrl(item.url);
83
- const prev = this.inflightByUrl.get(urlKey) ?? Promise.resolve();
84
- const inflight = prev.then(() => this._ingestInner(item));
85
- this.inflightByUrl.set(urlKey, inflight);
76
+ // try/finally around the whole drain: the latch and the drain notification used
77
+ // to sit on the normal exit path only. Anything escaping the loop — including
78
+ // from the catch block below, which itself awaits and logs — left `processing`
79
+ // stuck true forever: no further item would ever be ingested, drain() would never
80
+ // resolve, and dispose() would burn its full timeout on every later shutdown.
81
+ // enqueue() swallows this promise's rejection, so the wedge was silent.
82
+ try {
83
+ while (this.queue.length > 0) {
84
+ const item = this.queue.shift()!;
86
85
  try {
87
- await inflight;
88
- } finally {
89
- if (this.inflightByUrl.get(urlKey) === inflight) {
90
- this.inflightByUrl.delete(urlKey);
91
- }
92
- }
93
- } catch (err) {
94
- if (isEmbedderUnreachable(err)) {
95
- logger.warn(`[writer-queue] Embedder unreachable for ${item.url}, retrying once after 2s...`);
96
- await new Promise(r => setTimeout(r, 2000));
86
+ // FIX (#2): Chain per-URL to serialize concurrent ingests of the same URL.
87
+ // process() already runs sequentially, so we chain only for the race case
88
+ // where two enqueue() calls trigger concurrent process() calls.
89
+ const urlKey = normalizeUrl(item.url);
90
+ const prev = this.inflightByUrl.get(urlKey) ?? Promise.resolve();
91
+ const inflight = prev.then(() => this._ingestInner(item));
92
+ this.inflightByUrl.set(urlKey, inflight);
97
93
  try {
98
- await this.ingest(item);
99
- } catch (retryErr) {
100
- logger.error(`[writer-queue] Retry failed for ${item.url}, dropping:`, retryErr);
101
- metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'embedder_unreachable' });
94
+ await inflight;
95
+ } finally {
96
+ if (this.inflightByUrl.get(urlKey) === inflight) {
97
+ this.inflightByUrl.delete(urlKey);
98
+ }
99
+ }
100
+ } catch (err) {
101
+ if (isEmbedderUnreachable(err)) {
102
+ logger.warn(`[writer-queue] Embedder unreachable for ${item.url}, retrying once after 2s...`);
103
+ await new Promise(r => setTimeout(r, 2000));
104
+ try {
105
+ await this.ingest(item);
106
+ } catch (retryErr) {
107
+ logger.error(`[writer-queue] Retry failed for ${item.url}, dropping:`, retryErr);
108
+ metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'embedder_unreachable' });
109
+ }
110
+ } else if (isNoSpace(err)) {
111
+ logger.error(`[writer-queue] ENOSPC: disk full — dropping ${item.url}. Free up disk space to resume knowledge ingestion.`);
112
+ metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'enospc' });
113
+ } else {
114
+ logger.error(`[writer-queue] Failed to ingest ${item.url}:`, err);
115
+ metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'ingest_error' });
102
116
  }
103
- } else if (isNoSpace(err)) {
104
- logger.error(`[writer-queue] ENOSPC: disk full — dropping ${item.url}. Free up disk space to resume knowledge ingestion.`);
105
- metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'enospc' });
106
- } else {
107
- logger.error(`[writer-queue] Failed to ingest ${item.url}:`, err);
108
- metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'ingest_error' });
109
117
  }
110
118
  }
111
- }
119
+ } finally {
120
+ this.processing = false;
112
121
 
113
- this.processing = false;
114
-
115
- // Notify all drain callers
116
- const resolvers = [...this.drainResolvers];
117
- this.drainResolvers = [];
118
- for (const resolve of resolvers) {
119
- resolve();
122
+ // Notify all drain callers
123
+ const resolvers = [...this.drainResolvers];
124
+ this.drainResolvers = [];
125
+ for (const resolve of resolvers) {
126
+ resolve();
127
+ }
120
128
  }
121
129
  }
122
130
 
@@ -172,15 +180,28 @@ export class WriterQueue implements IWriterQueue {
172
180
  // Dedup: check if we already have this exact URL+type with identical content.
173
181
  // findByUrl applies scope filtering, so this only dedupes within the
174
182
  // current project's visible scope (local workspace + global entries).
183
+ // Compared against the NEWEST generation, not row order: after a failed prune
184
+ // two generations coexist and findByUrl's order is arbitrary — matching the
185
+ // OLD generation's hash would skip the refresh with the duplicates in place.
175
186
  const existing = await this.options.store.findByUrl(normalizedUrl);
176
187
  const sameType = existing.filter(c => c.metadata['ingestionType'] === incomingType);
177
- if (sameType.length > 0 && sameType[0]!.metadata['contentHash'] === hash) {
178
- logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) — content unchanged.`);
179
- return;
180
- }
181
-
182
188
  if (sameType.length > 0) {
183
- await this.options.store.deleteByUrlAndType(normalizedUrl, incomingType);
189
+ const newestTs = Math.max(...sameType.map(d => Number(d.timestamp) || 0));
190
+ const newest = sameType.find(d => (Number(d.timestamp) || 0) === newestTs)!;
191
+ if (newest.metadata['contentHash'] === hash) {
192
+ // Content unchanged vs the current generation — but a prior failed prune
193
+ // may still have left older generations behind, and with stable content
194
+ // this skip is the only code that ever looks at this URL again. Collect
195
+ // them now instead of preserving the duplicates forever.
196
+ if (sameType.some(d => (Number(d.timestamp) || 0) < newestTs)) {
197
+ await this.options.store.deleteByUrlAndType(normalizedUrl, incomingType, newestTs).catch((err) => {
198
+ logger.warn(`[writer-queue] Sweeping superseded duplicates for ${normalizedUrl} (${incomingType}) failed:`, err);
199
+ metrics.increment('knowledge_ingest_stale_prune_failed_total', 1);
200
+ });
201
+ }
202
+ logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) — content unchanged.`);
203
+ return;
204
+ }
184
205
  }
185
206
 
186
207
  const rawChunks = this.options.chunker
@@ -189,6 +210,10 @@ export class WriterQueue implements IWriterQueue {
189
210
 
190
211
  if (rawChunks.length === 0) return;
191
212
 
213
+ // Single timestamp shared by every chunk of this write: it is both the row's
214
+ // recency value and the boundary used to prune the rows this write replaces.
215
+ const insertedAt = Date.now();
216
+
192
217
  const docs: StoreDocument[] = rawChunks.map((chunk, i) => ({
193
218
  url: normalizedUrl,
194
219
  text: chunk.text,
@@ -203,10 +228,27 @@ export class WriterQueue implements IWriterQueue {
203
228
  chunkIndex: i,
204
229
  totalChunks: rawChunks.length,
205
230
  },
206
- timestamp: Date.now(),
231
+ timestamp: insertedAt,
207
232
  }));
208
233
 
234
+ // Write the replacement BEFORE removing what it replaces. Deleting first made
235
+ // the refresh destructive: addDocuments can fail (embedder unreachable, ENOSPC,
236
+ // store closing mid-write) and the perfectly good previous entry was already
237
+ // gone, so a failed refresh left the URL with no entry at all rather than a
238
+ // stale one. In this order a failure leaves the old entry intact and the retry
239
+ // simply tries again.
209
240
  await this.options.store.addDocuments(docs);
241
+
242
+ if (sameType.length > 0) {
243
+ // Prune only rows older than this write, so the delete cannot take the rows
244
+ // just inserted. Failing here leaves duplicate chunks — visible but harmless,
245
+ // and cleaned up by the next successful ingest of this URL — which is a far
246
+ // better outcome than throwing away a write that already succeeded.
247
+ await this.options.store.deleteByUrlAndType(normalizedUrl, incomingType, insertedAt).catch((err) => {
248
+ logger.warn(`[writer-queue] Replacement for ${normalizedUrl} (${incomingType}) is stored, but pruning the superseded chunks failed; duplicates remain until the next refresh:`, err);
249
+ metrics.increment('knowledge_ingest_stale_prune_failed_total', 1);
250
+ });
251
+ }
210
252
  }
211
253
 
212
254
  async drain(timeoutMs?: number): Promise<void> {
@@ -10,7 +10,7 @@ import { logger } from '../logger.ts';
10
10
  import { safeUnref } from '../utils/safe-unref.ts';
11
11
  import { metrics } from '../utils/metrics.ts';
12
12
  import { getSteeringMessages, consumeQueuedMessages, getActiveSteeringMessages, getFailedResearchers, getResearcherFailureReasons } from './session-state.ts';
13
- import { MAX_EXTRA_ROUNDS_WITH_STEERING } from '../constants.ts';
13
+ import { MAX_EXTRA_ROUNDS_WITH_STEERING, resolveExcludedTools } from '../constants.ts';
14
14
  import {
15
15
  getMaxRounds,
16
16
  } from '../core/planning-utils.ts';
@@ -218,6 +218,17 @@ export class DeepResearchOrchestrator {
218
218
  excludeTools: this.options.excludeTools,
219
219
  steeringMessages: steeringTexts,
220
220
  });
221
+ } else if (this.currentRound >= maxRounds) {
222
+ // Last iteration: the `plan.action === 'synthesize' || currentRound >=
223
+ // maxRounds` break below fires no matter what the evaluator decides, and
224
+ // the forced-synthesis path after the loop runs updatePlanForRound again
225
+ // with mustSynthesize. So on every capped run whose evaluator would have
226
+ // said `delegate` — the common case, since it is being cut off mid-plan —
227
+ // this call is a second full-context coordinator request whose result is
228
+ // discarded. Break straight to the forced synthesis: exactly one
229
+ // coordinator call either way, and the same outcome.
230
+ logger.log(`[DeepOrchestrator] Round cap (${maxRounds}) reached — skipping the evaluator and going straight to final synthesis ${this.elapsed()}`);
231
+ break;
221
232
  } else {
222
233
  const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
223
234
  observer?.onEvaluationStart?.(this.currentRound);
@@ -382,7 +393,7 @@ export class DeepResearchOrchestrator {
382
393
  options: {
383
394
  ...this.options,
384
395
  config: this.config,
385
- excludeTools: this.options.excludeTools || ['grep'],
396
+ excludeTools: resolveExcludedTools(this.options.excludeTools),
386
397
  observer: this.observer,
387
398
  },
388
399
  currentRound: this.currentRound,
@@ -18,7 +18,7 @@ import { logger } from '../logger.ts';
18
18
  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
- import { getMaxScrapeBatches } from '../constants.ts';
21
+ import { getMaxScrapeBatches, resolveExcludedTools } from '../constants.ts';
22
22
  import type { ResearchObserver } from '../core/interfaces/observer-interfaces.ts';
23
23
  import { HeadlessObserver, makeSafeObserver, type HeadlessObserverOptions } from './headless-observer.ts';
24
24
  import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
@@ -180,7 +180,7 @@ export class QuickResearchOrchestrator {
180
180
  modelRegistry: ctx.modelRegistry,
181
181
  systemPrompt: prompt,
182
182
  extensionCtx: ctx,
183
- excludeTools: this.options.excludeTools || ['grep'],
183
+ excludeTools: resolveExcludedTools(this.options.excludeTools),
184
184
  config: this.config,
185
185
  getGlobalState: (): SystemResearchState => ({
186
186
  version: 1,
@@ -14,7 +14,7 @@ import type { Model } from '@earendil-works/pi-ai';
14
14
  import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
15
15
  import type { QueryResultWithError } from '../web-research/types.ts';
16
16
  import type { RunResearchersOptions } from './orchestration-types.ts';
17
- import { RESEARCHER_LAUNCH_DELAY_MS } from '../constants.ts';
17
+ import { RESEARCHER_LAUNCH_DELAY_MS, resolveExcludedTools } from '../constants.ts';
18
18
  import { search } from '../web-research/search.ts';
19
19
  import { parseCitations } from '../utils/text-utils.ts';
20
20
  import { logger, resetLogger } from '../logger.ts';
@@ -98,9 +98,7 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
98
98
  const configDisabledTools = researchConfig.DISABLED_TOOLS
99
99
  ? researchConfig.DISABLED_TOOLS.split(',').map((s) => s.trim()).filter(Boolean)
100
100
  : [];
101
- const mergedExcludeTools = configDisabledTools.length > 0
102
- ? [...new Set([...(excludeTools ?? []), ...configDisabledTools])]
103
- : excludeTools;
101
+ const mergedExcludeTools = resolveExcludedTools(excludeTools, configDisabledTools);
104
102
 
105
103
  // Resolve model using centralized priority logic
106
104
  const selectedModel = await this.resolveResearchModel(options);