@pi-unipi/memory 0.1.5 → 0.1.8

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/commands.ts CHANGED
@@ -198,7 +198,7 @@ For each item, use the memory_store tool to save it with an appropriate title an
198
198
  return;
199
199
  }
200
200
 
201
- await showMemorySettings(pi);
201
+ await showMemorySettings(ctx);
202
202
  },
203
203
  });
204
204
  }
package/embedding.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * openai/text-embedding-3 supports custom dimensions via API param.
9
9
  */
10
10
 
11
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
12
12
  import {
13
13
  loadEmbeddingConfig,
14
14
  getApiKey,
@@ -174,7 +174,7 @@ export async function generateEmbeddingsBatch(
174
174
  * Re-embed all memories across all projects.
175
175
  * Returns count of successfully re-embedded memories.
176
176
  */
177
- export async function reembedAllMemories(pi: ExtensionAPI): Promise<number> {
177
+ export async function reembedAllMemories(ctx: ExtensionCommandContext): Promise<number> {
178
178
  const { getAllProjectDirs, MemoryStorage } = await import("./storage.js");
179
179
  const projectDirs = getAllProjectDirs();
180
180
  let count = 0;
@@ -197,7 +197,7 @@ export async function reembedAllMemories(pi: ExtensionAPI): Promise<number> {
197
197
 
198
198
  // Generate embeddings in batch
199
199
  const texts = fullRecords.map((r) => `${r.title} ${r.content}`);
200
- const embeddings = await generateEmbeddingsBatch(texts, pi);
200
+ const embeddings = await generateEmbeddingsBatch(texts);
201
201
 
202
202
  // Update records
203
203
  for (let i = 0; i < fullRecords.length; i++) {
package/index.ts CHANGED
@@ -73,7 +73,18 @@ export default function (pi: ExtensionAPI) {
73
73
  // Initialize project storage
74
74
  const projectName = getProjectName(ctx.cwd);
75
75
  projectStorage = new MemoryStorage(projectName);
76
- projectStorage.init();
76
+ try {
77
+ projectStorage.init();
78
+
79
+ // Sync any orphaned markdown files into the database
80
+ const synced = projectStorage.syncOrphanedFiles();
81
+ if (synced > 0) {
82
+ console.warn(`[unipi/memory] Synced ${synced} orphaned memory files into database`);
83
+ }
84
+ } catch (err) {
85
+ console.warn("[unipi/memory] Failed to initialize storage, running without memory:", (err as any)?.message ?? err);
86
+ projectStorage = null;
87
+ }
77
88
 
78
89
 
79
90
  // Announce module
@@ -127,8 +138,14 @@ export default function (pi: ExtensionAPI) {
127
138
  };
128
139
  }
129
140
 
130
- const projectMemories = projectStorage.listAll();
131
- const allMemories = listAllProjects();
141
+ let projectMemories: Array<{ id: string; title: string; type: string }> = [];
142
+ let allMemories: Array<{ project: string; id: string; title: string; type: string }> = [];
143
+ try {
144
+ projectMemories = projectStorage.listAll();
145
+ allMemories = listAllProjects();
146
+ } catch (err) {
147
+ console.warn("[unipi/memory] Failed to list memories for info panel:", err);
148
+ }
132
149
  const uniqueProjects = [...new Set(allMemories.map((m) => m.project))];
133
150
 
134
151
  // Get 3 most recent memories (sorted by updated DESC in listAll)
@@ -153,9 +170,14 @@ export default function (pi: ExtensionAPI) {
153
170
 
154
171
  // Show memory status in UI
155
172
  if (ctx.hasUI) {
156
- const projectCount = projectStorage.listAll().length;
157
- const allMemories = listAllProjects();
158
- const projectCountAll = allMemories.length;
173
+ let projectCount = 0;
174
+ let projectCountAll = 0;
175
+ try {
176
+ projectCount = projectStorage?.listAll()?.length ?? 0;
177
+ projectCountAll = listAllProjects().length;
178
+ } catch (err) {
179
+ console.warn("[unipi/memory] Failed to count memories for status:", err);
180
+ }
159
181
  const vecReady = isEmbeddingReady();
160
182
  const vecIcon = vecReady ? "⚡" : "📝";
161
183
  ctx.ui.setStatus(
@@ -171,7 +193,14 @@ export default function (pi: ExtensionAPI) {
171
193
  if (!projectStorage) return;
172
194
 
173
195
  const projectName = getProjectName(ctx.cwd);
174
- const projectMemories = projectStorage.listAll();
196
+ let projectMemories: Array<{ id: string; title: string; type: string }> = [];
197
+ try {
198
+ projectMemories = projectStorage.listAll();
199
+ } catch (err) {
200
+ console.warn("[unipi/memory] Failed to list memories for recall:", err);
201
+ recallDone = true; // Skip recall on error
202
+ return;
203
+ }
175
204
 
176
205
  if (projectMemories.length === 0) {
177
206
  recallDone = true; // Nothing to recall, skip
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "0.1.5",
3
+ "version": "0.1.8",
4
4
  "description": "Persistent cross-session memory with vector search for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/storage.ts CHANGED
@@ -44,7 +44,22 @@ interface MemoryFrontmatter {
44
44
  }
45
45
 
46
46
  const MEMORY_DB_NAME = "memory.db";
47
- const MEMORY_EMBEDDING_DIM = 384;
47
+ /**
48
+ * Get the configured embedding dimensions.
49
+ * Reads from config, falls back to 384.
50
+ */
51
+ function getEmbeddingDims(): number {
52
+ try {
53
+ const configPath = path.join(os.homedir(), ".unipi", "memory", "config.json");
54
+ if (fs.existsSync(configPath)) {
55
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
56
+ if (typeof raw.dimensions === "number" && raw.dimensions >= 64) {
57
+ return raw.dimensions;
58
+ }
59
+ }
60
+ } catch { /* ignore */ }
61
+ return 384;
62
+ }
48
63
 
49
64
  /**
50
65
  * Get the base memory directory (~/.unipi/memory/)
@@ -173,6 +188,12 @@ export class MemoryStorage {
173
188
 
174
189
  /**
175
190
  * Initialize the storage (create DB, tables, load extension).
191
+ *
192
+ * Uses retry logic to handle concurrent access from multiple Pi sessions,
193
+ * especially on WSL/Windows filesystem where SQLite locking can be flaky.
194
+ *
195
+ * IMPORTANT: We never delete the DB here — another session may have it open.
196
+ * If all retries fail, we throw and let this session run without memory.
176
197
  */
177
198
  init(): void {
178
199
  // Ensure directory exists
@@ -181,10 +202,56 @@ export class MemoryStorage {
181
202
  }
182
203
 
183
204
  const dbPath = path.join(this.scopeDir, MEMORY_DB_NAME);
184
- this.db = new Database(dbPath);
205
+ const maxRetries = 5;
206
+
207
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
208
+ try {
209
+ this.initDb(dbPath);
210
+ return; // Success
211
+ } catch (err: any) {
212
+ const isTransient =
213
+ err?.message?.includes("disk I/O error") ||
214
+ err?.code === "SQLITE_IOERR" ||
215
+ err?.code === "SQLITE_BUSY" ||
216
+ err?.message?.includes("database is locked");
217
+
218
+ this.close();
219
+
220
+ if (isTransient && attempt < maxRetries) {
221
+ // Likely concurrent access — back off and retry.
222
+ // Do NOT delete the DB: another session may have it open
223
+ // and deleting open files on WSL/Windows is unsafe.
224
+ const delayMs = 50 * Math.pow(2, attempt - 1); // 50, 100, 200, 400
225
+ console.warn(
226
+ `[unipi/memory] Transient error on attempt ${attempt}/${maxRetries}, retrying in ${delayMs}ms...`
227
+ );
228
+ const end = Date.now() + delayMs;
229
+ while (Date.now() < end) { /* busy wait */ }
230
+ continue;
231
+ }
232
+
233
+ // Either non-transient error, or retries exhausted.
234
+ // Log and throw — this session will run without memory.
235
+ if (isTransient) {
236
+ console.warn(
237
+ "[unipi/memory] Could not open database after retries. " +
238
+ "Another session may have the DB locked. Memory unavailable this session."
239
+ );
240
+ }
241
+ throw err;
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Open database and set up schema. Called by init() with retry logic.
248
+ */
249
+ private initDb(dbPath: string): void {
250
+ this.db = new Database(dbPath, { timeout: 5000 });
185
251
 
186
252
  // Enable WAL mode for concurrent reads
187
253
  this.db.pragma("journal_mode = WAL");
254
+ this.db.pragma("busy_timeout = 5000");
188
255
 
189
256
  // Load sqlite-vec extension
190
257
  try {
@@ -211,11 +278,14 @@ export class MemoryStorage {
211
278
  // Create vector table if sqlite-vec loaded
212
279
  try {
213
280
  this.db.exec(`
214
- CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(embedding float[${MEMORY_EMBEDDING_DIM}])
281
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(embedding float[${getEmbeddingDims()}])
215
282
  `);
216
283
  } catch {
217
284
  // vec0 table may already exist or sqlite-vec not loaded
218
285
  }
286
+
287
+ // Verify database is usable
288
+ this.db.prepare("SELECT 1 FROM memories LIMIT 0").get();
219
289
  }
220
290
 
221
291
  /**
@@ -228,8 +298,40 @@ export class MemoryStorage {
228
298
  }
229
299
  }
230
300
 
301
+ /**
302
+ * Remove corrupted database files (db, wal, shm).
303
+ */
304
+ private removeCorruptedDb(): void {
305
+ const dbPath = path.join(this.scopeDir, MEMORY_DB_NAME);
306
+ const files = [dbPath, `${dbPath}-wal`, `${dbPath}-shm`];
307
+ for (const file of files) {
308
+ try {
309
+ if (fs.existsSync(file)) {
310
+ fs.unlinkSync(file);
311
+ console.warn(`[unipi/memory] Removed corrupted file: ${file}`);
312
+ }
313
+ } catch {
314
+ // Ignore removal errors
315
+ }
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Check if database is healthy.
321
+ */
322
+ isHealthy(): boolean {
323
+ if (!this.db) return false;
324
+ try {
325
+ this.db.prepare("SELECT 1").get();
326
+ return true;
327
+ } catch {
328
+ return false;
329
+ }
330
+ }
331
+
231
332
  /**
232
333
  * Store or update a memory record.
334
+ * Uses transaction to ensure atomicity — either all writes succeed or none do.
233
335
  */
234
336
  store(record: MemoryRecord): void {
235
337
  if (!this.db) throw new Error("Storage not initialized");
@@ -247,52 +349,181 @@ export class MemoryStorage {
247
349
  // Set project if not provided
248
350
  if (!record.project) record.project = this.projectName;
249
351
 
250
- // Upsert into memories table
251
- const stmt = this.db.prepare(`
252
- INSERT OR REPLACE INTO memories (id, title, content, tags, project, type, created, updated, embedding)
253
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
254
- `);
352
+ // Prepare markdown content BEFORE transaction (fail fast)
353
+ const mdPath = path.join(this.scopeDir, `${record.id}.md`);
354
+ const frontmatter: MemoryFrontmatter = {
355
+ title: record.title,
356
+ tags: record.tags,
357
+ project: record.project,
358
+ created: record.created,
359
+ updated: record.updated,
360
+ type: record.type,
361
+ };
362
+ const mdContent = `---\n${yaml.dump(frontmatter, { lineWidth: -1 })}---\n\n${record.content}\n`;
363
+
364
+ // Use transaction for atomicity
365
+ const storeInTx = this.db.transaction(() => {
366
+ // Upsert into memories table
367
+ const stmt = this.db!.prepare(`
368
+ INSERT OR REPLACE INTO memories (id, title, content, tags, project, type, created, updated, embedding)
369
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
370
+ `);
255
371
 
256
- const tagsJson = JSON.stringify(record.tags);
257
- const embeddingBuf = record.embedding ? Buffer.from(record.embedding.buffer) : null;
258
-
259
- stmt.run(
260
- record.id,
261
- record.title,
262
- record.content,
263
- tagsJson,
264
- record.project,
265
- record.type,
266
- record.created,
267
- record.updated,
268
- embeddingBuf
269
- );
372
+ const tagsJson = JSON.stringify(record.tags);
373
+ const embeddingBuf = record.embedding ? Buffer.from(record.embedding.buffer) : null;
374
+
375
+ stmt.run(
376
+ record.id,
377
+ record.title,
378
+ record.content,
379
+ tagsJson,
380
+ record.project,
381
+ record.type,
382
+ record.created,
383
+ record.updated,
384
+ embeddingBuf
385
+ );
270
386
 
271
- // Update vector table
272
- if (record.embedding) {
273
- try {
274
- // Delete old vector if exists
275
- this.db.prepare("DELETE FROM memories_vec WHERE rowid = ?").run(BigInt(this.idToRowid(record.id)));
276
- } catch {
277
- // Ignore if not found
387
+ // Update vector table
388
+ if (record.embedding) {
389
+ try {
390
+ // Delete old vector if exists
391
+ this.db!.prepare("DELETE FROM memories_vec WHERE rowid = ?").run(BigInt(this.idToRowid(record.id)));
392
+ } catch {
393
+ // Ignore if not found
394
+ }
395
+
396
+ try {
397
+ const vecStmt = this.db!.prepare(
398
+ "INSERT INTO memories_vec(rowid, embedding) VALUES (?, ?)"
399
+ );
400
+ vecStmt.run(
401
+ BigInt(this.idToRowid(record.id)),
402
+ Buffer.from(record.embedding.buffer)
403
+ );
404
+ } catch (err) {
405
+ console.warn("[unipi/memory] Failed to insert vector:", err);
406
+ }
278
407
  }
408
+ });
279
409
 
410
+ // Execute transaction
411
+ storeInTx();
412
+
413
+ // Write markdown file AFTER successful DB write
414
+ try {
415
+ // Ensure directory exists
416
+ const dir = path.dirname(mdPath);
417
+ if (!fs.existsSync(dir)) {
418
+ fs.mkdirSync(dir, { recursive: true });
419
+ }
420
+ fs.writeFileSync(mdPath, mdContent, "utf-8");
421
+ } catch (err) {
422
+ // DB write succeeded but file write failed — log but don't throw
423
+ // Memory is still in DB and searchable
424
+ console.warn("[unipi/memory] Failed to write markdown file:", err);
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Sync orphaned markdown files into the database.
430
+ * Reads all .md files in the project dir, parses frontmatter,
431
+ * and inserts any that are missing from the DB.
432
+ * Returns count of synced files.
433
+ */
434
+ syncOrphanedFiles(): number {
435
+ if (!this.db) throw new Error("Storage not initialized");
436
+
437
+ const files = fs.readdirSync(this.scopeDir)
438
+ .filter(f => f.endsWith(".md") && !f.startsWith("."));
439
+
440
+ // Get existing IDs from DB
441
+ const existingIds = new Set(
442
+ (this.db.prepare("SELECT id FROM memories").all() as any[])
443
+ .map(r => r.id)
444
+ );
445
+
446
+ let synced = 0;
447
+ for (const file of files) {
448
+ const filePath = path.join(this.scopeDir, file);
449
+ const record = parseMemoryFile(filePath);
450
+ if (!record) continue;
451
+
452
+ // Generate ID from title (same logic as store())
453
+ const id = record.title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
454
+
455
+ if (existingIds.has(id)) continue; // Already in DB
456
+
457
+ // Insert into DB
280
458
  try {
281
- const vecStmt = this.db.prepare(
282
- "INSERT INTO memories_vec(rowid, embedding) VALUES (?, ?)"
283
- );
284
- vecStmt.run(
285
- BigInt(this.idToRowid(record.id)),
286
- Buffer.from(record.embedding.buffer)
459
+ record.id = id;
460
+ const tagsJson = JSON.stringify(record.tags);
461
+
462
+ this.db.prepare(`
463
+ INSERT OR IGNORE INTO memories (id, title, content, tags, project, type, created, updated, embedding)
464
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)
465
+ `).run(
466
+ id,
467
+ record.title,
468
+ record.content,
469
+ tagsJson,
470
+ record.project || this.projectName,
471
+ record.type,
472
+ record.created,
473
+ record.updated
287
474
  );
475
+
476
+ synced++;
477
+ console.warn(`[unipi/memory] Synced orphaned file: ${file}`);
288
478
  } catch (err) {
289
- console.warn("[unipi/memory] Failed to insert vector:", err);
479
+ console.warn(`[unipi/memory] Failed to sync ${file}:`, err);
290
480
  }
291
481
  }
292
482
 
293
- // Write markdown file
294
- const mdPath = path.join(this.scopeDir, `${record.id}.md`);
295
- writeMemoryFile(mdPath, record);
483
+ return synced;
484
+ }
485
+
486
+ /**
487
+ * Check if a memory with the given title already exists.
488
+ */
489
+ hasByTitle(title: string): boolean {
490
+ if (!this.db) throw new Error("Storage not initialized");
491
+ const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
492
+ const row = this.db.prepare("SELECT 1 FROM memories WHERE id = ?").get(id);
493
+ return !!row;
494
+ }
495
+
496
+ /**
497
+ * Find memories with similar titles (fuzzy match).
498
+ * Returns array of { record, similarity } sorted by similarity desc.
499
+ */
500
+ findSimilarByTitle(title: string, threshold = 0.6): Array<{ record: MemoryRecord; similarity: number }> {
501
+ if (!this.db) throw new Error("Storage not initialized");
502
+
503
+ const allRows = this.db.prepare("SELECT id, title FROM memories").all() as any[];
504
+ const results: Array<{ record: MemoryRecord; similarity: number }> = [];
505
+
506
+ const normalizedTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, " ");
507
+ const titleWords = new Set(normalizedTitle.split(/\s+/).filter((w: string) => w.length > 2));
508
+
509
+ for (const row of allRows) {
510
+ const normalizedRowTitle = row.title.toLowerCase().replace(/[^a-z0-9]+/g, " ");
511
+ const rowWords = new Set(normalizedRowTitle.split(/\s+/).filter((w: string) => w.length > 2));
512
+
513
+ // Calculate Jaccard similarity
514
+ const intersection = new Set([...titleWords].filter((w: string) => rowWords.has(w)));
515
+ const union = new Set([...titleWords, ...rowWords]);
516
+ const similarity = union.size > 0 ? intersection.size / union.size : 0;
517
+
518
+ if (similarity >= threshold) {
519
+ const record = this.getById(row.id);
520
+ if (record) {
521
+ results.push({ record, similarity });
522
+ }
523
+ }
524
+ }
525
+
526
+ return results.sort((a, b) => b.similarity - a.similarity);
296
527
  }
297
528
 
298
529
  /**
package/tools.ts CHANGED
@@ -72,10 +72,28 @@ export function registerMemoryTools(
72
72
  const storage = getStorage();
73
73
  onActivity?.(); // Mark store as done for lifecycle
74
74
 
75
- // Check if similar memory exists
75
+ // Step 1: Check for exact duplicate
76
76
  const existing = storage.getByTitle(params.title);
77
77
  if (existing) {
78
- // Update existing
78
+ // Exact match found — check if content is also the same
79
+ const isSameContent = existing.content.trim() === params.content.trim();
80
+
81
+ if (isSameContent) {
82
+ // Duplicate with same content — gentle error asking to read first
83
+ return {
84
+ content: [
85
+ {
86
+ type: "text",
87
+ text: `⚠️ Memory already exists with this title and content: "${params.title}"\n\n` +
88
+ `Please read the existing memory first using memory_search before saving.\n` +
89
+ `If you want to update it, provide new or modified content.`,
90
+ },
91
+ ],
92
+ details: { action: "duplicate_detected", id: existing.id },
93
+ };
94
+ }
95
+
96
+ // Same title but different content — update existing
79
97
  const updated: MemoryRecord = {
80
98
  ...existing,
81
99
  content: params.content,
@@ -102,7 +120,50 @@ export function registerMemoryTools(
102
120
  };
103
121
  }
104
122
 
105
- // Create new memory
123
+ // Step 2: Check for similar memories
124
+ const similarMemories = storage.findSimilarByTitle(params.title, 0.6);
125
+
126
+ if (similarMemories.length > 0) {
127
+ // Found similar memories — save but notify
128
+ const similarList = similarMemories
129
+ .slice(0, 3)
130
+ .map(s => ` - "${s.record.title}" (${Math.round(s.similarity * 100)}% similar)`)
131
+ .join("\n");
132
+
133
+ // Create new memory
134
+ const record: MemoryRecord = {
135
+ id: "",
136
+ title: params.title,
137
+ content: params.content,
138
+ tags: params.tags || [],
139
+ project: getProjectName(ctx.cwd),
140
+ type: (params.type as MemoryRecord["type"]) || "summary",
141
+ created: "",
142
+ updated: "",
143
+ };
144
+
145
+ const embedding = await generateEmbedding(
146
+ params.title + " " + params.content,
147
+ pi
148
+ );
149
+ record.embedding = embedding;
150
+
151
+ storage.store(record);
152
+
153
+ return {
154
+ content: [
155
+ {
156
+ type: "text",
157
+ text: `Stored memory: ${params.title}\n\n` +
158
+ `⚠️ Similar memories found:\n${similarList}\n\n` +
159
+ `Consider reviewing these to avoid redundancy.`,
160
+ },
161
+ ],
162
+ details: { action: "created_with_similar", id: record.id, similar: similarMemories.map(s => s.record.id) },
163
+ };
164
+ }
165
+
166
+ // Step 3: No duplicates or similar — create new memory
106
167
  const record: MemoryRecord = {
107
168
  id: "",
108
169
  title: params.title,
@@ -2,10 +2,10 @@
2
2
  * @unipi/memory — Settings TUI
3
3
  *
4
4
  * Interactive settings dialog for embedding configuration.
5
- * Uses pi's UI primitives (select, input, notify).
5
+ * Uses ctx.ui primitives (select, input, notify).
6
6
  */
7
7
 
8
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
+ import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
9
9
  import {
10
10
  loadEmbeddingConfig,
11
11
  saveEmbeddingConfig,
@@ -19,20 +19,12 @@ import {
19
19
  type EmbeddingConfig,
20
20
  } from "../settings.js";
21
21
 
22
- /** pi.ui type that's available when TUI is present */
23
- type PiUI = {
24
- select: (opts: { title: string; message: string; options: Array<{ label: string; value: string; description?: string }> }) => Promise<string | null | undefined>;
25
- input: (opts: { title: string; message: string; placeholder?: string; validate?: (value: string) => Promise<string | null> }) => Promise<string | null | undefined>;
26
- notify: (opts: { message: string; level: string }) => Promise<void>;
27
- };
28
-
29
22
  /**
30
23
  * Show memory settings dialog.
31
24
  * Main entry point for /unipi:memory-settings command.
32
25
  */
33
- export async function showMemorySettings(pi: ExtensionAPI): Promise<void> {
34
- // Cast to access pi.ui which exists at runtime but isn't typed
35
- const ui = (pi as any).ui as PiUI;
26
+ export async function showMemorySettings(ctx: ExtensionCommandContext): Promise<void> {
27
+ const ui = ctx.ui;
36
28
  let running = true;
37
29
 
38
30
  while (running) {
@@ -115,28 +107,28 @@ export async function showMemorySettings(pi: ExtensionAPI): Promise<void> {
115
107
  description: "Exit settings",
116
108
  });
117
109
 
118
- const selected = await ui.select({
119
- title: "🧠 Memory Settings",
120
- message: statusLines.join("\n"),
121
- options,
122
- });
110
+ const labels = options.map(o => `${o.label} — ${o.description}`);
111
+ const selected = await ui.select(
112
+ "🧠 Memory Settings",
113
+ labels,
114
+ );
115
+ // Map selected label back to value
116
+ const selectedOpt = options.find(o => `${o.label} — ${o.description}` === selected);
117
+ const selectedValue = selectedOpt?.value;
123
118
 
124
- if (!selected || selected === "__exit__") {
119
+ if (!selectedValue || selectedValue === "__exit__") {
125
120
  running = false;
126
121
  continue;
127
122
  }
128
123
 
129
- switch (selected) {
124
+ switch (selectedValue) {
130
125
  case "__add_key__":
131
126
  case "__update_key__":
132
127
  await handleApiKeyInput(ui);
133
128
  break;
134
129
  case "__remove_key__":
135
130
  clearApiKey();
136
- await ui.notify({
137
- message: "API key removed. Vector search disabled.",
138
- level: "info",
139
- });
131
+ ui.notify("API key removed. Vector search disabled.", "info");
140
132
  break;
141
133
  case "__select_model__":
142
134
  await handleModelSelection(ui);
@@ -145,16 +137,13 @@ export async function showMemorySettings(pi: ExtensionAPI): Promise<void> {
145
137
  await handleDimensionsInput(ui);
146
138
  break;
147
139
  case "__reembed__":
148
- await handleReembed(ui, pi);
140
+ await handleReembed(ui, ctx);
149
141
  break;
150
142
  case "__suppress__":
151
143
  const cfg = loadEmbeddingConfig();
152
144
  cfg.suppressMigrationWarning = true;
153
145
  saveEmbeddingConfig(cfg);
154
- await ui.notify({
155
- message: "Migration warning suppressed.",
156
- level: "info",
157
- });
146
+ ui.notify("Migration warning suppressed.", "info");
158
147
  break;
159
148
  }
160
149
  }
@@ -163,66 +152,65 @@ export async function showMemorySettings(pi: ExtensionAPI): Promise<void> {
163
152
  /**
164
153
  * Handle API key input.
165
154
  */
166
- async function handleApiKeyInput(ui: PiUI): Promise<void> {
167
- const key = await ui.input({
168
- title: "OpenRouter API Key",
169
- message: "Enter your OpenRouter API key (sk-or-v1-...):",
170
- placeholder: "sk-or-v1-...",
171
- validate: async (value: string) => {
172
- if (!value || value.trim().length === 0) {
173
- return "API key cannot be empty";
174
- }
175
- if (!value.startsWith("sk-or-") && !value.startsWith("sk-")) {
176
- return "Key should start with sk-or- or sk-";
177
- }
178
- return null;
179
- },
180
- });
155
+ async function handleApiKeyInput(ui: ExtensionCommandContext["ui"]): Promise<void> {
156
+ const key = await ui.input(
157
+ "Enter your OpenRouter API key (sk-or-v1-...):",
158
+ "sk-or-v1-...",
159
+ );
181
160
 
182
161
  if (key) {
183
- setApiKey(key.trim());
184
- await ui.notify({
185
- message: "API key saved. Vector search enabled.",
186
- level: "success",
187
- });
162
+ const trimmed = key.trim();
163
+ if (trimmed.length === 0) {
164
+ ui.notify("API key cannot be empty.", "warning");
165
+ return;
166
+ }
167
+ if (!trimmed.startsWith("sk-or-") && !trimmed.startsWith("sk-")) {
168
+ ui.notify("Key should start with sk-or- or sk-.", "warning");
169
+ return;
170
+ }
171
+ setApiKey(trimmed);
172
+ ui.notify("API key saved. Vector search enabled.", "info");
188
173
  }
189
174
  }
190
175
 
191
176
  /**
192
177
  * Handle model selection.
193
178
  */
194
- async function handleModelSelection(ui: PiUI): Promise<void> {
179
+ async function handleModelSelection(ui: ExtensionCommandContext["ui"]): Promise<void> {
195
180
  const config = loadEmbeddingConfig();
196
181
 
197
- const options = OPENROUTER_EMBEDDING_MODELS.map((m) => ({
182
+ const modelOptions = OPENROUTER_EMBEDDING_MODELS.map((m) => ({
198
183
  label: `${m.name}${m.id === config.model ? " ✓" : ""}`,
199
184
  value: m.id,
200
185
  description: `${m.description} (${m.dimensions}d, ~${m.costPer1k}/1k tokens)`,
201
186
  }));
202
187
 
203
188
  // Add custom option
204
- options.push({
189
+ modelOptions.push({
205
190
  label: "✏️ Custom Model ID",
206
191
  value: "__custom__",
207
192
  description: "Enter a custom OpenRouter model ID",
208
193
  });
209
194
 
210
- const selected = await ui.select({
211
- title: "Select Embedding Model",
212
- message: "Choose an embedding model. ⚠ Changing model invalidates existing embeddings.",
213
- options,
214
- });
195
+ const labels = modelOptions.map(o => `${o.label} — ${o.description}`);
196
+ const selected = await ui.select(
197
+ "Select Embedding Model",
198
+ labels,
199
+ );
215
200
 
216
201
  if (!selected) return;
217
202
 
218
- let modelId = selected;
203
+ // Map label back to value
204
+ const selectedOpt = modelOptions.find(o => `${o.label} — ${o.description}` === selected);
205
+ let modelId = selectedOpt?.value;
219
206
 
220
- if (selected === "__custom__") {
221
- const custom = await ui.input({
222
- title: "Custom Model ID",
223
- message: "Enter the OpenRouter model ID:",
224
- placeholder: "openai/text-embedding-3-small",
225
- });
207
+ if (!modelId) return;
208
+
209
+ if (modelId === "__custom__") {
210
+ const custom = await ui.input(
211
+ "Enter the OpenRouter model ID:",
212
+ "openai/text-embedding-3-small",
213
+ );
226
214
  if (!custom) return;
227
215
  modelId = custom.trim();
228
216
  }
@@ -235,40 +223,33 @@ async function handleModelSelection(ui: PiUI): Promise<void> {
235
223
  config.dimensions = dimensions;
236
224
  saveEmbeddingConfig(config);
237
225
 
238
- await ui.notify({
239
- message: `Model set to ${modelId} (${dimensions}d).${hasModelChanged() ? " Re-embed existing memories to use new model." : ""}`,
240
- level: "success",
241
- });
226
+ ui.notify(
227
+ `Model set to ${modelId} (${dimensions}d).${hasModelChanged() ? " Re-embed existing memories to use new model." : ""}`,
228
+ "info",
229
+ );
242
230
  }
243
231
 
244
232
  /**
245
233
  * Handle dimensions input.
246
234
  */
247
- async function handleDimensionsInput(ui: PiUI): Promise<void> {
235
+ async function handleDimensionsInput(ui: ExtensionCommandContext["ui"]): Promise<void> {
248
236
  const config = loadEmbeddingConfig();
249
237
 
250
- const dimStr = await ui.input({
251
- title: "Embedding Dimensions",
252
- message: `Enter dimensions (default: 384). Lower = faster, less storage.\nNote: openai/text-embedding-3 supports 256-3072.\nada-002 only supports 1536.`,
253
- placeholder: "384",
254
- validate: async (value: string) => {
255
- const num = parseInt(value, 10);
256
- if (isNaN(num) || num < 64 || num > 3072) {
257
- return "Must be a number between 64 and 3072";
258
- }
259
- return null;
260
- },
261
- });
238
+ const dimStr = await ui.input(
239
+ `Enter dimensions (default: 384). Lower = faster, less storage.\nNote: openai/text-embedding-3 supports 256-3072.\nada-002 only supports 1536.`,
240
+ "384",
241
+ );
262
242
 
263
243
  if (dimStr) {
264
244
  const dims = parseInt(dimStr, 10);
245
+ if (isNaN(dims) || dims < 64 || dims > 3072) {
246
+ ui.notify("Must be a number between 64 and 3072.", "warning");
247
+ return;
248
+ }
265
249
  config.dimensions = dims;
266
250
  saveEmbeddingConfig(config);
267
251
 
268
- await ui.notify({
269
- message: `Dimensions set to ${dims}. Re-embed existing memories to apply.`,
270
- level: "success",
271
- });
252
+ ui.notify(`Dimensions set to ${dims}. Re-embed existing memories to apply.`, "info");
272
253
  }
273
254
  }
274
255
 
@@ -276,26 +257,25 @@ async function handleDimensionsInput(ui: PiUI): Promise<void> {
276
257
  * Handle re-embedding all memories.
277
258
  * This is a destructive operation — warns user first.
278
259
  */
279
- async function handleReembed(ui: PiUI, pi: ExtensionAPI): Promise<void> {
280
- const confirm = await ui.select({
281
- title: "Re-embed All Memories",
282
- message: " This will re-generate ALL embeddings using the current model.\nOld embeddings will be overwritten.\nThis may take a while and costs API calls.",
283
- options: [
284
- { label: "Yes, re-embed all", value: "yes", description: "Proceed with re-embedding" },
285
- { label: "Cancel", value: "no", description: "Abort" },
286
- ],
287
- });
288
-
289
- if (confirm !== "yes") return;
260
+ async function handleReembed(ui: ExtensionCommandContext["ui"], ctx: ExtensionCommandContext): Promise<void> {
261
+ const confirmOptions = [
262
+ { label: "Yes, re-embed all — Proceed with re-embedding", value: "yes" },
263
+ { label: "Cancel Abort", value: "no" },
264
+ ];
265
+ const confirmLabels = confirmOptions.map(o => o.label);
266
+ const confirm = await ui.select(
267
+ "Re-embed All Memories",
268
+ confirmLabels,
269
+ );
270
+
271
+ const confirmOpt = confirmOptions.find(o => o.label === confirm);
272
+ if (confirmOpt?.value !== "yes") return;
290
273
 
291
274
  // Import here to avoid circular deps
292
275
  const { reembedAllMemories } = await import("../embedding.js");
293
- const count = await reembedAllMemories(pi);
276
+ const count = await reembedAllMemories(ctx);
294
277
 
295
278
  markModelUsed();
296
279
 
297
- await ui.notify({
298
- message: `Re-embedded ${count} memories with current model.`,
299
- level: "success",
300
- });
280
+ ui.notify(`Re-embedded ${count} memories with current model.`, "info");
301
281
  }