memorix 1.9.0 → 1.9.2
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/CHANGELOG.md +39 -0
- package/README.md +22 -5
- package/README.zh-CN.md +9 -5
- package/dist/cli/index.js +2635 -1942
- package/dist/index.js +2445 -1766
- package/dist/maintenance-runner.js +1696 -1036
- package/dist/media-audio-runner.js +2197 -1537
- package/dist/media-video-runner.js +2195 -1535
- package/dist/memcode-runtime/CHANGELOG.md +39 -0
- package/dist/sdk.js +2507 -1764
- package/dist/vector-backfill-runner.js +2193 -1533
- package/docs/CONFIGURATION.md +22 -0
- package/docs/DEVELOPMENT.md +2 -2
- package/docs/INTEGRATIONS.md +4 -0
- package/docs/PERFORMANCE.md +77 -20
- package/docs/README.md +1 -1
- package/docs/SETUP.md +5 -0
- package/package.json +2 -2
- package/plugins/antigravity/memorix/plugin.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/hermes/memorix/plugin.yaml +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/cli/commands/memory.ts +2 -1
- package/src/cli/default-mode.ts +15 -0
- package/src/cli/index.ts +10 -1
- package/src/codegraph/lite-provider.ts +10 -1
- package/src/compact/engine.ts +14 -4
- package/src/dashboard/maintenance.ts +11 -2
- package/src/memory/consolidation.ts +15 -6
- package/src/memory/freshness.ts +7 -4
- package/src/memory/observations.ts +118 -6
- package/src/runtime/vector-backfill-runner.ts +1 -1
- package/src/sdk.ts +13 -6
- package/src/search/semantic-index.ts +340 -0
- package/src/server.ts +1 -1
- package/src/store/obs-store.ts +54 -2
- package/src/store/orama-store.ts +300 -12
- package/src/store/sqlite-db.ts +6 -0
- package/src/store/sqlite-fts.ts +164 -0
- package/src/store/sqlite-store.ts +83 -6
- package/assets/star-history-dark.svg +0 -30
- package/assets/star-history-light.svg +0 -30
- package/assets/star-history.json +0 -712
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
getDb,
|
|
30
30
|
getDeferredCachedVectorHydration,
|
|
31
31
|
getVectorDimensions,
|
|
32
|
+
queueSemanticVector,
|
|
32
33
|
hydrateIndexForStartup,
|
|
33
34
|
hasObservationVector,
|
|
34
35
|
isEmbeddingEnabled,
|
|
@@ -64,13 +65,25 @@ export interface ObservationRuntimeOptions {
|
|
|
64
65
|
* tens of thousands of rows on every PostToolUse.
|
|
65
66
|
*/
|
|
66
67
|
skipCorpusLoad?: boolean;
|
|
68
|
+
/** Force a maintenance worker to load the corpus for vector backfill. */
|
|
69
|
+
forceCorpusLoad?: boolean;
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
let embeddingWriteMode: ObservationEmbeddingWriteMode = 'background';
|
|
70
73
|
let embeddingWorkerProjectRoot: string | undefined;
|
|
71
74
|
let corpusLoaded = false;
|
|
75
|
+
let largeCorpusLazyMode = false;
|
|
72
76
|
let loadedObservationStore: ObservationStore | null = null;
|
|
73
77
|
|
|
78
|
+
const DEFAULT_ORAMA_HYDRATION_THRESHOLD = 10_000;
|
|
79
|
+
|
|
80
|
+
function resolveOramaHydrationThreshold(): number {
|
|
81
|
+
const configured = Number.parseInt(process.env.MEMORIX_ORAMA_HYDRATION_THRESHOLD ?? '', 10);
|
|
82
|
+
return Number.isSafeInteger(configured) && configured > 0
|
|
83
|
+
? configured
|
|
84
|
+
: DEFAULT_ORAMA_HYDRATION_THRESHOLD;
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
/** @internal Reset in-process observation state between tests. */
|
|
75
88
|
export function resetObservationRuntime(): void {
|
|
76
89
|
observations = [];
|
|
@@ -78,6 +91,7 @@ export function resetObservationRuntime(): void {
|
|
|
78
91
|
projectDir = null;
|
|
79
92
|
searchIndexPrepared = false;
|
|
80
93
|
corpusLoaded = false;
|
|
94
|
+
largeCorpusLazyMode = false;
|
|
81
95
|
loadedObservationStore = null;
|
|
82
96
|
embeddingWriteMode = 'background';
|
|
83
97
|
embeddingWorkerProjectRoot = undefined;
|
|
@@ -148,6 +162,22 @@ function vectorBackfillError(error: unknown): string {
|
|
|
148
162
|
return sanitizeCredentials(normalizeEmbeddingFailure(error).message).slice(0, 1_000);
|
|
149
163
|
}
|
|
150
164
|
|
|
165
|
+
function queueSemanticVectorSafely(
|
|
166
|
+
dataDir: string,
|
|
167
|
+
record: Parameters<typeof queueSemanticVector>[1],
|
|
168
|
+
): void {
|
|
169
|
+
try {
|
|
170
|
+
// The derived index is best-effort. Catch both synchronous compatibility
|
|
171
|
+
// failures from older test adapters and asynchronous native-index errors.
|
|
172
|
+
void Promise.resolve(queueSemanticVector(dataDir, record)).catch(() => {
|
|
173
|
+
// Durable SQLite/FTS remains authoritative; backfill can rebuild vectors.
|
|
174
|
+
});
|
|
175
|
+
} catch {
|
|
176
|
+
// Test adapters and older runtimes may not expose the optional derived
|
|
177
|
+
// index hook. The durable observation and Orama fallback remain valid.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
151
181
|
function queueVectorBackfill(
|
|
152
182
|
projectId: string,
|
|
153
183
|
options: { detachedWorker?: boolean; projectRoot?: string } = {},
|
|
@@ -300,6 +330,22 @@ export async function initObservations(
|
|
|
300
330
|
loadedObservationStore = store;
|
|
301
331
|
return;
|
|
302
332
|
}
|
|
333
|
+
|
|
334
|
+
const durableCount = await store.countAll?.();
|
|
335
|
+
largeCorpusLazyMode = Boolean(
|
|
336
|
+
!options.forceCorpusLoad &&
|
|
337
|
+
durableCount !== undefined &&
|
|
338
|
+
durableCount >= resolveOramaHydrationThreshold() &&
|
|
339
|
+
store.hasLexicalIndex?.(),
|
|
340
|
+
);
|
|
341
|
+
if (largeCorpusLazyMode) {
|
|
342
|
+
// SQLite/FTS5 and the optional persistent semantic index serve the search
|
|
343
|
+
// path. Management/detail paths explicitly call ensureCorpusLoaded().
|
|
344
|
+
observations = [];
|
|
345
|
+
corpusLoaded = false;
|
|
346
|
+
loadedObservationStore = store;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
303
349
|
observations = await store.loadAll();
|
|
304
350
|
corpusLoaded = true;
|
|
305
351
|
loadedObservationStore = store;
|
|
@@ -349,6 +395,15 @@ function scheduleObservationEmbedding(input: {
|
|
|
349
395
|
try {
|
|
350
396
|
await removeObservation(makeOramaObservationId(projectId, observationId));
|
|
351
397
|
await insertObservation(Object.assign({}, doc, { embedding }));
|
|
398
|
+
if (projectDir) {
|
|
399
|
+
queueSemanticVectorSafely(projectDir, {
|
|
400
|
+
observationId,
|
|
401
|
+
projectId,
|
|
402
|
+
status: doc.status,
|
|
403
|
+
visibility: doc.visibility,
|
|
404
|
+
vector: embedding,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
352
407
|
vectorMissingIds.delete(observationId);
|
|
353
408
|
} catch {
|
|
354
409
|
console.error(`[memorix] Embedding index update failed for obs-${observationId} (kept in backfill queue)`);
|
|
@@ -387,16 +442,27 @@ function scheduleObservationEmbedding(input: {
|
|
|
387
442
|
*
|
|
388
443
|
* For DegradedBackend this is a no-op (always returns false).
|
|
389
444
|
*/
|
|
390
|
-
export async function ensureFreshObservations(): Promise<boolean> {
|
|
391
|
-
if (!projectDir
|
|
445
|
+
export async function ensureFreshObservations(options: { loadCorpus?: boolean } = {}): Promise<boolean> {
|
|
446
|
+
if (!projectDir) return false;
|
|
447
|
+
const loadCorpus = options.loadCorpus !== false;
|
|
392
448
|
try {
|
|
393
449
|
const store = getObservationStore();
|
|
394
450
|
const wasStale = await store.ensureFresh();
|
|
395
|
-
if (
|
|
451
|
+
if (!corpusLoaded && loadCorpus) {
|
|
396
452
|
observations = await store.loadAll();
|
|
397
453
|
nextId = await store.loadIdCounter();
|
|
398
|
-
|
|
399
|
-
|
|
454
|
+
corpusLoaded = true;
|
|
455
|
+
largeCorpusLazyMode = false;
|
|
456
|
+
searchIndexPrepared = false;
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
if (wasStale) {
|
|
460
|
+
nextId = await store.loadIdCounter();
|
|
461
|
+
if (corpusLoaded) {
|
|
462
|
+
observations = await store.loadAll();
|
|
463
|
+
await reindexObservations();
|
|
464
|
+
searchIndexPrepared = true;
|
|
465
|
+
}
|
|
400
466
|
return true;
|
|
401
467
|
}
|
|
402
468
|
} catch {
|
|
@@ -405,6 +471,11 @@ export async function ensureFreshObservations(): Promise<boolean> {
|
|
|
405
471
|
return false;
|
|
406
472
|
}
|
|
407
473
|
|
|
474
|
+
/** Load the full durable corpus for management/detail operations only. */
|
|
475
|
+
export async function ensureCorpusLoaded(): Promise<void> {
|
|
476
|
+
await ensureFreshObservations({ loadCorpus: true });
|
|
477
|
+
}
|
|
478
|
+
|
|
408
479
|
/**
|
|
409
480
|
* @internal Observation-only freshness gate.
|
|
410
481
|
*
|
|
@@ -491,7 +562,7 @@ export async function storeObservation(input: {
|
|
|
491
562
|
// Sync the local cache before using it as the topicKey fast path. This costs a
|
|
492
563
|
// generation read in the normal case and only reloads when another process wrote.
|
|
493
564
|
// Write-only / hook processes skip the corpus cache; disk findByTopicKey is authoritative.
|
|
494
|
-
await ensureFreshObservations();
|
|
565
|
+
await ensureFreshObservations({ loadCorpus: false });
|
|
495
566
|
|
|
496
567
|
// Topic key upsert: fast-path check in-memory (optimistic, may be stale).
|
|
497
568
|
// A second authoritative check happens inside the file lock to prevent TOCTOU races
|
|
@@ -612,6 +683,21 @@ export async function storeObservation(input: {
|
|
|
612
683
|
// Phase 4a: confirm writeGeneration matches actual post-bump value
|
|
613
684
|
observation.writeGeneration = store.getGeneration();
|
|
614
685
|
|
|
686
|
+
// A long-lived writer can cross the hydration threshold after startup.
|
|
687
|
+
// Release the hot Orama corpus at that point and let FTS5/semantic shadow
|
|
688
|
+
// indexes serve subsequent searches. Durable SQLite rows are untouched.
|
|
689
|
+
if (
|
|
690
|
+
corpusLoaded &&
|
|
691
|
+
observations.length >= resolveOramaHydrationThreshold() &&
|
|
692
|
+
store.hasLexicalIndex?.()
|
|
693
|
+
) {
|
|
694
|
+
observations = [];
|
|
695
|
+
corpusLoaded = false;
|
|
696
|
+
largeCorpusLazyMode = true;
|
|
697
|
+
searchIndexPrepared = false;
|
|
698
|
+
await resetDb();
|
|
699
|
+
}
|
|
700
|
+
|
|
615
701
|
if (upsertedInsideLock || reloadCacheAfterCommit) {
|
|
616
702
|
if (corpusLoaded) {
|
|
617
703
|
observations = await store.loadAll();
|
|
@@ -1101,6 +1187,11 @@ export function getObservationCount(): number {
|
|
|
1101
1187
|
return observations.length;
|
|
1102
1188
|
}
|
|
1103
1189
|
|
|
1190
|
+
/** The current durable data directory used by the observation runtime. */
|
|
1191
|
+
export function getObservationDataDir(): string | null {
|
|
1192
|
+
return projectDir;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1104
1195
|
/**
|
|
1105
1196
|
* Suggest a stable topic key from type + title.
|
|
1106
1197
|
* Uses family heuristics (architecture/*, bug/*, decision/*, etc.)
|
|
@@ -1246,6 +1337,18 @@ export async function reindexObservations(): Promise<number> {
|
|
|
1246
1337
|
export async function prepareSearchIndex(options: { skipCachedVectors?: boolean } = {}): Promise<number> {
|
|
1247
1338
|
if (searchIndexPrepared) return 0;
|
|
1248
1339
|
|
|
1340
|
+
if (largeCorpusLazyMode && getObservationStore().hasLexicalIndex?.()) {
|
|
1341
|
+
// Search is served by SQLite FTS5 plus the optional persistent semantic
|
|
1342
|
+
// shadow index. Do not pay the old full-corpus Orama hydration cost.
|
|
1343
|
+
searchIndexPrepared = true;
|
|
1344
|
+
if (!isEmbeddingExplicitlyDisabled()) {
|
|
1345
|
+
const store = getObservationStore();
|
|
1346
|
+
const projects = await store.listProjectIds?.() ?? [];
|
|
1347
|
+
for (const projectId of projects) queueVectorBackfill(projectId);
|
|
1348
|
+
}
|
|
1349
|
+
return 0;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1249
1352
|
const count = await hydrateIndexForStartup(observations as unknown as any[], {
|
|
1250
1353
|
skipCachedVectors: options.skipCachedVectors,
|
|
1251
1354
|
});
|
|
@@ -1487,6 +1590,15 @@ export async function backfillVectorEmbeddings(options: {
|
|
|
1487
1590
|
embedding,
|
|
1488
1591
|
};
|
|
1489
1592
|
await insertObservation(doc);
|
|
1593
|
+
if (projectDir) {
|
|
1594
|
+
queueSemanticVectorSafely(projectDir, {
|
|
1595
|
+
observationId: id,
|
|
1596
|
+
projectId: obs.projectId,
|
|
1597
|
+
status: doc.status,
|
|
1598
|
+
visibility: doc.visibility,
|
|
1599
|
+
vector: embedding,
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1490
1602
|
vectorMissingIds.delete(id);
|
|
1491
1603
|
succeeded++;
|
|
1492
1604
|
} catch (error) {
|
|
@@ -252,7 +252,7 @@ export async function executeVectorBackfill(request: VectorBackfillRequest) {
|
|
|
252
252
|
initProjectRoot(request.projectRoot);
|
|
253
253
|
loadDotenv(request.projectRoot);
|
|
254
254
|
await initObservationStore(request.dataDir);
|
|
255
|
-
await initObservations(request.dataDir);
|
|
255
|
+
await initObservations(request.dataDir, { forceCorpusLoad: true });
|
|
256
256
|
await prepareSearchIndex();
|
|
257
257
|
await getDeferredCachedVectorHydration()?.catch(() => {});
|
|
258
258
|
|
package/src/sdk.ts
CHANGED
|
@@ -214,7 +214,7 @@ export class MemoryClient {
|
|
|
214
214
|
*/
|
|
215
215
|
async search(options: ClientSearchOptions): Promise<IndexEntry[]> {
|
|
216
216
|
this._ensureOpen();
|
|
217
|
-
await this._freshness.withFreshIndex(() => {});
|
|
217
|
+
await this._freshness.withFreshIndex(() => {}, { loadCorpus: false });
|
|
218
218
|
|
|
219
219
|
const searchOpts: SearchOptions = {
|
|
220
220
|
query: options.query,
|
|
@@ -235,6 +235,7 @@ export class MemoryClient {
|
|
|
235
235
|
*/
|
|
236
236
|
async get(id: number): Promise<Observation | undefined> {
|
|
237
237
|
this._ensureOpen();
|
|
238
|
+
await this._observations.ensureCorpusLoaded();
|
|
238
239
|
await this._freshness.withFreshIndex(() => {});
|
|
239
240
|
const observation = this._observations.getObservation(id, this._projectId);
|
|
240
241
|
return observation && canReadObservation(observation, this._reader) ? observation : undefined;
|
|
@@ -245,6 +246,7 @@ export class MemoryClient {
|
|
|
245
246
|
*/
|
|
246
247
|
async getAll(): Promise<Observation[]> {
|
|
247
248
|
this._ensureOpen();
|
|
249
|
+
await this._observations.ensureCorpusLoaded();
|
|
248
250
|
await this._freshness.withFreshIndex(() => {});
|
|
249
251
|
return filterReadableObservations(
|
|
250
252
|
this._observations.getProjectObservations(this._projectId),
|
|
@@ -257,11 +259,9 @@ export class MemoryClient {
|
|
|
257
259
|
*/
|
|
258
260
|
async count(): Promise<number> {
|
|
259
261
|
this._ensureOpen();
|
|
260
|
-
await this._freshness.withFreshIndex(() => {});
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
this._reader,
|
|
264
|
-
).length;
|
|
262
|
+
await this._freshness.withFreshIndex(() => {}, { loadCorpus: false });
|
|
263
|
+
const store = this._obsStore.getObservationStore();
|
|
264
|
+
return store.countByProject(this._projectId, { visibility: 'project' });
|
|
265
265
|
}
|
|
266
266
|
|
|
267
267
|
/**
|
|
@@ -278,6 +278,7 @@ export class MemoryClient {
|
|
|
278
278
|
*/
|
|
279
279
|
async resolve(ids: number[], status: ObservationStatus = 'resolved'): Promise<ResolveResult> {
|
|
280
280
|
this._ensureOpen();
|
|
281
|
+
await this._observations.ensureCorpusLoaded();
|
|
281
282
|
const manageableIds = ids.filter((id) => {
|
|
282
283
|
const observation = this._observations.getObservation(id, this._projectId);
|
|
283
284
|
return observation && canManageObservation(observation, this._reader);
|
|
@@ -311,6 +312,12 @@ export class MemoryClient {
|
|
|
311
312
|
} catch {
|
|
312
313
|
// best-effort cleanup
|
|
313
314
|
}
|
|
315
|
+
try {
|
|
316
|
+
const { closeSemanticIndex } = await import('./search/semantic-index.js');
|
|
317
|
+
await closeSemanticIndex(this._dataDir);
|
|
318
|
+
} catch {
|
|
319
|
+
// best-effort cleanup
|
|
320
|
+
}
|
|
314
321
|
}
|
|
315
322
|
}
|
|
316
323
|
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { EmbeddingProvider } from '../embedding/provider.js';
|
|
5
|
+
|
|
6
|
+
export interface SemanticIndexProfile {
|
|
7
|
+
key: string;
|
|
8
|
+
provider: string;
|
|
9
|
+
dimensions: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SemanticVectorRecord {
|
|
13
|
+
observationId: number;
|
|
14
|
+
projectId: string;
|
|
15
|
+
status: string;
|
|
16
|
+
visibility?: string;
|
|
17
|
+
vector: number[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SemanticVectorHit {
|
|
21
|
+
observationId: number;
|
|
22
|
+
projectId: string;
|
|
23
|
+
status?: string;
|
|
24
|
+
visibility?: string;
|
|
25
|
+
distance: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SEMANTIC_INDEX_DIR = 'semantic-index';
|
|
29
|
+
const TABLE_PREFIX = 'observations_';
|
|
30
|
+
const DEFAULT_INDEX_THRESHOLD = 10_000;
|
|
31
|
+
const MAX_INDEX_PARTITIONS = 32;
|
|
32
|
+
|
|
33
|
+
type SemanticIndexState = {
|
|
34
|
+
key: string;
|
|
35
|
+
dataDir: string;
|
|
36
|
+
profile: SemanticIndexProfile;
|
|
37
|
+
connection: any;
|
|
38
|
+
table: any | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const statePromises = new Map<string, Promise<SemanticIndexState | null>>();
|
|
42
|
+
const writeQueues = new Map<string, Promise<void>>();
|
|
43
|
+
const indexBuilds = new Map<string, Promise<boolean>>();
|
|
44
|
+
let availability: boolean | undefined;
|
|
45
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
46
|
+
|
|
47
|
+
export function createSemanticIndexProfile(provider: Pick<EmbeddingProvider, 'name' | 'dimensions'>): SemanticIndexProfile {
|
|
48
|
+
return {
|
|
49
|
+
key: `${provider.name}:${provider.dimensions}`,
|
|
50
|
+
provider: provider.name,
|
|
51
|
+
dimensions: provider.dimensions,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stateKey(dataDir: string, profile: SemanticIndexProfile): string {
|
|
56
|
+
return `${path.resolve(dataDir)}::${profile.key}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function tableName(profile: SemanticIndexProfile): string {
|
|
60
|
+
const digest = createHash('sha256').update(profile.key).digest('hex').slice(0, 20);
|
|
61
|
+
return `${TABLE_PREFIX}${digest}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function indexRoot(dataDir: string): string {
|
|
65
|
+
return path.join(dataDir, SEMANTIC_INDEX_DIR);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function loadLanceDb(): Promise<any | null> {
|
|
69
|
+
if (availability === false) return null;
|
|
70
|
+
try {
|
|
71
|
+
// Keep the native optional dependency out of the main tsup bundle. The
|
|
72
|
+
// package is resolved only on the machine that actually opts into the
|
|
73
|
+
// persistent semantic accelerator.
|
|
74
|
+
let load: NodeRequire;
|
|
75
|
+
try {
|
|
76
|
+
load = requireFromHere;
|
|
77
|
+
load.resolve('@lancedb/lancedb');
|
|
78
|
+
} catch {
|
|
79
|
+
// Vite/Vitest virtual modules have no normal file URL. Resolve from the
|
|
80
|
+
// package cwd as a test/runtime fallback.
|
|
81
|
+
load = createRequire(path.join(process.cwd(), 'package.json'));
|
|
82
|
+
load.resolve('@lancedb/lancedb');
|
|
83
|
+
}
|
|
84
|
+
// Use the runtime require handle rather than a static import: LanceDB ships
|
|
85
|
+
// platform-specific native files and must remain outside the main bundle.
|
|
86
|
+
const module = load('@lancedb/lancedb');
|
|
87
|
+
availability = true;
|
|
88
|
+
return module;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
availability = false;
|
|
91
|
+
if (process.env.MEMORIX_SEMANTIC_INDEX === 'lancedb') {
|
|
92
|
+
console.warn(
|
|
93
|
+
`[memorix] LanceDB semantic index unavailable; falling back to Orama: ${error instanceof Error ? error.message : String(error)}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function isSemanticIndexAvailable(): Promise<boolean> {
|
|
101
|
+
return (await loadLanceDb()) !== null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function semanticIndexEnabled(): boolean {
|
|
105
|
+
return process.env.MEMORIX_SEMANTIC_INDEX !== 'off' && process.env.MEMORIX_SEMANTIC_INDEX !== 'orama';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function openState(dataDir: string, profile: SemanticIndexProfile): Promise<SemanticIndexState | null> {
|
|
109
|
+
if (!semanticIndexEnabled()) return null;
|
|
110
|
+
const lance = await loadLanceDb();
|
|
111
|
+
if (!lance) return null;
|
|
112
|
+
|
|
113
|
+
const connection = await lance.connect(indexRoot(dataDir));
|
|
114
|
+
const names = typeof connection.tableNames === 'function'
|
|
115
|
+
? await connection.tableNames()
|
|
116
|
+
: (await connection.listTables()).tables;
|
|
117
|
+
const name = tableName(profile);
|
|
118
|
+
const table = names.includes(name) ? await connection.openTable(name) : null;
|
|
119
|
+
return { key: stateKey(dataDir, profile), dataDir, profile, connection, table };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function toLanceRow(record: SemanticVectorRecord): Record<string, unknown> {
|
|
123
|
+
return {
|
|
124
|
+
observation_id: record.observationId,
|
|
125
|
+
project_id: record.projectId,
|
|
126
|
+
status: record.status,
|
|
127
|
+
visibility: record.visibility ?? 'project',
|
|
128
|
+
vector: record.vector,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function getState(
|
|
133
|
+
dataDir: string,
|
|
134
|
+
profile: SemanticIndexProfile,
|
|
135
|
+
seed?: SemanticVectorRecord[],
|
|
136
|
+
): Promise<SemanticIndexState | null> {
|
|
137
|
+
const key = stateKey(dataDir, profile);
|
|
138
|
+
let statePromise = statePromises.get(key);
|
|
139
|
+
if (!statePromise) {
|
|
140
|
+
statePromise = openState(dataDir, profile);
|
|
141
|
+
statePromises.set(key, statePromise);
|
|
142
|
+
}
|
|
143
|
+
const state = await statePromise;
|
|
144
|
+
if (!state || state.table || !seed || seed.length === 0) return state;
|
|
145
|
+
|
|
146
|
+
// A write queue serializes table creation, so this branch is only reached by
|
|
147
|
+
// the first writer for a profile.
|
|
148
|
+
const lance = await loadLanceDb();
|
|
149
|
+
if (!lance) return null;
|
|
150
|
+
try {
|
|
151
|
+
state.table = await state.connection.createTable(tableName(profile), seed.map(toLanceRow));
|
|
152
|
+
} catch {
|
|
153
|
+
// Another process may have created it between tableNames() and createTable().
|
|
154
|
+
state.table = await state.connection.openTable(tableName(profile));
|
|
155
|
+
}
|
|
156
|
+
return state;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function escapeSqlString(value: string): string {
|
|
160
|
+
return value.replace(/'/g, "''");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function projectPredicate(projectIds?: string | string[]): string | null {
|
|
164
|
+
if (!projectIds) return null;
|
|
165
|
+
const values = (Array.isArray(projectIds) ? projectIds : [projectIds]).filter(Boolean);
|
|
166
|
+
if (values.length === 0) return '1 = 0';
|
|
167
|
+
return values.length === 1
|
|
168
|
+
? `project_id = '${escapeSqlString(values[0])}'`
|
|
169
|
+
: `project_id IN (${values.map((value) => `'${escapeSqlString(value)}'`).join(', ')})`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function queueOperation(key: string, operation: () => Promise<void>): Promise<void> {
|
|
173
|
+
const previous = writeQueues.get(key) ?? Promise.resolve();
|
|
174
|
+
const run = previous.catch(() => undefined).then(operation);
|
|
175
|
+
writeQueues.set(key, run.catch(() => undefined));
|
|
176
|
+
return run;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function mergeVectors(dataDir: string, profile: SemanticIndexProfile, records: SemanticVectorRecord[]): Promise<void> {
|
|
180
|
+
if (records.length === 0) return;
|
|
181
|
+
const state = await getState(dataDir, profile, records);
|
|
182
|
+
if (!state?.table) return;
|
|
183
|
+
const rows = records.map(toLanceRow);
|
|
184
|
+
await state.table
|
|
185
|
+
.mergeInsert('observation_id')
|
|
186
|
+
.whenMatchedUpdateAll()
|
|
187
|
+
.whenNotMatchedInsertAll()
|
|
188
|
+
.execute(rows);
|
|
189
|
+
// Build the ANN index asynchronously once the table is large enough. The
|
|
190
|
+
// current write remains cheap and the unindexed tail stays queryable while
|
|
191
|
+
// LanceDB trains the derived HNSW/SQ index.
|
|
192
|
+
void ensureSemanticIndex(dataDir, profile);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function upsertSemanticVector(
|
|
196
|
+
dataDir: string,
|
|
197
|
+
profile: SemanticIndexProfile,
|
|
198
|
+
record: SemanticVectorRecord,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
return queueOperation(stateKey(dataDir, profile), () => mergeVectors(dataDir, profile, [record]));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function upsertSemanticVectors(
|
|
204
|
+
dataDir: string,
|
|
205
|
+
profile: SemanticIndexProfile,
|
|
206
|
+
records: SemanticVectorRecord[],
|
|
207
|
+
): Promise<void> {
|
|
208
|
+
return queueOperation(stateKey(dataDir, profile), () => mergeVectors(dataDir, profile, records));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function deleteSemanticVector(
|
|
212
|
+
dataDir: string,
|
|
213
|
+
profile: SemanticIndexProfile,
|
|
214
|
+
observationId: number,
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
return queueOperation(stateKey(dataDir, profile), async () => {
|
|
217
|
+
const state = await getState(dataDir, profile);
|
|
218
|
+
if (!state?.table) return;
|
|
219
|
+
await state.table.delete(`observation_id = ${Math.trunc(observationId)}`);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function resolveIndexThreshold(): number {
|
|
224
|
+
const raw = Number.parseInt(process.env.MEMORIX_SEMANTIC_INDEX_THRESHOLD ?? '', 10);
|
|
225
|
+
return Number.isSafeInteger(raw) && raw > 0 ? raw : DEFAULT_INDEX_THRESHOLD;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function resolvePartitionCount(rowCount: number): number {
|
|
229
|
+
return Math.max(1, Math.min(MAX_INDEX_PARTITIONS, Math.round(Math.sqrt(rowCount) / 8)));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function buildIndex(state: SemanticIndexState): Promise<boolean> {
|
|
233
|
+
if (!state.table) return false;
|
|
234
|
+
const indices = await state.table.listIndices();
|
|
235
|
+
if (indices.some((index: any) => index.columns?.includes('vector'))) return true;
|
|
236
|
+
|
|
237
|
+
const rowCount = await state.table.countRows();
|
|
238
|
+
if (rowCount < resolveIndexThreshold()) return false;
|
|
239
|
+
|
|
240
|
+
const lance = await loadLanceDb();
|
|
241
|
+
if (!lance?.Index?.hnswSq) return false;
|
|
242
|
+
await state.table.createIndex('vector', {
|
|
243
|
+
config: lance.Index.hnswSq({
|
|
244
|
+
distanceType: 'cosine',
|
|
245
|
+
numPartitions: resolvePartitionCount(rowCount),
|
|
246
|
+
}),
|
|
247
|
+
waitTimeoutSeconds: 3_600,
|
|
248
|
+
});
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Start or await a persistent HNSW/SQ build for a large semantic table. */
|
|
253
|
+
export function ensureSemanticIndex(
|
|
254
|
+
dataDir: string,
|
|
255
|
+
profile: SemanticIndexProfile,
|
|
256
|
+
): Promise<boolean> {
|
|
257
|
+
const key = stateKey(dataDir, profile);
|
|
258
|
+
const existing = indexBuilds.get(key);
|
|
259
|
+
if (existing) return existing;
|
|
260
|
+
const build = getState(dataDir, profile)
|
|
261
|
+
.then((state) => state ? buildIndex(state) : false)
|
|
262
|
+
.catch(() => false)
|
|
263
|
+
.finally(() => {
|
|
264
|
+
indexBuilds.delete(key);
|
|
265
|
+
});
|
|
266
|
+
indexBuilds.set(key, build);
|
|
267
|
+
return build;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Search the local semantic shadow index. Results contain IDs only; callers
|
|
272
|
+
* fetch authoritative observation details from SQLite for scope enforcement.
|
|
273
|
+
*/
|
|
274
|
+
export async function searchSemanticVectors(options: {
|
|
275
|
+
dataDir: string;
|
|
276
|
+
profile: SemanticIndexProfile;
|
|
277
|
+
vector: number[];
|
|
278
|
+
projectId?: string | string[];
|
|
279
|
+
status?: string | 'all';
|
|
280
|
+
limit?: number;
|
|
281
|
+
}): Promise<SemanticVectorHit[] | null> {
|
|
282
|
+
const state = await getState(options.dataDir, options.profile);
|
|
283
|
+
if (!state?.table) return null;
|
|
284
|
+
|
|
285
|
+
const requestedLimit = Math.max(1, Math.floor(options.limit ?? 20));
|
|
286
|
+
const limit = Math.min(10_000, requestedLimit);
|
|
287
|
+
let query = state.table.vectorSearch(options.vector);
|
|
288
|
+
const projectWhere = projectPredicate(options.projectId);
|
|
289
|
+
if (projectWhere) query = query.where(projectWhere);
|
|
290
|
+
// Status and visibility are intentionally checked against SQLite after the
|
|
291
|
+
// bounded candidate fetch. The shadow row can lag a lifecycle update by a
|
|
292
|
+
// few milliseconds; filtering here would make a newly-reactivated memory
|
|
293
|
+
// invisible until the next vector write.
|
|
294
|
+
const rows = await query
|
|
295
|
+
.limit(limit)
|
|
296
|
+
.select(['observation_id', 'project_id', 'status', 'visibility', '_distance'])
|
|
297
|
+
.toArray();
|
|
298
|
+
|
|
299
|
+
return rows
|
|
300
|
+
.map((row: any) => ({
|
|
301
|
+
observationId: Number(row.observation_id),
|
|
302
|
+
projectId: String(row.project_id),
|
|
303
|
+
...(row.status ? { status: String(row.status) } : {}),
|
|
304
|
+
...(row.visibility ? { visibility: String(row.visibility) } : {}),
|
|
305
|
+
distance: Number(row._distance ?? Number.POSITIVE_INFINITY),
|
|
306
|
+
}))
|
|
307
|
+
.filter((row: SemanticVectorHit) => Number.isFinite(row.distance));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Close native connections and release this process's derived-index cache. */
|
|
311
|
+
export async function closeSemanticIndexes(): Promise<void> {
|
|
312
|
+
for (const statePromise of statePromises.values()) {
|
|
313
|
+
const state = await statePromise.catch(() => null);
|
|
314
|
+
try { state?.connection?.close?.(); } catch { /* best effort */ }
|
|
315
|
+
}
|
|
316
|
+
statePromises.clear();
|
|
317
|
+
writeQueues.clear();
|
|
318
|
+
indexBuilds.clear();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Close only the semantic tables rooted in one Memorix data directory. */
|
|
322
|
+
export async function closeSemanticIndex(dataDir: string): Promise<void> {
|
|
323
|
+
const prefix = `${path.resolve(dataDir)}::`;
|
|
324
|
+
for (const [key, statePromise] of statePromises.entries()) {
|
|
325
|
+
if (!key.startsWith(prefix)) continue;
|
|
326
|
+
const state = await statePromise.catch(() => null);
|
|
327
|
+
try { state?.connection?.close?.(); } catch { /* best effort */ }
|
|
328
|
+
statePromises.delete(key);
|
|
329
|
+
writeQueues.delete(key);
|
|
330
|
+
indexBuilds.delete(key);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** @internal Reset state for isolated tests. */
|
|
335
|
+
export function resetSemanticIndexRuntime(): void {
|
|
336
|
+
statePromises.clear();
|
|
337
|
+
writeQueues.clear();
|
|
338
|
+
indexBuilds.clear();
|
|
339
|
+
availability = undefined;
|
|
340
|
+
}
|