memorix 1.1.11 → 1.1.13

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.
@@ -18,8 +18,12 @@ import { detectQueryIntent, applyIntentBoost } from '../search/intent-detector.j
18
18
  import { maybeExpandSearchQuery } from '../search/query-expansion.js';
19
19
 
20
20
  let db: AnyOrama | null = null;
21
+ let dbInitPromise: Promise<AnyOrama> | null = null;
22
+ let dbGeneration = 0;
21
23
  let embeddingEnabled = false;
22
24
  let embeddingDimensions: number | null = null;
25
+ let indexEmbeddingProvider: EmbeddingProvider | null = null;
26
+ let deferredCachedVectorHydration: Promise<void> | null = null;
23
27
  const docByObservationKey = new Map<string, MemorixDocument>();
24
28
  const NON_CJK_HYBRID_SIMILARITY = 0.45;
25
29
  const lastSearchModeByProject = new Map<string, string>();
@@ -143,13 +147,14 @@ function stripVectorSearchParams(params: Record<string, unknown>): Record<string
143
147
  * Schema conditionally includes vector field based on embedding provider.
144
148
  * Graceful degradation: no provider → fulltext only, provider → hybrid.
145
149
  */
146
- export async function getDb(): Promise<AnyOrama> {
147
- if (db) return db;
148
-
149
- // Check if embedding provider is available
150
- const provider = await getEmbeddingProvider();
151
- embeddingEnabled = provider !== null;
152
- embeddingDimensions = provider?.dimensions ?? null;
150
+ async function initializeDb(
151
+ options: { allowNetworkProbe?: boolean },
152
+ generation: number,
153
+ ): Promise<AnyOrama> {
154
+ // Check if embedding provider is available.
155
+ const provider = await getEmbeddingProvider({ allowNetworkProbe: options.allowNetworkProbe });
156
+ const nextEmbeddingEnabled = provider !== null;
157
+ const nextEmbeddingDimensions = provider?.dimensions ?? null;
153
158
 
154
159
  const baseSchema = {
155
160
  id: 'string' as const,
@@ -175,23 +180,54 @@ export async function getDb(): Promise<AnyOrama> {
175
180
  };
176
181
 
177
182
  // Dynamic vector dimensions based on provider (384 for local, 1024+ for API)
178
- const dims = embeddingDimensions ?? 384;
179
- const schema = embeddingEnabled
183
+ const dims = nextEmbeddingDimensions ?? 384;
184
+ const schema = nextEmbeddingEnabled
180
185
  ? { ...baseSchema, embedding: `vector[${dims}]` as const }
181
186
  : baseSchema;
182
187
 
183
- db = await create({ schema });
188
+ const nextDb = await create({ schema });
189
+
190
+ // resetDb() may have started a new lifecycle while the provider was loading.
191
+ // Discard this obsolete instance rather than letting it overwrite the new one.
192
+ if (generation !== dbGeneration) {
193
+ return getDb(options);
194
+ }
195
+
196
+ indexEmbeddingProvider = provider;
197
+ embeddingEnabled = nextEmbeddingEnabled;
198
+ embeddingDimensions = nextEmbeddingDimensions;
199
+ db = nextDb;
200
+
201
+ return nextDb;
202
+ }
203
+
204
+ export async function getDb(options: { allowNetworkProbe?: boolean } = {}): Promise<AnyOrama> {
205
+ if (db) return db;
206
+ if (dbInitPromise) return dbInitPromise;
207
+
208
+ const initPromise = initializeDb(options, dbGeneration);
209
+ dbInitPromise = initPromise;
184
210
 
185
- return db;
211
+ try {
212
+ return await initPromise;
213
+ } finally {
214
+ if (dbInitPromise === initPromise) {
215
+ dbInitPromise = null;
216
+ }
217
+ }
186
218
  }
187
219
 
188
220
  /**
189
221
  * Reset the database instance (useful for testing).
190
222
  */
191
223
  export async function resetDb(): Promise<void> {
224
+ dbGeneration++;
192
225
  db = null;
226
+ dbInitPromise = null;
193
227
  embeddingEnabled = false;
194
228
  embeddingDimensions = null;
229
+ indexEmbeddingProvider = null;
230
+ deferredCachedVectorHydration = null;
195
231
  lastSearchModeByProject.clear();
196
232
  docByObservationKey.clear();
197
233
  }
@@ -240,20 +276,103 @@ export async function batchGenerateEmbeddings(texts: string[]): Promise<(number[
240
276
  }
241
277
  }
242
278
 
279
+ async function getCachedEmbeddings(texts: string[]): Promise<(number[] | null)[]> {
280
+ if (!embeddingEnabled || texts.length === 0) return texts.map(() => null);
281
+ const provider = indexEmbeddingProvider;
282
+ if (!provider?.getCachedEmbeddings) return texts.map(() => null);
283
+ try {
284
+ return await provider.getCachedEmbeddings(texts);
285
+ } catch {
286
+ // Cached vectors are an optimization. Missing entries use background recovery.
287
+ return texts.map(() => null);
288
+ }
289
+ }
290
+
243
291
  /**
244
292
  * Hydrate the Orama index from persisted observations.
245
293
  * Must be called before searching if the index was freshly created (TUI / CLI startup).
246
294
  * Skips observations already in the index (idempotent).
247
295
  */
248
- export async function hydrateIndex(observations: any[]): Promise<number> {
249
- const database = await getDb();
296
+ export interface HydrateIndexOptions {
297
+ /** Do not issue an API dimension probe while building the startup index. */
298
+ allowNetworkProbe?: boolean;
299
+ /** Attach cached vectors after lexical hydration instead of awaiting cache I/O. */
300
+ deferCachedVectors?: boolean;
301
+ }
302
+
303
+ type HydrationCandidate = { observation: any; id: string };
304
+
305
+ function observationEmbeddingText(observation: any): string {
306
+ return [
307
+ observation.title ?? '',
308
+ observation.narrative ?? '',
309
+ ...(Array.isArray(observation.facts) ? observation.facts : []),
310
+ ].join(' ');
311
+ }
312
+
313
+ function documentEmbeddingText(document: Pick<MemorixDocument, 'title' | 'narrative' | 'facts'>): string {
314
+ return [document.title ?? '', document.narrative ?? '', document.facts ?? ''].join(' ');
315
+ }
316
+
317
+ function isCompatibleCachedVector(vector: number[] | null): vector is number[] {
318
+ return Boolean(
319
+ vector &&
320
+ embeddingDimensions !== null &&
321
+ vector.length === embeddingDimensions &&
322
+ vector.every(Number.isFinite),
323
+ );
324
+ }
325
+
326
+ async function attachCachedVectors(
327
+ database: AnyOrama,
328
+ candidates: HydrationCandidate[],
329
+ ): Promise<void> {
330
+ const cachedVectors = await getCachedEmbeddings(
331
+ candidates.map(({ observation }) => observationEmbeddingText(observation)),
332
+ );
333
+
334
+ // A reset or project switch created a new index while the cache was loading.
335
+ // Do not attach stale vectors to that new index.
336
+ if (db !== database) return;
337
+
338
+ for (let index = 0; index < candidates.length; index++) {
339
+ const vector = cachedVectors[index];
340
+ if (!isCompatibleCachedVector(vector)) continue;
341
+ const existing = getByID(database, candidates[index].id) as MemorixDocument | undefined;
342
+ if (!existing || documentEmbeddingText(existing) !== observationEmbeddingText(candidates[index].observation)) continue;
343
+ try {
344
+ await update(database, candidates[index].id, { ...existing, embedding: vector });
345
+ } catch {
346
+ // Vector cache hydration is best-effort. The normal backfill lane owns misses.
347
+ }
348
+ }
349
+ }
350
+
351
+ export async function hydrateIndex(
352
+ observations: any[],
353
+ options: HydrateIndexOptions = {},
354
+ ): Promise<number> {
355
+ const database = await getDb({ allowNetworkProbe: options.allowNetworkProbe });
356
+
357
+ const candidates: HydrationCandidate[] = [];
358
+ for (const observation of observations) {
359
+ if (!observation || !observation.id || !observation.projectId) continue;
360
+ const id = makeOramaObservationId(observation.projectId, observation.id);
361
+ if (getByID(database, id)) continue;
362
+ candidates.push({ observation, id });
363
+ }
364
+
365
+ const deferCachedVectors = options.deferCachedVectors === true && Boolean(indexEmbeddingProvider?.getCachedEmbeddings);
366
+ const cachedVectors = deferCachedVectors
367
+ ? candidates.map(() => null)
368
+ : await getCachedEmbeddings(candidates.map(({ observation }) => observationEmbeddingText(observation)));
250
369
 
251
370
  let inserted = 0;
252
- for (const obs of observations) {
253
- if (!obs || !obs.id || !obs.projectId) continue;
371
+ for (let index = 0; index < candidates.length; index++) {
372
+ const { observation: obs, id } = candidates[index];
254
373
  try {
255
- const id = makeOramaObservationId(obs.projectId, obs.id);
256
- if (getByID(database, id)) continue;
374
+ const vector = cachedVectors[index];
375
+ const compatibleVector = isCompatibleCachedVector(vector) ? vector : null;
257
376
  const doc: MemorixDocument = {
258
377
  id,
259
378
  observationId: obs.id,
@@ -273,15 +392,48 @@ export async function hydrateIndex(observations: any[]): Promise<number> {
273
392
  source: obs.source || 'agent',
274
393
  documentType: 'observation',
275
394
  knowledgeLayer: resolveKnowledgeLayer('observation', obs.sourceDetail, obs.source),
395
+ ...(compatibleVector ? { embedding: compatibleVector } : {}),
276
396
  };
277
397
  await insert(database, doc);
278
398
  rememberObservationDoc(doc);
279
399
  inserted++;
280
400
  } catch { /* skip malformed entries */ }
281
401
  }
402
+
403
+ deferredCachedVectorHydration = deferCachedVectors
404
+ ? attachCachedVectors(database, candidates).catch(() => {})
405
+ : null;
282
406
  return inserted;
283
407
  }
284
408
 
409
+ /**
410
+ * Prepare the lexical index without probing a remote embedding API. Cached
411
+ * vectors attach after the disk cache is ready, outside the startup path.
412
+ */
413
+ export async function hydrateIndexForStartup(observations: any[]): Promise<number> {
414
+ return hydrateIndex(observations, {
415
+ allowNetworkProbe: false,
416
+ deferCachedVectors: true,
417
+ });
418
+ }
419
+
420
+ /**
421
+ * Returns the background cache-only hydration started by the most recent
422
+ * startup index pass. It never makes an embedding API call.
423
+ */
424
+ export function getDeferredCachedVectorHydration(): Promise<void> | null {
425
+ return deferredCachedVectorHydration;
426
+ }
427
+
428
+ /** Return whether the current in-memory index already has a usable vector. */
429
+ export function hasObservationVector(projectId: string, observationId: number): boolean {
430
+ if (!db || !embeddingEnabled || embeddingDimensions === null) return false;
431
+ const document = getByID(db, makeOramaObservationId(projectId, observationId)) as MemorixDocument | undefined;
432
+ return Array.isArray(document?.embedding) &&
433
+ document.embedding.length === embeddingDimensions &&
434
+ document.embedding.every(Number.isFinite);
435
+ }
436
+
285
437
  /**
286
438
  * Insert an observation document into the store.
287
439
  */