memorix 1.2.8 → 1.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "description": "Local-first shared memory layer for AI coding agents across MCP clients, Git history, reasoning context, and project sessions.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "memorix",
4
- "version": "1.2.8",
4
+ "version": "1.2.9",
5
5
  "description": "Shared workspace memory for Claude Code and other AI coding agents.",
6
6
  "author": {
7
7
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "description": "Shared workspace memory for Codex and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "description": "Shared local workspace memory for GitHub Copilot CLI and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "mcpServers": {
5
5
  "memorix": {
6
6
  "command": "memorix",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix-omp-package",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "description": "Memorix shared workspace memory package for Oh-my-Pi.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "description": "Shared workspace memory for OpenClaw and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix-pi-package",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "description": "Memorix shared workspace memory package for Pi coding agent.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -110,7 +110,10 @@ export async function getCliProjectContext(options?: CliContextOptions): Promise
110
110
  } catch {
111
111
  // CLI reads remain available even if optional background maintenance metadata fails.
112
112
  }
113
- await initObservations(dataDir);
113
+ await initObservations(dataDir, {
114
+ embeddingWriteMode: 'deferred',
115
+ projectRoot: project.rootPath,
116
+ });
114
117
  await initSessionStore(dataDir);
115
118
  const teamStore = await initTeamStore(dataDir);
116
119
 
@@ -24,6 +24,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
24
24
  import type { ObservationStore } from '../../store/obs-store.js';
25
25
  import { resolveToolProfile } from '../../server/tool-profile.js';
26
26
  import { scopeKnowledgeGraphToProject } from '../../memory/graph-scope.js';
27
+ import { projectObservationRetention, summarizeRetentionProjections } from '../../memory/retention.js';
27
28
  import { canManageObservation, filterReadableObservations } from '../../memory/visibility.js';
28
29
 
29
30
  export const DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1000;
@@ -760,20 +761,7 @@ export default defineCommand({
760
761
  await initGraphStore(statsDataDir);
761
762
  const graph = { entities: getGraphStore().loadEntities(), relations: getGraphStore().loadRelations() };
762
763
 
763
- const observations = await loadDashboardProjectObservations(statsDataDir, statsProjectId, 'active') as Array<{
764
- type?: string;
765
- id?: number;
766
- title?: string;
767
- entityName?: string;
768
- createdAt?: string;
769
- source?: string;
770
- commitHash?: string;
771
- filesModified?: string[];
772
- relatedEntities?: string[];
773
- status?: string;
774
- importance?: number;
775
- accessCount?: number;
776
- }>;
764
+ const observations = await loadDashboardProjectObservations(statsDataDir, statsProjectId, 'active');
777
765
  const statsStore = await getDashboardObservationStore(statsDataDir);
778
766
  const nextId = await statsStore.loadIdCounter();
779
767
  const typeCounts: Record<string, number> = {};
@@ -811,20 +799,17 @@ export default defineCommand({
811
799
  filesModified: o.filesModified,
812
800
  }));
813
801
 
814
- let retentionSummary = { active: 0, stale: 0, archive: 0, immune: 0 };
815
- for (const obs of observations) {
816
- const age = now - new Date(obs.createdAt || now).getTime();
817
- const ageHours = age / (1000 * 60 * 60);
818
- const importance = obs.importance ?? 5;
819
- const accessCount = obs.accessCount ?? 0;
820
- const lambda = 0.01;
821
- const score = Math.min(importance * Math.exp(-lambda * ageHours) + Math.min(accessCount * 0.5, 3), 10);
822
- const isImmune = importance >= 8 || obs.type === 'gotcha' || obs.type === 'decision';
823
- if (isImmune) retentionSummary.immune++;
824
- if (score >= 3) retentionSummary.active++;
825
- else if (score >= 1) retentionSummary.stale++;
826
- else retentionSummary.archive++;
827
- }
802
+ const retention = summarizeRetentionProjections(
803
+ observations
804
+ .filter((observation) => observation.type !== 'probe')
805
+ .map((observation) => projectObservationRetention(observation, { referenceTime: new Date(now) })),
806
+ );
807
+ const retentionSummary = {
808
+ active: retention.active,
809
+ stale: retention.stale,
810
+ archive: retention.archiveCandidates,
811
+ immune: retention.immune,
812
+ };
828
813
 
829
814
  const sorted = [...observations].filter(o => o.type !== 'probe').sort((a, b) => (b.id || 0) - (a.id || 0)).slice(0, 10);
830
815
 
@@ -959,26 +944,38 @@ export default defineCommand({
959
944
 
960
945
  if (apiPath === '/retention') {
961
946
  const { projectId: retProjectId, dataDir: retDataDir } = await resolveRequestProject(url);
962
- const observations = await loadDashboardProjectObservations(retDataDir, retProjectId, 'active') as Array<{ id?: number; title?: string; type?: string; importance?: number; accessCount?: number; lastAccessedAt?: string; createdAt?: string; entityName?: string }>;
963
- const now = Date.now();
964
- const scored = observations.map(obs => {
965
- const age = now - new Date(obs.createdAt || now).getTime();
966
- const ageHours = age / (1000 * 60 * 60);
967
- const importance = obs.importance ?? 5;
968
- const accessCount = obs.accessCount ?? 0;
969
- const lambda = 0.01;
970
- const decayScore = importance * Math.exp(-lambda * ageHours);
971
- const accessBonus = Math.min(accessCount * 0.5, 3);
972
- const score = Math.min(decayScore + accessBonus, 10);
973
- const isImmune = importance >= 8 || obs.type === 'gotcha' || obs.type === 'decision';
974
- return { id: obs.id, title: obs.title, type: obs.type, entityName: obs.entityName, score: Math.round(score * 100) / 100, isImmune, ageHours: Math.round(ageHours * 10) / 10, accessCount };
947
+ const observations = await loadDashboardProjectObservations(retDataDir, retProjectId, 'active');
948
+ const referenceTime = new Date();
949
+ const rows = observations
950
+ .filter((observation) => observation.type !== 'probe')
951
+ .map((observation) => ({
952
+ observation,
953
+ retention: projectObservationRetention(observation, { referenceTime }),
954
+ }))
955
+ .sort((a, b) => b.retention.displayScore - a.retention.displayScore);
956
+ const summary = summarizeRetentionProjections(rows.map((row) => row.retention));
957
+ const items = rows.map(({ observation, retention }) => ({
958
+ id: observation.id,
959
+ title: observation.title,
960
+ type: observation.type,
961
+ entityName: observation.entityName,
962
+ score: retention.displayScore,
963
+ isImmune: retention.immune,
964
+ zone: retention.zone,
965
+ ageHours: retention.ageHours,
966
+ accessCount: retention.accessCount,
967
+ effectiveRetentionDays: retention.effectiveRetentionDays,
968
+ immunityReason: retention.immunityReason,
969
+ }));
970
+ sendJson({
971
+ summary: {
972
+ active: summary.active,
973
+ stale: summary.stale,
974
+ archive: summary.archiveCandidates,
975
+ immune: summary.immune,
976
+ },
977
+ items,
975
978
  });
976
- scored.sort((a, b) => b.score - a.score);
977
- const activeCount = scored.filter(s => s.score >= 3).length;
978
- const staleCount = scored.filter(s => s.score < 3 && s.score >= 1).length;
979
- const archiveCount = scored.filter(s => s.score < 1).length;
980
- const immuneCount = scored.filter(s => s.isImmune).length;
981
- sendJson({ summary: { active: activeCount, stale: staleCount, archive: archiveCount, immune: immuneCount }, items: scored });
982
979
  return;
983
980
  }
984
981
 
@@ -23,6 +23,7 @@ import { resetDotenv } from '../config/dotenv-loader.js';
23
23
  import { initProjectRoot } from '../config/yaml-loader.js';
24
24
  import { clearProjectRoot } from '../config/yaml-loader.js';
25
25
  import { scopeKnowledgeGraphToProject } from '../memory/graph-scope.js';
26
+ import { projectObservationRetention, summarizeRetentionProjections } from '../memory/retention.js';
26
27
  import { canManageObservation, filterReadableObservations } from '../memory/visibility.js';
27
28
  import type { Observation } from '../types.js';
28
29
 
@@ -249,7 +250,7 @@ async function handleApi(
249
250
  const observations = filterDashboardObservations(
250
251
  await getObservationStore().loadByProject(effectiveProjectId, { status: 'active' }),
251
252
  effectiveProjectId,
252
- ) as Array<{ type?: string; id?: number; createdAt?: string; title?: string; entityName?: string; relatedEntities?: string[]; status?: string }>;
253
+ );
253
254
  const nextId = await getObservationStore().loadIdCounter();
254
255
 
255
256
  // Project-scoped graph counts (must match /api/graph and /api/export)
@@ -290,21 +291,18 @@ async function handleApi(
290
291
  filesModified: (o as any).filesModified,
291
292
  }));
292
293
 
293
- // Retention summary
294
- let retentionSummary = { active: 0, stale: 0, archive: 0, immune: 0 };
295
- for (const obs of observations) {
296
- const age = now - new Date((obs as any).createdAt || now).getTime();
297
- const ageHours = age / (1000 * 60 * 60);
298
- const importance = (obs as any).importance ?? 5;
299
- const accessCount = (obs as any).accessCount ?? 0;
300
- const lambda = 0.01;
301
- const score = Math.min(importance * Math.exp(-lambda * ageHours) + Math.min(accessCount * 0.5, 3), 10);
302
- const isImmune = importance >= 8 || obs.type === 'gotcha' || obs.type === 'decision';
303
- if (isImmune) retentionSummary.immune++;
304
- if (score >= 3) retentionSummary.active++;
305
- else if (score >= 1) retentionSummary.stale++;
306
- else retentionSummary.archive++;
307
- }
294
+ const retentionReferenceTime = new Date(now);
295
+ const retention = summarizeRetentionProjections(
296
+ observations
297
+ .filter((observation) => observation.type !== 'probe')
298
+ .map((observation) => projectObservationRetention(observation, { referenceTime: retentionReferenceTime })),
299
+ );
300
+ const retentionSummary = {
301
+ active: retention.active,
302
+ stale: retention.stale,
303
+ archive: retention.archiveCandidates,
304
+ immune: retention.immune,
305
+ };
308
306
 
309
307
  // Recent observations (last 10, exclude probe)
310
308
  const sorted = [...observations].filter(o => o.type !== 'probe')
@@ -399,56 +397,38 @@ async function handleApi(
399
397
  const observations = filterDashboardObservations(
400
398
  await getObservationStore().loadByProject(effectiveProjectId, { status: 'active' }),
401
399
  effectiveProjectId,
402
- ) as Array<{
403
- id?: number;
404
- title?: string;
405
- type?: string;
406
- importance?: number;
407
- accessCount?: number;
408
- lastAccessedAt?: string;
409
- createdAt?: string;
410
- entityName?: string;
411
- }>;
412
-
413
- const now = Date.now();
414
- // Exclude probe from retention display -- not durable knowledge
415
- const scored = observations.filter(obs => obs.type !== 'probe').map((obs) => {
416
- const age = now - new Date(obs.createdAt || now).getTime();
417
- const ageHours = age / (1000 * 60 * 60);
418
- const importance = obs.importance ?? 5;
419
- const accessCount = obs.accessCount ?? 0;
420
-
421
- // Exponential decay: score = importance * e^(-λt) + access_bonus
422
- const lambda = 0.01;
423
- const decayScore = importance * Math.exp(-lambda * ageHours);
424
- const accessBonus = Math.min(accessCount * 0.5, 3);
425
- const score = Math.min(decayScore + accessBonus, 10);
426
-
427
- // Immune if importance >= 8 or type is 'gotcha' or 'decision'
428
- const isImmune = importance >= 8 || obs.type === 'gotcha' || obs.type === 'decision';
429
-
430
- return {
431
- id: obs.id,
432
- title: obs.title,
433
- type: obs.type,
434
- entityName: obs.entityName,
435
- score: Math.round(score * 100) / 100,
436
- isImmune,
437
- ageHours: Math.round(ageHours * 10) / 10,
438
- accessCount,
439
- };
440
- });
441
-
442
- // Sort by score descending
443
- scored.sort((a, b) => b.score - a.score);
400
+ );
444
401
 
445
- const activeCount = scored.filter((s) => s.score >= 3).length;
446
- const staleCount = scored.filter((s) => s.score < 3 && s.score >= 1).length;
447
- const archiveCount = scored.filter((s) => s.score < 1).length;
448
- const immuneCount = scored.filter((s) => s.isImmune).length;
402
+ const referenceTime = new Date();
403
+ const rows = observations
404
+ .filter((observation) => observation.type !== 'probe')
405
+ .map((observation) => ({
406
+ observation,
407
+ retention: projectObservationRetention(observation, { referenceTime }),
408
+ }))
409
+ .sort((a, b) => b.retention.displayScore - a.retention.displayScore);
410
+ const summary = summarizeRetentionProjections(rows.map((row) => row.retention));
411
+ const scored = rows.map(({ observation, retention }) => ({
412
+ id: observation.id,
413
+ title: observation.title,
414
+ type: observation.type,
415
+ entityName: observation.entityName,
416
+ score: retention.displayScore,
417
+ isImmune: retention.immune,
418
+ zone: retention.zone,
419
+ ageHours: retention.ageHours,
420
+ accessCount: retention.accessCount,
421
+ effectiveRetentionDays: retention.effectiveRetentionDays,
422
+ immunityReason: retention.immunityReason,
423
+ }));
449
424
 
450
425
  sendJson(res, {
451
- summary: { active: activeCount, stale: staleCount, archive: archiveCount, immune: immuneCount },
426
+ summary: {
427
+ active: summary.active,
428
+ stale: summary.stale,
429
+ archive: summary.archiveCandidates,
430
+ immune: summary.immune,
431
+ },
452
432
  items: scored,
453
433
  });
454
434
  break;
@@ -49,6 +49,17 @@ let observations: Observation[] = [];
49
49
  let nextId = 1;
50
50
  let projectDir: string | null = null;
51
51
  let searchIndexPrepared = false;
52
+ export type ObservationEmbeddingWriteMode = 'background' | 'deferred';
53
+
54
+ export interface ObservationRuntimeOptions {
55
+ /** Defer remote/local embedding to a detached worker for short-lived CLI writes. */
56
+ embeddingWriteMode?: ObservationEmbeddingWriteMode;
57
+ /** Git root used by the detached vector worker. */
58
+ projectRoot?: string;
59
+ }
60
+
61
+ let embeddingWriteMode: ObservationEmbeddingWriteMode = 'background';
62
+ let embeddingWorkerProjectRoot: string | undefined;
52
63
 
53
64
  // ── Vector-missing tracking ──────────────────────────────────────
54
65
  // Tracks observation IDs whose async embedding write failed or was skipped.
@@ -106,7 +117,10 @@ function vectorBackfillError(error: unknown): string {
106
117
  return sanitizeCredentials(normalizeEmbeddingFailure(error).message).slice(0, 1_000);
107
118
  }
108
119
 
109
- function queueVectorBackfill(projectId: string): void {
120
+ function queueVectorBackfill(
121
+ projectId: string,
122
+ options: { detachedWorker?: boolean; projectRoot?: string } = {},
123
+ ): void {
110
124
  const dataDir = projectDir;
111
125
  if (!dataDir) return;
112
126
  void import('../runtime/maintenance-jobs.js')
@@ -117,6 +131,19 @@ function queueVectorBackfill(projectId: string): void {
117
131
  dedupeKey: 'vector-backfill',
118
132
  payload: { limit: 12 },
119
133
  });
134
+ if (options.detachedWorker && options.projectRoot) {
135
+ void import('../runtime/vector-backfill-runner.js')
136
+ .then(({ launchDetachedVectorBackfill }) => {
137
+ launchDetachedVectorBackfill({
138
+ projectId,
139
+ projectRoot: options.projectRoot!,
140
+ dataDir,
141
+ });
142
+ })
143
+ .catch(() => {
144
+ // The durable queue remains available to a later MCP session.
145
+ });
146
+ }
120
147
  })
121
148
  .catch(() => {
122
149
  // Memory writes remain durable even when the optional maintenance queue is unavailable.
@@ -199,7 +226,12 @@ async function upgradeVectorSchemaAfterFirstEmbedding(embedding: number[]): Prom
199
226
  * Initialize the observations manager with a project directory.
200
227
  * Auto-initializes the ObservationStore if not already set.
201
228
  */
202
- export async function initObservations(dir: string): Promise<void> {
229
+ export async function initObservations(
230
+ dir: string,
231
+ options: ObservationRuntimeOptions = {},
232
+ ): Promise<void> {
233
+ embeddingWriteMode = options.embeddingWriteMode ?? 'background';
234
+ embeddingWorkerProjectRoot = options.projectRoot;
203
235
  if (projectDir === dir) return;
204
236
  await initObservationStore(dir);
205
237
  const store = getObservationStore();
@@ -210,6 +242,70 @@ export async function initObservations(dir: string): Promise<void> {
210
242
  vectorSchemaUpgradePromise = null;
211
243
  }
212
244
 
245
+ function scheduleObservationEmbedding(input: {
246
+ observationId: number;
247
+ projectId: string;
248
+ searchableText: string;
249
+ doc: MemorixDocument;
250
+ }): void {
251
+ const { observationId, projectId, searchableText, doc } = input;
252
+ vectorMissingIds.add(observationId);
253
+
254
+ // A network fetch keeps Node's event loop alive even when its Promise is not
255
+ // awaited. One-shot CLI commands therefore persist first and let a detached
256
+ // worker consume the same durable queue.
257
+ if (embeddingWriteMode === 'deferred') {
258
+ if (isEmbeddingExplicitlyDisabled()) {
259
+ vectorMissingIds.delete(observationId);
260
+ return;
261
+ }
262
+ queueVectorBackfill(projectId, {
263
+ detachedWorker: true,
264
+ projectRoot: embeddingWorkerProjectRoot,
265
+ });
266
+ return;
267
+ }
268
+
269
+ void generateEmbedding(searchableText).then(async (embedding) => {
270
+ if (embedding) {
271
+ if (!isVectorCompatibleWithCurrentIndex(embedding)) {
272
+ await upgradeVectorSchemaAfterFirstEmbedding(embedding);
273
+ }
274
+ if (!isVectorCompatibleWithCurrentIndex(embedding)) {
275
+ const vectorDimensions = getVectorDimensions();
276
+ console.error(
277
+ `[memorix] Embedding dimension mismatch for obs-${observationId}: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d (kept in backfill queue)`,
278
+ );
279
+ queueVectorBackfill(projectId);
280
+ return;
281
+ }
282
+ try {
283
+ await removeObservation(makeOramaObservationId(projectId, observationId));
284
+ await insertObservation(Object.assign({}, doc, { embedding }));
285
+ vectorMissingIds.delete(observationId);
286
+ } catch {
287
+ console.error(`[memorix] Embedding index update failed for obs-${observationId} (kept in backfill queue)`);
288
+ queueVectorBackfill(projectId);
289
+ }
290
+ } else if (isEmbeddingExplicitlyDisabled()) {
291
+ vectorMissingIds.delete(observationId);
292
+ } else {
293
+ queueVectorBackfill(projectId);
294
+ logEmbeddingFailureOnce(
295
+ 'provider-unavailable',
296
+ `[memorix] Embedding provider unavailable (using BM25 until embedding recovers; queued obs-${observationId} for retry)`,
297
+ );
298
+ }
299
+ }).catch((err) => {
300
+ queueVectorBackfill(projectId);
301
+ const failure = normalizeEmbeddingFailure(err);
302
+ logEmbeddingFailureOnce(
303
+ failure.key,
304
+ `[memorix] Async embedding failed (using BM25 until embedding recovers; queued obs-${observationId} for retry): ${failure.message}`,
305
+ );
306
+ });
307
+ }
308
+
213
309
  /**
214
310
  * Check cross-process freshness and reload if another process has written.
215
311
  *
@@ -507,49 +603,11 @@ export async function storeObservation(input: {
507
603
  queueClaimDerivation(observation);
508
604
  }
509
605
 
510
- // Generate embedding async (fire-and-forget) — never blocks MCP response
511
- // Track in vectorMissingIds until embedding is successfully written.
512
- const obsId = observation.id;
513
- vectorMissingIds.add(obsId);
514
- const searchableText = [input.title, input.narrative, ...(input.facts ?? [])].join(' ');
515
- generateEmbedding(searchableText).then(async (embedding) => {
516
- if (embedding) {
517
- if (!isVectorCompatibleWithCurrentIndex(embedding)) {
518
- await upgradeVectorSchemaAfterFirstEmbedding(embedding);
519
- }
520
- if (!isVectorCompatibleWithCurrentIndex(embedding)) {
521
- const vectorDimensions = getVectorDimensions();
522
- console.error(
523
- `[memorix] Embedding dimension mismatch for obs-${obsId}: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d (kept in backfill queue)`,
524
- );
525
- queueVectorBackfill(input.projectId);
526
- return;
527
- }
528
- try {
529
- const { removeObservation: removeObs } = await import('../store/orama-store.js');
530
- await removeObs(makeOramaObservationId(input.projectId, obsId));
531
- await insertObservation(Object.assign({}, doc, { embedding }));
532
- vectorMissingIds.delete(obsId);
533
- } catch {
534
- console.error(`[memorix] Embedding index update failed for obs-${obsId} (kept in backfill queue)`);
535
- queueVectorBackfill(input.projectId);
536
- }
537
- } else if (isEmbeddingExplicitlyDisabled()) {
538
- vectorMissingIds.delete(obsId);
539
- } else {
540
- queueVectorBackfill(input.projectId);
541
- logEmbeddingFailureOnce(
542
- 'provider-unavailable',
543
- `[memorix] Embedding provider unavailable (using BM25 until embedding recovers; queued obs-${obsId} for retry)`,
544
- );
545
- }
546
- }).catch((err) => {
547
- queueVectorBackfill(input.projectId);
548
- const failure = normalizeEmbeddingFailure(err);
549
- logEmbeddingFailureOnce(
550
- failure.key,
551
- `[memorix] Async embedding failed (using BM25 until embedding recovers; queued obs-${obsId} for retry): ${failure.message}`,
552
- );
606
+ scheduleObservationEmbedding({
607
+ observationId: observation.id,
608
+ projectId: input.projectId,
609
+ searchableText: [input.title, input.narrative, ...(input.facts ?? [])].join(' '),
610
+ doc,
553
611
  });
554
612
 
555
613
  return { observation, upserted: false };
@@ -678,40 +736,11 @@ async function upsertObservation(
678
736
  queueClaimDerivation(existing);
679
737
  }
680
738
 
681
- // Generate embedding async (fire-and-forget) — never blocks MCP response
682
- const searchableText = [input.title, input.narrative, ...(input.facts ?? [])].join(' ');
683
- const obsId = existing.id;
684
- vectorMissingIds.add(obsId);
685
- generateEmbedding(searchableText).then(async (embedding) => {
686
- if (embedding) {
687
- if (!isVectorCompatibleWithCurrentIndex(embedding)) {
688
- await upgradeVectorSchemaAfterFirstEmbedding(embedding);
689
- }
690
- if (!isVectorCompatibleWithCurrentIndex(embedding)) {
691
- queueVectorBackfill(existing.projectId);
692
- return;
693
- }
694
- try {
695
- const { removeObservation: removeObs } = await import('../store/orama-store.js');
696
- await removeObs(makeOramaObservationId(existing.projectId, obsId));
697
- await insertObservation(Object.assign({}, doc, { embedding }));
698
- vectorMissingIds.delete(obsId);
699
- } catch {
700
- // Embedding index update failed — observation still persisted without vector
701
- queueVectorBackfill(existing.projectId);
702
- }
703
- } else if (isEmbeddingExplicitlyDisabled()) {
704
- vectorMissingIds.delete(obsId);
705
- } else {
706
- queueVectorBackfill(existing.projectId);
707
- }
708
- }).catch((err) => {
709
- queueVectorBackfill(existing.projectId);
710
- const failure = normalizeEmbeddingFailure(err);
711
- logEmbeddingFailureOnce(
712
- failure.key,
713
- `[memorix] Async embedding failed (using BM25 until embedding recovers; queued obs-${obsId} for retry): ${failure.message}`,
714
- );
739
+ scheduleObservationEmbedding({
740
+ observationId: existing.id,
741
+ projectId: existing.projectId,
742
+ searchableText: [input.title, input.narrative, ...(input.facts ?? [])].join(' '),
743
+ doc,
715
744
  });
716
745
 
717
746
  return existing;