claude-memory-layer 1.0.19 → 1.0.21

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/dist/ui/app.js CHANGED
@@ -14,6 +14,10 @@ const state = {
14
14
  retrievalTraces: null,
15
15
  adherenceSummary: null,
16
16
  adherenceWindow: '24h',
17
+ userPromptSearchQuery: '',
18
+ userPromptItems: [],
19
+ userPromptPage: 1,
20
+ userPromptPageSize: 30,
17
21
  currentLevel: 'L0',
18
22
  currentSort: 'recent',
19
23
  currentView: 'overview',
@@ -149,6 +153,39 @@ function setupEventListeners() {
149
153
  searchInput.addEventListener('input', debounce((e) => handleSearch(e.target.value), 300));
150
154
  }
151
155
 
156
+ // User prompt search
157
+ const userPromptSearch = document.getElementById('user-prompt-search');
158
+ if (userPromptSearch) {
159
+ userPromptSearch.addEventListener('input', debounce(async (e) => {
160
+ state.userPromptSearchQuery = e.target.value || '';
161
+ state.userPromptPage = 1;
162
+ await loadUserPromptsView();
163
+ }, 250));
164
+ }
165
+ const userPromptRefresh = document.getElementById('user-prompt-refresh');
166
+ if (userPromptRefresh) {
167
+ userPromptRefresh.addEventListener('click', async () => {
168
+ await loadUserPromptsView();
169
+ });
170
+ }
171
+ const userPromptPrev = document.getElementById('user-prompt-prev');
172
+ if (userPromptPrev) {
173
+ userPromptPrev.addEventListener('click', async () => {
174
+ if (state.userPromptPage <= 1) return;
175
+ state.userPromptPage -= 1;
176
+ await renderUserPromptList();
177
+ });
178
+ }
179
+ const userPromptNext = document.getElementById('user-prompt-next');
180
+ if (userPromptNext) {
181
+ userPromptNext.addEventListener('click', async () => {
182
+ const totalPages = Math.max(1, Math.ceil((state.userPromptItems?.length || 0) / state.userPromptPageSize));
183
+ if (state.userPromptPage >= totalPages) return;
184
+ state.userPromptPage += 1;
185
+ await renderUserPromptList();
186
+ });
187
+ }
188
+
152
189
  // Project selector
153
190
  const projectSelect = document.getElementById('project-select');
154
191
  if (projectSelect) {
@@ -1197,6 +1234,7 @@ function switchView(viewName) {
1197
1234
  switch (viewName) {
1198
1235
  case 'knowledge-graph': loadKnowledgeGraphView(); break;
1199
1236
  case 'memory-banks': loadMemoryBanksView(); break;
1237
+ case 'user-prompts': loadUserPromptsView(); break;
1200
1238
  case 'configuration': loadConfigurationView(); break;
1201
1239
  }
1202
1240
  }
@@ -1430,6 +1468,94 @@ async function loadMemoryBankLevel(level) {
1430
1468
  }
1431
1469
  }
1432
1470
 
1471
+ // --- User Prompts View ---
1472
+
1473
+ async function renderUserPromptList() {
1474
+ const listEl = document.getElementById('user-prompt-list');
1475
+ const pageEl = document.getElementById('user-prompt-page');
1476
+ const prevBtn = document.getElementById('user-prompt-prev');
1477
+ const nextBtn = document.getElementById('user-prompt-next');
1478
+ const metaEl = document.getElementById('user-prompt-meta');
1479
+ if (!listEl) return;
1480
+
1481
+ const items = state.userPromptItems || [];
1482
+ const pageSize = state.userPromptPageSize;
1483
+ const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
1484
+ if (state.userPromptPage > totalPages) state.userPromptPage = totalPages;
1485
+
1486
+ const start = (state.userPromptPage - 1) * pageSize;
1487
+ const paged = items.slice(start, start + pageSize);
1488
+
1489
+ if (pageEl) pageEl.textContent = `${state.userPromptPage} / ${totalPages}`;
1490
+ if (prevBtn) prevBtn.disabled = state.userPromptPage <= 1;
1491
+ if (nextBtn) nextBtn.disabled = state.userPromptPage >= totalPages;
1492
+
1493
+ if (metaEl) {
1494
+ const sessionCount = new Set(items.map(i => i.sessionId)).size;
1495
+ metaEl.textContent = `${items.length} prompts · ${sessionCount} sessions${state.userPromptSearchQuery ? ` · query: "${state.userPromptSearchQuery}"` : ''}`;
1496
+ }
1497
+
1498
+ if (paged.length === 0) {
1499
+ listEl.innerHTML = '<div style="padding:20px; text-align:center; color:var(--text-muted);">No user prompts found.</div>';
1500
+ return;
1501
+ }
1502
+
1503
+ // Group current page by session
1504
+ const groups = new Map();
1505
+ for (const e of paged) {
1506
+ const key = e.sessionId || 'unknown';
1507
+ const arr = groups.get(key) || [];
1508
+ arr.push(e);
1509
+ groups.set(key, arr);
1510
+ }
1511
+
1512
+ const html = Array.from(groups.entries()).map(([sessionId, sessionItems]) => {
1513
+ const heading = `
1514
+ <div style="margin:10px 0 6px; font-size:12px; color:var(--text-muted); font-weight:600;">
1515
+ <i class="ri-chat-1-line"></i> Session ${escapeHtml((sessionId || '').slice(0, 16))}... · ${sessionItems.length} prompts
1516
+ </div>
1517
+ `;
1518
+
1519
+ const cards = sessionItems.map((e) => `
1520
+ <div class="event-item" style="cursor:pointer;" onclick="openDetailModal('${e.id}')">
1521
+ <div class="event-header">
1522
+ <span class="event-type-badge type-user-prompt">user_prompt</span>
1523
+ <span class="event-time">${new Date(e.timestamp).toLocaleString()}</span>
1524
+ </div>
1525
+ <div class="event-content" style="-webkit-line-clamp:4;">${escapeHtml(e.preview || '')}</div>
1526
+ </div>
1527
+ `).join('');
1528
+
1529
+ return heading + cards;
1530
+ }).join('');
1531
+
1532
+ listEl.innerHTML = html;
1533
+ }
1534
+
1535
+ async function loadUserPromptsView() {
1536
+ const listEl = document.getElementById('user-prompt-list');
1537
+ if (!listEl) return;
1538
+
1539
+ listEl.innerHTML = '<div style="padding:20px; text-align:center; color:var(--text-muted);">Loading user prompts...</div>';
1540
+
1541
+ try {
1542
+ const params = {
1543
+ type: 'user_prompt',
1544
+ sort: 'recent',
1545
+ limit: 500,
1546
+ q: state.userPromptSearchQuery || undefined
1547
+ };
1548
+ const res = await fetch(apiUrl(`${API_BASE}/events`, params));
1549
+ const data = await res.json();
1550
+ const items = data.events || [];
1551
+ state.userPromptItems = items;
1552
+
1553
+ await renderUserPromptList();
1554
+ } catch (error) {
1555
+ listEl.innerHTML = `<div style="padding:20px; text-align:center; color:var(--error);">Failed to load user prompts: ${escapeHtml(error.message)}</div>`;
1556
+ }
1557
+ }
1558
+
1433
1559
  // --- Configuration View ---
1434
1560
 
1435
1561
  async function loadConfigurationView() {
@@ -51,6 +51,10 @@
51
51
  <i class="ri-brain-line"></i>
52
52
  <span>Memory Banks</span>
53
53
  </li>
54
+ <li class="nav-item" data-nav="user-prompts">
55
+ <i class="ri-message-2-line"></i>
56
+ <span>User Prompts</span>
57
+ </li>
54
58
  <li class="nav-item" data-nav="configuration">
55
59
  <i class="ri-settings-4-line"></i>
56
60
  <span>Configuration</span>
@@ -363,6 +367,41 @@
363
367
  </div>
364
368
  </div>
365
369
 
370
+ <!-- ========== VIEW: User Prompts ========== -->
371
+ <div id="view-user-prompts" class="page-view">
372
+ <header class="top-header">
373
+ <div class="page-title">
374
+ <h1>User Prompts</h1>
375
+ <p>Search and browse recent user prompts</p>
376
+ </div>
377
+ </header>
378
+ <div class="card" style="margin-bottom:16px;">
379
+ <div style="display:flex; gap:10px; align-items:center;">
380
+ <div class="search-wrapper" style="width:420px; max-width:100%;">
381
+ <i class="ri-search-line"></i>
382
+ <input type="text" id="user-prompt-search" class="search-input" placeholder="Search user prompts...">
383
+ </div>
384
+ <button id="user-prompt-refresh" class="btn btn-secondary"><i class="ri-refresh-line"></i><span>Refresh</span></button>
385
+ </div>
386
+ </div>
387
+ <div class="card">
388
+ <div class="card-header" style="align-items:flex-end;">
389
+ <div>
390
+ <div class="card-title"><i class="ri-history-line"></i><span>Latest User Prompt History</span></div>
391
+ <div id="user-prompt-meta" style="font-size:12px; color:var(--text-muted); margin-top:6px;"></div>
392
+ </div>
393
+ <div style="display:flex; gap:8px; align-items:center;">
394
+ <button id="user-prompt-prev" class="sort-btn">Prev</button>
395
+ <span id="user-prompt-page" style="font-size:12px; color:var(--text-muted);">1 / 1</span>
396
+ <button id="user-prompt-next" class="sort-btn">Next</button>
397
+ </div>
398
+ </div>
399
+ <div id="user-prompt-list" class="event-list">
400
+ <div style="padding:20px; text-align:center; color:var(--text-muted);">Loading...</div>
401
+ </div>
402
+ </div>
403
+ </div>
404
+
366
405
  <!-- ========== VIEW: Configuration ========== -->
367
406
  <div id="view-configuration" class="page-view">
368
407
  <header class="top-header">
package/dist/ui/style.css CHANGED
@@ -502,8 +502,11 @@ body {
502
502
  }
503
503
 
504
504
  .type-user { background: rgba(59, 130, 246, 0.2); color: #60A5FA; }
505
+ .type-user-prompt { background: rgba(59, 130, 246, 0.2); color: #60A5FA; }
505
506
  .type-agent { background: rgba(16, 185, 129, 0.2); color: #34D399; }
507
+ .type-agent-response { background: rgba(16, 185, 129, 0.2); color: #34D399; }
506
508
  .type-tool { background: rgba(245, 158, 11, 0.2); color: #FBBF24; }
509
+ .type-tool-observation { background: rgba(245, 158, 11, 0.2); color: #FBBF24; }
507
510
  .type-system { background: rgba(139, 92, 246, 0.2); color: #A78BFA; }
508
511
 
509
512
  .event-time {
@@ -597,6 +600,10 @@ body {
597
600
  color: var(--accent-primary);
598
601
  font-weight: 600;
599
602
  }
603
+ .sort-btn:disabled {
604
+ opacity: 0.35;
605
+ cursor: not-allowed;
606
+ }
600
607
 
601
608
  /* Access Badge */
602
609
  .access-badge {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-memory-layer",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "description": "Claude Code plugin that learns from conversations to provide personalized assistance",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -38,7 +38,7 @@
38
38
  "dependencies": {
39
39
  "@hono/node-server": "^1.13.0",
40
40
  "@lancedb/lancedb": "^0.5.0",
41
- "@xenova/transformers": "^2.17.0",
41
+ "@huggingface/transformers": "^3.8.1",
42
42
  "better-sqlite3": "^12.6.2",
43
43
  "commander": "^12.0.0",
44
44
  "duckdb": "^0.10.0",
package/scripts/build.ts CHANGED
@@ -29,6 +29,7 @@ const commonOptions: esbuild.BuildOptions = {
29
29
  '@hono/node-server/serve-static',
30
30
  '@lancedb/lancedb',
31
31
  '@xenova/transformers',
32
+ '@huggingface/transformers',
32
33
  'duckdb',
33
34
  'better-sqlite3',
34
35
  'commander',
package/src/cli/index.ts CHANGED
@@ -814,6 +814,7 @@ program
814
814
  .option('-a, --all', 'Import all sessions from all projects')
815
815
  .option('-l, --limit <number>', 'Limit messages per session')
816
816
  .option('-f, --force', 'Force reimport: delete existing events and reimport with turn_id grouping')
817
+ .option('--embedding-model <name>', 'Embedding model override (default: jinaai/jina-embeddings-v5-text-nano-text-matching, or env CLAUDE_MEMORY_EMBEDDING_MODEL; fallback env: CLAUDE_MEMORY_EMBEDDING_FALLBACK_MODEL)')
817
818
  .option('-v, --verbose', 'Show detailed progress')
818
819
  .action(async (options) => {
819
820
  const startTime = Date.now();
@@ -821,6 +822,10 @@ program
821
822
  // Determine target project path for storage
822
823
  const targetProjectPath = options.project || process.cwd();
823
824
 
825
+ if (options.embeddingModel) {
826
+ process.env.CLAUDE_MEMORY_EMBEDDING_MODEL = options.embeddingModel;
827
+ }
828
+
824
829
  // Use project-specific memory service
825
830
  const service = getMemoryServiceForProject(targetProjectPath);
826
831
  const importer = createSessionHistoryImporter(service);
@@ -835,7 +840,16 @@ program
835
840
  try {
836
841
  console.log('\n⏳ Initializing memory service...');
837
842
  await service.initialize();
838
- console.log(' ✅ Ready\n');
843
+ console.log(` ✅ Ready (embedder: ${service.getEmbeddingModelName()})\n`);
844
+
845
+ const migration = await service.ensureEmbeddingModelForImport({ autoMigrate: true });
846
+ if (migration.changed) {
847
+ console.log('🔁 Embedding model migration detected/required');
848
+ console.log(` Previous: ${migration.previousModel || 'legacy-unknown'}`);
849
+ console.log(` Current: ${migration.currentModel}`);
850
+ console.log(` Re-queued embeddings: ${migration.enqueued}`);
851
+ console.log(' (Import will continue and process embeddings with the new model)\n');
852
+ }
839
853
 
840
854
  if (options.force) {
841
855
  console.log('🔄 Force mode: existing events will be deleted and reimported with turn_id grouping\n');
@@ -862,6 +876,14 @@ program
862
876
  const globalService = getDefaultMemoryService();
863
877
  const globalImporter = createSessionHistoryImporter(globalService);
864
878
  await globalService.initialize();
879
+ console.log(` ✅ Global service ready (embedder: ${globalService.getEmbeddingModelName()})`);
880
+ const globalMigration = await globalService.ensureEmbeddingModelForImport({ autoMigrate: true });
881
+ if (globalMigration.changed) {
882
+ console.log('🔁 Global embedding migration detected');
883
+ console.log(` Previous: ${globalMigration.previousModel || 'legacy-unknown'}`);
884
+ console.log(` Current: ${globalMigration.currentModel}`);
885
+ console.log(` Re-queued embeddings: ${globalMigration.enqueued}`);
886
+ }
865
887
  result = await globalImporter.importAll(importOpts);
866
888
 
867
889
  // Process embeddings
@@ -3,7 +3,7 @@
3
3
  * AXIOMMIND Principle 7: Standard JSON format for vectors
4
4
  */
5
5
 
6
- import { pipeline, Pipeline } from '@xenova/transformers';
6
+ import { pipeline, Pipeline } from '@huggingface/transformers';
7
7
 
8
8
  export interface EmbeddingResult {
9
9
  vector: number[];
@@ -14,10 +14,12 @@ export interface EmbeddingResult {
14
14
  export class Embedder {
15
15
  private pipeline: Pipeline | null = null;
16
16
  private readonly modelName: string;
17
+ private activeModelName: string;
17
18
  private initialized = false;
18
19
 
19
- constructor(modelName: string = 'Xenova/all-MiniLM-L6-v2') {
20
+ constructor(modelName: string = 'jinaai/jina-embeddings-v5-text-nano-text-matching') {
20
21
  this.modelName = modelName;
22
+ this.activeModelName = modelName;
21
23
  }
22
24
 
23
25
  /**
@@ -26,8 +28,22 @@ export class Embedder {
26
28
  async initialize(): Promise<void> {
27
29
  if (this.initialized) return;
28
30
 
29
- this.pipeline = await pipeline('feature-extraction', this.modelName);
30
- this.initialized = true;
31
+ try {
32
+ this.pipeline = await pipeline('feature-extraction', this.modelName);
33
+ this.activeModelName = this.modelName;
34
+ this.initialized = true;
35
+ return;
36
+ } catch (primaryError) {
37
+ const fallbackModel = process.env.CLAUDE_MEMORY_EMBEDDING_FALLBACK_MODEL || 'onnx-community/embeddinggemma-300m-ONNX';
38
+ if (fallbackModel === this.modelName) {
39
+ throw primaryError;
40
+ }
41
+
42
+ console.warn(`[Embedder] Primary model failed (${this.modelName}). Falling back to ${fallbackModel}`);
43
+ this.pipeline = await pipeline('feature-extraction', fallbackModel);
44
+ this.activeModelName = fallbackModel;
45
+ this.initialized = true;
46
+ }
31
47
  }
32
48
 
33
49
  /**
@@ -49,7 +65,7 @@ export class Embedder {
49
65
 
50
66
  return {
51
67
  vector,
52
- model: this.modelName,
68
+ model: this.activeModelName,
53
69
  dimensions: vector.length
54
70
  };
55
71
  }
@@ -81,7 +97,7 @@ export class Embedder {
81
97
 
82
98
  results.push({
83
99
  vector,
84
- model: this.modelName,
100
+ model: this.activeModelName,
85
101
  dimensions: vector.length
86
102
  });
87
103
  }
@@ -109,7 +125,7 @@ export class Embedder {
109
125
  * Get model name
110
126
  */
111
127
  getModelName(): string {
112
- return this.modelName;
128
+ return this.activeModelName;
113
129
  }
114
130
  }
115
131
 
@@ -117,8 +133,9 @@ export class Embedder {
117
133
  let defaultEmbedder: Embedder | null = null;
118
134
 
119
135
  export function getDefaultEmbedder(): Embedder {
136
+ const envModel = process.env.CLAUDE_MEMORY_EMBEDDING_MODEL;
120
137
  if (!defaultEmbedder) {
121
- defaultEmbedder = new Embedder();
138
+ defaultEmbedder = new Embedder(envModel || undefined);
122
139
  }
123
140
  return defaultEmbedder;
124
141
  }
@@ -865,6 +865,38 @@ export class SQLiteEventStore {
865
865
  );
866
866
  }
867
867
 
868
+ /**
869
+ * Clear embedding outbox (used for embedding model migration)
870
+ */
871
+ async clearEmbeddingOutbox(): Promise<void> {
872
+ await this.initialize();
873
+ sqliteRun(this.db, `DELETE FROM embedding_outbox`);
874
+ }
875
+
876
+ /**
877
+ * Count total events
878
+ */
879
+ async countEvents(): Promise<number> {
880
+ await this.initialize();
881
+ const row = sqliteGet<{ count: number }>(this.db, `SELECT COUNT(*) as count FROM events`);
882
+ return row?.count || 0;
883
+ }
884
+
885
+ /**
886
+ * Get events page in timestamp ascending order (stable migration/reindex scans)
887
+ */
888
+ async getEventsPage(limit: number = 1000, offset: number = 0): Promise<MemoryEvent[]> {
889
+ await this.initialize();
890
+
891
+ const rows = sqliteAll<Record<string, unknown>>(
892
+ this.db,
893
+ `SELECT * FROM events ORDER BY timestamp ASC LIMIT ? OFFSET ?`,
894
+ [limit, offset]
895
+ );
896
+
897
+ return rows.map(this.rowToEvent);
898
+ }
899
+
868
900
  /**
869
901
  * Mark outbox items as failed
870
902
  */
package/src/core/types.ts CHANGED
@@ -152,8 +152,8 @@ export const ConfigSchema = z.object({
152
152
  }).default({}),
153
153
  embedding: z.object({
154
154
  provider: z.enum(['local', 'openai']).default('local'),
155
- model: z.string().default('Xenova/all-MiniLM-L6-v2'),
156
- openaiModel: z.string().default('text-embedding-3-small'),
155
+ model: z.string().default('jinaai/jina-embeddings-v5-text-nano-text-matching'),
156
+ openaiModel: z.string().default('jinaai/jina-embeddings-v5-text-nano-text-matching'),
157
157
  batchSize: z.number().default(32)
158
158
  }).default({}),
159
159
  retrieval: z.object({
@@ -194,6 +194,26 @@ export class VectorStore {
194
194
  return result;
195
195
  }
196
196
 
197
+ /**
198
+ * Clear all vectors (used for embedding model migration)
199
+ */
200
+ async clearAll(): Promise<void> {
201
+ await this.initialize();
202
+ if (!this.db) return;
203
+
204
+ try {
205
+ if (typeof (this.db as any).dropTable === 'function') {
206
+ await (this.db as any).dropTable(this.tableName);
207
+ } else if (typeof (this.db as any).drop_table === 'function') {
208
+ await (this.db as any).drop_table(this.tableName);
209
+ }
210
+ } catch {
211
+ // Ignore if table does not exist
212
+ }
213
+
214
+ this.table = null;
215
+ }
216
+
197
217
  /**
198
218
  * Check if vector exists for event
199
219
  */
@@ -14,6 +14,7 @@ eventsRouter.get('/', async (c) => {
14
14
  const eventType = c.req.query('type');
15
15
  const level = c.req.query('level');
16
16
  const sort = c.req.query('sort') || 'recent'; // recent | accessed | oldest
17
+ const q = (c.req.query('q') || '').trim().toLowerCase();
17
18
  const limit = parseInt(c.req.query('limit') || '100', 10);
18
19
  const offset = parseInt(c.req.query('offset') || '0', 10);
19
20
  const memoryService = getServiceFromQuery(c);
@@ -40,6 +41,11 @@ eventsRouter.get('/', async (c) => {
40
41
  events = events.filter(e => e.eventType === eventType);
41
42
  }
42
43
 
44
+ // Content query filter
45
+ if (q) {
46
+ events = events.filter(e => (e.content || '').toLowerCase().includes(q));
47
+ }
48
+
43
49
  // Sort
44
50
  if (sort === 'accessed') {
45
51
  events.sort((a: any, b: any) => {
@@ -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,113 @@ 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
+
1477
1587
  /**
1478
1588
  * Backward-compatible alias used by some hooks
1479
1589
  */