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
@@ -319,6 +319,40 @@ export interface RetentionExplanation {
319
319
  summary: string;
320
320
  }
321
321
 
322
+ /** Access metadata maintained outside the persisted observation record. */
323
+ export type ObservationAccessMap = ReadonlyMap<number, {
324
+ accessCount: number;
325
+ lastAccessedAt: string;
326
+ }>;
327
+
328
+ /**
329
+ * One canonical retention result for a persisted observation.
330
+ *
331
+ * Consumers should use this projection instead of recreating a local decay
332
+ * formula. That keeps the UI, HTTP control plane, and archive worker aligned
333
+ * on the same retention state.
334
+ */
335
+ export interface RetentionProjection extends RetentionExplanation {
336
+ relevance: RelevanceScore;
337
+ /** A backwards-compatible 0-10 display value derived from relevance. */
338
+ displayScore: number;
339
+ ageHours: number;
340
+ accessCount: number;
341
+ lastAccessedAt: string;
342
+ }
343
+
344
+ export interface ObservationRetentionProjectionOptions {
345
+ referenceTime?: Date;
346
+ accessMap?: ObservationAccessMap;
347
+ }
348
+
349
+ export interface RetentionProjectionSummary {
350
+ active: number;
351
+ stale: number;
352
+ archiveCandidates: number;
353
+ immune: number;
354
+ }
355
+
322
356
  /**
323
357
  * Produce a structured explanation of why an observation has its current
324
358
  * retention posture. Designed for operator-facing reporting.
@@ -371,27 +405,16 @@ export function explainRetention(
371
405
  };
372
406
  }
373
407
 
374
- // ── Auto-Archive ────────────────────────────────────────────────────
375
-
376
- export interface ArchiveExpiredBatchOptions {
377
- projectId: string;
378
- afterId?: number;
379
- limit?: number;
380
- referenceTime?: Date;
381
- accessMap?: Map<number, { accessCount: number; lastAccessedAt: string }>;
382
- /** Omit only for trusted background maintenance. */
383
- reader?: ObservationReader;
384
- }
385
-
386
- export interface ArchiveExpiredBatchResult {
387
- archived: number;
388
- scanned: number;
389
- nextCursor?: number;
390
- }
391
-
392
- function toRetentionDocument(
408
+ /**
409
+ * Convert a stored observation into the search/retention document shape.
410
+ *
411
+ * Observation fields such as concepts are arrays, while the retrieval schema
412
+ * intentionally stores them as strings. Centralising that conversion prevents
413
+ * callers from accidentally feeding raw observations into retention helpers.
414
+ */
415
+ export function toRetentionDocument(
393
416
  obs: Observation,
394
- accessMap?: Map<number, { accessCount: number; lastAccessedAt: string }>,
417
+ accessMap?: ObservationAccessMap,
395
418
  ): MemorixDocument {
396
419
  const access = accessMap?.get(obs.id);
397
420
  return {
@@ -418,6 +441,62 @@ function toRetentionDocument(
418
441
  };
419
442
  }
420
443
 
444
+ /** Build the canonical retention result used by every Observation consumer. */
445
+ export function projectObservationRetention(
446
+ observation: Observation,
447
+ options: ObservationRetentionProjectionOptions = {},
448
+ ): RetentionProjection {
449
+ const document = toRetentionDocument(observation, options.accessMap);
450
+ const relevance = calculateRelevance(document, options.referenceTime);
451
+ const explanation = explainRetention(document, options.referenceTime);
452
+
453
+ return {
454
+ ...explanation,
455
+ relevance,
456
+ displayScore: Math.round(Math.min(10, relevance.totalScore * 10) * 100) / 100,
457
+ ageHours: Math.round(relevance.ageDays * 24 * 10) / 10,
458
+ accessCount: document.accessCount,
459
+ lastAccessedAt: document.lastAccessedAt,
460
+ };
461
+ }
462
+
463
+ /** Summarise canonical projections without reimplementing retention thresholds. */
464
+ export function summarizeRetentionProjections(
465
+ projections: readonly Pick<RetentionProjection, 'zone' | 'immune'>[],
466
+ ): RetentionProjectionSummary {
467
+ let active = 0;
468
+ let stale = 0;
469
+ let archiveCandidates = 0;
470
+ let immune = 0;
471
+
472
+ for (const projection of projections) {
473
+ if (projection.zone === 'active') active++;
474
+ else if (projection.zone === 'stale') stale++;
475
+ else archiveCandidates++;
476
+ if (projection.immune) immune++;
477
+ }
478
+
479
+ return { active, stale, archiveCandidates, immune };
480
+ }
481
+
482
+ // ── Auto-Archive ────────────────────────────────────────────────────
483
+
484
+ export interface ArchiveExpiredBatchOptions {
485
+ projectId: string;
486
+ afterId?: number;
487
+ limit?: number;
488
+ referenceTime?: Date;
489
+ accessMap?: ObservationAccessMap;
490
+ /** Omit only for trusted background maintenance. */
491
+ reader?: ObservationReader;
492
+ }
493
+
494
+ export interface ArchiveExpiredBatchResult {
495
+ archived: number;
496
+ scanned: number;
497
+ nextCursor?: number;
498
+ }
499
+
421
500
  /**
422
501
  * Archive one bounded page of a project's active memories. The caller owns the
423
502
  * cursor, which makes the operation safe to run from a durable maintenance job.
@@ -438,10 +517,10 @@ export async function archiveExpiredBatch(
438
517
  const scanned = hasMore ? page.slice(0, limit) : page;
439
518
  const candidateIds = scanned
440
519
  .filter((observation) => !options.reader || canManageObservation(observation, options.reader))
441
- .filter((observation) => getRetentionZone(
442
- toRetentionDocument(observation, options.accessMap),
443
- options.referenceTime,
444
- ) === 'archive-candidate')
520
+ .filter((observation) => projectObservationRetention(observation, {
521
+ accessMap: options.accessMap,
522
+ referenceTime: options.referenceTime,
523
+ }).zone === 'archive-candidate')
445
524
  .map((observation) => observation.id);
446
525
 
447
526
  const archived = candidateIds.length === 0
@@ -474,7 +553,7 @@ export async function archiveExpiredBatch(
474
553
  export async function archiveExpired(
475
554
  projectDir: string,
476
555
  referenceTime?: Date,
477
- accessMap?: Map<number, { accessCount: number; lastAccessedAt: string }>,
556
+ accessMap?: ObservationAccessMap,
478
557
  projectId?: string,
479
558
  reader?: ObservationReader,
480
559
  ): Promise<{ archived: number; remaining: number }> {
@@ -512,9 +591,8 @@ export async function archiveExpired(
512
591
  const archivedIds: number[] = [];
513
592
 
514
593
  for (const obs of activeObs) {
515
- const doc = toRetentionDocument(obs, accessMap);
516
- const zone = getRetentionZone(doc, referenceTime);
517
- if (zone === 'archive-candidate') {
594
+ const retention = projectObservationRetention(obs, { accessMap, referenceTime });
595
+ if (retention.zone === 'archive-candidate') {
518
596
  archivedIds.push(obs.id);
519
597
  }
520
598
  }
@@ -21,7 +21,18 @@ import { KnowledgeGraphManager } from './graph.js';
21
21
  import { redactCredentials, sanitizeCredentials } from './secret-filter.js';
22
22
  import { canReadObservation } from './visibility.js';
23
23
 
24
- const PRIORITY_TYPES = new Set(['gotcha', 'decision', 'problem-solution', 'trade-off', 'discovery']);
24
+ // Types eligible for L2 "Key Project Memories" injection. Session requests carry
25
+ // the original goal and change records carry the latest outcome, so both matter
26
+ // when an agent resumes work after a handoff.
27
+ const PRIORITY_TYPES = new Set([
28
+ 'gotcha',
29
+ 'decision',
30
+ 'problem-solution',
31
+ 'trade-off',
32
+ 'discovery',
33
+ 'session-request',
34
+ 'what-changed',
35
+ ]);
25
36
  const RESUME_TYPES = new Set([
26
37
  'gotcha',
27
38
  'decision',
@@ -31,7 +42,6 @@ const RESUME_TYPES = new Set([
31
42
  'how-it-works',
32
43
  'what-changed',
33
44
  'reasoning',
34
- 'session-request',
35
45
  ]);
36
46
  const TYPE_EMOJI: Record<string, string> = {
37
47
  'gotcha': '[DISCOVERY]',
@@ -50,19 +60,24 @@ const TYPE_WEIGHTS: Record<string, number> = {
50
60
  'decision': 5.5,
51
61
  'problem-solution': 5.25,
52
62
  'trade-off': 4.75,
63
+ 'session-request': 4.5,
53
64
  'discovery': 4.25,
65
+ 'what-changed': 4,
54
66
  };
67
+ // Markers that identify a memory as a demo/scratch artifact rather than real
68
+ // working context. These are TITLE conventions — see isNoiseObservation.
69
+ //
70
+ // Deliberately excluded: 验证 / 兼容 / compat / 交接 / handoff. Those are ordinary
71
+ // engineering vocabulary, not noise signals. Matching them silently dropped real
72
+ // memories: any Chinese note about verification or compatibility, and any handoff
73
+ // note that merely named a handoff tool, disappeared from session context with no
74
+ // diagnostic. A noise filter must not veto the words its users normally write.
55
75
  const NOISE_PATTERNS = [
56
76
  /\[测试\]/i,
57
77
  /\[test\]/i,
58
- /验证/i,
59
- /兼容/i,
60
- /\bcompat(?:ibility)?\b/i,
61
78
  /\bdemo\b/i,
62
79
  /展示/i,
63
80
  /全能力/i,
64
- /handoff/i,
65
- /交接/i,
66
81
  /for_memmcp_test/i,
67
82
  /\bbenchmark\b/i,
68
83
  /\bsandbox\b/i,
@@ -180,8 +195,13 @@ function isCommandTrace(obs: Observation): boolean {
180
195
  }
181
196
 
182
197
  function isNoiseObservation(obs: Observation): boolean {
183
- const text = stringifyObservation(obs, false);
184
- return NOISE_PATTERNS.some((pattern) => pattern.test(text)) || isCommandTrace(obs);
198
+ // Match the title only. Demo/scratch markers are a titling convention, so scanning
199
+ // the narrative/facts/concepts produced false positives on real memories that merely
200
+ // *mentioned* one of these words in prose — and because this filter runs before the
201
+ // PRIORITY_TYPES check and drops the observation outright (not a score penalty), a
202
+ // single unlucky word in a long narrative made the whole memory unreachable.
203
+ const title = obs.title ?? '';
204
+ return NOISE_PATTERNS.some((pattern) => pattern.test(title)) || isCommandTrace(obs);
185
205
  }
186
206
 
187
207
  function isSystemSelfObservation(obs: Observation): boolean {
@@ -321,6 +341,42 @@ export async function getSessionResumeBrief(
321
341
  };
322
342
  }
323
343
 
344
+ const AUTO_RESUME_SUMMARY_LIMIT = 480;
345
+ const AUTO_RESUME_TITLE_LIMIT = 180;
346
+
347
+ function compactResumeText(text: string, limit: number): string {
348
+ const normalized = text.replace(/\s+/g, ' ').trim();
349
+ return normalized.length <= limit ? normalized : `${normalized.slice(0, Math.max(0, limit - 3)).trimEnd()}...`;
350
+ }
351
+
352
+ /**
353
+ * Render the bounded automatic delivery form of a resume brief.
354
+ *
355
+ * Full session context remains available through getSessionContext() and
356
+ * individual memory details remain on demand. Automatic session start gets an
357
+ * index, not a transcript, so it cannot crowd out the new task's context.
358
+ */
359
+ export function renderSessionResumeCard(brief: SessionResumeBrief): string {
360
+ if (!brief.previousSession && brief.memories.length === 0) return '';
361
+
362
+ const lines = ['## Memorix Resume'];
363
+ if (brief.previousSession) {
364
+ const agent = brief.previousSession.agent ? ` (${brief.previousSession.agent})` : '';
365
+ lines.push(`Previous session: ${brief.previousSession.id}${agent}`);
366
+ lines.push(compactResumeText(brief.previousSession.summary, AUTO_RESUME_SUMMARY_LIMIT));
367
+ }
368
+
369
+ if (brief.memories.length > 0) {
370
+ lines.push('', 'Relevant memory references:');
371
+ for (const memory of brief.memories) {
372
+ lines.push(`- #${memory.id} [${memory.type}] ${compactResumeText(memory.title, AUTO_RESUME_TITLE_LIMIT)}`);
373
+ }
374
+ lines.push('Read a listed memory only when its title is relevant to the current task.');
375
+ }
376
+
377
+ return lines.join('\n');
378
+ }
379
+
324
380
  export function scoreObservationForSessionContext(obs: Observation, projectTokens: string[], now = Date.now()): number {
325
381
  let score = TYPE_WEIGHTS[obs.type] ?? 1;
326
382
  const text = stringifyObservation(obs);
@@ -384,7 +440,7 @@ export function scoreObservationForSessionContext(obs: Observation, projectToken
384
440
  * so the agent can resume work without re-explaining everything.
385
441
  */
386
442
  export async function startSession(
387
- projectDir: string,
443
+ _projectDir: string,
388
444
  projectId: string,
389
445
  opts?: { sessionId?: string; agent?: string; reader?: ObservationReader },
390
446
  ): Promise<{ session: Session; previousContext: string }> {
@@ -399,8 +455,11 @@ export async function startSession(
399
455
  agent: opts?.agent,
400
456
  };
401
457
 
402
- // Load previous context before creating new session
403
- const previousContext = await getSessionContext(projectDir, projectId, 3, opts?.reader);
458
+ // Automatic delivery stays intentionally small. The explicit context tool can
459
+ // still provide the expanded packet when an agent chooses to inspect it.
460
+ const previousContext = renderSessionResumeCard(
461
+ await getSessionResumeBrief(projectId, undefined, opts?.reader),
462
+ );
404
463
 
405
464
  // Atomic rollover: complete all active sessions for this project's aliases
406
465
  // and insert the new session in a single SQLite transaction.
@@ -67,7 +67,18 @@ export interface MaintenanceJobSummary {
67
67
 
68
68
  export type MaintenanceJobRunResult =
69
69
  | { action: 'complete' }
70
- | { action: 'reschedule'; delayMs: number; resetAttempts?: boolean; payload?: Record<string, unknown> };
70
+ | {
71
+ action: 'reschedule';
72
+ delayMs: number;
73
+ resetAttempts?: boolean;
74
+ payload?: Record<string, unknown>;
75
+ /** Keep recoverable work out of the immediate pending queue during a cooldown. */
76
+ status?: 'pending' | 'retry';
77
+ /** Persist a sanitized diagnostic without consuming the job's retry budget. */
78
+ lastError?: string;
79
+ /** Clear a stale transient diagnostic after the job makes progress. */
80
+ clearLastError?: boolean;
81
+ };
71
82
 
72
83
  export type MaintenanceJobHandler = (
73
84
  job: MaintenanceJob,
@@ -160,6 +171,12 @@ export class MaintenanceJobStore {
160
171
  `).get(input.projectId, input.kind, dedupeKey);
161
172
 
162
173
  if (existing) {
174
+ // A vector backfill in cooldown is a circuit-breaker state. A new MCP
175
+ // process must not pull it forward and recreate the original retry storm.
176
+ if (input.kind === 'vector-backfill' && existing.status === 'retry') {
177
+ this.commit();
178
+ return rowToJob(existing);
179
+ }
163
180
  const nextRunAfter = Math.min(Number(existing.run_after), runAfter);
164
181
  this.db.prepare(`
165
182
  UPDATE maintenance_jobs
@@ -171,6 +188,30 @@ export class MaintenanceJobStore {
171
188
  return rowToJob(updated);
172
189
  }
173
190
 
191
+ // Older releases exhausted vector jobs on transient provider/index errors.
192
+ // Reuse one failed record so the next healthy session recovers work instead
193
+ // of adding another permanent failure row for the same project.
194
+ if (input.kind === 'vector-backfill') {
195
+ const failed = this.db.prepare(`
196
+ SELECT * FROM maintenance_jobs
197
+ WHERE project_id = ? AND kind = ? AND dedupe_key = ? AND status = 'failed'
198
+ ORDER BY updated_at DESC
199
+ LIMIT 1
200
+ `).get(input.projectId, input.kind, dedupeKey);
201
+ if (failed) {
202
+ this.db.prepare(`
203
+ UPDATE maintenance_jobs
204
+ SET status = 'pending', attempts = 0, max_attempts = ?, run_after = ?,
205
+ payload_json = ?, lease_owner = NULL, lease_expires_at = NULL,
206
+ last_error = NULL, completed_at = NULL, updated_at = ?
207
+ WHERE id = ?
208
+ `).run(maxAttempts, runAfter, payloadJson, now, failed.id);
209
+ const revived = this.getRow(failed.id)!;
210
+ this.commit();
211
+ return rowToJob(revived);
212
+ }
213
+ }
214
+
174
215
  const id = randomUUID();
175
216
  this.db.prepare(`
176
217
  INSERT INTO maintenance_jobs (
@@ -361,21 +402,57 @@ export class MaintenanceJobStore {
361
402
  reschedule(
362
403
  id: string,
363
404
  workerId: string,
364
- options: { delayMs: number; resetAttempts?: boolean; payload?: Record<string, unknown>; now?: number },
405
+ options: {
406
+ delayMs: number;
407
+ resetAttempts?: boolean;
408
+ payload?: Record<string, unknown>;
409
+ status?: 'pending' | 'retry';
410
+ lastError?: string;
411
+ clearLastError?: boolean;
412
+ now?: number;
413
+ },
365
414
  ): MaintenanceJob | undefined {
366
415
  const now = options.now ?? Date.now();
367
416
  const delayMs = Number.isFinite(options.delayMs) ? Math.max(0, Math.floor(options.delayMs)) : 0;
368
417
  const payloadJson = options.payload === undefined ? null : JSON.stringify(options.payload);
418
+ const status = options.status === 'retry' ? 'retry' : 'pending';
419
+ const hasLastError = options.lastError !== undefined;
420
+ const lastError = hasLastError ? errorText(options.lastError) : null;
369
421
  this.db.prepare(`
370
422
  UPDATE maintenance_jobs
371
- SET status = 'pending', run_after = ?, attempts = CASE WHEN ? THEN 0 ELSE attempts END,
423
+ SET status = ?, run_after = ?, attempts = CASE WHEN ? THEN 0 ELSE attempts END,
372
424
  payload_json = COALESCE(?, payload_json),
425
+ last_error = CASE
426
+ WHEN ? THEN NULL
427
+ WHEN ? THEN ?
428
+ ELSE last_error
429
+ END,
373
430
  lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
374
431
  WHERE id = ? AND status = 'running' AND lease_owner = ?
375
- `).run(now + delayMs, options.resetAttempts ? 1 : 0, payloadJson, now, id, workerId);
432
+ `).run(
433
+ status,
434
+ now + delayMs,
435
+ options.resetAttempts ? 1 : 0,
436
+ payloadJson,
437
+ options.clearLastError ? 1 : 0,
438
+ hasLastError ? 1 : 0,
439
+ lastError,
440
+ now,
441
+ id,
442
+ workerId,
443
+ );
376
444
  return this.get(id);
377
445
  }
378
446
 
447
+ resolveFailedVectorBackfills(projectId: string, dedupeKey = 'vector-backfill', now = Date.now()): number {
448
+ const result = this.db.prepare(`
449
+ UPDATE maintenance_jobs
450
+ SET status = 'completed', completed_at = ?, updated_at = ?
451
+ WHERE project_id = ? AND kind = 'vector-backfill' AND dedupe_key = ? AND status = 'failed'
452
+ `).run(now, now, projectId, dedupeKey);
453
+ return Number(result.changes ?? 0);
454
+ }
455
+
379
456
  fail(id: string, workerId: string, error: unknown, now = Date.now()): MaintenanceJob | undefined {
380
457
  this.begin();
381
458
  try {
@@ -486,6 +563,9 @@ export class MaintenanceJobWorker {
486
563
  delayMs: result.delayMs,
487
564
  resetAttempts: result.resetAttempts,
488
565
  payload: result.payload,
566
+ status: result.status,
567
+ lastError: result.lastError,
568
+ clearLastError: result.clearLastError,
489
569
  now,
490
570
  });
491
571
  return { state: 'rescheduled', job: updated };
@@ -1,6 +1,7 @@
1
- import type {
2
- MaintenanceJobHandler,
3
- MaintenanceJobRunResult,
1
+ import {
2
+ MaintenanceJobStore,
3
+ type MaintenanceJobHandler,
4
+ type MaintenanceJobRunResult,
4
5
  } from './maintenance-jobs.js';
5
6
  import { runMaintenanceInChildProcess } from './isolated-maintenance.js';
6
7
  import {
@@ -18,6 +19,8 @@ const DEFAULT_CODEGRAPH_MAX_FILES = 5_000;
18
19
  const DEFAULT_CLAIM_DERIVATION_BATCH_SIZE = 100;
19
20
  const DEFAULT_OBSERVATION_QUALIFICATION_BATCH_SIZE = 100;
20
21
  const VECTOR_RETRY_DELAY_MS = 5_000;
22
+ const VECTOR_FAILURE_BASE_RETRY_DELAY_MS = 60_000;
23
+ const VECTOR_FAILURE_MAX_RETRY_DELAY_MS = 15 * 60_000;
21
24
  const DEDUP_PER_PAIR_TIMEOUT_MS = 5_000;
22
25
 
23
26
  function vectorBatchSize(payload: Record<string, unknown>): number {
@@ -26,6 +29,33 @@ function vectorBatchSize(payload: Record<string, unknown>): number {
26
29
  return Math.min(100, Math.max(1, Math.floor(value)));
27
30
  }
28
31
 
32
+ function vectorBackfillFailureStreak(payload: Record<string, unknown>): number {
33
+ const value = payload.vectorBackfillFailureStreak;
34
+ return typeof value === 'number' && Number.isFinite(value)
35
+ ? Math.min(16, Math.max(0, Math.floor(value)))
36
+ : 0;
37
+ }
38
+
39
+ function vectorBackfillRetryDelayMs(streak: number): number {
40
+ return Math.min(
41
+ VECTOR_FAILURE_MAX_RETRY_DELAY_MS,
42
+ VECTOR_FAILURE_BASE_RETRY_DELAY_MS * (2 ** Math.max(0, streak - 1)),
43
+ );
44
+ }
45
+
46
+ function withoutVectorBackfillFailureStreak(payload: Record<string, unknown>): Record<string, unknown> {
47
+ const { vectorBackfillFailureStreak: _streak, ...rest } = payload;
48
+ return rest;
49
+ }
50
+
51
+ function resolveSupersededVectorFailures(projectDir: string, projectId: string): void {
52
+ try {
53
+ new MaintenanceJobStore(projectDir).resolveFailedVectorBackfills(projectId);
54
+ } catch {
55
+ // Health cleanup must never block the live memory path.
56
+ }
57
+ }
58
+
29
59
  function retentionBatchSize(payload: Record<string, unknown>): number {
30
60
  const value = payload.limit;
31
61
  if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_RETENTION_BATCH_SIZE;
@@ -498,23 +528,50 @@ export function createProjectMaintenanceHandler(
498
528
 
499
529
  const { backfillVectorEmbeddings, getVectorStatus } = await import('../memory/observations.js');
500
530
  const before = getVectorStatus(projectId);
501
- if (before.missing === 0) return { action: 'complete' };
531
+ if (before.missing === 0) {
532
+ resolveSupersededVectorFailures(projectDir, projectId);
533
+ return { action: 'complete' };
534
+ }
502
535
 
503
536
  const result = await backfillVectorEmbeddings({
504
537
  projectId,
505
538
  limit: vectorBatchSize(job.payload),
506
539
  });
507
540
  const after = getVectorStatus(projectId);
508
- if (after.missing === 0) return { action: 'complete' };
541
+ if (after.missing === 0) {
542
+ resolveSupersededVectorFailures(projectDir, projectId);
543
+ return { action: 'complete' };
544
+ }
509
545
 
510
546
  if (result.failed > 0 && result.succeeded === 0) {
511
- throw new Error(`vector backfill made no progress (${result.failed}/${result.attempted} failed)`);
547
+ const streak = vectorBackfillFailureStreak(job.payload) + 1;
548
+ return {
549
+ action: 'reschedule',
550
+ status: 'retry',
551
+ delayMs: vectorBackfillRetryDelayMs(streak),
552
+ // This is a recoverable provider/index state, not a terminal job error.
553
+ // Keeping one retry row prevents new MCP processes from creating a storm.
554
+ resetAttempts: true,
555
+ payload: {
556
+ ...job.payload,
557
+ vectorBackfillFailureStreak: streak,
558
+ },
559
+ lastError: result.lastError ?? `vector backfill made no progress (${result.failed}/${result.attempted} failed)`,
560
+ };
512
561
  }
513
562
 
563
+ const priorFailureStreak = vectorBackfillFailureStreak(job.payload);
564
+ const payload = withoutVectorBackfillFailureStreak(job.payload);
514
565
  return {
515
566
  action: 'reschedule',
516
567
  delayMs: result.attempted === 0 ? VECTOR_RETRY_DELAY_MS : 0,
517
568
  resetAttempts: result.succeeded > 0,
569
+ ...(result.succeeded > 0 ? {
570
+ ...(priorFailureStreak > 0 ? { payload } : {}),
571
+ ...(result.failed > 0 && result.lastError
572
+ ? { lastError: result.lastError }
573
+ : priorFailureStreak > 0 ? { clearLastError: true } : {}),
574
+ } : {}),
518
575
  };
519
576
  };
520
577
  }