@lincoln504/pi-research 1.1.2 → 1.3.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 (119) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +2599 -1268
  3. package/dist/thread-worker.mjs +224 -147
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +20 -10
  6. package/docs/CHANGELOG.md +195 -17
  7. package/docs/CONFIGURATION.md +9 -7
  8. package/docs/KNOWLEDGE-STORE.md +4 -3
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +55 -0
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/cleanup.cjs +114 -12
  14. package/scripts/prepare.cjs +15 -5
  15. package/scripts/setup.cjs +24 -4
  16. package/skills/pi-research/README.md +9 -2
  17. package/skills/pi-research/SKILL.md +6 -3
  18. package/skills/pi-research/references/configuration.md +6 -2
  19. package/skills/pi-research/scripts/run.mjs +24 -2
  20. package/src/cleanup/research-cleanup.ts +12 -4
  21. package/src/cli.ts +372 -86
  22. package/src/config.ts +134 -17
  23. package/src/constants.ts +31 -0
  24. package/src/core/interfaces/health-check-interfaces.ts +3 -39
  25. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  26. package/src/core/interfaces/planning-interfaces.ts +8 -0
  27. package/src/core/interfaces/service-names.ts +0 -1
  28. package/src/core/interfaces/state-manager-interfaces.ts +11 -3
  29. package/src/core/llm/agentic-repair.ts +14 -0
  30. package/src/core/pi-version.ts +136 -0
  31. package/src/core/planning-service.ts +43 -1
  32. package/src/core/service-initialization.ts +6 -14
  33. package/src/core/service-interfaces.ts +1 -1
  34. package/src/core/service-registry.ts +117 -8
  35. package/src/healthcheck/index.ts +7 -2
  36. package/src/index.ts +11 -18
  37. package/src/infrastructure/browser/browser-client.ts +80 -13
  38. package/src/infrastructure/browser/browser-error-utils.ts +6 -1
  39. package/src/infrastructure/browser/browser-server.ts +41 -12
  40. package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
  41. package/src/infrastructure/browser/cleanup-utils.ts +95 -9
  42. package/src/infrastructure/browser/client-agent.ts +6 -5
  43. package/src/infrastructure/browser/config.ts +14 -3
  44. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  45. package/src/infrastructure/browser/scheduler-factory.ts +44 -4
  46. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  47. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  48. package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
  49. package/src/infrastructure/browser/thread-worker-types.ts +3 -1
  50. package/src/infrastructure/browser/thread-worker.mjs +224 -147
  51. package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
  52. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  53. package/src/infrastructure/embedding/embedding-factory.ts +61 -15
  54. package/src/infrastructure/embedding/embedding-server.ts +105 -11
  55. package/src/infrastructure/file-lock-service.ts +295 -28
  56. package/src/infrastructure/knowledge-store-service.ts +48 -3
  57. package/src/infrastructure/process-lifecycle-service.ts +134 -34
  58. package/src/infrastructure/research-run-semaphore.ts +37 -3
  59. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  60. package/src/infrastructure/state/state-browser-api.ts +5 -2
  61. package/src/infrastructure/state/state-browser-manager.ts +15 -1
  62. package/src/infrastructure/state/state-manager-service.ts +5 -5
  63. package/src/infrastructure/state/state-manager.ts +34 -23
  64. package/src/infrastructure/types/state-types.ts +9 -0
  65. package/src/knowledge/chunker.ts +88 -1
  66. package/src/knowledge/embedder-init.ts +1 -1
  67. package/src/knowledge/embedder.ts +188 -46
  68. package/src/knowledge/index.ts +1 -1
  69. package/src/knowledge/store.ts +101 -21
  70. package/src/knowledge/webgpu-viability.ts +9 -4
  71. package/src/knowledge/writer-queue.ts +88 -46
  72. package/src/logger.ts +5 -1
  73. package/src/observers/research-observer-impl.ts +31 -0
  74. package/src/orchestration/abort-utils.ts +60 -0
  75. package/src/orchestration/deep-research-orchestrator.ts +25 -4
  76. package/src/orchestration/headless-observer.ts +8 -0
  77. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  78. package/src/orchestration/research-orchestration-service.ts +65 -15
  79. package/src/orchestration/research-session-service.ts +37 -11
  80. package/src/orchestration/research-synthesis-service.ts +33 -11
  81. package/src/orchestration/researcher-executor.ts +100 -20
  82. package/src/orchestration/researcher.ts +39 -20
  83. package/src/orchestration/session-state.ts +11 -0
  84. package/src/sdk.ts +125 -19
  85. package/src/security/cisa-kev.ts +10 -3
  86. package/src/security/github-advisories.ts +18 -7
  87. package/src/security/index.ts +51 -30
  88. package/src/security/nvd-client.ts +24 -8
  89. package/src/security/osv-client.ts +14 -4
  90. package/src/skill-install/skill-installer.ts +153 -11
  91. package/src/stackexchange/index.ts +20 -5
  92. package/src/stackexchange/output/table.ts +7 -2
  93. package/src/stackexchange/rest-client.ts +41 -3
  94. package/src/tools/research-knowledge-search.ts +15 -0
  95. package/src/tools/research-tool-definition.ts +8 -2
  96. package/src/tools/scrape.ts +48 -25
  97. package/src/tools/search.ts +16 -2
  98. package/src/tools/security.ts +68 -7
  99. package/src/tui/research-health.ts +18 -2
  100. package/src/utils/atomic-replace.ts +101 -0
  101. package/src/utils/circuit-breaker.ts +9 -2
  102. package/src/utils/citation-utils.ts +3 -1
  103. package/src/utils/disk-space-checker.ts +11 -6
  104. package/src/utils/http-body.ts +176 -0
  105. package/src/utils/json-utils.ts +116 -76
  106. package/src/utils/llm-usage.ts +17 -1
  107. package/src/utils/log-rotation.ts +42 -10
  108. package/src/utils/log-utils.ts +40 -0
  109. package/src/utils/metrics-summary.ts +27 -9
  110. package/src/utils/stdio-capture.ts +63 -22
  111. package/src/utils/text-utils.ts +8 -1
  112. package/src/utils/tool-usage-tracker.ts +9 -2
  113. package/src/web-research/browser-search.ts +70 -5
  114. package/src/web-research/scraper-utils.ts +13 -0
  115. package/src/web-research/search.ts +25 -6
  116. package/src/web-research/types.ts +19 -4
  117. package/src/web-research/web-scraper.ts +72 -54
  118. package/src/youtube/transcript-client.ts +5 -1
  119. package/src/core/health-check-service.ts +0 -265
@@ -68,6 +68,36 @@ 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
+ * Embed calls actively INSIDE the model (a pipeline invocation in flight), as
73
+ * opposed to activeEmbeddings, which counts whole embed()/embedMany() calls —
74
+ * including callers parked on recoveryPromise after a device loss. recoverToCpu
75
+ * drains THIS count before swapping the pipeline: parked callers still hold
76
+ * their activeEmbeddings slot (decremented downstream in their finally), so a
77
+ * drain on activeEmbeddings could never succeed with ≥2 concurrent device-loss
78
+ * failures and always burned the full timeout.
79
+ */
80
+ private activeInferences = 0;
81
+ /**
82
+ * Callers parked on recoveryPromise — they JOINED an in-flight recovery while
83
+ * still holding their activeEmbeddings slot (the decrement runs downstream in
84
+ * their finally, after the joined recovery settles). recoverToCpu's drain must
85
+ * distinguish them from callers still progressing toward the pipeline (parked
86
+ * on the per-call GPU lock, or between embedMany batches): parked joiners can
87
+ * never finish until recovery completes (waiting on them deadlocks into the
88
+ * drain timeout), while the progressing callers MUST be waited for — disposing
89
+ * the pipeline under them raises a TypeError no device-error classifier
90
+ * recognizes, so their embed fails unrecovered.
91
+ */
92
+ private recoveryWaiters = 0;
93
+ /**
94
+ * Why the in-flight dispose was started. An IDLE dispose is a memory-reclaim
95
+ * pause that a later embed is expected to undo, so initialize() waits it out and
96
+ * re-initializes. A TERMINAL dispose (shutdown, config change, explicit
97
+ * disposal) must never be undone — re-initializing there would resurrect the
98
+ * ONNX pipeline during teardown. Only meaningful while state === 'disposing'.
99
+ */
100
+ private disposeReason: 'idle' | 'terminal' = 'terminal';
71
101
 
72
102
  constructor(options: EmbedderOptions) {
73
103
  this.model = options.model;
@@ -113,7 +143,7 @@ export class Embedder {
113
143
  }
114
144
  if (this.state === 'ready') {
115
145
  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));
146
+ this.dispose('idle').catch(err => logger.warn('[embedder] Failed to dispose on idle:', err));
117
147
  }
118
148
  }, this.IDLE_TIMEOUT_MS);
119
149
  if (this.idleTimer) safeUnref(this.idleTimer);
@@ -129,7 +159,36 @@ export class Embedder {
129
159
  async initialize(): Promise<void> {
130
160
  if (this.state === 'ready') return;
131
161
  if (this.state === 'disposing') {
132
- throw new Error('Cannot initialize while disposing');
162
+ // An IDLE dispose is a pause, not an end: the pipeline was released purely
163
+ // to reclaim GPU/host memory and the next embed is meant to bring it back.
164
+ // Throwing here lost that race — the idle timer fires, dispose() flips the
165
+ // state, and a document that arrives during the (up to ~5s) teardown got
166
+ // "Cannot initialize while disposing", which nothing classifies as
167
+ // transient, so the writer queue dropped it with no retry. Wait the dispose
168
+ // out and re-initialize instead.
169
+ if (this.disposeReason === 'idle') {
170
+ logger.debug('[embedder] initialize() raced an idle dispose — waiting for it to finish, then re-initializing.');
171
+ await this.disposePromise?.catch(() => { /* a failed idle dispose must not block the revive */ });
172
+ // Re-check: another caller may have revived (or terminally disposed) the
173
+ // embedder while we waited.
174
+ if ((this.state as EmbedderState) === 'ready') return;
175
+ // A terminal dispose landed as a DOWNGRADE onto the idle dispose we just
176
+ // awaited: state is 'idle', but reviving is forbidden — pre-fix this fell
177
+ // through to a full model load during process teardown. (A LATER, fresh
178
+ // initialize()/embed() after the completed dispose never enters this
179
+ // branch and stays revivable — that re-use is pinned behaviour.) The cast
180
+ // defeats control-flow narrowing, which cannot see the awaited dispose
181
+ // mutating the field — same idiom as the state re-checks around it.
182
+ if ((this.disposeReason as 'idle' | 'terminal') === 'terminal') {
183
+ throw new Error('Cannot initialize while disposing');
184
+ }
185
+ if ((this.state as EmbedderState) === 'disposing') {
186
+ throw new Error('Cannot initialize while disposing');
187
+ }
188
+ } else {
189
+ // Terminal dispose (shutdown / config change): must NOT be revived.
190
+ throw new Error('Cannot initialize while disposing');
191
+ }
133
192
  }
134
193
 
135
194
  if (this.initializingPromise) {
@@ -352,13 +411,36 @@ export class Embedder {
352
411
  return text;
353
412
  }
354
413
 
414
+ /**
415
+ * Run the pipeline over an input, tracked in activeInferences so recoverToCpu's
416
+ * drain loop waits only on calls genuinely inside the model.
417
+ */
418
+ private async runInference(input: string | string[]): Promise<any> {
419
+ // A disposal/recovery that raced this call may have nulled the pipeline out
420
+ // from under us. Calling through null raises a bare TypeError that nothing
421
+ // classifies (the writer queue drops the document as a generic ingest_error);
422
+ // throw the reconnect-classified signal instead — isEmbedderUnreachable
423
+ // matches "aborted" — so the caller retries via a fresh embedder.
424
+ if (!this.pipeline) {
425
+ throw new Error('[embedder] embed aborted: pipeline disposed before inference could run — reconnect required');
426
+ }
427
+ this.activeInferences++;
428
+ try {
429
+ return await logger.runCapturingStderr(async () => {
430
+ return await this.pipeline!(input, this.pipelineOpts());
431
+ });
432
+ } finally {
433
+ this.activeInferences--;
434
+ }
435
+ }
436
+
355
437
  async embed(text: string): Promise<Float32Array> {
356
438
  await this.initialize();
357
439
  // Compute the input BEFORE the active-count increment: truncateText is a pure
358
440
  // string op, and keeping it (and nothing else) ahead of the try/finally means
359
441
  // every path that can throw or await is inside the finally that decrements the
360
442
  // counter. Otherwise a throw here (or in acquireGpuLock) would leak the count,
361
- // permanently wedging recoverToCpu (15s spin) and dispose (5s hang).
443
+ // permanently wedging dispose (5s hang).
362
444
  const input = this.truncateText(this.queryPrefix ? this.queryPrefix + text : text);
363
445
  this.stopIdleTimer();
364
446
  this.activeEmbeddings++;
@@ -371,9 +453,7 @@ export class Embedder {
371
453
  logger.warn('[embedder] GPU per-call lock timeout after 15s — proceeding without lock');
372
454
  }
373
455
  }
374
- const output = await logger.runCapturingStderr(async () => {
375
- return await this.pipeline!(input, this.pipelineOpts());
376
- });
456
+ const output = await this.runInference(input);
377
457
  return output.data as Float32Array;
378
458
  } catch (err) {
379
459
  if (isWebGpuDeviceError(err)) {
@@ -388,9 +468,7 @@ export class Embedder {
388
468
  // dereferencing a null pipeline would raise a TypeError that MASKS the
389
469
  // original device error — rethrow the original instead.
390
470
  if (!this.pipeline) throw err;
391
- const output = await logger.runCapturingStderr(async () => {
392
- return await this.pipeline!(input, this.pipelineOpts());
393
- });
471
+ const output = await this.runInference(input);
394
472
  return output.data as Float32Array;
395
473
  }
396
474
  throw err;
@@ -435,9 +513,7 @@ export class Embedder {
435
513
 
436
514
  let output: any;
437
515
  try {
438
- output = await logger.runCapturingStderr(async () => {
439
- return await this.pipeline!(batch, this.pipelineOpts());
440
- });
516
+ output = await this.runInference(batch);
441
517
  } catch (err) {
442
518
  if (isWebGpuDeviceError(err)) {
443
519
  if (lockAcquired && this.stateManager) {
@@ -450,9 +526,7 @@ export class Embedder {
450
526
  // Same guard as embed(): recoverToCpu may have skipped (disposing/idle),
451
527
  // leaving pipeline null — rethrow the original error, not a TypeError.
452
528
  if (!this.pipeline) throw err;
453
- output = await logger.runCapturingStderr(async () => {
454
- return await this.pipeline!(batch, this.pipelineOpts());
455
- });
529
+ output = await this.runInference(batch);
456
530
  } else {
457
531
  throw err;
458
532
  }
@@ -476,7 +550,17 @@ export class Embedder {
476
550
  }
477
551
 
478
552
  private async recoverToCpu(): Promise<void> {
479
- if (this.recoveryPromise) return this.recoveryPromise;
553
+ if (this.recoveryPromise) {
554
+ // Counted join: this caller still holds its activeEmbeddings slot while
555
+ // parked here, and the drain below must not wait on parked joiners (see
556
+ // the recoveryWaiters field comment).
557
+ this.recoveryWaiters++;
558
+ try {
559
+ return await this.recoveryPromise;
560
+ } finally {
561
+ this.recoveryWaiters--;
562
+ }
563
+ }
480
564
 
481
565
  // Guard: if disposal has already started, skip recovery — the embedder is going away.
482
566
  if (this.state === 'disposing' || this.state === 'idle') {
@@ -484,22 +568,42 @@ export class Embedder {
484
568
  return;
485
569
  }
486
570
 
487
- this.recoveryPromise = (async () => {
571
+ // Close the initialize() re-entry window SYNCHRONOUSLY, before the first
572
+ // await: initialize() only checks 'ready'/'disposing' and initializingPromise,
573
+ // so until the recovery promise below is published, a concurrent
574
+ // embed()/embedMany() passed every guard and launched a second concurrent
575
+ // _initializeInternal — racing pipeline/state assignments, leaking the losing
576
+ // pipeline, and (device was flipped to 'cpu' only after the drain loop)
577
+ // reloading the exact WebGPU backend that just failed. Flip device first so
578
+ // no joiner can ever observe 'webgpu' again this session.
579
+ this.state = 'initializing';
580
+ this.device = 'cpu';
581
+
582
+ const recovery = (async () => {
488
583
  logger.warn('[embedder] WebGPU device error detected during operation — falling back to CPU for the remainder of this session');
489
-
584
+
490
585
  markWebGpuFallback();
491
-
586
+
492
587
  await releaseGpuLock(this.stateManager, this.gpuLockHeld);
493
588
  this.gpuLockHeld = false;
494
-
495
- this.state = 'initializing';
496
-
497
- // Wait for other concurrent embeddings to finish before disposing the pipeline
589
+
590
+ // Drain in-flight callers before disposing the pipeline, on BOTH counts:
591
+ // - activeInferences > 0: a call genuinely inside the model.
592
+ // - activeEmbeddings - recoveryWaiters > 1: callers past initialize() but
593
+ // not yet inside the model — parked on the per-call GPU lock, or between
594
+ // embedMany batches. Self counts as 1 (this recovery runs from a caller
595
+ // holding its own slot); parked joiners are subtracted because they can
596
+ // never finish until THIS recovery completes (an uncorrected
597
+ // activeEmbeddings drain always burned the full timeout under
598
+ // multi-caller device loss). An inferences-only drain (the prior form)
599
+ // disposed the pipeline under a lock-parked caller, whose embed then
600
+ // failed with an unclassified TypeError, masking the device loss.
498
601
  const maxWaitMs = 15000;
499
602
  const startTime = Date.now();
500
- // We expect activeEmbeddings to be at least 1 (the current caller).
501
- // If multiple callers are in recoverToCpu, they all wait for recoveryPromise.
502
- while (this.activeEmbeddings > 1 && (Date.now() - startTime) < maxWaitMs) {
603
+ while (
604
+ (this.activeInferences > 0 || this.activeEmbeddings - this.recoveryWaiters > 1) &&
605
+ (Date.now() - startTime) < maxWaitMs
606
+ ) {
503
607
  await new Promise(resolve => setTimeout(resolve, 50));
504
608
  }
505
609
 
@@ -507,48 +611,77 @@ export class Embedder {
507
611
  try { if (typeof (this.pipeline as any).dispose === 'function') await (this.pipeline as DisposablePipeline).dispose(); } catch (err) { logger.warn('[embedder] Error disposing pipeline:', err); }
508
612
  this.pipeline = null;
509
613
  }
510
- this.device = 'cpu';
511
-
512
- this.initializingPromise = this._initializeInternal();
614
+
513
615
  try {
514
- await this.initializingPromise;
515
- this.state = 'ready';
616
+ await this._initializeInternal();
516
617
  } catch (e) {
517
- this.state = 'failed';
518
- throw e;
519
- } finally {
520
- this.initializingPromise = null;
618
+ this.state = 'failed';
619
+ throw e;
521
620
  }
621
+ // Same guard as initialize(): a terminal dispose may have landed while the
622
+ // CPU pipeline loaded — never overwrite 'disposing' with 'ready'. (Cast
623
+ // defeats control-flow narrowing, which cannot see the awaited init racing
624
+ // dispose() — same idiom as the state re-checks in initialize().)
625
+ if ((this.state as EmbedderState) === 'disposing') {
626
+ logger.warn('[embedder] Recovery finished but embedder was disposed in the meantime.');
627
+ return;
628
+ }
629
+ this.state = 'ready';
522
630
  logger.warn('[embedder] CPU fallback recovery complete.');
523
631
  })();
524
632
 
633
+ this.recoveryPromise = recovery;
634
+ // Publish for initialize(): a caller arriving mid-recovery must join the
635
+ // WHOLE recovery (drain + pipeline swap + re-init), not start its own. This
636
+ // and the assignments above run before recovery's first await, so there is
637
+ // no window in which a joiner can slip past.
638
+ this.initializingPromise = recovery;
639
+
525
640
  try {
526
- await this.recoveryPromise;
641
+ await recovery;
527
642
  } finally {
528
- this.recoveryPromise = null;
643
+ if (this.recoveryPromise === recovery) this.recoveryPromise = null;
644
+ // Guarded: by the time this finally runs, a fresh initialize() may already
645
+ // own initializingPromise (e.g. after a failed recovery) — never null it out
646
+ // from under that caller.
647
+ if (this.initializingPromise === recovery) this.initializingPromise = null;
529
648
  }
530
649
  }
531
650
 
532
- async dispose(): Promise<void> {
651
+ /**
652
+ * @param reason `'idle'` for the memory-reclaim pause driven by the idle timer —
653
+ * a later embed is allowed to revive the embedder. Anything else is terminal
654
+ * (shutdown, config change, explicit disposal) and must never be revived, so
655
+ * the default is deliberately the safe one: callers that do not opt in get
656
+ * terminal semantics.
657
+ */
658
+ async dispose(reason: 'idle' | 'terminal' = 'terminal'): Promise<void> {
533
659
  if (this.state === 'idle') return;
534
660
  this.stopIdleTimer();
535
661
 
536
662
  if (this.state === 'disposing' && this.disposePromise) {
663
+ // A terminal dispose landing on top of an in-flight idle dispose must
664
+ // downgrade the reason, so a concurrent initialize() cannot revive what is
665
+ // now a shutdown. Never the reverse.
666
+ if (reason === 'terminal') this.disposeReason = 'terminal';
537
667
  return this.disposePromise;
538
668
  }
539
669
 
670
+ this.disposeReason = reason;
540
671
  this.state = 'disposing';
541
672
  this.disposePromise = (async () => {
542
673
  // Wait for all active embeddings to complete
543
674
  const maxWaitMs = 5000;
544
- const startTime = Date.now();
545
- while (this.activeEmbeddings > 0 && (Date.now() - startTime) < maxWaitMs) {
546
- await new Promise(resolve => setTimeout(resolve, 50));
547
- }
548
-
549
- if (this.activeEmbeddings > 0) {
550
- logger.warn(`[embedder] Disposing with ${this.activeEmbeddings} active embeddings (timed out)`);
551
- }
675
+ const drainActiveEmbeddings = async (): Promise<void> => {
676
+ const startTime = Date.now();
677
+ while (this.activeEmbeddings > 0 && (Date.now() - startTime) < maxWaitMs) {
678
+ await new Promise(resolve => setTimeout(resolve, 50));
679
+ }
680
+ if (this.activeEmbeddings > 0) {
681
+ logger.warn(`[embedder] Disposing with ${this.activeEmbeddings} active embeddings (timed out)`);
682
+ }
683
+ };
684
+ await drainActiveEmbeddings();
552
685
 
553
686
  if (this.initializingPromise) {
554
687
  try {
@@ -556,6 +689,15 @@ export class Embedder {
556
689
  } catch (_e) {
557
690
  // Initialize failed, which is fine
558
691
  }
692
+ // A caller parked on that same init increments activeEmbeddings only
693
+ // AFTER init resolves — i.e. after the drain above already passed — and
694
+ // initialize()'s disposed-mid-init branch returns cleanly, so its embed
695
+ // proceeds into runInference. Yield one macrotask so those callers reach
696
+ // their increment, then drain AGAIN; otherwise the pipeline below is
697
+ // torn down under a just-started inference (an unclassified TypeError
698
+ // the writer queue drops as a generic ingest_error).
699
+ await new Promise(resolve => setTimeout(resolve, 0));
700
+ await drainActiveEmbeddings();
559
701
  }
560
702
 
561
703
  if (this.pipeline) {
@@ -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) {
@@ -256,7 +256,12 @@ export class KnowledgeStore implements IKnowledgeStore {
256
256
  // getDimension() report the correct new width. (re-embed needs the warm
257
257
  // embedder regardless.)
258
258
  if (this.options.embedder.getDimension() === null) {
259
- await this.options.embedder.embedMany([' ']);
259
+ // Reconnect-wrapped: after a service re-scope this warm-up can be
260
+ // the FIRST embed against a stale cached instance (a stepped-down
261
+ // leader fast-fails with ECONNREFUSED); without the wrapper that
262
+ // single fast-fail aborted the whole open, burning an init retry
263
+ // per attempt against the same dead instance.
264
+ await this.withEmbedderReconnect(embedder => embedder.embedMany([' ']));
260
265
  }
261
266
  try {
262
267
  await this.handleModelChange(storedModel || 'unknown', this.options.modelName, strategy);
@@ -721,12 +726,28 @@ export class KnowledgeStore implements IKnowledgeStore {
721
726
  }
722
727
  }
723
728
 
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));
729
+ /**
730
+ * @param olderThan When set, only rows with `timestamp < olderThan` are removed.
731
+ * This is what lets a refresh write the replacement chunks FIRST and prune the
732
+ * superseded ones after, instead of deleting first and losing the entry
733
+ * outright if the insert then fails. `timestamp` is used as the discriminator
734
+ * because it is a real (indexed) column — `contentHash` lives inside the
735
+ * JSON-encoded `metadata` blob and cannot be filtered on.
736
+ */
737
+ async deleteByUrlAndType(url: string, ingestionType: string, olderThan?: number): Promise<void> {
738
+ // Coordinate with close() — see deleteByUrl. The closing-skip is surfaced
739
+ // rather than silently resolving: a prune skipped because the store is
740
+ // closing (a post-run background ingest racing a Ctrl+C close) leaves
741
+ // duplicates exactly like a failed prune does, but resolved cleanly, so the
742
+ // writer-queue's failed-prune accounting never saw its most common cause.
743
+ if (this.isClosing) {
744
+ logger.warn(`[store] Prune of ${ingestionType} chunks for ${url} skipped — store is closing; superseded duplicates remain until the next refresh.`);
745
+ metrics.increment('knowledge_store_delete_total', 1, { operation: 'by_url_and_type', status: 'skipped_closing' });
746
+ }
747
+ return this.trackOperation('deleteByUrlAndType', undefined, () => this.deleteByUrlAndTypeInner(url, ingestionType, olderThan));
727
748
  }
728
749
 
729
- private async deleteByUrlAndTypeInner(url: string, ingestionType: string): Promise<void> {
750
+ private async deleteByUrlAndTypeInner(url: string, ingestionType: string, olderThan?: number): Promise<void> {
730
751
  let retryCount = 0;
731
752
  const MAX_RETRIES = 5;
732
753
  const BASE_DELAY = 100;
@@ -738,11 +759,15 @@ export class KnowledgeStore implements IKnowledgeStore {
738
759
  try {
739
760
  const escapedUrl = url.replace(/'/g, "''");
740
761
  const escapedType = ingestionType.replace(/'/g, "''");
741
- await table.delete(`url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND (${scopeFilter})`);
762
+ // Integer-coerced so the predicate can never carry anything but digits.
763
+ const ageClause = Number.isFinite(olderThan as number)
764
+ ? ` AND timestamp < ${Math.floor(olderThan as number)}`
765
+ : '';
766
+ await table.delete(`url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND (${scopeFilter})${ageClause}`);
742
767
  const duration = Date.now() - startTime;
743
768
  metrics.observe('knowledge_store_delete_duration_ms', duration, { operation: 'by_url_and_type' });
744
769
  metrics.increment('knowledge_store_delete_total', 1, { operation: 'by_url_and_type', status: 'success' });
745
- logger.log(`[store] Deleted ${ingestionType} chunks for ${url} (scoped)`);
770
+ logger.log(`[store] Deleted ${ingestionType} chunks for ${url} (scoped)${ageClause ? ' older than the replacement' : ''}`);
746
771
  return;
747
772
  } catch (err) {
748
773
  if (isLanceCommitConflict(err)) {
@@ -842,10 +867,14 @@ export class KnowledgeStore implements IKnowledgeStore {
842
867
  const startTime = Date.now();
843
868
  const escapedUrl = url.replace(/'/g, "''");
844
869
 
870
+ // limit > 1 deliberately: after a failed prune two generations of this URL can
871
+ // coexist, and LanceDB's fragment-scan order typically returns the OLDEST row
872
+ // first — limit(1) would serve the superseded content (and feed its older
873
+ // timestamp to the serve-age gate) until the next successful ingest.
845
874
  const results = await table
846
875
  .query()
847
876
  .where(`url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`)
848
- .limit(1)
877
+ .limit(16)
849
878
  .toArray();
850
879
 
851
880
  const duration = Date.now() - startTime;
@@ -856,6 +885,8 @@ export class KnowledgeStore implements IKnowledgeStore {
856
885
  metrics.increment('knowledge_store_cache_hits_total', 1, { status: 'miss' });
857
886
  return null;
858
887
  }
888
+ // Newest generation wins, never row order.
889
+ results.sort((a: any, b: any) => Number(b.timestamp) - Number(a.timestamp));
859
890
 
860
891
  const r = results[0];
861
892
  // Read-time freshness. The stored timestamp (ms) is otherwise discarded here;
@@ -951,9 +982,19 @@ export class KnowledgeStore implements IKnowledgeStore {
951
982
  config: Index.fts(),
952
983
  replace: true,
953
984
  });
954
- // Record the version AFTER indexing (createIndex commits its own transactions) so the
955
- // next call compares against this post-rebuild baseline and only rebuilds on real writes.
956
- this.lastFtsVersion = await (await this.getFreshTable()).version();
985
+ // Record the post-rebuild baseline from THIS handle, whose version() is
986
+ // checkout-stable: it advances with our own createIndex commits but not with
987
+ // commits from other handles/processes. Re-reading through a fresh handle
988
+ // here (the old code) read the LATEST version instead — addDocuments takes
989
+ // no lock, so rows committed DURING the rebuild were absorbed into the
990
+ // baseline as already-indexed and stayed out of the BM25 leg until an
991
+ // unrelated later commit. Each createIndex commits exactly one version
992
+ // (measured on lancedb 0.29, fresh and replace alike); anything beyond
993
+ // version+2 means a concurrent commit rebased beneath our index commits, so
994
+ // fall back to the conservative pre-rebuild baseline — the next cleanup
995
+ // sees version > baseline and rebuilds over the missed rows.
996
+ const postVersion = await table.version();
997
+ this.lastFtsVersion = postVersion <= version + 2 ? postVersion : version;
957
998
  logger.info('[store] FTS indexes rebuilt (text + content).');
958
999
  return true;
959
1000
  } catch (err) {
@@ -1001,7 +1042,12 @@ export class KnowledgeStore implements IKnowledgeStore {
1001
1042
  // rebuilds + re-optimizes (the disk-bloat regression). A stale baseline (index not
1002
1043
  // current, or never built) is left untouched so the next rebuild still detects the
1003
1044
  // pending change.
1004
- const wasFtsIndexCurrent = !stale && this.lastFtsVersion !== null && this.lastFtsVersion === await table.version();
1045
+ // The row count is sampled BEFORE the version read: since optimize never changes
1046
+ // the row set, a same-handle count that differs after the call proves rows were
1047
+ // committed DURING optimize's window (rebased beneath its compaction commits) —
1048
+ // rows the FTS index does not cover, which the baseline advance below must skip.
1049
+ const rowCountBefore = !stale && this.lastFtsVersion !== null ? await table.countRows() : null;
1050
+ const wasFtsIndexCurrent = rowCountBefore !== null && this.lastFtsVersion === await table.version();
1005
1051
  // cleanupOlderThan defaults to now: prune every prunable old version. The
1006
1052
  // current version is always retained, and (with deleteUnverified=false)
1007
1053
  // physical files inside LanceDB's safety window are kept regardless.
@@ -1017,9 +1063,14 @@ export class KnowledgeStore implements IKnowledgeStore {
1017
1063
  const bytesRemoved = stats?.prune?.bytesRemoved ?? 0;
1018
1064
  logger.info(`[store] Optimize complete: removed ${removed} old version(s), reclaimed ${bytesRemoved} bytes (${duration}ms).`);
1019
1065
  // Keep the FTS skip-guard baseline in sync with optimize's layout-only version bump
1020
- // so the next rebuildFtsIndex correctly skips when no content changed.
1021
- if (wasFtsIndexCurrent) {
1022
- this.lastFtsVersion = await (await this.getFreshTable()).version();
1066
+ // so the next rebuildFtsIndex correctly skips when no content changed. Stamp from
1067
+ // THIS handle (checkout-stable: it advances with optimize's own commits but not
1068
+ // with foreign ones — a fresh re-open here read the latest version and absorbed
1069
+ // concurrent commits), and only when the row count is unchanged: a differing count
1070
+ // means an addDocuments landed inside optimize's window and must not be recorded
1071
+ // as already-indexed (the baseline stays put, so the next rebuild catches it).
1072
+ if (wasFtsIndexCurrent && (await table.countRows()) === rowCountBefore) {
1073
+ this.lastFtsVersion = await table.version();
1023
1074
  }
1024
1075
  return true;
1025
1076
  } catch (err) {
@@ -1081,20 +1132,28 @@ export class KnowledgeStore implements IKnowledgeStore {
1081
1132
  for (const name of migrationDirs) {
1082
1133
  const fullPath = path.join(this.options.dbDir, name);
1083
1134
  logger.info(`[store] Removing orphaned migration temp directory: ${name}`);
1084
- await fsPromises.rm(fullPath, { recursive: true, force: true });
1135
+ await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
1085
1136
  }
1086
1137
 
1087
1138
  // `_backup_` dirs are intentional, user-recoverable snapshots from the
1088
1139
  // 'backup' migration strategy. RETAIN the most recent one (deleting it
1089
1140
  // would make 'backup' silently equivalent to 'drop'); prune only older,
1090
- // superseded backups so they don't accumulate. ISO-timestamp names sort
1091
- // chronologically, so the last entry is newest.
1141
+ // superseded backups so they don't accumulate. Rank by the trailing
1142
+ // `_backup_<timestamp>` suffix, NOT by whole-name sort: the prefix is the
1143
+ // table name AT BACKUP TIME — legitimately `knowledge` OR
1144
+ // `knowledge_migration_<ts>` (persisted re-embed rename failure) — and
1145
+ // `knowledge_b…` < `knowledge_m…` lexicographically, so a whole-name sort
1146
+ // retained an OLD migration-named backup while deleting a NEWER
1147
+ // `knowledge_backup_*`.
1092
1148
  if (backupDirs.length > 1) {
1093
- backupDirs.sort();
1094
- for (const name of backupDirs.slice(0, -1)) {
1149
+ const ranked = await Promise.all(
1150
+ backupDirs.map(async name => ({ name, ts: await this.backupDirTimestamp(name) }))
1151
+ );
1152
+ ranked.sort((a, b) => a.ts - b.ts);
1153
+ for (const { name } of ranked.slice(0, -1)) {
1095
1154
  const fullPath = path.join(this.options.dbDir, name);
1096
1155
  logger.info(`[store] Removing superseded backup directory: ${name}`);
1097
- await fsPromises.rm(fullPath, { recursive: true, force: true });
1156
+ await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
1098
1157
  }
1099
1158
  }
1100
1159
  } catch (err) {
@@ -1102,6 +1161,27 @@ export class KnowledgeStore implements IKnowledgeStore {
1102
1161
  }
1103
1162
  }
1104
1163
 
1164
+ /**
1165
+ * Creation time of a backup dir, for retention ranking in
1166
+ * pruneOrphanedMigrationDirs. Parses the trailing `_backup_<timestamp>` suffix
1167
+ * (an ISO timestamp with `:`/`.` mapped to `-`, exactly as migrationBackup
1168
+ * writes it); falls back to directory mtime for names that don't parse, and to
1169
+ * 0 (ranked oldest, pruned first) if even stat fails.
1170
+ */
1171
+ private async backupDirTimestamp(name: string): Promise<number> {
1172
+ const m = name.match(/_backup_(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z\.lance$/);
1173
+ if (m) {
1174
+ const parsed = Date.parse(`${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`);
1175
+ if (!Number.isNaN(parsed)) return parsed;
1176
+ }
1177
+ try {
1178
+ const stat = await fsPromises.stat(path.join(this.options.dbDir, name));
1179
+ return stat.mtimeMs;
1180
+ } catch {
1181
+ return 0;
1182
+ }
1183
+ }
1184
+
1105
1185
  async count(): Promise<number> {
1106
1186
  if (!this.db) return 0;
1107
1187
  // Track against close() — this read had no try/catch, so a teardown racing db.close()
@@ -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);