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.
Files changed (50) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +22 -5
  3. package/README.zh-CN.md +9 -5
  4. package/dist/cli/index.js +2635 -1942
  5. package/dist/index.js +2445 -1766
  6. package/dist/maintenance-runner.js +1696 -1036
  7. package/dist/media-audio-runner.js +2197 -1537
  8. package/dist/media-video-runner.js +2195 -1535
  9. package/dist/memcode-runtime/CHANGELOG.md +39 -0
  10. package/dist/sdk.js +2507 -1764
  11. package/dist/vector-backfill-runner.js +2193 -1533
  12. package/docs/CONFIGURATION.md +22 -0
  13. package/docs/DEVELOPMENT.md +2 -2
  14. package/docs/INTEGRATIONS.md +4 -0
  15. package/docs/PERFORMANCE.md +77 -20
  16. package/docs/README.md +1 -1
  17. package/docs/SETUP.md +5 -0
  18. package/package.json +2 -2
  19. package/plugins/antigravity/memorix/plugin.json +1 -1
  20. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  21. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  22. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  23. package/plugins/copilot/memorix/plugin.json +1 -1
  24. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  25. package/plugins/hermes/memorix/plugin.yaml +1 -1
  26. package/plugins/omp/memorix/package.json +1 -1
  27. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  28. package/plugins/pi/memorix/package.json +1 -1
  29. package/server.json +2 -2
  30. package/src/cli/commands/memory.ts +2 -1
  31. package/src/cli/default-mode.ts +15 -0
  32. package/src/cli/index.ts +10 -1
  33. package/src/codegraph/lite-provider.ts +10 -1
  34. package/src/compact/engine.ts +14 -4
  35. package/src/dashboard/maintenance.ts +11 -2
  36. package/src/memory/consolidation.ts +15 -6
  37. package/src/memory/freshness.ts +7 -4
  38. package/src/memory/observations.ts +118 -6
  39. package/src/runtime/vector-backfill-runner.ts +1 -1
  40. package/src/sdk.ts +13 -6
  41. package/src/search/semantic-index.ts +340 -0
  42. package/src/server.ts +1 -1
  43. package/src/store/obs-store.ts +54 -2
  44. package/src/store/orama-store.ts +300 -12
  45. package/src/store/sqlite-db.ts +6 -0
  46. package/src/store/sqlite-fts.ts +164 -0
  47. package/src/store/sqlite-store.ts +83 -6
  48. package/assets/star-history-dark.svg +0 -30
  49. package/assets/star-history-light.svg +0 -30
  50. 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 || !corpusLoaded) return false;
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 (wasStale) {
451
+ if (!corpusLoaded && loadCorpus) {
396
452
  observations = await store.loadAll();
397
453
  nextId = await store.loadIdCounter();
398
- await reindexObservations();
399
- searchIndexPrepared = true;
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
- return filterReadableObservations(
262
- this._observations.getProjectObservations(this._projectId),
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
+ }
package/src/server.ts CHANGED
@@ -1528,7 +1528,7 @@ export async function createMemorixServer(
1528
1528
  },
1529
1529
  ],
1530
1530
  };
1531
- }); // withFreshIndex
1531
+ }, { loadCorpus: false }); // withFreshIndex
1532
1532
  },
1533
1533
  );
1534
1534