memorix 1.2.7 → 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cli/index.js +19531 -19017
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/dashboard/static/app.js +7 -2
  5. package/dist/index.js +7870 -7497
  6. package/dist/index.js.map +1 -1
  7. package/dist/maintenance-jobs-o1rYSFcM.d.ts +36 -0
  8. package/dist/maintenance-runner.d.ts +1 -28
  9. package/dist/maintenance-runner.js +5937 -5596
  10. package/dist/maintenance-runner.js.map +1 -1
  11. package/dist/memcode-runtime/CHANGELOG.md +15 -0
  12. package/dist/sdk.js +7866 -7493
  13. package/dist/sdk.js.map +1 -1
  14. package/dist/vector-backfill-runner.d.ts +31 -0
  15. package/dist/vector-backfill-runner.js +12670 -0
  16. package/dist/vector-backfill-runner.js.map +1 -0
  17. package/docs/dev-log/progress.txt +25 -0
  18. package/package.json +3 -3
  19. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  20. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  21. package/plugins/copilot/memorix/plugin.json +1 -1
  22. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  23. package/plugins/omp/memorix/package.json +1 -1
  24. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  25. package/plugins/pi/memorix/package.json +1 -1
  26. package/src/cli/commands/agent-integrations.ts +102 -5
  27. package/src/cli/commands/operator-shared.ts +4 -1
  28. package/src/cli/commands/serve-http.ts +58 -50
  29. package/src/cli/commands/setup.ts +44 -0
  30. package/src/dashboard/server.ts +54 -63
  31. package/src/memory/observations.ts +175 -99
  32. package/src/memory/retention.ts +106 -28
  33. package/src/memory/session.ts +71 -12
  34. package/src/runtime/maintenance-jobs.ts +84 -4
  35. package/src/runtime/project-maintenance.ts +63 -6
  36. package/src/runtime/vector-backfill-runner.ts +144 -0
  37. package/src/search/intent-detector.ts +39 -1
  38. package/src/server.ts +5 -5
@@ -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')
@@ -351,6 +349,17 @@ async function handleApi(
351
349
  sourceCounts,
352
350
  recentObservations: sorted,
353
351
  embedding: embeddingStatus,
352
+ // A standalone dashboard has no access to an active MCP
353
+ // process's in-memory Orama index. Keep this explicit so the
354
+ // UI never presents an empty local singleton as a healthy
355
+ // all-vectors-indexed result.
356
+ vectorStatus: {
357
+ available: false,
358
+ total: 0,
359
+ missing: 0,
360
+ missingIds: [],
361
+ backfillRunning: false,
362
+ },
354
363
  storage: storageInfo,
355
364
  maintenance,
356
365
  ...(lifecycle ? { lifecycle } : {}),
@@ -388,56 +397,38 @@ async function handleApi(
388
397
  const observations = filterDashboardObservations(
389
398
  await getObservationStore().loadByProject(effectiveProjectId, { status: 'active' }),
390
399
  effectiveProjectId,
391
- ) as Array<{
392
- id?: number;
393
- title?: string;
394
- type?: string;
395
- importance?: number;
396
- accessCount?: number;
397
- lastAccessedAt?: string;
398
- createdAt?: string;
399
- entityName?: string;
400
- }>;
401
-
402
- const now = Date.now();
403
- // Exclude probe from retention display -- not durable knowledge
404
- const scored = observations.filter(obs => obs.type !== 'probe').map((obs) => {
405
- const age = now - new Date(obs.createdAt || now).getTime();
406
- const ageHours = age / (1000 * 60 * 60);
407
- const importance = obs.importance ?? 5;
408
- const accessCount = obs.accessCount ?? 0;
409
-
410
- // Exponential decay: score = importance * e^(-λt) + access_bonus
411
- const lambda = 0.01;
412
- const decayScore = importance * Math.exp(-lambda * ageHours);
413
- const accessBonus = Math.min(accessCount * 0.5, 3);
414
- const score = Math.min(decayScore + accessBonus, 10);
415
-
416
- // Immune if importance >= 8 or type is 'gotcha' or 'decision'
417
- const isImmune = importance >= 8 || obs.type === 'gotcha' || obs.type === 'decision';
418
-
419
- return {
420
- id: obs.id,
421
- title: obs.title,
422
- type: obs.type,
423
- entityName: obs.entityName,
424
- score: Math.round(score * 100) / 100,
425
- isImmune,
426
- ageHours: Math.round(ageHours * 10) / 10,
427
- accessCount,
428
- };
429
- });
430
-
431
- // Sort by score descending
432
- scored.sort((a, b) => b.score - a.score);
400
+ );
433
401
 
434
- const activeCount = scored.filter((s) => s.score >= 3).length;
435
- const staleCount = scored.filter((s) => s.score < 3 && s.score >= 1).length;
436
- const archiveCount = scored.filter((s) => s.score < 1).length;
437
- 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
+ }));
438
424
 
439
425
  sendJson(res, {
440
- 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
+ },
441
432
  items: scored,
442
433
  });
443
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.
@@ -102,7 +113,14 @@ function normalizeEmbeddingFailure(error: unknown): { key: string; message: stri
102
113
  };
103
114
  }
104
115
 
105
- function queueVectorBackfill(projectId: string): void {
116
+ function vectorBackfillError(error: unknown): string {
117
+ return sanitizeCredentials(normalizeEmbeddingFailure(error).message).slice(0, 1_000);
118
+ }
119
+
120
+ function queueVectorBackfill(
121
+ projectId: string,
122
+ options: { detachedWorker?: boolean; projectRoot?: string } = {},
123
+ ): void {
106
124
  const dataDir = projectDir;
107
125
  if (!dataDir) return;
108
126
  void import('../runtime/maintenance-jobs.js')
@@ -113,6 +131,19 @@ function queueVectorBackfill(projectId: string): void {
113
131
  dedupeKey: 'vector-backfill',
114
132
  payload: { limit: 12 },
115
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
+ }
116
147
  })
117
148
  .catch(() => {
118
149
  // Memory writes remain durable even when the optional maintenance queue is unavailable.
@@ -195,7 +226,12 @@ async function upgradeVectorSchemaAfterFirstEmbedding(embedding: number[]): Prom
195
226
  * Initialize the observations manager with a project directory.
196
227
  * Auto-initializes the ObservationStore if not already set.
197
228
  */
198
- 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;
199
235
  if (projectDir === dir) return;
200
236
  await initObservationStore(dir);
201
237
  const store = getObservationStore();
@@ -206,6 +242,70 @@ export async function initObservations(dir: string): Promise<void> {
206
242
  vectorSchemaUpgradePromise = null;
207
243
  }
208
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
+
209
309
  /**
210
310
  * Check cross-process freshness and reload if another process has written.
211
311
  *
@@ -503,49 +603,11 @@ export async function storeObservation(input: {
503
603
  queueClaimDerivation(observation);
504
604
  }
505
605
 
506
- // Generate embedding async (fire-and-forget) — never blocks MCP response
507
- // Track in vectorMissingIds until embedding is successfully written.
508
- const obsId = observation.id;
509
- vectorMissingIds.add(obsId);
510
- const searchableText = [input.title, input.narrative, ...(input.facts ?? [])].join(' ');
511
- generateEmbedding(searchableText).then(async (embedding) => {
512
- if (embedding) {
513
- if (!isVectorCompatibleWithCurrentIndex(embedding)) {
514
- await upgradeVectorSchemaAfterFirstEmbedding(embedding);
515
- }
516
- if (!isVectorCompatibleWithCurrentIndex(embedding)) {
517
- const vectorDimensions = getVectorDimensions();
518
- console.error(
519
- `[memorix] Embedding dimension mismatch for obs-${obsId}: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d (kept in backfill queue)`,
520
- );
521
- queueVectorBackfill(input.projectId);
522
- return;
523
- }
524
- try {
525
- const { removeObservation: removeObs } = await import('../store/orama-store.js');
526
- await removeObs(makeOramaObservationId(input.projectId, obsId));
527
- await insertObservation(Object.assign({}, doc, { embedding }));
528
- vectorMissingIds.delete(obsId);
529
- } catch {
530
- console.error(`[memorix] Embedding index update failed for obs-${obsId} (kept in backfill queue)`);
531
- queueVectorBackfill(input.projectId);
532
- }
533
- } else if (isEmbeddingExplicitlyDisabled()) {
534
- vectorMissingIds.delete(obsId);
535
- } else {
536
- queueVectorBackfill(input.projectId);
537
- logEmbeddingFailureOnce(
538
- 'provider-unavailable',
539
- `[memorix] Embedding provider unavailable (using BM25 until embedding recovers; queued obs-${obsId} for retry)`,
540
- );
541
- }
542
- }).catch((err) => {
543
- queueVectorBackfill(input.projectId);
544
- const failure = normalizeEmbeddingFailure(err);
545
- logEmbeddingFailureOnce(
546
- failure.key,
547
- `[memorix] Async embedding failed (using BM25 until embedding recovers; queued obs-${obsId} for retry): ${failure.message}`,
548
- );
606
+ scheduleObservationEmbedding({
607
+ observationId: observation.id,
608
+ projectId: input.projectId,
609
+ searchableText: [input.title, input.narrative, ...(input.facts ?? [])].join(' '),
610
+ doc,
549
611
  });
550
612
 
551
613
  return { observation, upserted: false };
@@ -674,40 +736,11 @@ async function upsertObservation(
674
736
  queueClaimDerivation(existing);
675
737
  }
676
738
 
677
- // Generate embedding async (fire-and-forget) — never blocks MCP response
678
- const searchableText = [input.title, input.narrative, ...(input.facts ?? [])].join(' ');
679
- const obsId = existing.id;
680
- vectorMissingIds.add(obsId);
681
- generateEmbedding(searchableText).then(async (embedding) => {
682
- if (embedding) {
683
- if (!isVectorCompatibleWithCurrentIndex(embedding)) {
684
- await upgradeVectorSchemaAfterFirstEmbedding(embedding);
685
- }
686
- if (!isVectorCompatibleWithCurrentIndex(embedding)) {
687
- queueVectorBackfill(existing.projectId);
688
- return;
689
- }
690
- try {
691
- const { removeObservation: removeObs } = await import('../store/orama-store.js');
692
- await removeObs(makeOramaObservationId(existing.projectId, obsId));
693
- await insertObservation(Object.assign({}, doc, { embedding }));
694
- vectorMissingIds.delete(obsId);
695
- } catch {
696
- // Embedding index update failed — observation still persisted without vector
697
- queueVectorBackfill(existing.projectId);
698
- }
699
- } else if (isEmbeddingExplicitlyDisabled()) {
700
- vectorMissingIds.delete(obsId);
701
- } else {
702
- queueVectorBackfill(existing.projectId);
703
- }
704
- }).catch((err) => {
705
- queueVectorBackfill(existing.projectId);
706
- const failure = normalizeEmbeddingFailure(err);
707
- logEmbeddingFailureOnce(
708
- failure.key,
709
- `[memorix] Async embedding failed (using BM25 until embedding recovers; queued obs-${obsId} for retry): ${failure.message}`,
710
- );
739
+ scheduleObservationEmbedding({
740
+ observationId: existing.id,
741
+ projectId: existing.projectId,
742
+ searchableText: [input.title, input.narrative, ...(input.facts ?? [])].join(' '),
743
+ doc,
711
744
  });
712
745
 
713
746
  return existing;
@@ -1202,6 +1235,7 @@ export async function backfillVectorEmbeddings(options: {
1202
1235
  attempted: number;
1203
1236
  succeeded: number;
1204
1237
  failed: number;
1238
+ lastError?: string;
1205
1239
  }> {
1206
1240
  if (vectorBackfillRunning) {
1207
1241
  return { attempted: 0, succeeded: 0, failed: 0 };
@@ -1218,19 +1252,63 @@ export async function backfillVectorEmbeddings(options: {
1218
1252
  let succeeded = 0;
1219
1253
  let failed = 0;
1220
1254
  let lastFailure: string | undefined;
1255
+ const recordFailure = (message: string): void => {
1256
+ // A single batch may have several symptoms after one root cause. Preserve
1257
+ // the first one so an informative provider/index error is not overwritten
1258
+ // by later missing-result fallout.
1259
+ if (!lastFailure) lastFailure = message;
1260
+ };
1261
+
1262
+ const candidates: Array<{ id: number; observation: Observation; text: string }> = [];
1263
+ for (const id of ids) {
1264
+ const observation = observationById.get(id);
1265
+ if (!observation) {
1266
+ vectorMissingIds.delete(id);
1267
+ continue;
1268
+ }
1269
+ candidates.push({
1270
+ id,
1271
+ observation,
1272
+ text: [observation.title, observation.narrative, ...observation.facts].join(' '),
1273
+ });
1274
+ }
1221
1275
 
1222
1276
  try {
1223
- for (const id of ids) {
1224
- const obs = observationById.get(id);
1225
- if (!obs) {
1226
- vectorMissingIds.delete(id);
1227
- continue;
1277
+ if (candidates.length === 0) {
1278
+ return { attempted: ids.length, succeeded, failed };
1279
+ }
1280
+
1281
+ let embeddings: number[][] | null = null;
1282
+ try {
1283
+ const provider = await getEmbeddingProvider();
1284
+ if (!provider) {
1285
+ if (isEmbeddingExplicitlyDisabled()) {
1286
+ for (const candidate of candidates) vectorMissingIds.delete(candidate.id);
1287
+ } else {
1288
+ recordFailure('embedding provider unavailable');
1289
+ failed += candidates.length;
1290
+ }
1291
+ } else {
1292
+ // API providers batch and cache this efficiently; local providers can use
1293
+ // their native batch path. Index writes stay sequential because Orama is
1294
+ // process-local mutable state.
1295
+ embeddings = await provider.embedBatch(candidates.map((candidate) => candidate.text));
1228
1296
  }
1297
+ } catch (error) {
1298
+ recordFailure(vectorBackfillError(error));
1299
+ failed += candidates.length;
1300
+ }
1229
1301
 
1230
- const text = [obs.title, obs.narrative, ...obs.facts].join(' ');
1231
- try {
1232
- const embedding = await generateEmbedding(text);
1233
- if (embedding) {
1302
+ if (embeddings) {
1303
+ for (let index = 0; index < candidates.length; index++) {
1304
+ const { id, observation: obs } = candidates[index];
1305
+ const embedding = embeddings[index];
1306
+ if (!embedding || embedding.length === 0) {
1307
+ recordFailure('embedding provider returned no vector');
1308
+ failed++;
1309
+ continue;
1310
+ }
1311
+ try {
1234
1312
  if (!isVectorCompatibleWithCurrentIndex(embedding)) {
1235
1313
  await upgradeVectorSchemaAfterFirstEmbedding(embedding);
1236
1314
  }
@@ -1239,7 +1317,7 @@ export async function backfillVectorEmbeddings(options: {
1239
1317
  console.error(
1240
1318
  `[memorix] Backfill embedding mismatch for obs-${id}: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d (kept in queue)`,
1241
1319
  );
1242
- lastFailure = `dimension mismatch: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d`;
1320
+ recordFailure(`dimension mismatch: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d`);
1243
1321
  failed++;
1244
1322
  continue;
1245
1323
  }
@@ -1277,17 +1355,10 @@ export async function backfillVectorEmbeddings(options: {
1277
1355
  await insertObservation(doc);
1278
1356
  vectorMissingIds.delete(id);
1279
1357
  succeeded++;
1280
- } else if (isEmbeddingExplicitlyDisabled()) {
1281
- // Embedding explicitly off — nothing to backfill from
1282
- vectorMissingIds.delete(id);
1283
- } else {
1284
- // Provider temporarily unavailable — keep in queue for next backfill cycle
1285
- lastFailure = 'embedding provider unavailable';
1358
+ } catch (error) {
1359
+ recordFailure(vectorBackfillError(error));
1286
1360
  failed++;
1287
1361
  }
1288
- } catch (err) {
1289
- lastFailure = err instanceof Error ? err.message : String(err);
1290
- failed++;
1291
1362
  }
1292
1363
  }
1293
1364
  } finally {
@@ -1301,5 +1372,10 @@ export async function backfillVectorEmbeddings(options: {
1301
1372
  };
1302
1373
  }
1303
1374
 
1304
- return { attempted: ids.length, succeeded, failed };
1375
+ return {
1376
+ attempted: ids.length,
1377
+ succeeded,
1378
+ failed,
1379
+ ...(lastFailure ? { lastError: lastFailure } : {}),
1380
+ };
1305
1381
  }