memorix 1.8.8 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +15 -1
  3. package/README.zh-CN.md +15 -1
  4. package/dist/cli/index.js +3722 -1599
  5. package/dist/cli/memcode.js +0 -0
  6. package/dist/index.js +634 -363
  7. package/dist/maintenance-runner.js +285 -103
  8. package/dist/media-audio-runner.js +285 -103
  9. package/dist/media-video-runner.js +285 -103
  10. package/dist/memcode-runtime/CHANGELOG.md +36 -0
  11. package/dist/sdk.js +627 -356
  12. package/dist/vector-backfill-runner.d.ts +4 -1
  13. package/dist/vector-backfill-runner.js +288 -104
  14. package/docs/AGENT_OPERATOR_PLAYBOOK.md +4 -2
  15. package/docs/API_REFERENCE.md +14 -0
  16. package/docs/CONFIGURATION.md +46 -0
  17. package/docs/DEVELOPMENT.md +2 -2
  18. package/docs/INTEGRATIONS.md +1 -0
  19. package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +344 -0
  20. package/docs/PERFORMANCE.md +32 -0
  21. package/docs/README.md +4 -2
  22. package/docs/hooks-architecture.md +18 -13
  23. package/llms-full.txt +4 -1
  24. package/llms.txt +3 -1
  25. package/package.json +2 -1
  26. package/plugins/antigravity/memorix/plugin.json +1 -1
  27. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  28. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  29. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  30. package/plugins/copilot/memorix/plugin.json +1 -1
  31. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  32. package/plugins/hermes/memorix/plugin.yaml +1 -1
  33. package/plugins/omp/memorix/package.json +1 -1
  34. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  35. package/plugins/pi/memorix/package.json +1 -1
  36. package/server.json +2 -2
  37. package/src/cli/command-guide.ts +9 -1
  38. package/src/cli/commands/agent-integrations.ts +2 -1
  39. package/src/cli/commands/hooks-install.ts +2 -0
  40. package/src/cli/commands/integrate.ts +2 -0
  41. package/src/cli/commands/serve-http.ts +11 -0
  42. package/src/cli/commands/setup.ts +11 -4
  43. package/src/cli/commands/sync-store.ts +191 -0
  44. package/src/cli/commands/sync.ts +8 -0
  45. package/src/hooks/handler.ts +6 -0
  46. package/src/hooks/installers/index.ts +181 -6
  47. package/src/hooks/normalizer.ts +60 -1
  48. package/src/hooks/types.ts +3 -1
  49. package/src/integrations/registry.ts +9 -0
  50. package/src/memory/observations.ts +15 -2
  51. package/src/runtime/maintenance-targets.ts +6 -0
  52. package/src/runtime/vector-backfill-runner.ts +185 -7
  53. package/src/store/orama-store.ts +85 -102
  54. package/src/sync/adapters/fs.ts +179 -0
  55. package/src/sync/adapters/github.ts +258 -0
  56. package/src/sync/adapters/object-store.ts +267 -0
  57. package/src/sync/adapters/postgres.ts +269 -0
  58. package/src/sync/engine.ts +353 -0
  59. package/src/sync/journal.ts +162 -0
  60. package/src/sync/merge.ts +116 -0
  61. package/src/sync/namespace.ts +15 -0
  62. package/src/sync/paging.ts +45 -0
  63. package/src/sync/policy.ts +37 -0
  64. package/src/sync/remote-factory.ts +49 -0
  65. package/src/sync/store-port.ts +288 -0
  66. package/src/sync/types.ts +183 -0
@@ -1,5 +1,14 @@
1
1
  import { spawn, type ChildProcess } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ statSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
3
12
  import path from 'node:path';
4
13
  import { fileURLToPath } from 'node:url';
5
14
 
@@ -11,8 +20,12 @@ import { initObservationStore } from '../store/obs-store.js';
11
20
  import { getDeferredCachedVectorHydration } from '../store/orama-store.js';
12
21
  import { closeAllDatabases } from '../store/sqlite-db.js';
13
22
  import { MaintenanceJobStore, MaintenanceJobWorker } from './maintenance-jobs.js';
23
+ import { MaintenanceTargetStore, type MaintenanceTarget } from './maintenance-targets.js';
14
24
  import { createProjectMaintenanceHandler } from './project-maintenance.js';
15
25
 
26
+ const VECTOR_BACKFILL_LOCK_FILE = '.memorix-vector-backfill.lock';
27
+ const STALE_LOCK_RECOVERY_MS = 24 * 60 * 60 * 1_000;
28
+
16
29
  export interface VectorBackfillRequest {
17
30
  projectId: string;
18
31
  projectRoot: string;
@@ -23,6 +36,20 @@ export interface VectorBackfillLauncherOptions {
23
36
  runnerPath?: string;
24
37
  exists?: (path: string) => boolean;
25
38
  spawn?: typeof spawn;
39
+ lockPath?: string;
40
+ }
41
+
42
+ interface VectorBackfillLock {
43
+ parentPid: number;
44
+ childPid?: number;
45
+ startedAt: number;
46
+ projectId: string;
47
+ }
48
+
49
+ interface VectorBackfillLockHandle {
50
+ path: string;
51
+ setChildPid(pid: number): void;
52
+ release(): void;
26
53
  }
27
54
 
28
55
  function isNonEmptyString(value: unknown): value is string {
@@ -68,6 +95,111 @@ export function parseVectorBackfillRequest(raw: string): VectorBackfillRequest {
68
95
  };
69
96
  }
70
97
 
98
+ export function vectorBackfillLockPath(dataDir: string): string {
99
+ return path.join(dataDir, VECTOR_BACKFILL_LOCK_FILE);
100
+ }
101
+
102
+ function processIsAlive(pid: number | undefined): boolean {
103
+ if (!Number.isSafeInteger(pid) || pid == null || pid <= 0) return false;
104
+ try {
105
+ process.kill(pid, 0);
106
+ return true;
107
+ } catch (error) {
108
+ // EPERM means the process exists but this process cannot signal it.
109
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
110
+ }
111
+ }
112
+
113
+ function readVectorBackfillLock(lockPath: string): VectorBackfillLock | undefined {
114
+ try {
115
+ const value = JSON.parse(readFileSync(lockPath, 'utf8')) as Partial<VectorBackfillLock>;
116
+ if (
117
+ typeof value.parentPid !== 'number'
118
+ || !Number.isSafeInteger(value.parentPid)
119
+ || typeof value.startedAt !== 'number'
120
+ || !Number.isFinite(value.startedAt)
121
+ || typeof value.projectId !== 'string'
122
+ ) return undefined;
123
+ return {
124
+ parentPid: value.parentPid,
125
+ ...(typeof value.childPid === 'number' ? { childPid: value.childPid } : {}),
126
+ startedAt: value.startedAt,
127
+ projectId: value.projectId,
128
+ };
129
+ } catch {
130
+ return undefined;
131
+ }
132
+ }
133
+
134
+ function writeVectorBackfillLock(lockPath: string, value: VectorBackfillLock): void {
135
+ writeFileSync(lockPath, `${JSON.stringify(value)}\n`, 'utf8');
136
+ }
137
+
138
+ function acquireVectorBackfillLock(
139
+ dataDir: string,
140
+ projectId: string,
141
+ preferredPath?: string,
142
+ ): VectorBackfillLockHandle | undefined {
143
+ const lockPath = preferredPath ?? vectorBackfillLockPath(dataDir);
144
+ mkdirSync(path.dirname(lockPath), { recursive: true });
145
+ const initial: VectorBackfillLock = {
146
+ parentPid: process.pid,
147
+ startedAt: Date.now(),
148
+ projectId,
149
+ };
150
+
151
+ for (let attempt = 0; attempt < 2; attempt++) {
152
+ try {
153
+ const fd = openSync(lockPath, 'wx');
154
+ try {
155
+ writeFileSync(fd, `${JSON.stringify(initial)}\n`, 'utf8');
156
+ } finally {
157
+ closeSync(fd);
158
+ }
159
+ return {
160
+ path: lockPath,
161
+ setChildPid(pid: number) {
162
+ try {
163
+ writeVectorBackfillLock(lockPath, { ...initial, childPid: pid });
164
+ } catch {
165
+ // The child can still release the lock on normal shutdown; a dead
166
+ // owner is recoverable on the next launch.
167
+ }
168
+ },
169
+ release() {
170
+ try { unlinkSync(lockPath); } catch { /* already released */ }
171
+ },
172
+ };
173
+ } catch (error) {
174
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') return undefined;
175
+ const existing = readVectorBackfillLock(lockPath);
176
+ const ownerAlive = existing && (processIsAlive(existing.childPid) || processIsAlive(existing.parentPid));
177
+ if (ownerAlive) return undefined;
178
+
179
+ // A malformed lock is not deleted immediately: another process may be
180
+ // between create and write. A dead, well-formed owner is safe to reap;
181
+ // an unreadable lock needs a long recovery window so it cannot cause a
182
+ // second heavy worker during a slow filesystem operation.
183
+ let stale = false;
184
+ try {
185
+ stale = Date.now() - statSync(lockPath).mtimeMs >= STALE_LOCK_RECOVERY_MS;
186
+ } catch {
187
+ stale = false;
188
+ }
189
+ if (!existing && !stale) return undefined;
190
+ try { unlinkSync(lockPath); } catch { return undefined; }
191
+ }
192
+ }
193
+ return undefined;
194
+ }
195
+
196
+ export function releaseVectorBackfillLock(lockPath = process.env.MEMORIX_VECTOR_BACKFILL_LOCK): void {
197
+ if (!lockPath) return;
198
+ const lock = readVectorBackfillLock(lockPath);
199
+ if (lock && lock.childPid !== process.pid) return;
200
+ try { unlinkSync(lockPath); } catch { /* already released */ }
201
+ }
202
+
71
203
  /**
72
204
  * Start a detached one-shot worker. The caller has already persisted the
73
205
  * observation and durable vector job, so failure to start is recoverable by a
@@ -81,6 +213,9 @@ export function launchDetachedVectorBackfill(
81
213
  const exists = options.exists ?? existsSync;
82
214
  if (!exists(runnerPath)) return false;
83
215
 
216
+ const lock = acquireVectorBackfillLock(request.dataDir, request.projectId, options.lockPath);
217
+ if (!lock) return false;
218
+
84
219
  try {
85
220
  const child = (options.spawn ?? spawn)(process.execPath, [runnerPath], {
86
221
  cwd: request.projectRoot,
@@ -90,13 +225,24 @@ export function launchDetachedVectorBackfill(
90
225
  stdio: 'ignore',
91
226
  // The request contains only local project metadata, never credentials.
92
227
  // Environment transport avoids a live stdin pipe keeping the CLI alive.
93
- env: { ...process.env, MEMORIX_VECTOR_BACKFILL_REQUEST: JSON.stringify(request) },
228
+ env: {
229
+ ...process.env,
230
+ MEMORIX_VECTOR_BACKFILL_REQUEST: JSON.stringify(request),
231
+ MEMORIX_VECTOR_BACKFILL_LOCK: lock.path,
232
+ },
94
233
  windowsHide: true,
95
234
  }) as ChildProcess;
235
+ if (child.pid) lock.setChildPid(child.pid);
236
+ else lock.release();
96
237
  child.once?.('error', () => {});
238
+ child.once?.('close', () => lock.release());
239
+ // Test doubles and unusual spawn adapters may not expose ChildProcess
240
+ // events. Do not leave a lock behind when there is no lifecycle to own it.
241
+ if (typeof child.once !== 'function') lock.release();
97
242
  child.unref();
98
- return true;
243
+ return Boolean(child.pid);
99
244
  } catch {
245
+ lock.release();
100
246
  return false;
101
247
  }
102
248
  }
@@ -110,12 +256,43 @@ export async function executeVectorBackfill(request: VectorBackfillRequest) {
110
256
  await prepareSearchIndex();
111
257
  await getDeferredCachedVectorHydration()?.catch(() => {});
112
258
 
259
+ const queue = new MaintenanceJobStore(request.dataDir);
260
+ const targets = new MaintenanceTargetStore(request.dataDir);
261
+ let preparedRoot = request.projectRoot;
262
+ const prepareTarget = async (target: MaintenanceTarget): Promise<void> => {
263
+ if (target.projectRoot === preparedRoot) return;
264
+ initProjectRoot(target.projectRoot);
265
+ loadDotenv(target.projectRoot);
266
+ preparedRoot = target.projectRoot;
267
+ // The data directory is shared across projects. The in-process index is
268
+ // intentionally global, so switching the target only changes the config
269
+ // and the job scope; it does not create another corpus/index copy.
270
+ };
271
+
272
+ const handler = async (job: Parameters<ReturnType<typeof createProjectMaintenanceHandler>>[0]) => {
273
+ const target = targets.get(job.projectId)
274
+ ?? (job.projectId === request.projectId
275
+ ? { projectId: request.projectId, projectRoot: request.projectRoot, dataDir: request.dataDir, updatedAt: Date.now() }
276
+ : undefined);
277
+ if (!target) return { action: 'reschedule' as const, delayMs: 30_000 };
278
+ await prepareTarget(target);
279
+ return createProjectMaintenanceHandler(job.projectId, target.dataDir, target.projectRoot)(job);
280
+ };
281
+
113
282
  const worker = new MaintenanceJobWorker(
114
- new MaintenanceJobStore(request.dataDir),
115
- createProjectMaintenanceHandler(request.projectId, request.dataDir, request.projectRoot),
116
- { projectId: request.projectId, kinds: ['vector-backfill'] },
283
+ queue,
284
+ handler,
285
+ { kinds: ['vector-backfill'] },
117
286
  );
118
- return worker.runOnce();
287
+ // Drain currently due vector jobs while this one process owns the lock. A
288
+ // hook storm therefore creates at most one heavy process, and pending jobs
289
+ // for other projects are picked up without requiring another hook event.
290
+ let result: Awaited<ReturnType<typeof worker.runOnce>> = { state: 'idle' };
291
+ while (true) {
292
+ result = await worker.runOnce();
293
+ if (result.state === 'idle') break;
294
+ }
295
+ return result;
119
296
  }
120
297
 
121
298
  async function readStdin(): Promise<string> {
@@ -137,6 +314,7 @@ export async function main(): Promise<void> {
137
314
  process.stderr.write(`[memorix] vector backfill worker failed: ${detail}\n`);
138
315
  process.exitCode = 1;
139
316
  } finally {
317
+ releaseVectorBackfillLock();
140
318
  closeAllDatabases();
141
319
  }
142
320
  }
@@ -33,10 +33,19 @@ let embeddingEnabled = false;
33
33
  let embeddingDimensions: number | null = null;
34
34
  let indexEmbeddingProvider: EmbeddingProvider | null = null;
35
35
  let deferredCachedVectorHydration: Promise<void> | null = null;
36
- const docByObservationKey = new Map<string, MemorixDocument>();
37
36
  const NON_CJK_HYBRID_SIMILARITY = 0.45;
38
37
  const lastSearchModeByProject = new Map<string, string>();
39
38
  const SEARCH_MODE_DEFAULT_KEY = '__global__';
39
+ const MAX_SEARCH_MODE_KEYS = 4096;
40
+
41
+ function rememberSearchMode(key: string, value: string): void {
42
+ if (!lastSearchModeByProject.has(key) && lastSearchModeByProject.size >= MAX_SEARCH_MODE_KEYS) {
43
+ const oldest = lastSearchModeByProject.keys().next().value;
44
+ if (oldest !== undefined) lastSearchModeByProject.delete(oldest);
45
+ }
46
+ lastSearchModeByProject.delete(key);
47
+ lastSearchModeByProject.set(key, value);
48
+ }
40
49
  export function getLastSearchMode(projectId?: string): string {
41
50
  return lastSearchModeByProject.get(projectId ?? SEARCH_MODE_DEFAULT_KEY) ?? 'fulltext';
42
51
  }
@@ -68,9 +77,6 @@ function makeEntryKey(projectId: string | undefined, observationId: number): str
68
77
  function rememberObservationDoc(doc: MemorixDocument): MemorixDocument {
69
78
  const publicDoc = { ...doc };
70
79
  delete publicDoc.embedding;
71
- if (doc.projectId && typeof doc.observationId === 'number') {
72
- docByObservationKey.set(makeEntryKey(doc.projectId, doc.observationId), publicDoc);
73
- }
74
80
  return publicDoc;
75
81
  }
76
82
 
@@ -244,7 +250,6 @@ export async function resetDb(): Promise<void> {
244
250
  indexEmbeddingProvider = null;
245
251
  deferredCachedVectorHydration = null;
246
252
  lastSearchModeByProject.clear();
247
- docByObservationKey.clear();
248
253
  }
249
254
 
250
255
  /**
@@ -336,8 +341,6 @@ export interface HydrateIndexOptions {
336
341
  skipCachedVectors?: boolean;
337
342
  }
338
343
 
339
- type HydrationCandidate = { observation: any; id: string };
340
-
341
344
  function observationEmbeddingText(observation: any): string {
342
345
  return [
343
346
  observation.title ?? '',
@@ -361,29 +364,31 @@ function isCompatibleCachedVector(vector: number[] | null): vector is number[] {
361
364
 
362
365
  async function attachCachedVectors(
363
366
  database: AnyOrama,
364
- candidates: HydrationCandidate[],
367
+ observations: any[],
365
368
  ): Promise<void> {
366
- const cachedVectors = await getCachedEmbeddings(
367
- candidates.map(({ observation }) => observationEmbeddingText(observation)),
368
- );
369
+ const batchSize = 200;
370
+ for (let start = 0; start < observations.length; start += batchSize) {
371
+ // Keep cache lookup and the temporary vector array bounded to one batch.
372
+ const batch = observations.slice(start, start + batchSize);
373
+ const cachedVectors = await getCachedEmbeddings(batch.map(observationEmbeddingText));
369
374
 
370
- // A reset or project switch created a new index while the cache was loading.
371
- // Do not attach stale vectors to that new index.
372
- if (db !== database) return;
375
+ // A reset or project switch created a new index while the cache was loading.
376
+ // Do not attach stale vectors to that new index.
377
+ if (db !== database) return;
373
378
 
374
- for (let index = 0; index < candidates.length; index++) {
375
- const vector = cachedVectors[index];
376
- if (!isCompatibleCachedVector(vector)) continue;
377
- const existing = getByID(database, candidates[index].id) as MemorixDocument | undefined;
378
- if (!existing || documentEmbeddingText(existing) !== observationEmbeddingText(candidates[index].observation)) continue;
379
- try {
380
- await update(database, candidates[index].id, { ...existing, embedding: vector });
381
- } catch {
382
- // Vector cache hydration is best-effort. The normal backfill lane owns misses.
383
- }
384
- if (index % 50 === 0) {
385
- await new Promise<void>((resolve) => setImmediate(resolve));
379
+ for (let index = 0; index < batch.length; index++) {
380
+ const vector = cachedVectors[index];
381
+ if (!isCompatibleCachedVector(vector)) continue;
382
+ const id = makeOramaObservationId(batch[index].projectId, batch[index].id);
383
+ const existing = getByID(database, id) as MemorixDocument | undefined;
384
+ if (!existing || documentEmbeddingText(existing) !== observationEmbeddingText(batch[index])) continue;
385
+ try {
386
+ await update(database, id, { ...existing, embedding: vector });
387
+ } catch {
388
+ // Vector cache hydration is best-effort. The normal backfill lane owns misses.
389
+ }
386
390
  }
391
+ await new Promise<void>((resolve) => setImmediate(resolve));
387
392
  }
388
393
  }
389
394
 
@@ -392,62 +397,54 @@ export async function hydrateIndex(
392
397
  options: HydrateIndexOptions = {},
393
398
  ): Promise<number> {
394
399
  const database = await getDb({ allowNetworkProbe: options.allowNetworkProbe });
395
-
396
- const candidates: HydrationCandidate[] = [];
397
- for (const observation of observations) {
398
- if (!observation || !observation.id || !observation.projectId) continue;
399
- const id = makeOramaObservationId(observation.projectId, observation.id);
400
- if (getByID(database, id)) continue;
401
- candidates.push({ observation, id });
402
- }
403
-
404
400
  const deferCachedVectors = options.deferCachedVectors === true && Boolean(indexEmbeddingProvider?.getCachedEmbeddings);
405
- const cachedVectors = deferCachedVectors
406
- ? candidates.map(() => null)
407
- : await getCachedEmbeddings(candidates.map(({ observation }) => observationEmbeddingText(observation)));
408
-
409
- const documents: MemorixDocument[] = [];
410
- for (let index = 0; index < candidates.length; index++) {
411
- const { observation: obs, id } = candidates[index];
412
- try {
413
- const vector = cachedVectors[index];
414
- const compatibleVector = isCompatibleCachedVector(vector) ? vector : null;
415
- const doc: MemorixDocument = {
416
- id,
417
- observationId: obs.id,
418
- entityName: obs.entityName || '',
419
- type: obs.type || 'discovery',
420
- title: obs.title || '',
421
- narrative: obs.narrative || '',
422
- facts: Array.isArray(obs.facts) ? obs.facts.join(' ') : '',
423
- filesModified: Array.isArray(obs.filesModified) ? obs.filesModified.join(' ') : '',
424
- concepts: Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '',
425
- tokens: obs.tokens ?? 0,
426
- createdAt: obs.createdAt || '',
427
- projectId: obs.projectId,
428
- accessCount: obs.accessCount ?? 0,
429
- lastAccessedAt: obs.lastAccessedAt || '',
430
- status: obs.status ?? 'active',
431
- source: obs.source || 'agent',
432
- sourceDetail: obs.sourceDetail ?? '',
433
- valueCategory: obs.valueCategory ?? '',
434
- admissionState: obs.admissionState ?? '',
435
- admissionReason: obs.admissionReason ?? '',
436
- visibility: obs.visibility ?? 'project',
437
- createdByAgentId: obs.createdByAgentId ?? '',
438
- sharedWithAgentIds: JSON.stringify(obs.sharedWithAgentIds ?? []),
439
- documentType: 'observation',
440
- knowledgeLayer: resolveKnowledgeLayer('observation', obs.sourceDetail, obs.source),
441
- ...(compatibleVector ? { embedding: compatibleVector } : {}),
442
- };
443
- documents.push(doc);
444
- } catch { /* skip malformed entries */ }
445
- }
446
-
447
401
  let inserted = 0;
448
402
  const hydrationBatchSize = 200;
449
- for (let start = 0; start < documents.length; start += hydrationBatchSize) {
450
- const batch = documents.slice(start, start + hydrationBatchSize);
403
+ for (let start = 0; start < observations.length; start += hydrationBatchSize) {
404
+ const batchObservations = observations.slice(start, start + hydrationBatchSize)
405
+ .filter((observation) => observation && observation.id && observation.projectId)
406
+ .filter((observation) => !getByID(database, makeOramaObservationId(observation.projectId, observation.id)));
407
+ if (batchObservations.length === 0) continue;
408
+
409
+ const cachedVectors = deferCachedVectors || options.skipCachedVectors
410
+ ? batchObservations.map(() => null)
411
+ : await getCachedEmbeddings(batchObservations.map(observationEmbeddingText));
412
+ const batch: MemorixDocument[] = [];
413
+ for (let index = 0; index < batchObservations.length; index++) {
414
+ const obs = batchObservations[index];
415
+ try {
416
+ const compatibleVector = isCompatibleCachedVector(cachedVectors[index]) ? cachedVectors[index] : null;
417
+ batch.push({
418
+ id: makeOramaObservationId(obs.projectId, obs.id),
419
+ observationId: obs.id,
420
+ entityName: obs.entityName || '',
421
+ type: obs.type || 'discovery',
422
+ title: obs.title || '',
423
+ narrative: obs.narrative || '',
424
+ facts: Array.isArray(obs.facts) ? obs.facts.join(' ') : '',
425
+ filesModified: Array.isArray(obs.filesModified) ? obs.filesModified.join(' ') : '',
426
+ concepts: Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '',
427
+ tokens: obs.tokens ?? 0,
428
+ createdAt: obs.createdAt || '',
429
+ projectId: obs.projectId,
430
+ accessCount: obs.accessCount ?? 0,
431
+ lastAccessedAt: obs.lastAccessedAt || '',
432
+ status: obs.status ?? 'active',
433
+ source: obs.source || 'agent',
434
+ sourceDetail: obs.sourceDetail ?? '',
435
+ valueCategory: obs.valueCategory ?? '',
436
+ admissionState: obs.admissionState ?? '',
437
+ admissionReason: obs.admissionReason ?? '',
438
+ visibility: obs.visibility ?? 'project',
439
+ createdByAgentId: obs.createdByAgentId ?? '',
440
+ sharedWithAgentIds: JSON.stringify(obs.sharedWithAgentIds ?? []),
441
+ documentType: 'observation',
442
+ knowledgeLayer: resolveKnowledgeLayer('observation', obs.sourceDetail, obs.source),
443
+ ...(compatibleVector ? { embedding: compatibleVector } : {}),
444
+ });
445
+ } catch { /* skip malformed entries */ }
446
+ }
447
+ if (batch.length === 0) continue;
451
448
  try {
452
449
  // Orama can rebalance its indexes once per batch instead of once per
453
450
  // document. Keep batches bounded so HTTP health remains responsive.
@@ -476,7 +473,7 @@ export async function hydrateIndex(
476
473
  deferredCachedVectorHydration = options.skipCachedVectors
477
474
  ? null
478
475
  : deferCachedVectors
479
- ? attachCachedVectors(database, candidates).catch(() => {})
476
+ ? attachCachedVectors(database, observations).catch(() => {})
480
477
  : null;
481
478
  return inserted;
482
479
  }
@@ -548,12 +545,6 @@ export async function updateObservationMetadata(
548
545
  export async function removeObservation(oramaId: string): Promise<void> {
549
546
  const database = await getDb();
550
547
  await remove(database, oramaId);
551
- for (const [key, doc] of docByObservationKey.entries()) {
552
- if (doc.id === oramaId) {
553
- docByObservationKey.delete(key);
554
- break;
555
- }
556
- }
557
548
  }
558
549
 
559
550
  /**
@@ -569,7 +560,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
569
560
  const modeKey = options.projectId ?? SEARCH_MODE_DEFAULT_KEY;
570
561
  const quality = options.quality ?? 'balanced';
571
562
  const database = await getDb();
572
- lastSearchModeByProject.set(
563
+ rememberSearchMode(
573
564
  modeKey,
574
565
  quality === 'fast' ? 'fulltext (fast profile)' : embeddingEnabled ? 'hybrid' : 'fulltext',
575
566
  );
@@ -671,7 +662,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
671
662
  if (provider) {
672
663
  const activeVectorDimensions = getVectorDimensions();
673
664
  if (activeVectorDimensions !== null && provider.dimensions !== activeVectorDimensions) {
674
- lastSearchModeByProject.set(
665
+ rememberSearchMode(
675
666
  modeKey,
676
667
  `fulltext (embedding dimension mismatch: provider ${provider.dimensions}d vs index ${activeVectorDimensions}d)`,
677
668
  );
@@ -700,7 +691,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
700
691
  // Detect CJK-heavy queries: BM25 can't tokenize Chinese/Japanese/Korean well
701
692
  const cjkRatio = (originalQuery!.match(/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/g) || []).length / originalQuery!.length;
702
693
  const isCJKHeavy = cjkRatio > 0.3;
703
- lastSearchModeByProject.set(modeKey, 'hybrid');
694
+ rememberSearchMode(modeKey, 'hybrid');
704
695
  searchParams = {
705
696
  ...searchParams,
706
697
  mode: 'hybrid',
@@ -719,7 +710,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
719
710
  }
720
711
  } catch (error) {
721
712
  // Fallback to fulltext if embedding fails or times out
722
- lastSearchModeByProject.set(modeKey, 'fulltext (embedding unavailable)');
713
+ rememberSearchMode(modeKey, 'fulltext (embedding unavailable)');
723
714
  console.error('[memorix] Embedding failed or timed out, falling back to fulltext search');
724
715
  }
725
716
  }
@@ -730,7 +721,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
730
721
  results = await search(database, searchParams);
731
722
  } catch (error) {
732
723
  if (queryVector && isVectorDimensionMismatchError(error)) {
733
- lastSearchModeByProject.set(modeKey, 'fulltext (embedding dimension mismatch)');
724
+ rememberSearchMode(modeKey, 'fulltext (embedding dimension mismatch)');
734
725
  console.error('[memorix] Vector search dimension mismatch detected, retrying without embeddings');
735
726
  results = await search(database, stripVectorSearchParams(searchParams));
736
727
  } else {
@@ -754,7 +745,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
754
745
  },
755
746
  similarity: 0.25,
756
747
  };
757
- lastSearchModeByProject.set(modeKey, 'vector-only (hybrid empty fallback)');
748
+ rememberSearchMode(modeKey, 'vector-only (hybrid empty fallback)');
758
749
  results = await search(database, vectorOnlyParams);
759
750
  } catch {
760
751
  // Keep original empty results
@@ -1106,7 +1097,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
1106
1097
  .filter((entry): entry is NonNullable<typeof entry> => entry != null);
1107
1098
  if (rerankedTop.length > 0) {
1108
1099
  intermediate = [...rerankedTop, ...intermediate.slice(RERANK_TOP_K)];
1109
- lastSearchModeByProject.set(modeKey, (lastSearchModeByProject.get(modeKey) ?? 'fulltext') + ' + neural rerank');
1100
+ rememberSearchMode(modeKey, (lastSearchModeByProject.get(modeKey) ?? 'fulltext') + ' + neural rerank');
1110
1101
  mark('rerank(usedNeural=true)');
1111
1102
  }
1112
1103
  }
@@ -1123,7 +1114,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
1123
1114
  );
1124
1115
  mark(`rerank(usedLLM=${usedLLM})`);
1125
1116
  if (usedLLM) {
1126
- lastSearchModeByProject.set(modeKey, (lastSearchModeByProject.get(modeKey) ?? 'fulltext') + ' + LLM rerank');
1117
+ rememberSearchMode(modeKey, (lastSearchModeByProject.get(modeKey) ?? 'fulltext') + ' + LLM rerank');
1127
1118
  const rerankedTop = reranked
1128
1119
  .map((row) => candidateMap.get(row.id))
1129
1120
  .filter((entry): entry is NonNullable<typeof entry> => entry != null);
@@ -1224,14 +1215,6 @@ export async function getObservationsByIds(
1224
1215
  const results: MemorixDocument[] = [];
1225
1216
 
1226
1217
  for (const id of ids) {
1227
- if (projectId) {
1228
- const cached = docByObservationKey.get(makeEntryKey(projectId, id));
1229
- if (cached) {
1230
- results.push(cached);
1231
- continue;
1232
- }
1233
- }
1234
-
1235
1218
  const searchResult = await search(database, {
1236
1219
  term: '',
1237
1220
  includeVectors: true,