@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 +1 -1
- package/embedding.ts +3 -3
- package/index.ts +36 -7
- package/package.json +1 -1
- package/storage.ts +270 -39
- package/tools.ts +64 -3
- package/tui/settings-tui.ts +80 -100
package/commands.ts
CHANGED
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
package/storage.ts
CHANGED
|
@@ -44,7 +44,22 @@ interface MemoryFrontmatter {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const MEMORY_DB_NAME = "memory.db";
|
|
47
|
-
|
|
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
|
-
|
|
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[${
|
|
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
|
-
//
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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(
|
|
479
|
+
console.warn(`[unipi/memory] Failed to sync ${file}:`, err);
|
|
290
480
|
}
|
|
291
481
|
}
|
|
292
482
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
75
|
+
// Step 1: Check for exact duplicate
|
|
76
76
|
const existing = storage.getByTitle(params.title);
|
|
77
77
|
if (existing) {
|
|
78
|
-
//
|
|
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
|
-
//
|
|
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,
|
package/tui/settings-tui.ts
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* @unipi/memory — Settings TUI
|
|
3
3
|
*
|
|
4
4
|
* Interactive settings dialog for embedding configuration.
|
|
5
|
-
* Uses
|
|
5
|
+
* Uses ctx.ui primitives (select, input, notify).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
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(
|
|
34
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 (!
|
|
119
|
+
if (!selectedValue || selectedValue === "__exit__") {
|
|
125
120
|
running = false;
|
|
126
121
|
continue;
|
|
127
122
|
}
|
|
128
123
|
|
|
129
|
-
switch (
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
167
|
-
const key = await ui.input(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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:
|
|
179
|
+
async function handleModelSelection(ui: ExtensionCommandContext["ui"]): Promise<void> {
|
|
195
180
|
const config = loadEmbeddingConfig();
|
|
196
181
|
|
|
197
|
-
const
|
|
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
|
-
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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 (
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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:
|
|
235
|
+
async function handleDimensionsInput(ui: ExtensionCommandContext["ui"]): Promise<void> {
|
|
248
236
|
const config = loadEmbeddingConfig();
|
|
249
237
|
|
|
250
|
-
const dimStr = await ui.input(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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:
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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(
|
|
276
|
+
const count = await reembedAllMemories(ctx);
|
|
294
277
|
|
|
295
278
|
markModelUsed();
|
|
296
279
|
|
|
297
|
-
|
|
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
|
}
|