@lincoln504/pi-research 0.1.13 → 1.0.1

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 (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +55 -138
  3. package/dist/cli.mjs +23375 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +130 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +211 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +122 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +66 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1195 -0
  30. package/src/config.ts +1032 -155
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +258 -11
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -212
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -644
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
@@ -0,0 +1,1251 @@
1
+ /**
2
+ * Knowledge Store
3
+ *
4
+ * Vector database for storing and retrieving research documents using LanceDB.
5
+ */
6
+
7
+ // Type-only import for lancedb types; runtime values (connect, Index,
8
+ // rerankers) are resolved lazily via getLancedb() so the native binding is not
9
+ // hoisted into the CLI bundle and loaded at startup. See ./lancedb-loader.ts.
10
+ import type * as lancedb from '@lancedb/lancedb';
11
+ import { getLancedb } from './lancedb-loader.ts';
12
+ import { CircuitBreaker } from '../utils/circuit-breaker.ts';
13
+ import { logger } from '../logger.ts';
14
+ import {
15
+ IKnowledgeStore,
16
+ StoreDocument,
17
+ IEmbedder
18
+ } from '../core/interfaces/knowledge-interfaces.ts';
19
+ import * as fs from 'node:fs';
20
+ import * as fsPromises from 'node:fs/promises';
21
+ import * as path from 'node:path';
22
+ import { MigrationStrategy, MigrationResult } from './migration.ts';
23
+ import { metrics } from '../utils/metrics.ts';
24
+ import { createStoreTable, CURRENT_SCHEMA_VERSION } from './store-schema.ts';
25
+ import { addDocumentsToStore, searchStore, findDocumentsByUrl, findRelevantUrls } from './store-operations.ts';
26
+ import { isEmbedderUnreachable } from './embedder-utils.ts';
27
+ import { ServiceLifecycle } from '../core/service-registry.ts';
28
+ import { ServiceNames } from '../core/service-interfaces.ts';
29
+ import { normalizeWorkspacePath } from '../utils/text-utils.ts';
30
+
31
+ /**
32
+ * Detect a LanceDB concurrent-write conflict — the class of error worth retrying
33
+ * with backoff. LanceDB 0.29 surfaces these as "Commit conflict for version N",
34
+ * "Retryable commit conflict for version N" and "Too many concurrent writes,
35
+ * please retry later:" (strings confirmed in the 0.29.0 native binary). The three
36
+ * legacy strings ("Version mismatch" / "Lock error" / "Commit error") no longer
37
+ * appear in 0.29 but are kept as a safety net for other/older Lance versions.
38
+ */
39
+ export function isLanceCommitConflict(err: unknown): boolean {
40
+ const msg = err instanceof Error ? err.message : String(err);
41
+ return (
42
+ /commit conflict/i.test(msg) ||
43
+ /too many concurrent writes/i.test(msg) ||
44
+ msg.includes('Version mismatch') ||
45
+ msg.includes('Lock error') ||
46
+ msg.includes('Commit error')
47
+ );
48
+ }
49
+
50
+ export interface StoreOptions {
51
+ dbDir: string;
52
+ embedder: IEmbedder;
53
+ modelName: string;
54
+ migrationStrategy?: MigrationStrategy;
55
+ /** Called when embedder connection fails — should return a fresh IEmbedder. */
56
+ reconnectFactory?: () => Promise<IEmbedder>;
57
+ /** Optional lock wrapper for multi-process safety during initialization and migration */
58
+ withLock?: <T>(fn: () => Promise<T>) => Promise<T>;
59
+
60
+ /** Scoping options for unified database */
61
+ workspace?: string;
62
+ knowledgeMode: 'none' | 'project' | 'global';
63
+ /** Cache TTL in days for automatic eviction */
64
+ ttlDays?: number;
65
+ /** Max age (in days) a cached document may be served at read time. 0 (default)
66
+ * disables the read-time gate, preserving prior behaviour — eviction (ttlDays)
67
+ * still bounds the worst case. Set >0 for time-sensitive research to force a
68
+ * fresh scrape of anything older than this window. */
69
+ maxServeAgeDays?: number;
70
+ }
71
+
72
+ export class KnowledgeStore implements IKnowledgeStore {
73
+ readonly name = ServiceNames.KNOWLEDGE_STORE;
74
+ lifecycle = ServiceLifecycle.UNINITIALIZED;
75
+
76
+ private db: lancedb.Connection | null = null;
77
+ private table: lancedb.Table | null = null;
78
+ // Coalesces concurrent open() calls (see open()).
79
+ private _openPromise: Promise<void> | null = null;
80
+ private options: StoreOptions;
81
+ private tableName = 'knowledge';
82
+ private manifestPath: string;
83
+ private isClosing = false;
84
+ private activeWrites = new Set<Promise<any>>();
85
+ // LanceDB table version at the last successful FTS rebuild. rebuildFtsIndex() skips
86
+ // when the version is unchanged: a research run that committed no new transactions
87
+ // would otherwise create a fresh FTS index (and a new table version) on every cleanup,
88
+ // the dominant driver of the unbounded _indices/_versions bloat seen on disk. Row
89
+ // count is NOT a sufficient signal — a re-ingest of changed content deletes N rows and
90
+ // adds N (writer-queue hash-change path), leaving the count identical while the indexed
91
+ // row SET changed; the table version advances on every such transaction, so it is the
92
+ // correct proxy and is cross-process-correct (getFreshTable re-opens at the latest version).
93
+ private lastFtsVersion: number | null = null;
94
+ private rrfReranker: lancedb.rerankers.RRFReranker | null = null;
95
+ private circuitBreaker = new CircuitBreaker({
96
+ failureThreshold: 3,
97
+ resetTimeoutMs: 15000,
98
+ name: 'KnowledgeStore'
99
+ });
100
+
101
+ constructor(options: StoreOptions) {
102
+ this.options = options;
103
+ this.manifestPath = path.join(this.options.dbDir, 'store-manifest.json');
104
+ }
105
+
106
+ private getScopeFilter(): string {
107
+ const ws = this.getWorkspace().replace(/'/g, "''");
108
+
109
+ switch (this.options.knowledgeMode) {
110
+ case 'project':
111
+ return `workspace = '${ws}'`;
112
+ case 'global':
113
+ return 'is_global = true';
114
+ default: // 'none'
115
+ return '1 = 0';
116
+ }
117
+ }
118
+
119
+ private getWorkspace(): string {
120
+ return normalizeWorkspacePath(this.options.workspace || process.cwd());
121
+ }
122
+
123
+ async initialize(): Promise<void> {
124
+ this.lifecycle = ServiceLifecycle.INITIALIZING;
125
+ await this.loadManifest();
126
+ await this.open();
127
+ this.lifecycle = ServiceLifecycle.INITIALIZED;
128
+ }
129
+
130
+ private async loadManifest(): Promise<void> {
131
+ try {
132
+ if (fs.existsSync(this.manifestPath)) {
133
+ const content = await fsPromises.readFile(this.manifestPath, 'utf-8');
134
+ const manifest = JSON.parse(content);
135
+ if (manifest.activeTableName) {
136
+ this.tableName = manifest.activeTableName;
137
+ logger.debug(`[store] Loaded active table name from manifest: ${this.tableName}`);
138
+ }
139
+ }
140
+ } catch (err) {
141
+ logger.warn('[store] Failed to load manifest, using default table name:', err);
142
+ }
143
+ }
144
+
145
+ /** @returns true if the manifest was durably written, false on failure (logged). */
146
+ private async saveManifest(): Promise<boolean> {
147
+ const tempPath = `${this.manifestPath}.tmp`;
148
+ try {
149
+ const content = JSON.stringify({ activeTableName: this.tableName }, null, 2);
150
+ await fsPromises.writeFile(tempPath, content, { encoding: 'utf-8', mode: 0o600 });
151
+ await fsPromises.rename(tempPath, this.manifestPath);
152
+ return true;
153
+ } catch (err) {
154
+ logger.warn('[store] Failed to save manifest:', err);
155
+ if (fs.existsSync(tempPath)) {
156
+ try { await fsPromises.unlink(tempPath); } catch { /* ignore */ }
157
+ }
158
+ return false;
159
+ }
160
+ }
161
+
162
+ async open(): Promise<void> {
163
+ if (this.db) return;
164
+
165
+ // Coalesce concurrent opens in-process. withLock (when provided) is a
166
+ // cross-process FILE lock, not an in-process mutex — and when it's absent there
167
+ // is no serialization at all, so two concurrent open() calls would both run
168
+ // lancedb.connect() and the second would overwrite this.db, leaking the first
169
+ // connection's file handles. This sentinel makes all concurrent callers await
170
+ // the same open.
171
+ if (this._openPromise) return this._openPromise;
172
+
173
+ this._openPromise = (async () => {
174
+ try {
175
+ if (this.options.withLock) {
176
+ await this.options.withLock(() => this.openInternal());
177
+ } else {
178
+ await this.openInternal();
179
+ }
180
+ } finally {
181
+ this._openPromise = null;
182
+ }
183
+ })();
184
+
185
+ return this._openPromise;
186
+ }
187
+
188
+ private async openInternal(): Promise<void> {
189
+ // Re-check after acquiring lock
190
+ if (this.db) return;
191
+
192
+ try {
193
+ if (!fs.existsSync(this.options.dbDir)) {
194
+ fs.mkdirSync(this.options.dbDir, { recursive: true });
195
+ }
196
+ this.db = await (await getLancedb()).connect(this.options.dbDir);
197
+
198
+ const tableNames = await this.db.tableNames();
199
+ if (tableNames.includes(this.tableName)) {
200
+ this.table = await this.db.openTable(this.tableName);
201
+
202
+ // Tracks whether we've moved past schema-reading into migration. A failure
203
+ // while merely reading the schema is non-fatal (warn + skip). A failure
204
+ // DURING migration is fatal and must escape — without this flag the outer
205
+ // catch below mislabels it "Failed to read table schema" and the store
206
+ // opens on a mismatched table instead of surfacing the corruption.
207
+ let migrationInProgress = false;
208
+ try {
209
+ const schema = await this.table.schema();
210
+
211
+ // Read the stored model/version FIRST so we know whether a migration is
212
+ // imminent before deciding whether the old table's vector width is safe
213
+ // to adopt.
214
+ let storedModel = schema.metadata.get('embedding_model');
215
+ let storedVersion = schema.metadata.get('schema_version');
216
+
217
+ if (typeof storedModel === 'object' && storedModel !== null && 'byteLength' in (storedModel as any)) {
218
+ storedModel = new TextDecoder().decode(storedModel as unknown as Uint8Array);
219
+ }
220
+ if (typeof storedVersion === 'object' && storedVersion !== null && 'byteLength' in (storedVersion as any)) {
221
+ storedVersion = new TextDecoder().decode(storedVersion as unknown as Uint8Array);
222
+ }
223
+
224
+ const isModelMismatch = storedModel !== this.options.modelName;
225
+ const isVersionMismatch = storedVersion !== CURRENT_SCHEMA_VERSION;
226
+
227
+ // Seed the embedder dimension from the existing table ONLY when the model
228
+ // is unchanged (same model ⇒ same vector width). On a model change the old
229
+ // width is wrong for the new table; seeding it would make createTable()
230
+ // build the new table at the old dimension and every insert would then
231
+ // fail on an Arrow FixedSizeList width mismatch once the new model warms
232
+ // up. In that case we leave the dimension unset and warm the new embedder
233
+ // below before creating any table.
234
+ if (!isModelMismatch) {
235
+ const vectorField = schema.fields.find(f => f.name === 'vector');
236
+ if (vectorField && (vectorField.type as any).constructor.name === 'FixedSizeList') {
237
+ const dim = (vectorField.type as any).listSize;
238
+ if (this.options.embedder.getDimension() === null) {
239
+ this.options.embedder.setDimension(dim);
240
+ logger.debug(`[store] Extracted dimension ${dim} from existing table schema`);
241
+ }
242
+ }
243
+ }
244
+
245
+ if (isModelMismatch || isVersionMismatch) {
246
+ const reason = isModelMismatch ? 'Model change' : 'Schema version change';
247
+ logger.warn(`[store] ${reason} detected: ${storedModel} (v${storedVersion}) → ${this.options.modelName} (v${CURRENT_SCHEMA_VERSION})`);
248
+ const strategy = this.options.migrationStrategy || 'backup';
249
+
250
+ migrationInProgress = true;
251
+ // Ensure the embedder knows the NEW model's dimension before any
252
+ // createTable() runs inside migration. On a model change the old width
253
+ // was deliberately not seeded above, so getDimension() is null here; a
254
+ // single real embed warms the model across every IEmbedder impl
255
+ // (local Embedder, EmbeddingServer, EmbeddingClient) and makes
256
+ // getDimension() report the correct new width. (re-embed needs the warm
257
+ // embedder regardless.)
258
+ if (this.options.embedder.getDimension() === null) {
259
+ await this.options.embedder.embedMany([' ']);
260
+ }
261
+ try {
262
+ await this.handleModelChange(storedModel || 'unknown', this.options.modelName, strategy);
263
+ } catch (err) {
264
+ const errorMsg = `Model migration failed using strategy '${strategy}': ${err instanceof Error ? err.message : String(err)}`;
265
+ logger.error(`[store] ${errorMsg}`);
266
+
267
+ if (strategy !== 'drop' && strategy !== 'backup') {
268
+ logger.warn('[store] Falling back to backup strategy after migration failure');
269
+ await this.handleModelChange(storedModel || 'unknown', this.options.modelName, 'backup');
270
+ } else if (strategy === 'backup') {
271
+ logger.warn('[store] Falling back to drop strategy after backup failure');
272
+ await this.handleModelChange(storedModel || 'unknown', this.options.modelName, 'drop');
273
+ } else {
274
+ throw new Error(errorMsg, { cause: err });
275
+ }
276
+ }
277
+ }
278
+ } catch (schemaErr) {
279
+ // A migration failure is fatal — re-throw so the caller learns the store
280
+ // could not be brought to a consistent state. Only a pure schema-read
281
+ // failure (before migration began) is downgraded to a warning.
282
+ if (migrationInProgress) throw schemaErr;
283
+ logger.warn('[store] Failed to read table schema:', schemaErr);
284
+ }
285
+ } else {
286
+ this.table = await this.createTable();
287
+ }
288
+
289
+ await this.evictOldRecords();
290
+ await this.pruneOrphanedMigrationDirs();
291
+
292
+ // Persist manifest after every successful open so that table name
293
+ // changes (from migration, re-embed rename, etc.) survive crashes.
294
+ await this.saveManifest();
295
+ } catch (err) {
296
+ logger.error('[store] Failed to open database:', err);
297
+ // Roll back a partial open: connect() may have succeeded (this.db set) before a
298
+ // later step (createTable/migration/evict/saveManifest) threw. Leaving this.db set
299
+ // makes a subsequent open() short-circuit on `if (this.db) return` and treat a
300
+ // half-open store (this.table possibly null) as ready. Null both so the next open
301
+ // genuinely retries.
302
+ try { await this.db?.close(); } catch { /* best-effort */ }
303
+ this.db = null;
304
+ this.table = null;
305
+ throw err;
306
+ }
307
+ }
308
+
309
+ private async handleModelChange(oldModel: string, newModel: string, strategy: MigrationStrategy): Promise<MigrationResult> {
310
+ logger.info(`[store] Executing migration strategy: ${strategy}`);
311
+ logger.info(`[store] Old model: ${oldModel}, New model: ${newModel}`);
312
+
313
+ switch (strategy) {
314
+ case 'drop':
315
+ return this.migrationDrop(oldModel, newModel);
316
+ case 're-embed':
317
+ return this.migrationReEmbed(oldModel, newModel);
318
+ case 'backup':
319
+ return this.migrationBackup(oldModel, newModel);
320
+ default:
321
+ logger.warn(`[store] Unknown migration strategy '${strategy}', falling back to 'backup'`);
322
+ return this.migrationBackup(oldModel, newModel);
323
+ }
324
+ }
325
+
326
+ private async migrationBackup(_oldModel: string, newModel: string): Promise<MigrationResult> {
327
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
328
+ const backupTableName = `${this.tableName}_backup_${timestamp}`;
329
+ logger.warn(`[store] Backing up table to ${backupTableName} and recreating with model ${newModel}`);
330
+
331
+ if (!this.table || !this.db) {
332
+ throw new Error('Table not connected');
333
+ }
334
+
335
+ const count = await this.table.countRows();
336
+
337
+ // Close current table handle
338
+ this.table = null;
339
+
340
+ // In LanceDB, tables are directories. Rename the .lance directory.
341
+ const oldDir = path.join(this.options.dbDir, `${this.tableName}.lance`);
342
+ const backupDir = path.join(this.options.dbDir, `${backupTableName}.lance`);
343
+
344
+ try {
345
+ if (fs.existsSync(oldDir)) {
346
+ await fsPromises.rename(oldDir, backupDir);
347
+ logger.info(`[store] Successfully backed up database directory to ${backupDir}`);
348
+ }
349
+ } catch (err) {
350
+ logger.error(`[store] Atomic backup failed: ${err instanceof Error ? err.message : String(err)}`);
351
+ throw err;
352
+ }
353
+
354
+ // Create fresh table
355
+ this.table = await this.createTable();
356
+ // Table name unchanged (still canonical 'knowledge'), but persist to be safe.
357
+ await this.saveManifest();
358
+ logger.info(`[store] Migration complete: ${count} documents backed up, fresh table created with model ${newModel}`);
359
+
360
+ return { strategy: 'backup', success: true, documentsProcessed: count };
361
+ }
362
+
363
+ private async migrationDrop(_oldModel: string, newModel: string): Promise<MigrationResult> {
364
+ logger.warn(`[store] Dropping table and recreating with model ${newModel} (data will be lost)`);
365
+
366
+ if (!this.table || !this.db) {
367
+ throw new Error('Table not connected');
368
+ }
369
+
370
+ const count = await this.table.countRows();
371
+ logger.warn(`[store] Deleting ${count} existing documents`);
372
+
373
+ await this.db.dropTable(this.tableName);
374
+ this.table = await this.createTable();
375
+ // Table name unchanged (still canonical 'knowledge'), but persist to be safe.
376
+ await this.saveManifest();
377
+
378
+ logger.info(`[store] Migration complete: ${count} documents removed, table recreated with model ${newModel}`);
379
+
380
+ return { strategy: 'drop', success: true, documentsProcessed: count };
381
+ }
382
+
383
+ private async migrationReEmbed(_oldModel: string, newModel: string): Promise<MigrationResult> {
384
+ logger.info(`[store] Re-embedding documents with model ${newModel} (data will be preserved)`);
385
+
386
+ if (!this.table || !this.db) {
387
+ throw new Error('Table not connected');
388
+ }
389
+
390
+ const totalDocs = await this.table.countRows();
391
+ logger.info(`[store] Processing ${totalDocs} documents for re-embedding...`);
392
+
393
+ let embedded = 0;
394
+ const batchSize = 50;
395
+ const tempTableName = `${this.tableName}_migration_${Date.now()}`;
396
+
397
+ try {
398
+ // Create new table BEFORE dropping the old one to prevent data loss on failure
399
+ logger.info(`[store] Creating new table ${tempTableName} for migration...`);
400
+ const newTable = await this.createTable(tempTableName);
401
+
402
+ for (let i = 0; i < totalDocs; i += batchSize) {
403
+ const batchRows = await this.table.query().limit(batchSize).offset(i).toArray();
404
+
405
+ const batchDocs = batchRows.flatMap(row => {
406
+ let metadata: Record<string, any> = {};
407
+ try { metadata = JSON.parse(row.metadata as string); } catch {
408
+ logger.warn('[store] Skipping row with corrupted metadata during re-embed migration');
409
+ }
410
+ return [{
411
+ url: row.url as string,
412
+ text: row.text as string,
413
+ content: row.content as string | undefined,
414
+ metadata,
415
+ workspace: (row.workspace as string) || 'global',
416
+ is_global: !!row.is_global,
417
+ ingestion_type: (row.ingestion_type as string) || metadata['ingestionType'] || 'synthesis-description',
418
+ timestamp: Number(row.timestamp),
419
+ }];
420
+ });
421
+
422
+ const texts = batchDocs.map(d => d.text);
423
+ const vectors = await this.options.embedder.embedMany(texts);
424
+
425
+ const records = batchDocs.map((doc, idx) => ({
426
+ vector: Array.from(vectors[idx]!),
427
+ url: doc.url,
428
+ text: doc.text,
429
+ content: doc.content || null,
430
+ metadata: JSON.stringify(doc.metadata),
431
+ workspace: doc.workspace,
432
+ is_global: doc.is_global,
433
+ ingestion_type: doc.ingestion_type,
434
+ timestamp: BigInt(doc.timestamp),
435
+ }));
436
+
437
+ await newTable.add(records);
438
+ embedded += batchDocs.length;
439
+
440
+ if (embedded % 100 === 0 || embedded === totalDocs) {
441
+ logger.info(`[store] Re-embedded ${embedded}/${totalDocs} documents`);
442
+ }
443
+ }
444
+
445
+ // Only drop old table after successful completion
446
+ logger.info(`[store] Migration successful, dropping old table ${this.tableName}...`);
447
+ const canonicalName = 'knowledge'; // We try to return to the canonical name if possible
448
+ const oldTableName = this.tableName;
449
+
450
+ // Crash-ordering constraint: point the manifest at the fully populated
451
+ // temp table BEFORE dropping the old one. If the process dies between the
452
+ // drop and the post-rename manifest save below, the next startup would
453
+ // otherwise open the (now-dropped) old name, create an empty table under
454
+ // it, and pruneOrphanedMigrationDirs would reap the temp dir — the only
455
+ // remaining copy of the data. With this save, a manifest referencing a
456
+ // table that contains the data exists at every instant, and prune never
457
+ // touches the manifest-referenced dir. If the save fails, abort while the
458
+ // old table is still intact.
459
+ this.tableName = tempTableName;
460
+ if (!(await this.saveManifest())) {
461
+ this.tableName = oldTableName;
462
+ throw new Error(`Cannot persist manifest for ${tempTableName}; aborting before dropping ${oldTableName}`);
463
+ }
464
+ await this.db.dropTable(oldTableName);
465
+
466
+ // LanceDB has no rename API, but its tables are plain directories on disk.
467
+ // Rename the temp directory to the canonical table name so that the next
468
+ // process start finds 'knowledge' (not 'knowledge_migration_<ts>').
469
+ logger.info(`[store] Renaming ${tempTableName} to ${canonicalName}...`);
470
+ try {
471
+ const tempDir = path.join(this.options.dbDir, `${tempTableName}.lance`);
472
+ const canonicalDir = path.join(this.options.dbDir, `${canonicalName}.lance`);
473
+
474
+ // If canonical name was already something else and it still exists,
475
+ // we might need to handle it. But we just dropped it above.
476
+ await fsPromises.rename(tempDir, canonicalDir);
477
+
478
+ this.tableName = canonicalName;
479
+ await this.saveManifest();
480
+
481
+ // Reopen the canonical table so this.table reflects the renamed path
482
+ this.table = await this.db.openTable(this.tableName);
483
+ } catch (renameErr) {
484
+ // If rename fails (cross-device, permissions, etc.) we keep the temp name
485
+ // but CRITICALLY we now persist it in the manifest so future sessions
486
+ // will find it automatically.
487
+ logger.warn(`[store] Directory rename failed, persisting temp table name in manifest: ${renameErr}`);
488
+ this.tableName = tempTableName;
489
+ await this.saveManifest();
490
+
491
+ this.table = newTable;
492
+ logger.info(`[store] Migrated data is safe in ${tempTableName} and tracked via manifest.`);
493
+ }
494
+
495
+ logger.info(`[store] Migration complete: ${embedded} documents re-embedded with model ${newModel}`);
496
+
497
+ return { strategy: 're-embed', success: true, documentsProcessed: embedded };
498
+ } catch (error) {
499
+ logger.error(`[store] Migration failed at ${embedded}/${totalDocs}:`, error);
500
+ logger.error(`[store] Original data is still intact in table ${this.tableName}`);
501
+ throw error;
502
+ }
503
+ }
504
+
505
+ private async createTable(name: string = this.tableName): Promise<lancedb.Table> {
506
+ if (!this.db) throw new Error('Database not connected');
507
+ const dim = this.options.embedder.getDimension();
508
+ if (dim === null) throw new Error('[store] Cannot create table: embedder dimension unknown (not yet initialized)');
509
+ return createStoreTable(this.db, name, dim, this.options.modelName);
510
+ }
511
+
512
+ private async withEmbedderReconnect<T>(fn: (embedder: IEmbedder) => Promise<T>): Promise<T> {
513
+ try {
514
+ return await fn(this.options.embedder);
515
+ } catch (err) {
516
+ if (isEmbedderUnreachable(err) && this.options.reconnectFactory) {
517
+ logger.warn('[store] Embedder server unreachable, reconnecting and retrying...');
518
+ this.options.embedder = await this.options.reconnectFactory();
519
+ return fn(this.options.embedder);
520
+ }
521
+ throw err;
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Internal helper to get a fresh table handle, ensuring we see the latest
527
+ * documents added by other processes. Re-opening a table in LanceDB is
528
+ * a cheap manifest re-read.
529
+ */
530
+ private async getFreshTable(): Promise<lancedb.Table> {
531
+ return (await this.openFreshTable()).table;
532
+ }
533
+
534
+ /**
535
+ * Re-open the table and report whether the returned handle is a genuinely fresh
536
+ * re-open (`stale=false`) or a fallback to the previously-cached handle after a
537
+ * transient re-open failure (`stale=true`). The FTS skip-guards must reason about
538
+ * the freshness of the handle THEY hold, not a shared field a concurrent op can
539
+ * reset out from under them — so staleness is returned per-call, not stored.
540
+ */
541
+ private async openFreshTable(): Promise<{ table: lancedb.Table; stale: boolean }> {
542
+ if (!this.db) throw new Error('Store not open');
543
+ try {
544
+ this.table = await this.db.openTable(this.tableName);
545
+ return { table: this.table, stale: false };
546
+ } catch (err) {
547
+ const msg = err instanceof Error ? err.message : String(err);
548
+ // If the table genuinely no longer exists (dropped/renamed by another
549
+ // process's migration), do NOT serve a stale handle that points at a gone
550
+ // table — invalidate and surface the error so callers fail clearly.
551
+ if (/not found|does not exist|no such table/i.test(msg)) {
552
+ this.table = null;
553
+ throw err;
554
+ }
555
+ if (!this.table) throw err;
556
+ // Transient (lock/IO) refresh failure: keep using the existing handle, but
557
+ // log at WARN so the degraded path is visible rather than silent, and report
558
+ // the handle as stale so version-based skip guards fail safe (rebuild, don't skip).
559
+ logger.warn(`[store] Failed to refresh table handle, using existing: ${msg}`);
560
+ return { table: this.table, stale: true };
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Run a table operation under the same close-coordination guard addDocuments uses:
566
+ * register into activeWrites BEFORE checking isClosing (so close() can't observe an
567
+ * empty set and tear down the connection mid-flight), and skip cleanly if the store
568
+ * is already closing. Compaction (optimize/rebuildFtsIndex) runs in the background
569
+ * right after a run completes — exactly when a Ctrl+C-driven close() is most likely
570
+ * to race it — so both must be tracked, not just writes.
571
+ */
572
+ private async trackOperation<T>(label: string, whenClosing: T, fn: () => Promise<T>): Promise<T> {
573
+ let resolveWrite!: () => void;
574
+ const writePromise = new Promise<void>((resolve) => { resolveWrite = resolve; });
575
+ this.activeWrites.add(writePromise);
576
+ try {
577
+ if (this.isClosing) {
578
+ logger.debug(`[store] Skipping ${label} — store is closing`);
579
+ return whenClosing;
580
+ }
581
+ return await fn();
582
+ } finally {
583
+ this.activeWrites.delete(writePromise);
584
+ resolveWrite();
585
+ }
586
+ }
587
+
588
+ async addDocuments(docs: StoreDocument[]): Promise<void> {
589
+ // FIX (#3): Register the write promise BEFORE checking isClosing to prevent
590
+ // close() from reading an empty activeWrites set and closing the DB.
591
+ let writeResolve: () => void;
592
+ const writePromise = new Promise<void>((resolve) => { writeResolve = resolve; });
593
+ this.activeWrites.add(writePromise);
594
+
595
+ try {
596
+ // Now check isClosing after registering
597
+ if (this.isClosing) {
598
+ logger.warn(`[store] Skipping addDocuments — store is closing`);
599
+ return;
600
+ }
601
+
602
+ let table = await this.getFreshTable();
603
+ const workspace = this.getWorkspace();
604
+
605
+ await this.withEmbedderReconnect(async (embedder) => {
606
+ let retryCount = 0;
607
+ const MAX_RETRIES = 5;
608
+ const BASE_DELAY = 100;
609
+
610
+ while (retryCount <= MAX_RETRIES) {
611
+ try {
612
+ const isGlobal = this.options.knowledgeMode === 'global';
613
+ await addDocumentsToStore(
614
+ table,
615
+ docs,
616
+ embedder,
617
+ () => this.isClosing,
618
+ workspace,
619
+ isGlobal
620
+ );
621
+ return;
622
+ } catch (err) {
623
+ // Detect commit conflicts typical of cross-process write contention
624
+ if (isLanceCommitConflict(err)) {
625
+ retryCount++;
626
+ if (retryCount > MAX_RETRIES) throw err;
627
+
628
+ // Exponential backoff with jitter
629
+ const delay = Math.floor(Math.random() * (BASE_DELAY * Math.pow(2, retryCount - 1)));
630
+ logger.debug(`[store] Write contention detected, retrying ${retryCount}/${MAX_RETRIES} after ${delay}ms...`);
631
+ await new Promise(r => setTimeout(r, delay));
632
+
633
+ // Refresh the table handle AND rebind the local so the retry writes
634
+ // against the latest committed version. Discarding this return (the prior
635
+ // bug) left every retry re-running against the same version-pinned snapshot,
636
+ // so a Version-mismatch conflict re-failed all 5 attempts and the batch was
637
+ // silently dropped under concurrent cross-process writes.
638
+ table = await this.getFreshTable();
639
+ continue;
640
+ }
641
+ throw err;
642
+ }
643
+ }
644
+ });
645
+ } finally {
646
+ this.activeWrites.delete(writePromise);
647
+ writeResolve!();
648
+ }
649
+ }
650
+
651
+ private async getReranker(): Promise<lancedb.rerankers.RRFReranker> {
652
+ if (!this.rrfReranker) {
653
+ this.rrfReranker = await (await getLancedb()).rerankers.RRFReranker.create();
654
+ }
655
+ return this.rrfReranker;
656
+ }
657
+
658
+ async search(query: string, options: { limit?: number } = {}): Promise<StoreDocument[]> {
659
+ // Coordinate with close(): register in activeWrites and skip cleanly if the store is
660
+ // closing, so a teardown (Ctrl+C / dispose) can't run db.close() while this query is
661
+ // executing against the connection (use-after-close on the native handle).
662
+ return this.trackOperation('search', [] as StoreDocument[], async () => {
663
+ const table = await this.getFreshTable();
664
+ const scopeFilter = this.getScopeFilter();
665
+ return this.circuitBreaker.execute(() =>
666
+ this.withEmbedderReconnect(embedder =>
667
+ searchStore(table, embedder, query, this.getReranker.bind(this), options.limit ?? 5, scopeFilter)
668
+ )
669
+ );
670
+ });
671
+ }
672
+
673
+ async deleteByUrl(url: string): Promise<void> {
674
+ // Coordinate the delete with close() (register in activeWrites, skip if closing) so a
675
+ // teardown can't run db.close() while this mutation executes against the connection.
676
+ return this.trackOperation('deleteByUrl', undefined, () => this.deleteByUrlInner(url));
677
+ }
678
+
679
+ private async deleteByUrlInner(url: string): Promise<void> {
680
+ let retryCount = 0;
681
+ const MAX_RETRIES = 5;
682
+ const BASE_DELAY = 100;
683
+ const scopeFilter = this.getScopeFilter();
684
+
685
+ while (retryCount <= MAX_RETRIES) {
686
+ const table = await this.getFreshTable();
687
+ const startTime = Date.now();
688
+ try {
689
+ const escapedUrl = url.replace(/'/g, "''");
690
+ await table.delete(`url = '${escapedUrl}' AND (${scopeFilter})`);
691
+ const duration = Date.now() - startTime;
692
+ metrics.observe('knowledge_store_delete_duration_ms', duration);
693
+ metrics.increment('knowledge_store_delete_total', 1, { operation: 'by_url', status: 'success' });
694
+ logger.log(`[store] Deleted chunks for ${url} (scoped)`);
695
+ return;
696
+ } catch (err) {
697
+ if (isLanceCommitConflict(err)) {
698
+ retryCount++;
699
+ if (retryCount > MAX_RETRIES) throw err;
700
+ const delay = Math.floor(Math.random() * (BASE_DELAY * Math.pow(2, retryCount - 1)));
701
+ logger.debug(`[store] Delete contention detected for ${url}, retrying ${retryCount}/${MAX_RETRIES} after ${delay}ms...`);
702
+ await new Promise(r => setTimeout(r, delay));
703
+ continue;
704
+ }
705
+
706
+ const duration = Date.now() - startTime;
707
+ metrics.observe('knowledge_store_delete_duration_ms', duration, { status: 'error' });
708
+ metrics.increment('knowledge_store_delete_total', 1, { operation: 'by_url', status: 'error' });
709
+ throw err;
710
+ }
711
+ }
712
+ }
713
+
714
+ async deleteByUrlAndType(url: string, ingestionType: string): Promise<void> {
715
+ // Coordinate with close() — see deleteByUrl.
716
+ return this.trackOperation('deleteByUrlAndType', undefined, () => this.deleteByUrlAndTypeInner(url, ingestionType));
717
+ }
718
+
719
+ private async deleteByUrlAndTypeInner(url: string, ingestionType: string): Promise<void> {
720
+ let retryCount = 0;
721
+ const MAX_RETRIES = 5;
722
+ const BASE_DELAY = 100;
723
+ const scopeFilter = this.getScopeFilter();
724
+
725
+ while (retryCount <= MAX_RETRIES) {
726
+ const table = await this.getFreshTable();
727
+ const startTime = Date.now();
728
+ try {
729
+ const escapedUrl = url.replace(/'/g, "''");
730
+ const escapedType = ingestionType.replace(/'/g, "''");
731
+ await table.delete(`url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND (${scopeFilter})`);
732
+ const duration = Date.now() - startTime;
733
+ metrics.observe('knowledge_store_delete_duration_ms', duration, { operation: 'by_url_and_type' });
734
+ metrics.increment('knowledge_store_delete_total', 1, { operation: 'by_url_and_type', status: 'success' });
735
+ logger.log(`[store] Deleted ${ingestionType} chunks for ${url} (scoped)`);
736
+ return;
737
+ } catch (err) {
738
+ if (isLanceCommitConflict(err)) {
739
+ retryCount++;
740
+ if (retryCount > MAX_RETRIES) throw err;
741
+ const delay = Math.floor(Math.random() * (BASE_DELAY * Math.pow(2, retryCount - 1)));
742
+ logger.debug(`[store] Delete-by-type contention detected for ${url}, retrying ${retryCount}/${MAX_RETRIES} after ${delay}ms...`);
743
+ await new Promise(r => setTimeout(r, delay));
744
+ continue;
745
+ }
746
+
747
+ const duration = Date.now() - startTime;
748
+ metrics.observe('knowledge_store_delete_duration_ms', duration, { operation: 'by_url_and_type', status: 'error' });
749
+ metrics.increment('knowledge_store_delete_total', 1, { operation: 'by_url_and_type', status: 'error' });
750
+ throw err;
751
+ }
752
+ }
753
+ }
754
+
755
+ async findDocumentsByUrl(url: string): Promise<StoreDocument[]> {
756
+ // Coordinate with close(): findByUrl runs via the writer-queue in the post-run
757
+ // background window (link-description storage), exactly when a Ctrl+C-driven
758
+ // close() is most likely to race it — read against a freed native handle otherwise.
759
+ return this.trackOperation('findDocumentsByUrl', [] as StoreDocument[], async () => {
760
+ const table = await this.getFreshTable();
761
+ const scopeFilter = this.getScopeFilter();
762
+ return findDocumentsByUrl(table, url, scopeFilter);
763
+ });
764
+ }
765
+
766
+ async exportForWeb(outputPath: string): Promise<void> {
767
+ // Reachable from the public SDK (exportKnowledge()) at any time — coordinate with
768
+ // close() like the other reads so a concurrent teardown can't free the native handle
769
+ // mid-query and surface a use-after-close to the caller.
770
+ return this.trackOperation('exportForWeb', undefined, async () => {
771
+ const table = await this.getFreshTable();
772
+ const scopeFilter = this.getScopeFilter();
773
+
774
+ logger.info(`[store] Exporting knowledge store for web to: ${outputPath} (scope=${scopeFilter})`);
775
+
776
+ // 1. Fetch all synthesis-description entries
777
+ // We only want the high-quality summaries and their vectors for semantic search in the UI.
778
+ const results = await table
779
+ .query()
780
+ .where(`ingestion_type = 'synthesis-description' AND (${scopeFilter})`)
781
+ .toArray();
782
+
783
+ // 2. Format for web
784
+ // We use a compact format to keep the JSON size manageable
785
+ const exportData = results.map(r => {
786
+ let metadata: Record<string, any> = {};
787
+ try {
788
+ metadata = JSON.parse(r.metadata as string);
789
+ } catch {
790
+ logger.debug('[store] Corrupted metadata for row in rebuildDocument');
791
+ // Fallback for corrupted metadata
792
+ }
793
+
794
+ return {
795
+ url: r.url as string,
796
+ // The text is the summary/description
797
+ text: r.text as string,
798
+ // The vector is the model-dimension embedding
799
+ v: Array.from(r.vector as Float32Array),
800
+ // Minimal metadata needed for the UI
801
+ m: {
802
+ d: metadata['description'] || '',
803
+ t: Number(r.timestamp),
804
+ }
805
+ };
806
+ });
807
+
808
+ // 3. Save to file
809
+ try {
810
+ const dir = path.dirname(outputPath);
811
+ await fs.promises.mkdir(dir, { recursive: true });
812
+ await fs.promises.writeFile(outputPath, JSON.stringify(exportData), 'utf-8');
813
+ logger.info(`[store] Successfully exported ${exportData.length} entries to ${outputPath}`);
814
+ } catch (err) {
815
+ logger.error(`[store] Failed to export knowledge store for web:`, err);
816
+ throw err;
817
+ }
818
+ });
819
+ }
820
+
821
+ async findByUrl(url: string): Promise<StoreDocument[]> {
822
+ return this.findDocumentsByUrl(url);
823
+ }
824
+
825
+ async rebuildDocument(url: string): Promise<{ text: string; description: string | null; metadata: Record<string, any>; ageDays?: number } | null> {
826
+ // Runs mid-research (scrape cache lookup); coordinate with close() so a teardown
827
+ // can't free the native handle while this query is in flight.
828
+ return this.trackOperation('rebuildDocument', null, async () => {
829
+ const table = await this.getFreshTable();
830
+ const scopeFilter = this.getScopeFilter();
831
+
832
+ const startTime = Date.now();
833
+ const escapedUrl = url.replace(/'/g, "''");
834
+
835
+ const results = await table
836
+ .query()
837
+ .where(`url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`)
838
+ .limit(1)
839
+ .toArray();
840
+
841
+ const duration = Date.now() - startTime;
842
+ metrics.observe('knowledge_store_query_duration_ms', duration, { operation: 'rebuild_document' });
843
+ metrics.increment('knowledge_store_query_total', 1, { operation: 'rebuild_document' });
844
+
845
+ if (results.length === 0) {
846
+ metrics.increment('knowledge_store_cache_hits_total', 1, { status: 'miss' });
847
+ return null;
848
+ }
849
+
850
+ const r = results[0];
851
+ // Read-time freshness. The stored timestamp (ms) is otherwise discarded here;
852
+ // compute the row's age so we can (a) optionally treat an over-age row as a
853
+ // cache miss and (b) surface the age to callers so it never reaches the model
854
+ // as timeless/authoritative content.
855
+ const rawAgeMs = Date.now() - Number(r.timestamp);
856
+ // Guard against a missing/corrupt timestamp: Math.max does NOT sanitize NaN, and an
857
+ // undefined ageDays cleanly omits the age label rather than surfacing "~NaN day(s)".
858
+ const hasAge = Number.isFinite(rawAgeMs);
859
+ const ageMs = hasAge ? Math.max(0, rawAgeMs) : 0;
860
+ const ageDays = hasAge ? Math.floor(ageMs / 86_400_000) : undefined;
861
+ const maxServeAgeDays = this.options.maxServeAgeDays ?? 0;
862
+ if (maxServeAgeDays > 0 && hasAge && ageMs > maxServeAgeDays * 86_400_000) {
863
+ metrics.increment('knowledge_store_cache_hits_total', 1, { status: 'stale' });
864
+ logger.log(`[store] Cache stale for ${url} (${ageDays}d > ${maxServeAgeDays}d serve limit) — treating as miss`);
865
+ return null;
866
+ }
867
+ try {
868
+ const metadata = JSON.parse(r.metadata as string);
869
+ const description: string | null = typeof metadata.description === 'string' ? metadata.description : null;
870
+ const textToReturn = (r.content as string) || (r.text as string) || description || '';
871
+ metrics.increment('knowledge_store_cache_hits_total', 1, { status: 'hit' });
872
+ logger.log(`[store] Cache hit: synthesis-description for ${url} (${textToReturn.length} chars, ~${ageDays ?? '?'}d old)`);
873
+ return { text: textToReturn, description, metadata, ageDays };
874
+ } catch {
875
+ logger.debug(`[store] Parse error reading cached content for ${url}`);
876
+ metrics.increment('knowledge_store_cache_hits_total', 1, { status: 'parse_error' });
877
+ return null;
878
+ }
879
+ });
880
+ }
881
+
882
+ async findRelevantUrls(query: string, options: { limit?: number } = {}): Promise<{ url: string; description: string; provenance?: string }[]> {
883
+ // Runs mid-research from both orchestrators + the knowledge-search tool. Keep the
884
+ // circuitBreaker/embedder-reconnect INSIDE the tracked fn so a clean close-skip
885
+ // returns [] rather than surfacing a use-after-close error that trips the breaker.
886
+ return this.trackOperation('findRelevantUrls', [] as { url: string; description: string; provenance?: string }[], async () => {
887
+ const table = await this.getFreshTable();
888
+ const scopeFilter = this.getScopeFilter();
889
+ return this.circuitBreaker.execute(() =>
890
+ this.withEmbedderReconnect(embedder =>
891
+ findRelevantUrls(table, embedder, query, this.getReranker.bind(this), options.limit ?? 20, scopeFilter)
892
+ )
893
+ );
894
+ });
895
+ }
896
+
897
+ async rebuildFtsIndex(): Promise<boolean> {
898
+ return this.trackOperation('FTS index rebuild', false, () => this._rebuildFtsIndex());
899
+ }
900
+
901
+ private async _rebuildFtsIndex(): Promise<boolean> {
902
+ // Serialize under the same cross-process lock optimize() uses. rebuildFtsIndex and
903
+ // optimize are a matched pair that both commit new manifest versions; two processes
904
+ // finishing runs against a shared store could otherwise race two createIndex() calls,
905
+ // wasting a full rebuild (LanceDB's optimistic commit surfaces the loser as a conflict).
906
+ return this.options.withLock ? this.options.withLock(() => this._rebuildFtsIndexInner()) : this._rebuildFtsIndexInner();
907
+ }
908
+
909
+ private async _rebuildFtsIndexInner(): Promise<boolean> {
910
+ const { table, stale } = await this.openFreshTable();
911
+ try {
912
+ const count = await table.countRows();
913
+ if (count === 0) {
914
+ logger.debug('[store] Skipping FTS index rebuild (table is empty)');
915
+ return false;
916
+ }
917
+ // Skip when the table version is unchanged since our last rebuild: the existing
918
+ // FTS index already covers exactly these rows, so a rebuild would only churn out a
919
+ // new index version for no search benefit. The version advances on EVERY committed
920
+ // transaction (add/delete/optimize), so unlike row count it detects an equal-count
921
+ // re-ingest (delete N + add N) that changes the indexed row set — the case that would
922
+ // otherwise silently leave new content out of the keyword/BM25 index.
923
+ const version = await table.version();
924
+ // Only trust the skip when we hold a genuinely fresh handle. If getFreshTable
925
+ // fell back to a stale cached handle, version() may read an old snapshot that
926
+ // happens to equal lastFtsVersion — skipping a rebuild that new content needs.
927
+ // Fail safe: rebuild.
928
+ if (!stale && this.lastFtsVersion === version) {
929
+ logger.debug(`[store] Skipping FTS index rebuild (table version unchanged at ${version})`);
930
+ return false;
931
+ }
932
+ logger.info('[store] Rebuilding FTS indexes...');
933
+ const { Index } = await getLancedb();
934
+ // FTS on 'text' — synthesis descriptions (for semantic/hybrid search)
935
+ await table.createIndex('text', {
936
+ config: Index.fts(),
937
+ replace: true,
938
+ });
939
+ // FTS on 'content' — full article markdown (for keyword/BM25 grep)
940
+ await table.createIndex('content', {
941
+ config: Index.fts(),
942
+ replace: true,
943
+ });
944
+ // Record the version AFTER indexing (createIndex commits its own transactions) so the
945
+ // next call compares against this post-rebuild baseline and only rebuilds on real writes.
946
+ this.lastFtsVersion = await (await this.getFreshTable()).version();
947
+ logger.info('[store] FTS indexes rebuilt (text + content).');
948
+ return true;
949
+ } catch (err) {
950
+ logger.warn('[store] FTS index rebuild failed:', err);
951
+ return false;
952
+ }
953
+ }
954
+
955
+ /**
956
+ * Compact the table and prune stale versions/indices.
957
+ *
958
+ * LanceDB is append-/copy-on-write: every addDocuments, delete, and
959
+ * createIndex (FTS rebuild) writes a new manifest version and leaves the old
960
+ * data/index files behind. Nothing reclaims them automatically, so the store
961
+ * grew to ~924 MB on disk for ~21 MB of live data (1939 manifest versions,
962
+ * 752 MB of orphaned FTS indices). table.optimize() merges data fragments and
963
+ * removes versions older than `cleanupOlderThan`.
964
+ *
965
+ * Defaults are conservative for a possibly-shared store: deleteUnverified stays
966
+ * false so files younger than LanceDB's in-flight-transaction window are never
967
+ * removed, and the call is wrapped in withLock (when provided) so a concurrent
968
+ * process isn't mid-write. The `deleteUnverified`/`cleanupOlderThan` options are a
969
+ * reserved knob for a future maintenance path that could reclaim aggressively once
970
+ * the caller guarantees no other instance is running; no command exposes it today,
971
+ * so every production call uses the conservative defaults.
972
+ *
973
+ * Returns true if optimize ran, false if it was skipped (closing/disabled).
974
+ */
975
+ async optimize(options: { cleanupOlderThan?: Date; deleteUnverified?: boolean } = {}): Promise<boolean> {
976
+ return this.trackOperation('optimize', false, () => this._optimize(options));
977
+ }
978
+
979
+ private async _optimize(options: { cleanupOlderThan?: Date; deleteUnverified?: boolean } = {}): Promise<boolean> {
980
+ if (this.options.knowledgeMode === 'none') return false;
981
+
982
+ const run = async (): Promise<boolean> => {
983
+ const { table, stale } = await this.openFreshTable();
984
+ const startTime = Date.now();
985
+ try {
986
+ // optimize() commits a new manifest version (compaction) but never changes the
987
+ // logical row set, so an FTS index that covered all rows before this call still
988
+ // covers them after. Record whether our rebuild baseline was current going in: if
989
+ // so, we advance it past optimize's version bump below. Otherwise rebuildFtsIndex's
990
+ // unchanged-version skip guard is defeated and every steady-state run needlessly
991
+ // rebuilds + re-optimizes (the disk-bloat regression). A stale baseline (index not
992
+ // current, or never built) is left untouched so the next rebuild still detects the
993
+ // pending change.
994
+ const wasFtsIndexCurrent = !stale && this.lastFtsVersion !== null && this.lastFtsVersion === await table.version();
995
+ // cleanupOlderThan defaults to now: prune every prunable old version. The
996
+ // current version is always retained, and (with deleteUnverified=false)
997
+ // physical files inside LanceDB's safety window are kept regardless.
998
+ const cleanupOlderThan = options.cleanupOlderThan ?? new Date();
999
+ const stats = await table.optimize({
1000
+ cleanupOlderThan,
1001
+ deleteUnverified: options.deleteUnverified ?? false,
1002
+ });
1003
+ const duration = Date.now() - startTime;
1004
+ metrics.observe('knowledge_store_optimize_duration_ms', duration);
1005
+ metrics.increment('knowledge_store_optimize_total', 1);
1006
+ const removed = stats?.prune?.oldVersionsRemoved ?? 0;
1007
+ const bytesRemoved = stats?.prune?.bytesRemoved ?? 0;
1008
+ logger.info(`[store] Optimize complete: removed ${removed} old version(s), reclaimed ${bytesRemoved} bytes (${duration}ms).`);
1009
+ // Keep the FTS skip-guard baseline in sync with optimize's layout-only version bump
1010
+ // so the next rebuildFtsIndex correctly skips when no content changed.
1011
+ if (wasFtsIndexCurrent) {
1012
+ this.lastFtsVersion = await (await this.getFreshTable()).version();
1013
+ }
1014
+ return true;
1015
+ } catch (err) {
1016
+ logger.warn('[store] Optimize failed:', err);
1017
+ return false;
1018
+ }
1019
+ };
1020
+
1021
+ return this.options.withLock ? this.options.withLock(run) : run();
1022
+ }
1023
+
1024
+ private async evictOldRecords(): Promise<void> {
1025
+ const table = await this.getFreshTable();
1026
+
1027
+ try {
1028
+ const scopeFilter = this.getScopeFilter();
1029
+ // knowledgeMode='none' means no store is active — nothing to evict.
1030
+ if (scopeFilter === '1 = 0') return;
1031
+
1032
+ const ttlDays = this.options.ttlDays ?? 30;
1033
+ const cutoffTimestamp = Date.now() - (ttlDays * 24 * 60 * 60 * 1000);
1034
+ // Scope eviction to this workspace only — do not evict other projects' data.
1035
+ await table.delete(`timestamp < ${BigInt(cutoffTimestamp)} AND (${scopeFilter})`);
1036
+ logger.log(`[store] Ran eviction for records older than ${ttlDays} days [scope: ${scopeFilter}]`);
1037
+ } catch (err) {
1038
+ logger.warn('[store] Failed to evict old records:', err);
1039
+ }
1040
+ }
1041
+
1042
+ private async pruneOrphanedMigrationDirs(): Promise<void> {
1043
+ // Only prune once the canonical table is confirmed open. If it failed to
1044
+ // open, the real data may still live in a `_migration_`/`_backup_` dir that
1045
+ // the manifest points at — never reap dirs when we can't positively
1046
+ // establish which one is live.
1047
+ if (!this.table) return;
1048
+ try {
1049
+ // The dir the manifest currently points at is the live table — NEVER
1050
+ // delete it, even if its name contains `_migration_`/`_backup_` (a
1051
+ // persisted re-embed rename-failure leaves the live data in a temp dir).
1052
+ const activeDir = `${this.tableName}.lance`;
1053
+ const entries = await fsPromises.readdir(this.options.dbDir, { withFileTypes: true });
1054
+
1055
+ const migrationDirs: string[] = [];
1056
+ const backupDirs: string[] = [];
1057
+ for (const entry of entries) {
1058
+ if (!entry.isDirectory()) continue;
1059
+ const name = entry.name;
1060
+ if (name === activeDir || !name.endsWith('.lance')) continue;
1061
+ // Check `_backup_` FIRST: a re-embed rename-failure can leave the table
1062
+ // named `..._migration_<ts>`, and a later backup of it produces
1063
+ // `..._migration_<ts>_backup_<ts2>` — an intentional, recoverable backup
1064
+ // that must NOT be misclassified as a reapable migration temp.
1065
+ if (name.includes('_backup_')) backupDirs.push(name);
1066
+ else if (name.includes('_migration_')) migrationDirs.push(name);
1067
+ }
1068
+
1069
+ // `_migration_` dirs are transient temp tables from an interrupted
1070
+ // re-embed; the live one is excluded above, so the rest are safe garbage.
1071
+ for (const name of migrationDirs) {
1072
+ const fullPath = path.join(this.options.dbDir, name);
1073
+ logger.info(`[store] Removing orphaned migration temp directory: ${name}`);
1074
+ await fsPromises.rm(fullPath, { recursive: true, force: true });
1075
+ }
1076
+
1077
+ // `_backup_` dirs are intentional, user-recoverable snapshots from the
1078
+ // 'backup' migration strategy. RETAIN the most recent one (deleting it
1079
+ // would make 'backup' silently equivalent to 'drop'); prune only older,
1080
+ // superseded backups so they don't accumulate. ISO-timestamp names sort
1081
+ // chronologically, so the last entry is newest.
1082
+ if (backupDirs.length > 1) {
1083
+ backupDirs.sort();
1084
+ for (const name of backupDirs.slice(0, -1)) {
1085
+ const fullPath = path.join(this.options.dbDir, name);
1086
+ logger.info(`[store] Removing superseded backup directory: ${name}`);
1087
+ await fsPromises.rm(fullPath, { recursive: true, force: true });
1088
+ }
1089
+ }
1090
+ } catch (err) {
1091
+ logger.warn('[store] Failed to prune orphaned migration directories:', err);
1092
+ }
1093
+ }
1094
+
1095
+ async count(): Promise<number> {
1096
+ if (!this.db) return 0;
1097
+ // Track against close() — this read had no try/catch, so a teardown racing db.close()
1098
+ // here would surface as an uncaught use-after-close. trackOperation makes close() wait.
1099
+ return this.trackOperation('count', 0, async () => {
1100
+ const table = await this.getFreshTable();
1101
+ const scopeFilter = this.getScopeFilter();
1102
+ const count = await table.countRows(scopeFilter);
1103
+ metrics.setGauge('knowledge_store_total_documents', count);
1104
+ return count;
1105
+ });
1106
+ }
1107
+
1108
+ /**
1109
+ * Get granular counts for local vs global entries.
1110
+ * @param workspace - Optional workspace to filter project-specific counts.
1111
+ * Defaults to the store's configured workspace.
1112
+ */
1113
+ async countScoped(workspace?: string): Promise<{ local: number; global: number; projects: number }> {
1114
+ if (!this.db) return { local: 0, global: 0, projects: 0 };
1115
+
1116
+ // Coordinate with close() — runs from healthcheck + research-config status display.
1117
+ // Keep the contention-retry loop INSIDE the tracked fn so a clean close-skip returns
1118
+ // zeroed counts rather than throwing a non-retryable use-after-close.
1119
+ return this.trackOperation('countScoped', { local: 0, global: 0, projects: 0 }, async () => {
1120
+ let retryCount = 0;
1121
+ const MAX_RETRIES = 5;
1122
+ const BASE_DELAY = 100;
1123
+
1124
+ while (retryCount <= MAX_RETRIES) {
1125
+ try {
1126
+ const table = await this.getFreshTable();
1127
+ const ws = normalizeWorkspacePath(workspace || this.getWorkspace());
1128
+ const escaped = ws.replace(/'/g, "''");
1129
+
1130
+ const [local, global] = await Promise.all([
1131
+ table.countRows(`workspace = '${escaped}'`),
1132
+ table.countRows(`is_global = true`)
1133
+ ]);
1134
+
1135
+ const allWorkspaces = await table.query()
1136
+ .select(['workspace'])
1137
+ .toArray();
1138
+
1139
+ const uniqueWorkspaces = new Set<string>();
1140
+ for (const row of allWorkspaces) {
1141
+ if (row.workspace) uniqueWorkspaces.add(row.workspace as string);
1142
+ }
1143
+
1144
+ return { local, global, projects: uniqueWorkspaces.size };
1145
+ } catch (err) {
1146
+ if (isLanceCommitConflict(err)) {
1147
+ retryCount++;
1148
+ if (retryCount > MAX_RETRIES) throw err;
1149
+ const delay = Math.floor(Math.random() * (BASE_DELAY * Math.pow(2, retryCount - 1)));
1150
+ logger.debug(`[store] Read contention detected in countScoped, retrying ${retryCount}/${MAX_RETRIES} after ${delay}ms...`);
1151
+ await new Promise(r => setTimeout(r, delay));
1152
+ continue;
1153
+ }
1154
+ throw err;
1155
+ }
1156
+ }
1157
+ return { local: 0, global: 0, projects: 0 }; // Should not be reached
1158
+ });
1159
+ }
1160
+
1161
+ async clear(filter?: string): Promise<void> {
1162
+ if (!this.db) throw new Error('Store not open');
1163
+ // Coordinate with close() — see deleteByUrl.
1164
+ return this.trackOperation('clear', undefined, () => this.clearInner(filter));
1165
+ }
1166
+
1167
+ private async clearInner(filter?: string): Promise<void> {
1168
+ let retryCount = 0;
1169
+ const MAX_RETRIES = 5;
1170
+ const BASE_DELAY = 100;
1171
+
1172
+ while (retryCount <= MAX_RETRIES) {
1173
+ try {
1174
+ const table = await this.getFreshTable();
1175
+ const scopeFilter = filter || this.getScopeFilter();
1176
+ const startTime = Date.now();
1177
+
1178
+ // Scoped clear instead of dropping table to preserve other workspace data
1179
+ await table.delete(scopeFilter);
1180
+
1181
+ const duration = Date.now() - startTime;
1182
+ metrics.observe('knowledge_store_clear_duration_ms', duration);
1183
+ metrics.increment('knowledge_store_clear_total', 1, { status: 'success' });
1184
+ metrics.setGauge('knowledge_store_total_documents', 0);
1185
+ logger.info(`[store] Knowledge store cleared for scope: ${scopeFilter}`);
1186
+ return;
1187
+ } catch (err) {
1188
+ if (isLanceCommitConflict(err)) {
1189
+ retryCount++;
1190
+ if (retryCount > MAX_RETRIES) {
1191
+ metrics.increment('knowledge_store_clear_total', 1, { status: 'error' });
1192
+ throw err;
1193
+ }
1194
+ const delay = Math.floor(Math.random() * (BASE_DELAY * Math.pow(2, retryCount - 1)));
1195
+ logger.debug(`[store] Write contention detected in clear, retrying ${retryCount}/${MAX_RETRIES} after ${delay}ms...`);
1196
+ await new Promise(r => setTimeout(r, delay));
1197
+ continue;
1198
+ }
1199
+ metrics.increment('knowledge_store_clear_total', 1, { status: 'error' });
1200
+ logger.error('[store] Failed to clear knowledge store:', err);
1201
+ throw err;
1202
+ }
1203
+ }
1204
+ }
1205
+
1206
+ isStoreClosed(): boolean {
1207
+ return this.isClosing;
1208
+ }
1209
+
1210
+ async close(): Promise<void> {
1211
+ this.isClosing = true;
1212
+
1213
+ if (this.activeWrites.size > 0) {
1214
+ const maxWaitMs = 10000;
1215
+ const writesCount = this.activeWrites.size;
1216
+ let timeoutId: NodeJS.Timeout | undefined;
1217
+ try {
1218
+ const timeoutPromise = new Promise<never>((_, reject) => {
1219
+ timeoutId = setTimeout(() => reject(new Error('Close timeout')), maxWaitMs);
1220
+ });
1221
+ // We don't need a .catch() on Promise.allSettled because it never rejects,
1222
+ // but we DO need to ensure the timeoutPromise rejection is caught if it fires AFTER the race.
1223
+ timeoutPromise.catch((err: Error) => logger.debug(`[store] Background timeout rejection: ${err.message}`));
1224
+
1225
+ await Promise.race([
1226
+ Promise.allSettled(Array.from(this.activeWrites)),
1227
+ timeoutPromise
1228
+ ]);
1229
+ } catch (_err) {
1230
+ logger.warn(`[store] Closing with ${this.activeWrites.size} pending operations after timeout (started with ${writesCount})`);
1231
+ } finally {
1232
+ if (timeoutId) clearTimeout(timeoutId);
1233
+ }
1234
+ }
1235
+
1236
+ try {
1237
+ this.table = null;
1238
+ this.rrfReranker = null;
1239
+ if (this.db) {
1240
+ // close() is typed void, but the native LanceDB binding may dispatch
1241
+ // file-descriptor/manifest-flush teardown asynchronously. Await it (via
1242
+ // Promise.resolve so a sync void return is fine too) before dropping the
1243
+ // reference, so a rapid close→reconnect can't observe a half-flushed manifest.
1244
+ await Promise.resolve(this.db.close());
1245
+ this.db = null;
1246
+ }
1247
+ } catch (err) {
1248
+ logger.error('[store] Error during close:', err);
1249
+ }
1250
+ }
1251
+ }