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.
@@ -14,6 +14,7 @@ import type { EmbeddingProvider } from './provider.js';
14
14
  const CACHE_DIR = process.env.MEMORIX_DATA_DIR || join(homedir(), '.memorix', 'data');
15
15
  const CACHE_FILE = join(CACHE_DIR, '.embedding-api-cache.json');
16
16
  const DIMS_CACHE_FILE = join(CACHE_DIR, '.embedding-dims-cache.json');
17
+ const CACHE_META_FILE = join(CACHE_DIR, '.embedding-api-cache-meta.json');
17
18
 
18
19
  const cache = new Map<string, number[]>();
19
20
  const MAX_CACHE_SIZE = 10000;
@@ -82,6 +83,73 @@ function dimsCacheKey(config: Pick<APIEmbeddingConfig, 'baseUrl' | 'model' | 're
82
83
  ].join('|');
83
84
  }
84
85
 
86
+ interface CacheMetadataEntry {
87
+ namespace: string;
88
+ dimensions: number;
89
+ ts: number;
90
+ }
91
+
92
+ function isValidDimension(value: unknown): value is number {
93
+ return typeof value === 'number' && Number.isInteger(value) && value > 0;
94
+ }
95
+
96
+ /**
97
+ * The vector cache keeps a tiny, redundant namespace → dimensions map. It lets
98
+ * cache-first startup recover when the separate dimensions cache is missing.
99
+ */
100
+ async function loadCachedVectorDimensions(
101
+ config: Pick<APIEmbeddingConfig, 'baseUrl' | 'model' | 'requestedDimensions'>,
102
+ ): Promise<number | null> {
103
+ try {
104
+ const raw = await readFile(CACHE_META_FILE, 'utf-8');
105
+ const data = JSON.parse(raw);
106
+ const namespace = cacheNamespace(config);
107
+ if (!Array.isArray(data?.entries)) return null;
108
+ const entry = data.entries.find((candidate: unknown) =>
109
+ typeof candidate === 'object' &&
110
+ candidate !== null &&
111
+ (candidate as { namespace?: unknown }).namespace === namespace &&
112
+ isValidDimension((candidate as { dimensions?: unknown }).dimensions),
113
+ ) as CacheMetadataEntry | undefined;
114
+ return entry?.dimensions ?? null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ async function saveCachedVectorDimensions(
121
+ config: Pick<APIEmbeddingConfig, 'baseUrl' | 'model' | 'requestedDimensions'>,
122
+ dimensions: number,
123
+ ): Promise<void> {
124
+ if (!isValidDimension(dimensions)) return;
125
+ try {
126
+ await mkdir(CACHE_DIR, { recursive: true });
127
+ let entries: CacheMetadataEntry[] = [];
128
+ try {
129
+ const raw = await readFile(CACHE_META_FILE, 'utf-8');
130
+ const data = JSON.parse(raw);
131
+ if (Array.isArray(data?.entries)) {
132
+ entries = data.entries.filter((candidate: unknown): candidate is CacheMetadataEntry =>
133
+ typeof candidate === 'object' &&
134
+ candidate !== null &&
135
+ typeof (candidate as { namespace?: unknown }).namespace === 'string' &&
136
+ isValidDimension((candidate as { dimensions?: unknown }).dimensions) &&
137
+ typeof (candidate as { ts?: unknown }).ts === 'number',
138
+ );
139
+ }
140
+ } catch {
141
+ // The cache metadata is a best-effort acceleration layer.
142
+ }
143
+
144
+ const namespace = cacheNamespace(config);
145
+ entries = entries.filter((entry) => entry.namespace !== namespace);
146
+ entries.push({ namespace, dimensions, ts: Date.now() });
147
+ await writeFile(CACHE_META_FILE, JSON.stringify({ version: 1, entries }));
148
+ } catch {
149
+ // A missing or read-only cache must never block embedding requests.
150
+ }
151
+ }
152
+
85
153
  /** Load cached probe dimensions from disk. Returns null if not cached. */
86
154
  async function loadCachedDims(config: Pick<APIEmbeddingConfig, 'baseUrl' | 'model' | 'requestedDimensions'>): Promise<number | null> {
87
155
  try {
@@ -179,6 +247,7 @@ async function saveCachedDims(config: Pick<APIEmbeddingConfig, 'baseUrl' | 'mode
179
247
 
180
248
  await writeFile(DIMS_CACHE_FILE, JSON.stringify({ entries }));
181
249
  } catch { /* best-effort */ }
250
+ await saveCachedVectorDimensions(config, dimensions);
182
251
  }
183
252
 
184
253
  async function saveDiskCacheNow(): Promise<void> {
@@ -231,6 +300,14 @@ interface APIEmbeddingConfig {
231
300
  requestedDimensions: number | null;
232
301
  }
233
302
 
303
+ export interface APIEmbeddingProviderCreateOptions {
304
+ /**
305
+ * When false, only previously persisted dimension metadata may initialize
306
+ * the provider. This keeps startup/cache hydration off the remote API path.
307
+ */
308
+ allowNetworkProbe?: boolean;
309
+ }
310
+
234
311
  function resolveEnvEmbeddingApiKey(): string | undefined {
235
312
  return process.env.MEMORIX_EMBEDDING_API_KEY;
236
313
  }
@@ -272,18 +349,27 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
272
349
  this.name = `api-${config.model.replace(/\//g, '-')}`;
273
350
  }
274
351
 
275
- static async create(): Promise<APIEmbeddingProvider> {
352
+ static async create(): Promise<APIEmbeddingProvider>;
353
+ static async create(options: APIEmbeddingProviderCreateOptions & { allowNetworkProbe: false }): Promise<APIEmbeddingProvider | null>;
354
+ static async create(options: APIEmbeddingProviderCreateOptions): Promise<APIEmbeddingProvider | null>;
355
+ static async create(options: APIEmbeddingProviderCreateOptions = {}): Promise<APIEmbeddingProvider | null> {
276
356
  const config = APIEmbeddingProvider.resolveConfig();
277
-
278
- // Start loading the 45MB+ embedding cache in the background (non-blocking).
279
- // It will be awaited on first embed() call if not yet ready.
280
- startDiskCacheLoad();
357
+ const allowNetworkProbe = options.allowNetworkProbe !== false;
281
358
 
282
359
  // Try cached dimensions first to avoid a network probe on cold start
283
360
  let probeDimensions = await loadCachedDims(config);
361
+ let dimensionSource: 'dims-cache' | 'vector-cache' | 'probe' = 'dims-cache';
362
+ if (probeDimensions === null) {
363
+ probeDimensions = await loadCachedVectorDimensions(config);
364
+ if (probeDimensions !== null) dimensionSource = 'vector-cache';
365
+ }
284
366
  if (probeDimensions !== null) {
285
- console.error(`[memorix] API embedding: ${config.model} @ ${config.baseUrl} (${probeDimensions}d) [cached dims]`);
367
+ console.error(`[memorix] API embedding: ${config.model} @ ${config.baseUrl} (${probeDimensions}d) [${dimensionSource}]`);
368
+ if (dimensionSource === 'dims-cache') {
369
+ void saveCachedVectorDimensions(config, probeDimensions);
370
+ }
286
371
  } else {
372
+ if (!allowNetworkProbe) return null;
287
373
  probeDimensions = await APIEmbeddingProvider.probeAPI(config);
288
374
  console.error(`[memorix] API embedding: ${config.model} @ ${config.baseUrl} (${probeDimensions}d)`);
289
375
  // Persist for next cold start
@@ -293,6 +379,11 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
293
379
  console.error(`[memorix] Dimension shortening: ${config.requestedDimensions}d requested`);
294
380
  }
295
381
 
382
+ // The cache can only be used after dimensions are known. In cache-only
383
+ // startup with no metadata, skip parsing the potentially large cache file
384
+ // altogether and stay lexical until a normal embedding lane is needed.
385
+ startDiskCacheLoad();
386
+
296
387
  return new APIEmbeddingProvider(config, probeDimensions);
297
388
  }
298
389
 
@@ -519,6 +610,14 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
519
610
  return results;
520
611
  }
521
612
 
613
+ async getCachedEmbeddings(texts: string[]): Promise<(number[] | null)[]> {
614
+ await ensureDiskCacheLoaded();
615
+ return texts.map((text) => {
616
+ const hash = textHash(normalizeText(text), this.cacheKeyNamespace);
617
+ return cache.get(hash) ?? null;
618
+ });
619
+ }
620
+
522
621
  getStats(): { totalTokens: number; totalApiCalls: number; cacheSize: number } {
523
622
  return {
524
623
  totalTokens: this.totalTokensUsed,
@@ -36,6 +36,13 @@ export interface EmbeddingProvider {
36
36
  embed(text: string): Promise<number[]>;
37
37
  /** Generate embeddings for multiple texts (batch) */
38
38
  embedBatch(texts: string[]): Promise<number[][]>;
39
+ /** Return already-persisted embeddings without generating new vectors. */
40
+ getCachedEmbeddings?(texts: string[]): Promise<(number[] | null)[]>;
41
+ }
42
+
43
+ export interface GetEmbeddingProviderOptions {
44
+ /** Avoid remote API dimension probes while preparing a startup index. */
45
+ allowNetworkProbe?: boolean;
39
46
  }
40
47
 
41
48
  /** Singleton provider instance (null = not available) */
@@ -172,10 +179,10 @@ async function createTransformersProvider(): Promise<EmbeddingProvider | null> {
172
179
  }
173
180
  }
174
181
 
175
- async function createAPIProvider(): Promise<EmbeddingProvider | null> {
182
+ async function createAPIProvider(options?: GetEmbeddingProviderOptions): Promise<EmbeddingProvider | null> {
176
183
  try {
177
184
  const { APIEmbeddingProvider } = await import('./api-provider.js');
178
- return await APIEmbeddingProvider.create();
185
+ return await APIEmbeddingProvider.create(options ?? {});
179
186
  } catch (e) {
180
187
  warnOnce(`[memorix] Failed to init API embedding: ${e instanceof Error ? e.message : e}`);
181
188
  return null;
@@ -238,6 +245,13 @@ function wrapProvider(candidate: EmbeddingProvider): EmbeddingProvider {
238
245
  throw error;
239
246
  }
240
247
  },
248
+ ...(candidate.getCachedEmbeddings
249
+ ? {
250
+ async getCachedEmbeddings(texts: string[]): Promise<(number[] | null)[]> {
251
+ return candidate.getCachedEmbeddings!(texts);
252
+ },
253
+ }
254
+ : {}),
241
255
  };
242
256
  }
243
257
 
@@ -256,10 +270,24 @@ let lastFailureTimestamp = 0;
256
270
  *
257
271
  * Controlled by MEMORIX_EMBEDDING environment variable (default: off).
258
272
  */
259
- export async function getEmbeddingProvider(): Promise<EmbeddingProvider | null> {
273
+ export async function getEmbeddingProvider(
274
+ options: GetEmbeddingProviderOptions = {},
275
+ ): Promise<EmbeddingProvider | null> {
260
276
  // If we already have a successfully initialized provider, return it immediately
261
277
  if (provider) return provider;
262
278
 
279
+ // Startup hydration is allowed to use API vectors already described by local
280
+ // dimension metadata, but it must never turn a missing cache into a network
281
+ // probe. A normal retrieval/write path will initialize the provider later.
282
+ if (options.allowNetworkProbe === false) {
283
+ const mode = getEmbeddingMode();
284
+ if (mode !== 'api' && !(mode === 'auto' && hasAPIEmbeddingConfig())) {
285
+ return null;
286
+ }
287
+ const cached = await createAPIProvider({ allowNetworkProbe: false });
288
+ return cached ? wrapProvider(cached) : null;
289
+ }
290
+
263
291
  // If a previous attempt failed temporarily, allow retry after cooldown
264
292
  if (lastInitWasTemporaryFailure) {
265
293
  const elapsed = Date.now() - lastFailureTimestamp;
@@ -14,7 +14,7 @@ import type { ObservationType } from '../types.js';
14
14
  import { normalizeHookInput } from './normalizer.js';
15
15
  import { detectBestPattern, patternToObservationType } from './pattern-detector.js';
16
16
  import { isSignificantKnowledge, isRetrievedResult, isTrivialCommand } from './significance-filter.js';
17
- import type { HookEvent, HookOutput, NormalizedHookInput } from './types.js';
17
+ import type { AgentName, HookEvent, HookOutput, NormalizedHookInput } from './types.js';
18
18
 
19
19
  // ─── Constants ───
20
20
 
@@ -431,6 +431,55 @@ export async function handleHookEvent(input: NormalizedHookInput): Promise<{
431
431
  };
432
432
  }
433
433
 
434
+ /**
435
+ * Convert Memorix's neutral hook response into the host-specific response
436
+ * schema expected by each supported agent.
437
+ */
438
+ export function formatHookOutput(
439
+ agent: AgentName,
440
+ rawEventName: string,
441
+ output: HookOutput,
442
+ ): Record<string, unknown> {
443
+ if (agent === 'codex') {
444
+ const codexOutput: Record<string, unknown> = { continue: output.continue };
445
+ if (output.stopReason) codexOutput.stopReason = output.stopReason;
446
+
447
+ // Codex adds SessionStart additionalContext directly to developer context.
448
+ // Capture-only events deliberately stay quiet so automatic memory never
449
+ // becomes a stream of status messages in the agent's working context.
450
+ if (rawEventName === 'SessionStart' && output.systemMessage) {
451
+ codexOutput.hookSpecificOutput = {
452
+ hookEventName: 'SessionStart',
453
+ additionalContext: output.systemMessage,
454
+ };
455
+ }
456
+
457
+ return codexOutput;
458
+ }
459
+
460
+ const finalOutput: Record<string, unknown> = { ...output };
461
+ const hookSpecificOutputEvents = new Set([
462
+ 'PreToolUse',
463
+ 'UserPromptSubmit',
464
+ 'PostToolUse',
465
+ 'postToolUse',
466
+ 'preToolUse',
467
+ 'userPromptSubmitted',
468
+ ]);
469
+
470
+ if (rawEventName && hookSpecificOutputEvents.has(rawEventName)) {
471
+ const hookSpecificOutput: Record<string, unknown> = { hookEventName: rawEventName };
472
+ if (output.systemMessage) {
473
+ hookSpecificOutput.additionalContext = output.systemMessage;
474
+ } else if (rawEventName === 'UserPromptSubmit') {
475
+ hookSpecificOutput.additionalContext = '';
476
+ }
477
+ finalOutput.hookSpecificOutput = hookSpecificOutput;
478
+ }
479
+
480
+ return finalOutput;
481
+ }
482
+
434
483
  // ─── Entry Point ───
435
484
 
436
485
  /**
@@ -602,23 +651,11 @@ export async function runHook(agentOverride?: string, eventOverride?: string): P
602
651
  ?? (payload.hook_event_name as string)
603
652
  ?? (payload.hookEventName as string)
604
653
  ?? '';
605
- const finalOutput: Record<string, unknown> = { ...output };
606
654
  if (input.agent === 'antigravity') {
607
655
  process.stdout.write(JSON.stringify(toAntigravityHookOutput(rawEventName, output)));
608
656
  return;
609
657
  }
610
-
611
- const HSO_EVENTS = new Set(['PreToolUse', 'UserPromptSubmit', 'PostToolUse', 'postToolUse', 'preToolUse', 'userPromptSubmitted']);
612
- if (rawEventName && HSO_EVENTS.has(rawEventName)) {
613
- const hso: Record<string, unknown> = { hookEventName: rawEventName };
614
- // additionalContext is REQUIRED for UserPromptSubmit, optional for others
615
- if (output.systemMessage) {
616
- hso.additionalContext = output.systemMessage;
617
- } else if (rawEventName === 'UserPromptSubmit') {
618
- hso.additionalContext = '';
619
- }
620
- finalOutput.hookSpecificOutput = hso;
621
- }
658
+ const finalOutput = formatHookOutput(input.agent, rawEventName, output);
622
659
  process.stdout.write(JSON.stringify(finalOutput));
623
660
  }
624
661
 
@@ -30,6 +30,35 @@ function resolveHookCommand(): string {
30
30
  return 'memorix';
31
31
  }
32
32
 
33
+ function resolveWindowsMemorixShim(): string | null {
34
+ try {
35
+ const output = String(execSync('where.exe memorix.cmd', {
36
+ encoding: 'utf-8',
37
+ stdio: ['ignore', 'pipe', 'ignore'],
38
+ windowsHide: true,
39
+ }));
40
+ return output
41
+ .split(/\r?\n/)
42
+ .map((candidate) => candidate.trim())
43
+ .find((candidate) => path.isAbsolute(candidate)) ?? null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * OpenCode starts plugins with its own environment on Windows. Resolve the npm
51
+ * shim while setup still has the user's normal PATH, then embed that stable
52
+ * path in the generated plugin instead of assuming OpenCode inherited it.
53
+ */
54
+ export function resolveOpenCodeHookCommand(
55
+ platform = process.platform,
56
+ resolveWindowsShim: () => string | null = resolveWindowsMemorixShim,
57
+ ): string {
58
+ if (platform !== 'win32') return 'memorix';
59
+ return resolveWindowsShim() ?? 'memorix.cmd';
60
+ }
61
+
33
62
  /**
34
63
  * Generate Claude Code hook config.
35
64
  * Format: .claude/settings.json
@@ -318,7 +347,7 @@ function generateKiroHookFiles(): Array<{ filename: string; content: string }> {
318
347
  * protocol used by all agents. spawnSync works in both Node.js and Bun
319
348
  * runtimes (OpenCode may fall back to Node.js on Windows).
320
349
  */
321
- const OPENCODE_PLUGIN_VERSION = 6;
350
+ const OPENCODE_PLUGIN_VERSION = 7;
322
351
 
323
352
  const AGENT_SKILL_DIRS: Partial<Record<AgentName, { project: string; global?: string }>> = {
324
353
  cursor: { project: path.join('.cursor', 'skills'), global: path.join('.cursor', 'skills') },
@@ -368,6 +397,7 @@ async function installOfficialSkillsForAgent(
368
397
  }
369
398
 
370
399
  function generateOpenCodePlugin(): string {
400
+ const hookCommand = JSON.stringify(resolveOpenCodeHookCommand());
371
401
  return `/**
372
402
  * Memorix - Cross-Agent Memory Bridge Plugin for OpenCode
373
403
  * @generated-version ${OPENCODE_PLUGIN_VERSION}
@@ -386,6 +416,16 @@ export const MemorixPlugin = async ({ project, client, $, directory, worktree })
386
416
  const sessionId = \`opencode-\${Date.now().toString(36)}-\${Math.random().toString(36).slice(2, 8)}\`;
387
417
  let pendingAssistantResponse = null;
388
418
  let lastDeliveredAssistantKey = '';
419
+ let hookFailureReported = false;
420
+ const hookCommand = ${hookCommand};
421
+
422
+ function reportHookFailure(eventName, detail) {
423
+ // OpenCode renders console.error output inside the conversation. Delivery is
424
+ // best-effort, so keep normal sessions quiet while retaining opt-in diagnostics.
425
+ if (process.env.MEMORIX_HOOK_DEBUG !== '1' || hookFailureReported) return;
426
+ hookFailureReported = true;
427
+ console.error('[memorix-plugin] hook delivery failed:', eventName, detail);
428
+ }
389
429
 
390
430
  /**
391
431
  * Send event JSON to \`memorix hook\` via child_process.spawnSync.
@@ -401,8 +441,7 @@ export const MemorixPlugin = async ({ project, client, $, directory, worktree })
401
441
  const data = JSON.stringify(payload);
402
442
  const eventName = payload.hook_event_name || 'unknown';
403
443
  try {
404
- const cmd = process.platform === 'win32' ? 'memorix.cmd' : 'memorix';
405
- const result = spawnSync(cmd, ['hook'], {
444
+ const result = spawnSync(hookCommand, ['hook'], {
406
445
  input: data,
407
446
  timeout: 10_000,
408
447
  encoding: 'utf-8',
@@ -410,12 +449,14 @@ export const MemorixPlugin = async ({ project, client, $, directory, worktree })
410
449
  shell: process.platform === 'win32',
411
450
  });
412
451
  if (result.status !== 0) {
413
- console.error('[memorix-plugin] hook failed:', eventName,
414
- 'exit=', result.status,
415
- 'stderr=', (result.stderr || '').slice(0, 200));
452
+ reportHookFailure(eventName, {
453
+ exit: result.status,
454
+ stderr: (result.stderr || '').slice(0, 200),
455
+ error: result.error?.message,
456
+ });
416
457
  }
417
458
  } catch (e) {
418
- console.error('[memorix-plugin] hook delivery failed:', eventName, e?.message ?? e);
459
+ reportHookFailure(eventName, e?.message ?? e);
419
460
  }
420
461
  }
421
462
 
@@ -201,6 +201,10 @@ function extractEventName(payload: Record<string, unknown>, agent: AgentName): s
201
201
  return (payload.hook_event_name as string) ?? inferCursorEvent(payload);
202
202
  case 'claude':
203
203
  return (payload.hook_event_name as string) ?? '';
204
+ case 'codex':
205
+ // Codex uses the same lifecycle event field names as Claude Code, but
206
+ // the bundled plugin supplies an explicit agent identity.
207
+ return (payload.hook_event_name as string) ?? '';
204
208
  case 'antigravity':
205
209
  case 'gemini-cli':
206
210
  // Gemini CLI uses hook_event_name; Antigravity hooks.json commands pass
@@ -272,6 +276,17 @@ function normalizeClaude(payload: Record<string, unknown>, event: HookEvent): Pa
272
276
  result.userPrompt = payload.prompt as string;
273
277
  }
274
278
 
279
+ // Codex Stop provides the final response under last_assistant_message.
280
+ // Accept the adjacent field name too so hosts with the same payload shape
281
+ // can preserve the useful end-of-turn summary.
282
+ const assistantMessage = firstString(
283
+ payload.last_assistant_message,
284
+ payload.assistant_message,
285
+ );
286
+ if (assistantMessage) {
287
+ result.aiResponse = assistantMessage;
288
+ }
289
+
275
290
  return result;
276
291
  }
277
292
 
@@ -16,8 +16,11 @@ import {
16
16
  resetDb,
17
17
  generateEmbedding,
18
18
  batchGenerateEmbeddings,
19
+ getDb,
20
+ getDeferredCachedVectorHydration,
19
21
  getVectorDimensions,
20
- hydrateIndex,
22
+ hydrateIndexForStartup,
23
+ hasObservationVector,
21
24
  isEmbeddingEnabled,
22
25
  makeOramaObservationId,
23
26
  getLastSearchMode,
@@ -40,6 +43,7 @@ let searchIndexPrepared = false;
40
43
  // Enables observability ("how many memories lack vectors?") and backfill.
41
44
  const vectorMissingIds = new Set<number>();
42
45
  let vectorBackfillRunning = false;
46
+ let vectorSchemaUpgradePromise: Promise<boolean> | null = null;
43
47
  let lastVectorBackfill: {
44
48
  attempted: number;
45
49
  succeeded: number;
@@ -119,7 +123,30 @@ async function bindObservationCodeRefsBestEffort(observation: Observation): Prom
119
123
  function isVectorCompatibleWithCurrentIndex(embedding: number[] | null): boolean {
120
124
  if (!embedding) return false;
121
125
  const vectorDimensions = getVectorDimensions();
122
- return vectorDimensions === null || embedding.length === vectorDimensions;
126
+ return vectorDimensions !== null &&
127
+ embedding.length === vectorDimensions &&
128
+ embedding.every(Number.isFinite);
129
+ }
130
+
131
+ /**
132
+ * A cache-less API startup deliberately creates a lexical index so MCP can
133
+ * answer immediately. Once the background lane obtains its first vector, the
134
+ * in-memory index must be rebuilt with a vector field in this same process.
135
+ */
136
+ async function upgradeVectorSchemaAfterFirstEmbedding(embedding: number[]): Promise<boolean> {
137
+ if (isVectorCompatibleWithCurrentIndex(embedding)) return true;
138
+ if (getVectorDimensions() !== null || !embedding.every(Number.isFinite)) return false;
139
+
140
+ const targetProjectDir = projectDir;
141
+ if (!vectorSchemaUpgradePromise) {
142
+ vectorSchemaUpgradePromise = reindexObservations()
143
+ .then(() => projectDir === targetProjectDir && isVectorCompatibleWithCurrentIndex(embedding))
144
+ .catch(() => false)
145
+ .finally(() => {
146
+ vectorSchemaUpgradePromise = null;
147
+ });
148
+ }
149
+ return vectorSchemaUpgradePromise;
123
150
  }
124
151
 
125
152
  /**
@@ -134,6 +161,7 @@ export async function initObservations(dir: string): Promise<void> {
134
161
  nextId = await store.loadIdCounter();
135
162
  projectDir = dir;
136
163
  searchIndexPrepared = false;
164
+ vectorSchemaUpgradePromise = null;
137
165
  }
138
166
 
139
167
  /**
@@ -411,6 +439,9 @@ export async function storeObservation(input: {
411
439
  const searchableText = [input.title, input.narrative, ...(input.facts ?? [])].join(' ');
412
440
  generateEmbedding(searchableText).then(async (embedding) => {
413
441
  if (embedding) {
442
+ if (!isVectorCompatibleWithCurrentIndex(embedding)) {
443
+ await upgradeVectorSchemaAfterFirstEmbedding(embedding);
444
+ }
414
445
  if (!isVectorCompatibleWithCurrentIndex(embedding)) {
415
446
  const vectorDimensions = getVectorDimensions();
416
447
  console.error(
@@ -561,6 +592,13 @@ async function upsertObservation(
561
592
  vectorMissingIds.add(obsId);
562
593
  generateEmbedding(searchableText).then(async (embedding) => {
563
594
  if (embedding) {
595
+ if (!isVectorCompatibleWithCurrentIndex(embedding)) {
596
+ await upgradeVectorSchemaAfterFirstEmbedding(embedding);
597
+ }
598
+ if (!isVectorCompatibleWithCurrentIndex(embedding)) {
599
+ queueVectorBackfill(existing.projectId);
600
+ return;
601
+ }
564
602
  try {
565
603
  const { removeObservation: removeObs } = await import('../store/orama-store.js');
566
604
  await removeObs(makeOramaObservationId(existing.projectId, obsId));
@@ -778,20 +816,36 @@ export async function reindexObservations(): Promise<number> {
778
816
  searchIndexPrepared = false;
779
817
  vectorMissingIds.clear();
780
818
 
781
- // Batch-generate all embeddings at once (much faster than individual calls)
819
+ // Prefer already-persisted vectors for remote providers. This restores semantic
820
+ // recall after a restart without waiting on the network; cache misses remain
821
+ // in the existing bounded background recovery lane.
782
822
  let embeddings: (number[] | null)[] = observations.map(() => null);
783
823
  const provider = await getEmbeddingProvider();
784
824
  const canBatchEmbedAtStartup = provider !== null && !provider.name.startsWith('api-');
785
825
 
826
+ // This is an explicit rebuild path. Establish the current Orama schema before
827
+ // accepting cached vectors so a stale cache entry cannot make an observation
828
+ // disappear from the lexical index on insert.
829
+ await getDb();
830
+
831
+ const texts = observations.map(obs =>
832
+ [obs.title, obs.narrative, ...obs.facts].join(' '),
833
+ );
834
+
835
+ if (provider?.getCachedEmbeddings) {
836
+ try {
837
+ embeddings = await provider.getCachedEmbeddings(texts);
838
+ } catch {
839
+ // Cache lookup is optional; remote cache misses continue to background recovery.
840
+ }
841
+ }
842
+
786
843
  if (provider && !canBatchEmbedAtStartup) {
787
- console.error('[memorix] Startup reindex: skipping synchronous API embeddings; background backfill will hydrate vectors');
844
+ console.error('[memorix] Startup reindex: restored cached API embeddings; uncached vectors stay in background recovery');
788
845
  }
789
846
 
790
847
  if (canBatchEmbedAtStartup) {
791
848
  try {
792
- const texts = observations.map(obs =>
793
- [obs.title, obs.narrative, ...obs.facts].join(' '),
794
- );
795
849
  embeddings = await batchGenerateEmbeddings(texts);
796
850
  // Batch embedding failed — fall back to no embeddings
797
851
  } catch {
@@ -857,20 +911,38 @@ export async function reindexObservations(): Promise<number> {
857
911
  export async function prepareSearchIndex(): Promise<number> {
858
912
  if (searchIndexPrepared) return 0;
859
913
 
860
- const count = await hydrateIndex(observations as unknown as any[]);
914
+ const count = await hydrateIndexForStartup(observations as unknown as any[]);
861
915
  if (count === 0) {
862
916
  searchIndexPrepared = true;
863
917
  return 0;
864
918
  }
865
919
 
866
- vectorMissingIds.clear();
867
- if (isEmbeddingEnabled()) {
868
- for (const obs of observations) {
869
- // Queue ALL statuses for vector backfill — status filtering happens at query time,
870
- // not at index time. Omitting non-active observations here would permanently
871
- // exclude resolved/archived memories from hybrid search after restart.
872
- vectorMissingIds.add(obs.id);
920
+ const hydratedProjectDir = projectDir;
921
+ const queueMissingVectors = () => {
922
+ if (projectDir !== hydratedProjectDir) return;
923
+ vectorMissingIds.clear();
924
+ if (!isEmbeddingExplicitlyDisabled()) {
925
+ const projectsWithMissingVectors = new Set<string>();
926
+ for (const obs of observations) {
927
+ // Queue ALL statuses for vector backfill — status filtering happens at query time,
928
+ // not at index time. Omitting non-active observations here would permanently
929
+ // exclude resolved/archived memories from hybrid search after restart.
930
+ if (!hasObservationVector(obs.projectId, obs.id)) {
931
+ vectorMissingIds.add(obs.id);
932
+ projectsWithMissingVectors.add(obs.projectId);
933
+ }
934
+ }
935
+ for (const projectId of projectsWithMissingVectors) queueVectorBackfill(projectId);
873
936
  }
937
+ };
938
+
939
+ const cacheHydration = getDeferredCachedVectorHydration();
940
+ if (cacheHydration) {
941
+ // Let the disk cache finish outside the interactive startup path. Misses are
942
+ // queued only after it has had a chance to restore compatible vectors.
943
+ void cacheHydration.finally(queueMissingVectors);
944
+ } else {
945
+ queueMissingVectors();
874
946
  }
875
947
 
876
948
  searchIndexPrepared = true;
@@ -987,6 +1059,9 @@ export async function backfillVectorEmbeddings(options: {
987
1059
  try {
988
1060
  const embedding = await generateEmbedding(text);
989
1061
  if (embedding) {
1062
+ if (!isVectorCompatibleWithCurrentIndex(embedding)) {
1063
+ await upgradeVectorSchemaAfterFirstEmbedding(embedding);
1064
+ }
990
1065
  if (!isVectorCompatibleWithCurrentIndex(embedding)) {
991
1066
  const vectorDimensions = getVectorDimensions();
992
1067
  console.error(