@lincoln504/pi-research 0.1.12 → 1.0.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.
- package/LICENSE +1 -1
- package/README.md +64 -115
- package/dist/cli.mjs +23211 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +102 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +213 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +113 -0
- package/docs/THIRD-PARTY-NOTICES.md +64 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1044 -0
- package/src/config.ts +1036 -147
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +264 -7
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -174
- package/src/infrastructure/searxng-lifecycle.ts +0 -577
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- 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
|
+
}
|