claude-memory-layer 1.0.18 → 1.0.20

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 (42) hide show
  1. package/config/kpi-thresholds.json +7 -0
  2. package/dist/cli/index.js +532 -79
  3. package/dist/cli/index.js.map +3 -3
  4. package/dist/core/index.js +49 -4
  5. package/dist/core/index.js.map +2 -2
  6. package/dist/hooks/post-tool-use.js +140 -3
  7. package/dist/hooks/post-tool-use.js.map +2 -2
  8. package/dist/hooks/session-end.js +140 -3
  9. package/dist/hooks/session-end.js.map +2 -2
  10. package/dist/hooks/session-start.js +140 -3
  11. package/dist/hooks/session-start.js.map +2 -2
  12. package/dist/hooks/stop.js +140 -3
  13. package/dist/hooks/stop.js.map +2 -2
  14. package/dist/hooks/user-prompt-submit.js +379 -34
  15. package/dist/hooks/user-prompt-submit.js.map +3 -3
  16. package/dist/server/api/index.js +467 -34
  17. package/dist/server/api/index.js.map +3 -3
  18. package/dist/server/index.js +474 -41
  19. package/dist/server/index.js.map +3 -3
  20. package/dist/services/memory-service.js +140 -3
  21. package/dist/services/memory-service.js.map +2 -2
  22. package/dist/ui/app.js +362 -4
  23. package/dist/ui/index.html +90 -0
  24. package/dist/ui/style.css +41 -0
  25. package/memory/_index.md +3 -0
  26. package/memory/agent_response/uncategorized/2026-03-03.md +14 -0
  27. package/memory/session_summary/uncategorized/2026-03-03.md +5 -0
  28. package/memory/tool_observation/uncategorized/2026-03-03.md +21 -0
  29. package/package.json +3 -2
  30. package/scripts/delete-unknown-projects.js +154 -0
  31. package/src/cli/index.ts +23 -1
  32. package/src/core/embedder.ts +3 -2
  33. package/src/core/sqlite-event-store.ts +32 -0
  34. package/src/core/types.ts +2 -2
  35. package/src/core/vector-store.ts +20 -0
  36. package/src/hooks/user-prompt-submit.ts +225 -29
  37. package/src/server/api/events.ts +7 -0
  38. package/src/server/api/stats.ts +346 -0
  39. package/src/services/memory-service.ts +119 -2
  40. package/src/ui/app.js +362 -4
  41. package/src/ui/index.html +90 -0
  42. package/src/ui/style.css +41 -0
@@ -4,11 +4,213 @@
4
4
  */
5
5
 
6
6
  import { Hono } from 'hono';
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
7
9
  import { getMemoryServiceForProject } from '../../services/memory-service.js';
8
10
  import { getServiceFromQuery } from './utils.js';
11
+ import type { MemoryEvent } from '../../core/types.js';
9
12
 
10
13
  export const statsRouter = new Hono();
11
14
 
15
+ type KpiWindow = '24h' | '7d' | '30d';
16
+
17
+ type KpiThresholds = {
18
+ usefulRecallRateMin: number;
19
+ reworkRateMax: number;
20
+ postChangeFailureRateMax: number;
21
+ avgCompletionTurnsMax: number;
22
+ memoryHitRateMin: number;
23
+ };
24
+
25
+ const DEFAULT_KPI_THRESHOLDS: KpiThresholds = {
26
+ usefulRecallRateMin: 0.45,
27
+ reworkRateMax: 0.25,
28
+ postChangeFailureRateMax: 0.2,
29
+ avgCompletionTurnsMax: 12,
30
+ memoryHitRateMin: 0.35
31
+ };
32
+
33
+ function loadKpiThresholds(): KpiThresholds {
34
+ try {
35
+ const filePath = path.resolve(process.cwd(), 'config', 'kpi-thresholds.json');
36
+ if (!fs.existsSync(filePath)) return DEFAULT_KPI_THRESHOLDS;
37
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Partial<KpiThresholds>;
38
+ return {
39
+ usefulRecallRateMin: Number(parsed.usefulRecallRateMin ?? DEFAULT_KPI_THRESHOLDS.usefulRecallRateMin),
40
+ reworkRateMax: Number(parsed.reworkRateMax ?? DEFAULT_KPI_THRESHOLDS.reworkRateMax),
41
+ postChangeFailureRateMax: Number(parsed.postChangeFailureRateMax ?? DEFAULT_KPI_THRESHOLDS.postChangeFailureRateMax),
42
+ avgCompletionTurnsMax: Number(parsed.avgCompletionTurnsMax ?? DEFAULT_KPI_THRESHOLDS.avgCompletionTurnsMax),
43
+ memoryHitRateMin: Number(parsed.memoryHitRateMin ?? DEFAULT_KPI_THRESHOLDS.memoryHitRateMin)
44
+ };
45
+ } catch {
46
+ return DEFAULT_KPI_THRESHOLDS;
47
+ }
48
+ }
49
+
50
+ function windowToMs(window: KpiWindow): number {
51
+ if (window === '24h') return 24 * 60 * 60 * 1000;
52
+ if (window === '7d') return 7 * 24 * 60 * 60 * 1000;
53
+ return 30 * 24 * 60 * 60 * 1000;
54
+ }
55
+
56
+ function inWindow(e: MemoryEvent, now: number, window: KpiWindow): boolean {
57
+ return now - e.timestamp.getTime() <= windowToMs(window);
58
+ }
59
+
60
+ function isEditToolName(name: string): boolean {
61
+ return ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(name);
62
+ }
63
+
64
+ function parseToolPayload(e: MemoryEvent): { toolName?: string; success?: boolean; filePath?: string; command?: string } | null {
65
+ if (e.eventType !== 'tool_observation') return null;
66
+ try {
67
+ const payload = JSON.parse(e.content) as any;
68
+ return {
69
+ toolName: payload?.toolName,
70
+ success: payload?.success,
71
+ filePath: payload?.metadata?.filePath,
72
+ command: payload?.metadata?.command
73
+ };
74
+ } catch {
75
+ return {
76
+ toolName: (e.metadata as any)?.toolName,
77
+ success: (e.metadata as any)?.success,
78
+ filePath: (e.metadata as any)?.filePath,
79
+ command: (e.metadata as any)?.command
80
+ };
81
+ }
82
+ }
83
+
84
+ function isTestLikeCommand(command?: string): boolean {
85
+ if (!command) return false;
86
+ return /(test|jest|vitest|pytest|go test|cargo test|lint|eslint|build|tsc)/i.test(command);
87
+ }
88
+
89
+ function safeRatio(num: number, den: number): number {
90
+ if (!Number.isFinite(num) || !Number.isFinite(den) || den <= 0) return 0;
91
+ return num / den;
92
+ }
93
+
94
+ function round(value: number, digits = 4): number {
95
+ const factor = 10 ** digits;
96
+ return Math.round(value * factor) / factor;
97
+ }
98
+
99
+ function computeSessionTurnCount(sessionEvents: MemoryEvent[]): number {
100
+ const turnIds = new Set<string>();
101
+ for (const e of sessionEvents) {
102
+ const turnId = (e.metadata as any)?.turnId;
103
+ if (typeof turnId === 'string' && turnId.length > 0) turnIds.add(turnId);
104
+ }
105
+ if (turnIds.size > 0) return turnIds.size;
106
+ return sessionEvents.filter((e) => e.eventType === 'user_prompt').length;
107
+ }
108
+
109
+ type KpiMetrics = {
110
+ memoryHitRate: number;
111
+ usefulRecallRate: number;
112
+ avgCompletionTurns: number;
113
+ timeToFirstValidEditMinutes: number;
114
+ reworkRate: number;
115
+ postChangeFailureRate: number;
116
+ };
117
+
118
+ function computeKpiMetrics(events: MemoryEvent[], usefulRecallRate: number): KpiMetrics {
119
+ const prompts = events.filter((e) => e.eventType === 'user_prompt');
120
+ const promptCount = prompts.length;
121
+ const memoryHitPrompts = prompts.filter((p) => (p.metadata as any)?.adherence?.checked).length;
122
+ const memoryHitRate = round(safeRatio(memoryHitPrompts, promptCount));
123
+
124
+ const sessions = new Map<string, MemoryEvent[]>();
125
+ for (const e of events) {
126
+ const arr = sessions.get(e.sessionId) || [];
127
+ arr.push(e);
128
+ sessions.set(e.sessionId, arr);
129
+ }
130
+
131
+ let sessionTurnTotal = 0;
132
+ let sessionTurnSamples = 0;
133
+ let firstValidEditMinutesTotal = 0;
134
+ let firstValidEditSamples = 0;
135
+
136
+ for (const sessionEvents of sessions.values()) {
137
+ sessionEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
138
+ const turns = computeSessionTurnCount(sessionEvents);
139
+ if (turns > 0) {
140
+ sessionTurnTotal += turns;
141
+ sessionTurnSamples++;
142
+ }
143
+
144
+ const firstPrompt = sessionEvents.find((e) => e.eventType === 'user_prompt');
145
+ const firstEdit = sessionEvents.find((e) => {
146
+ const payload = parseToolPayload(e);
147
+ return payload?.toolName && isEditToolName(payload.toolName) && payload.success === true;
148
+ });
149
+ if (firstPrompt && firstEdit) {
150
+ const minutes = (firstEdit.timestamp.getTime() - firstPrompt.timestamp.getTime()) / 60000;
151
+ if (minutes >= 0) {
152
+ firstValidEditMinutesTotal += minutes;
153
+ firstValidEditSamples++;
154
+ }
155
+ }
156
+ }
157
+
158
+ const avgCompletionTurns = round(safeRatio(sessionTurnTotal, sessionTurnSamples), 2);
159
+ const timeToFirstValidEditMinutes = round(safeRatio(firstValidEditMinutesTotal, firstValidEditSamples), 2);
160
+
161
+ const editActions: Array<{ sessionId: string; timestamp: number; filePath?: string }> = [];
162
+ let testRunsAfterEdit = 0;
163
+ let failedTestRunsAfterEdit = 0;
164
+
165
+ for (const [sessionId, sessionEvents] of sessions.entries()) {
166
+ const sorted = [...sessionEvents].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
167
+ let seenEdit = false;
168
+
169
+ for (const e of sorted) {
170
+ const payload = parseToolPayload(e);
171
+ if (!payload?.toolName) continue;
172
+
173
+ if (isEditToolName(payload.toolName) && payload.success === true) {
174
+ editActions.push({ sessionId, timestamp: e.timestamp.getTime(), filePath: payload.filePath });
175
+ seenEdit = true;
176
+ continue;
177
+ }
178
+
179
+ if (seenEdit && isTestLikeCommand(payload.command)) {
180
+ testRunsAfterEdit++;
181
+ if (payload.success === false) failedTestRunsAfterEdit++;
182
+ }
183
+ }
184
+ }
185
+
186
+ const THIRTY_MIN_MS = 30 * 60 * 1000;
187
+ let reworkCount = 0;
188
+ const bySessionFile = new Map<string, number>();
189
+ const sortedEdits = [...editActions].sort((a, b) => a.timestamp - b.timestamp);
190
+ for (const edit of sortedEdits) {
191
+ if (!edit.filePath) continue;
192
+ const key = `${edit.sessionId}::${edit.filePath}`;
193
+ const prev = bySessionFile.get(key);
194
+ if (typeof prev === 'number' && edit.timestamp - prev <= THIRTY_MIN_MS) {
195
+ reworkCount++;
196
+ }
197
+ bySessionFile.set(key, edit.timestamp);
198
+ }
199
+
200
+ const reworkRate = round(safeRatio(reworkCount, editActions.length));
201
+ const postChangeFailureRate = round(safeRatio(failedTestRunsAfterEdit, testRunsAfterEdit));
202
+
203
+ return {
204
+ memoryHitRate,
205
+ usefulRecallRate,
206
+ avgCompletionTurns,
207
+ timeToFirstValidEditMinutes,
208
+ reworkRate,
209
+ postChangeFailureRate
210
+ };
211
+ }
212
+
213
+
12
214
  // GET /api/stats/shared - Get shared store statistics
13
215
  statsRouter.get('/shared', async (c) => {
14
216
  const memoryService = getServiceFromQuery(c);
@@ -336,6 +538,150 @@ statsRouter.get('/retrieval-traces', async (c) => {
336
538
  }
337
539
  });
338
540
 
541
+ // GET /api/stats/kpi - Productivity KPI summary + trend
542
+ statsRouter.get('/kpi', async (c) => {
543
+ const rawWindow = (c.req.query('window') || '7d') as KpiWindow;
544
+ const window: KpiWindow = rawWindow === '24h' || rawWindow === '30d' ? rawWindow : '7d';
545
+ const memoryService = getServiceFromQuery(c);
546
+
547
+ try {
548
+ await memoryService.initialize();
549
+ const now = Date.now();
550
+ const thresholds = loadKpiThresholds();
551
+ const allEvents = await memoryService.getRecentEvents(20000);
552
+ const events = allEvents.filter((e) => inWindow(e, now, window));
553
+
554
+ const helpfulness = await memoryService.getHelpfulnessStats();
555
+ const usefulRecallRate = helpfulness.totalEvaluated > 0
556
+ ? round(safeRatio(helpfulness.helpful, helpfulness.totalEvaluated))
557
+ : 0;
558
+
559
+ const metrics = computeKpiMetrics(events, usefulRecallRate);
560
+
561
+ const windowMs = windowToMs(window);
562
+ const prevEvents = allEvents.filter((e) => {
563
+ const age = now - e.timestamp.getTime();
564
+ return age > windowMs && age <= windowMs * 2;
565
+ });
566
+ const previousMetrics = computeKpiMetrics(prevEvents, usefulRecallRate);
567
+ const deltas = {
568
+ memoryHitRate: round(metrics.memoryHitRate - previousMetrics.memoryHitRate),
569
+ usefulRecallRate: round(metrics.usefulRecallRate - previousMetrics.usefulRecallRate),
570
+ avgCompletionTurns: round(metrics.avgCompletionTurns - previousMetrics.avgCompletionTurns, 2),
571
+ timeToFirstValidEditMinutes: round(metrics.timeToFirstValidEditMinutes - previousMetrics.timeToFirstValidEditMinutes, 2),
572
+ reworkRate: round(metrics.reworkRate - previousMetrics.reworkRate),
573
+ postChangeFailureRate: round(metrics.postChangeFailureRate - previousMetrics.postChangeFailureRate)
574
+ };
575
+
576
+ const THIRTY_MIN_MS = 30 * 60 * 1000;
577
+
578
+ // Trend (daily buckets for last 30 days)
579
+ const trendWindowMs = 30 * 24 * 60 * 60 * 1000;
580
+ const trendEvents = allEvents.filter((e) => now - e.timestamp.getTime() <= trendWindowMs);
581
+ const buckets = new Map<string, MemoryEvent[]>();
582
+ for (const e of trendEvents) {
583
+ const day = e.timestamp.toISOString().split('T')[0];
584
+ const arr = buckets.get(day) || [];
585
+ arr.push(e);
586
+ buckets.set(day, arr);
587
+ }
588
+
589
+ const trendDaily = Array.from(buckets.entries())
590
+ .sort((a, b) => a[0].localeCompare(b[0]))
591
+ .map(([date, dayEvents]) => {
592
+ const dayPrompts = dayEvents.filter((e) => e.eventType === 'user_prompt');
593
+ const dayPromptCount = dayPrompts.length;
594
+ const dayMemoryHit = dayPrompts.filter((p) => (p.metadata as any)?.adherence?.checked).length;
595
+
596
+ // lightweight day rework/failure approximation
597
+ const dayEdits = dayEvents.filter((e) => {
598
+ const p = parseToolPayload(e);
599
+ return Boolean(p?.toolName && isEditToolName(p.toolName) && p.success === true);
600
+ });
601
+ const dayEditActions = dayEdits
602
+ .map((e) => {
603
+ const p = parseToolPayload(e);
604
+ return { sessionId: e.sessionId, timestamp: e.timestamp.getTime(), filePath: p?.filePath };
605
+ })
606
+ .filter((x) => Boolean(x.filePath));
607
+ let dayReworkCount = 0;
608
+ const dayBySessionFile = new Map<string, number>();
609
+ for (const edit of dayEditActions) {
610
+ const key = `${edit.sessionId}::${edit.filePath}`;
611
+ const prev = dayBySessionFile.get(key);
612
+ if (typeof prev === 'number' && edit.timestamp - prev <= THIRTY_MIN_MS) dayReworkCount++;
613
+ dayBySessionFile.set(key, edit.timestamp);
614
+ }
615
+ const dayTests = dayEvents.filter((e) => {
616
+ const p = parseToolPayload(e);
617
+ return Boolean(p?.toolName && isTestLikeCommand(p.command));
618
+ });
619
+ const dayFailedTests = dayEvents.filter((e) => {
620
+ const p = parseToolPayload(e);
621
+ return Boolean(p?.toolName && isTestLikeCommand(p.command) && p.success === false);
622
+ });
623
+
624
+ const turnsBySession = new Map<string, MemoryEvent[]>();
625
+ for (const e of dayEvents) {
626
+ const arr = turnsBySession.get(e.sessionId) || [];
627
+ arr.push(e);
628
+ turnsBySession.set(e.sessionId, arr);
629
+ }
630
+ let dayTurnsTotal = 0;
631
+ let dayTurnsSamples = 0;
632
+ for (const sessionEvents of turnsBySession.values()) {
633
+ const turns = computeSessionTurnCount(sessionEvents);
634
+ if (turns > 0) {
635
+ dayTurnsTotal += turns;
636
+ dayTurnsSamples++;
637
+ }
638
+ }
639
+
640
+ return {
641
+ date,
642
+ memoryHitRate: round(safeRatio(dayMemoryHit, dayPromptCount)),
643
+ usefulRecallRate,
644
+ reworkRate: round(safeRatio(dayReworkCount, dayEditActions.length)),
645
+ postChangeFailureRate: round(safeRatio(dayFailedTests.length, dayTests.length)),
646
+ avgCompletionTurns: round(safeRatio(dayTurnsTotal, dayTurnsSamples), 2)
647
+ };
648
+ });
649
+
650
+ const alerts: Array<{ metric: string; level: 'warn'; message: string; value: number; threshold: number }> = [];
651
+ if (metrics.usefulRecallRate < thresholds.usefulRecallRateMin) {
652
+ alerts.push({ metric: 'usefulRecallRate', level: 'warn', message: 'Useful recall rate is below threshold', value: metrics.usefulRecallRate, threshold: thresholds.usefulRecallRateMin });
653
+ }
654
+ if (metrics.reworkRate > thresholds.reworkRateMax) {
655
+ alerts.push({ metric: 'reworkRate', level: 'warn', message: 'Rework rate is above threshold', value: metrics.reworkRate, threshold: thresholds.reworkRateMax });
656
+ }
657
+ if (metrics.postChangeFailureRate > thresholds.postChangeFailureRateMax) {
658
+ alerts.push({ metric: 'postChangeFailureRate', level: 'warn', message: 'Post-change failure rate is above threshold', value: metrics.postChangeFailureRate, threshold: thresholds.postChangeFailureRateMax });
659
+ }
660
+ if (metrics.avgCompletionTurns > thresholds.avgCompletionTurnsMax) {
661
+ alerts.push({ metric: 'avgCompletionTurns', level: 'warn', message: 'Average completion turns is above threshold', value: metrics.avgCompletionTurns, threshold: thresholds.avgCompletionTurnsMax });
662
+ }
663
+ if (metrics.memoryHitRate < thresholds.memoryHitRateMin) {
664
+ alerts.push({ metric: 'memoryHitRate', level: 'warn', message: 'Memory hit rate is below threshold', value: metrics.memoryHitRate, threshold: thresholds.memoryHitRateMin });
665
+ }
666
+
667
+ return c.json({
668
+ window,
669
+ metrics,
670
+ previousMetrics,
671
+ deltas,
672
+ trend: {
673
+ daily: trendDaily
674
+ },
675
+ thresholds,
676
+ alerts
677
+ });
678
+ } catch (error) {
679
+ return c.json({ error: (error as Error).message }, 500);
680
+ } finally {
681
+ await memoryService.shutdown();
682
+ }
683
+ });
684
+
339
685
  // POST /api/stats/graduation/run - Force graduation evaluation
340
686
  statsRouter.post('/graduation/run', async (c) => {
341
687
  const memoryService = getServiceFromQuery(c);
@@ -213,9 +213,11 @@ export class MemoryService {
213
213
  private readonly readOnly: boolean;
214
214
  private readonly lightweightMode: boolean;
215
215
  private readonly mdMirror: MarkdownMirror;
216
+ private readonly storagePath: string;
216
217
 
217
218
  constructor(config: MemoryServiceConfig & { projectHash?: string; projectPath?: string; sharedStoreConfig?: SharedStoreConfig }) {
218
219
  const storagePath = this.expandPath(config.storagePath);
220
+ this.storagePath = storagePath;
219
221
  this.readOnly = config.readOnly ?? false;
220
222
  this.lightweightMode = config.lightweightMode ?? false;
221
223
  this.mdMirror = new MarkdownMirror(process.cwd());
@@ -268,8 +270,9 @@ export class MemoryService {
268
270
  }
269
271
 
270
272
  this.vectorStore = new VectorStore(path.join(storagePath, 'vectors'));
271
- this.embedder = config.embeddingModel
272
- ? new Embedder(config.embeddingModel)
273
+ const embeddingModel = config.embeddingModel || process.env.CLAUDE_MEMORY_EMBEDDING_MODEL;
274
+ this.embedder = embeddingModel
275
+ ? new Embedder(embeddingModel)
273
276
  : getDefaultEmbedder();
274
277
  this.matcher = getDefaultMatcher();
275
278
  // Retriever uses SQLite as primary (always available)
@@ -1474,6 +1477,120 @@ export class MemoryService {
1474
1477
  this.graduation.recordAccess(eventId, sessionId, confidence);
1475
1478
  }
1476
1479
 
1480
+ getEmbeddingModelName(): string {
1481
+ return this.embedder.getModelName();
1482
+ }
1483
+
1484
+ /**
1485
+ * Ensure embedding model metadata is in sync and optionally migrate vectors.
1486
+ * Migration strategy: clear vector index + clear embedding outbox + re-enqueue all events.
1487
+ */
1488
+ async ensureEmbeddingModelForImport(options?: { autoMigrate?: boolean }): Promise<{
1489
+ changed: boolean;
1490
+ previousModel: string | null;
1491
+ currentModel: string;
1492
+ enqueued: number;
1493
+ reason?: string;
1494
+ }> {
1495
+ await this.initialize();
1496
+
1497
+ const currentModel = this.getEmbeddingModelName();
1498
+ const metaPath = path.join(this.storagePath, 'embedding-meta.json');
1499
+
1500
+ let previousModel: string | null = null;
1501
+ try {
1502
+ if (fs.existsSync(metaPath)) {
1503
+ const parsed = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as { model?: string };
1504
+ previousModel = parsed?.model || null;
1505
+ }
1506
+ } catch {
1507
+ previousModel = null;
1508
+ }
1509
+
1510
+ const stats = await this.getStats();
1511
+ const hasExistingVectors = (stats.vectorCount || 0) > 0;
1512
+
1513
+ // First-time metadata write (no migration needed unless legacy vectors exist)
1514
+ if (!previousModel && !hasExistingVectors) {
1515
+ fs.writeFileSync(metaPath, JSON.stringify({ model: currentModel, updatedAt: new Date().toISOString() }, null, 2));
1516
+ return { changed: false, previousModel: null, currentModel, enqueued: 0, reason: 'initialized-meta' };
1517
+ }
1518
+
1519
+ const modelChanged = previousModel !== currentModel;
1520
+ const legacyUnknownButVectorsExist = !previousModel && hasExistingVectors;
1521
+
1522
+ if (!modelChanged && !legacyUnknownButVectorsExist) {
1523
+ return { changed: false, previousModel, currentModel, enqueued: 0 };
1524
+ }
1525
+
1526
+ if (options?.autoMigrate === false) {
1527
+ return {
1528
+ changed: true,
1529
+ previousModel,
1530
+ currentModel,
1531
+ enqueued: 0,
1532
+ reason: legacyUnknownButVectorsExist ? 'legacy-vectors-without-meta' : 'model-mismatch'
1533
+ };
1534
+ }
1535
+
1536
+ // Pause background vector processing while preparing migration
1537
+ const wasRunning = this.vectorWorker?.isRunning() || false;
1538
+ if (wasRunning) this.vectorWorker?.stop();
1539
+
1540
+ // Reset vector and outbox state
1541
+ await this.vectorStore.clearAll();
1542
+ await this.sqliteStore.clearEmbeddingOutbox();
1543
+
1544
+ // Re-enqueue all events for new embeddings
1545
+ const pageSize = 1000;
1546
+ let offset = 0;
1547
+ let enqueued = 0;
1548
+
1549
+ while (true) {
1550
+ const page = await this.sqliteStore.getEventsPage(pageSize, offset);
1551
+ if (page.length === 0) break;
1552
+
1553
+ for (const event of page) {
1554
+ await this.sqliteStore.enqueueForEmbedding(event.id, event.content);
1555
+ enqueued += 1;
1556
+ }
1557
+
1558
+ offset += page.length;
1559
+ if (page.length < pageSize) break;
1560
+ }
1561
+
1562
+ fs.writeFileSync(
1563
+ metaPath,
1564
+ JSON.stringify(
1565
+ {
1566
+ model: currentModel,
1567
+ previousModel,
1568
+ migratedAt: new Date().toISOString(),
1569
+ enqueued
1570
+ },
1571
+ null,
1572
+ 2
1573
+ )
1574
+ );
1575
+
1576
+ if (wasRunning) this.vectorWorker?.start();
1577
+
1578
+ return {
1579
+ changed: true,
1580
+ previousModel,
1581
+ currentModel,
1582
+ enqueued,
1583
+ reason: legacyUnknownButVectorsExist ? 'legacy-vectors-without-meta' : 'model-mismatch'
1584
+ };
1585
+ }
1586
+
1587
+ /**
1588
+ * Backward-compatible alias used by some hooks
1589
+ */
1590
+ async close(): Promise<void> {
1591
+ await this.shutdown();
1592
+ }
1593
+
1477
1594
  /**
1478
1595
  * Shutdown service
1479
1596
  */