@pi-unipi/memory 2.6.1 → 2.9.0

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/embedding.ts CHANGED
@@ -31,12 +31,6 @@ function getConfig(): EmbeddingConfig {
31
31
  return cachedConfig;
32
32
  }
33
33
 
34
- /** Force refresh config cache */
35
- export function refreshConfig(): void {
36
- cachedConfig = null;
37
- lastConfigLoad = 0;
38
- }
39
-
40
34
  /**
41
35
  * Generate an embedding for the given text via OpenRouter API.
42
36
  * Returns null if not configured or on error.
@@ -216,28 +210,3 @@ export async function reembedAllMemories(ctx: ExtensionCommandContext): Promise<
216
210
  return count;
217
211
  }
218
212
 
219
- /**
220
- * Convert Float32Array to Buffer for SQLite storage.
221
- */
222
- export function vectorToBuffer(vec: Float32Array): Buffer {
223
- return Buffer.from(vec.buffer);
224
- }
225
-
226
- /**
227
- * Convert Buffer from SQLite to Float32Array.
228
- */
229
- export function bufferToVector(buf: Buffer): Float32Array {
230
- return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
231
- }
232
-
233
- /**
234
- * Check if embeddings are available (sqlite-vec loaded).
235
- */
236
- export function hasEmbeddings(db: { prepare(sql: string): { get(...args: unknown[]): unknown } }): boolean {
237
- try {
238
- db.prepare("SELECT * FROM memories_vec LIMIT 1").get();
239
- return true;
240
- } catch {
241
- return false;
242
- }
243
- }
package/mempalace.ts CHANGED
@@ -94,11 +94,6 @@ function getBridgePath(): string | null {
94
94
  return cachedBridgePath;
95
95
  }
96
96
 
97
- /** Clear bridge discovery cache (primarily for recovery/tests). */
98
- export function invalidateBridgePathCache(): void {
99
- cachedBridgePath = undefined;
100
- }
101
-
102
97
  export interface BridgeResponse<T> {
103
98
  ok: boolean;
104
99
  result?: T;
@@ -232,12 +227,6 @@ export function ensureMempalace(): MempalaceInstall | null {
232
227
  return install;
233
228
  }
234
229
 
235
- /** Drop the cached install record (forces re-detection next session). */
236
- export function invalidateInstallCache(): void {
237
- try { if (fs.existsSync(INSTALL_FLAG)) fs.unlinkSync(INSTALL_FLAG); } catch { /* ignore */ }
238
- invalidatePingVerified();
239
- }
240
-
241
230
  /** Was the palace ping-verified recently enough to trust without re-pinging? */
242
231
  export function isPingVerified(): boolean {
243
232
  try {
@@ -351,11 +340,6 @@ export function markMigrated(
351
340
  }
352
341
  }
353
342
 
354
- /** Force re-migration by clearing the flag. */
355
- export function clearMigratedFlag(flagPath = MIGRATED_FLAG): void {
356
- try { if (fs.existsSync(flagPath)) fs.unlinkSync(flagPath); } catch { /* ignore */ }
357
- }
358
-
359
343
  /**
360
344
  * Run one bridge command synchronously. Returns the parsed result, or null
361
345
  * on any failure (timeout, non-zero exit, bad JSON, ok=false).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "2.6.1",
3
+ "version": "2.9.0",
4
4
  "description": "Persistent cross-session memory with MemPalace backend (auto-installed) and SQLite fallback for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -28,7 +28,6 @@
28
28
  "files": [
29
29
  "index.ts",
30
30
  "storage.ts",
31
- "search.ts",
32
31
  "embedding.ts",
33
32
  "settings.ts",
34
33
  "tools.ts",
@@ -40,24 +39,25 @@
40
39
  "README.md"
41
40
  ],
42
41
  "dependencies": {
43
- "better-sqlite3": "^12.9.0",
44
- "sqlite-vec": "^0.1.9",
45
- "js-yaml": "^4.1.0",
46
- "@pi-unipi/core": "2.6.1",
47
- "@pi-unipi/info-screen": "2.6.1"
42
+ "@pi-unipi/core": "2.9.0",
43
+ "@pi-unipi/info-screen": "2.9.0",
44
+ "js-yaml": "^4.1.0"
48
45
  },
49
46
  "peerDependencies": {
50
- "@earendil-works/pi-coding-agent": "^0.80.0",
47
+ "@earendil-works/pi-coding-agent": "^0.84.0",
51
48
  "typebox": "^1.1.38"
52
49
  },
53
50
  "devDependencies": {
54
- "@types/better-sqlite3": "^7.6.0",
55
51
  "@types/js-yaml": "^4.0.0",
56
52
  "@types/node": "^25.6.0"
57
53
  },
58
54
  "pi": {
59
- "extensions": [],
60
- "skills": [],
55
+ "extensions": [
56
+ "./index.ts"
57
+ ],
58
+ "skills": [
59
+ "./skills"
60
+ ],
61
61
  "prompts": [],
62
62
  "themes": []
63
63
  }
package/storage.ts CHANGED
@@ -1,14 +1,11 @@
1
1
  /**
2
2
  * @unipi/memory — Storage layer
3
3
  *
4
- * Primary backend: MemPalace (auto-installed via uv, auto-migrated from
5
- * legacy data). Falls back to SQLite + sqlite-vec when MemPalace/uv is
6
- * unavailable, so memory never hard-fails. Markdown files remain the
4
+ * Backend: MemPalace (auto-installed via uv, auto-migrated from
5
+ * legacy SQLite/markdown data on first run). Markdown files remain the
7
6
  * durable human-readable tier and the migration source.
8
7
  */
9
8
 
10
- import Database from "better-sqlite3";
11
- import * as sqliteVec from "sqlite-vec";
12
9
  import * as yaml from "js-yaml";
13
10
  import * as fs from "node:fs";
14
11
  import * as path from "node:path";
@@ -33,7 +30,6 @@ import {
33
30
  type MigrationResult,
34
31
  } from "./mempalace.js";
35
32
 
36
- export type MemoryBackend = "mempalace" | "sqlite";
37
33
 
38
34
  /** Convert a MemPalace record (plain JSON) into a MemoryRecord. */
39
35
  function toMemoryRecord(r: MempalaceRecord): MemoryRecord {
@@ -50,38 +46,6 @@ function toMemoryRecord(r: MempalaceRecord): MemoryRecord {
50
46
  };
51
47
  }
52
48
 
53
- /** Whether MemPalace is detectable on this machine (for status display). */
54
- export function isMempalaceAvailable(): boolean {
55
- try {
56
- return ensureMempalace() !== null;
57
- } catch {
58
- return false;
59
- }
60
- }
61
-
62
- /** Memory row from SQLite queries */
63
- interface MemoryRow {
64
- id: string;
65
- title: string;
66
- content?: string;
67
- type?: string;
68
- project?: string;
69
- tags?: string;
70
- created?: string;
71
- updated?: string;
72
- embedding?: { buffer: ArrayBuffer };
73
- }
74
-
75
- /** Search result row from vector queries */
76
- interface SearchResultRow {
77
- id: string;
78
- title: string;
79
- distance: number;
80
- rowid?: number;
81
- title_match?: number;
82
- content_match?: number;
83
- }
84
-
85
49
  /** Memory record interface */
86
50
  export interface MemoryRecord {
87
51
  id: string;
@@ -113,24 +77,6 @@ interface MemoryFrontmatter {
113
77
  type: string;
114
78
  }
115
79
 
116
- const MEMORY_DB_NAME = "memory.db";
117
- /**
118
- * Get the configured embedding dimensions.
119
- * Reads from config, falls back to 384.
120
- */
121
- function getEmbeddingDims(): number {
122
- try {
123
- const configPath = path.join(os.homedir(), ".unipi", "memory", "config.json");
124
- if (fs.existsSync(configPath)) {
125
- const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
126
- if (typeof raw.dimensions === "number" && raw.dimensions >= 64) {
127
- return raw.dimensions;
128
- }
129
- }
130
- } catch { /* ignore */ }
131
- return 384;
132
- }
133
-
134
80
  /**
135
81
  * Get the base memory directory (~/.unipi/memory/)
136
82
  */
@@ -248,10 +194,8 @@ ${record.content}
248
194
  * MemoryStorage class — manages SQLite + markdown storage for a single project.
249
195
  */
250
196
  export class MemoryStorage {
251
- private db: Database.Database | null = null;
252
197
  private projectName: string;
253
198
  private scopeDir: string;
254
- private backend: MemoryBackend = "sqlite";
255
199
  private mempalaceInstall: MempalaceInstall | null = null;
256
200
  private palacePath: string = DEFAULT_PALACE;
257
201
 
@@ -260,14 +204,9 @@ export class MemoryStorage {
260
204
  this.scopeDir = getProjectDir(projectName);
261
205
  }
262
206
 
263
- /** Active backend ("mempalace" when available, else "sqlite"). */
264
- getBackend(): MemoryBackend {
265
- return this.backend;
266
- }
267
-
268
207
  /** True when the MemPalace backend is active for this instance. */
269
208
  isMempalace(): boolean {
270
- return this.backend === "mempalace" && this.mempalaceInstall !== null;
209
+ return this.mempalaceInstall !== null;
271
210
  }
272
211
 
273
212
  /**
@@ -304,66 +243,29 @@ export class MemoryStorage {
304
243
  * path (a Python spawn) actually needs to yield.
305
244
  */
306
245
  async listAllAsync(): Promise<Array<{ id: string; title: string; type: string }>> {
307
- if (this.isMempalace()) {
308
- return (await this.memPalaceCallAsync<MempalaceListItem[]>("list", {
309
- wing: this.projectName,
310
- })) ?? [];
311
- }
312
- return this.listAll();
246
+ return (await this.memPalaceCallAsync<MempalaceListItem[]>("list", {
247
+ wing: this.projectName,
248
+ })) ?? [];
313
249
  }
314
250
 
315
251
  /**
316
252
  * Initialize storage. Tries MemPalace first (auto-install + one-way
317
- * auto-migration of legacy memories); falls back to SQLite if MemPalace
318
- * is unavailable. Never throws for backend unavailability — only throws
319
- * if the SQLite fallback itself fails to open.
253
+ * auto-migration of legacy memories). Throws if MemPalace is unavailable.
320
254
  */
321
255
  init(): void {
322
- // Ensure directory exists (used by both backends for markdown tier).
323
256
  if (!fs.existsSync(this.scopeDir)) {
324
257
  fs.mkdirSync(this.scopeDir, { recursive: true });
325
258
  }
326
259
 
327
- if (this.tryInitMempalace()) {
328
- return;
329
- }
330
-
331
- // Fallback: SQLite + sqlite-vec.
332
- this.backend = "sqlite";
333
- const dbPath = path.join(this.scopeDir, MEMORY_DB_NAME);
334
- const maxRetries = 5;
335
-
336
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
337
- try {
338
- this.initDb(dbPath);
339
- return; // Success
340
- } catch (err: unknown) {
341
- const errMsg = err instanceof Error ? err.message : "";
342
- const errCode = (err instanceof Error && 'code' in err) ? (err as NodeJS.ErrnoException).code : undefined;
343
- const isTransient =
344
- errMsg.includes("disk I/O error") ||
345
- errCode === "SQLITE_IOERR" ||
346
- errCode === "SQLITE_BUSY" ||
347
- errMsg.includes("database is locked");
348
-
349
- this.close();
350
-
351
- if (isTransient && attempt < maxRetries) {
352
- const delayMs = 50 * Math.pow(2, attempt - 1); // 50, 100, 200, 400
353
- const end = Date.now() + delayMs;
354
- while (Date.now() < end) { /* busy wait */ }
355
- continue;
356
- }
357
-
358
- throw err;
359
- }
260
+ if (!this.tryInitMempalace()) {
261
+ throw new Error("MemPalace backend unavailable. Ensure uv is installed.");
360
262
  }
361
263
  }
362
264
 
363
265
  /**
364
266
  * Attempt to initialize the MemPalace backend. Returns true on success.
365
267
  * Handles auto-install and one-way auto-migration of legacy memories.
366
- * Never throws — any failure returns false so the SQLite fallback runs.
268
+ * Never throws — any failure returns false so init() can throw a clear error.
367
269
  */
368
270
  private tryInitMempalace(): boolean {
369
271
  let install: MempalaceInstall | null;
@@ -385,7 +287,6 @@ export class MemoryStorage {
385
287
  }
386
288
 
387
289
  this.mempalaceInstall = install;
388
- this.backend = "mempalace";
389
290
 
390
291
  // Idempotent migration + automatic catch-up. The source fingerprint turns
391
292
  // the old one-shot timestamp into a resumable state: new/changed markdown
@@ -415,73 +316,12 @@ export class MemoryStorage {
415
316
  return true;
416
317
  }
417
318
 
418
- /**
419
- * Open database and set up schema. Called by init() with retry logic.
420
- */
421
- private initDb(dbPath: string): void {
422
- this.db = new Database(dbPath, { timeout: 5000 });
423
-
424
- // Enable WAL mode for concurrent reads
425
- this.db.pragma("journal_mode = WAL");
426
- this.db.pragma("busy_timeout = 5000");
427
-
428
- // Load sqlite-vec extension
429
- try {
430
- sqliteVec.load(this.db);
431
- } catch (_err) {
432
- // sqlite-vec unavailable — fuzzy-only mode. Silent startup.
433
- }
434
-
435
- // Create tables
436
- this.db.exec(`
437
- CREATE TABLE IF NOT EXISTS memories (
438
- id TEXT PRIMARY KEY,
439
- title TEXT NOT NULL,
440
- content TEXT NOT NULL,
441
- tags TEXT,
442
- project TEXT,
443
- type TEXT,
444
- created TEXT,
445
- updated TEXT,
446
- embedding BLOB
447
- )
448
- `);
449
-
450
- // Create vector table if sqlite-vec loaded
451
- try {
452
- this.db.exec(`
453
- CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(embedding float[${getEmbeddingDims()}])
454
- `);
455
- } catch {
456
- // vec0 table may already exist or sqlite-vec not loaded
457
- }
458
-
459
- // Verify database is usable
460
- this.db.prepare("SELECT 1 FROM memories LIMIT 0").get();
461
- }
462
319
 
463
320
  /**
464
321
  * Close the database connection.
465
322
  */
466
323
  close(): void {
467
- if (this.db) {
468
- this.db.close();
469
- this.db = null;
470
- }
471
- }
472
-
473
- /**
474
- * Check if database is healthy.
475
- */
476
- isHealthy(): boolean {
477
- if (this.isMempalace()) return true;
478
- if (!this.db) return false;
479
- try {
480
- this.db.prepare("SELECT 1").get();
481
- return true;
482
- } catch {
483
- return false;
484
- }
324
+ // MemPalace is processless; nothing to close.
485
325
  }
486
326
 
487
327
  /**
@@ -502,85 +342,8 @@ export class MemoryStorage {
502
342
  // Set project if not provided
503
343
  if (!record.project) record.project = this.projectName;
504
344
 
505
- if (this.isMempalace()) {
506
- this.storeMempalace(record);
507
- return;
508
- }
509
-
510
- if (!this.db) throw new Error("Storage not initialized");
511
-
512
- // Prepare markdown content BEFORE transaction (fail fast)
513
- const mdPath = path.join(this.scopeDir, `${record.id}.md`);
514
- const frontmatter: MemoryFrontmatter = {
515
- title: record.title,
516
- tags: record.tags,
517
- project: record.project,
518
- created: record.created,
519
- updated: record.updated,
520
- type: record.type,
521
- };
522
- const mdContent = `---\n${yaml.dump(frontmatter, { lineWidth: -1 })}---\n\n${record.content}\n`;
523
-
524
- // Use transaction for atomicity
525
- const storeInTx = this.db.transaction(() => {
526
- // Upsert into memories table
527
- const stmt = this.db!.prepare(`
528
- INSERT OR REPLACE INTO memories (id, title, content, tags, project, type, created, updated, embedding)
529
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
530
- `);
531
-
532
- const tagsJson = JSON.stringify(record.tags);
533
- const embeddingBuf = record.embedding ? Buffer.from(record.embedding.buffer) : null;
534
-
535
- stmt.run(
536
- record.id,
537
- record.title,
538
- record.content,
539
- tagsJson,
540
- record.project,
541
- record.type,
542
- record.created,
543
- record.updated,
544
- embeddingBuf
545
- );
546
-
547
- // Update vector table
548
- if (record.embedding) {
549
- try {
550
- // Delete old vector if exists
551
- this.db!.prepare("DELETE FROM memories_vec WHERE rowid = ?").run(BigInt(this.idToRowid(record.id)));
552
- } catch {
553
- // Ignore if not found
554
- }
555
-
556
- try {
557
- const vecStmt = this.db!.prepare(
558
- "INSERT INTO memories_vec(rowid, embedding) VALUES (?, ?)"
559
- );
560
- vecStmt.run(
561
- BigInt(this.idToRowid(record.id)),
562
- Buffer.from(record.embedding.buffer)
563
- );
564
- } catch (_err) {
565
- // Vector insert failure — memory still searchable via text/FTS.
566
- }
567
- }
568
- });
569
-
570
- // Execute transaction
571
- storeInTx();
572
-
573
- // Write markdown file AFTER successful DB write
574
- try {
575
- // Ensure directory exists
576
- const dir = path.dirname(mdPath);
577
- if (!fs.existsSync(dir)) {
578
- fs.mkdirSync(dir, { recursive: true });
579
- }
580
- fs.writeFileSync(mdPath, mdContent, "utf-8");
581
- } catch (_err) {
582
- // DB write succeeded but file write failed — memory still in DB and searchable.
583
- }
345
+ this.storeMempalace(record);
346
+ return;
584
347
  }
585
348
 
586
349
  /**
@@ -620,80 +383,11 @@ export class MemoryStorage {
620
383
  * Returns count of synced files.
621
384
  */
622
385
  syncOrphanedFiles(): number {
623
- if (this.isMempalace()) {
624
- const synced = this.memPalaceCall<number>("sync_orphaned", {
625
- project_dir: this.scopeDir,
626
- wing: this.projectName,
627
- });
628
- return synced ?? 0;
629
- }
630
-
631
- if (!this.db) throw new Error("Storage not initialized");
632
-
633
- const files = fs.readdirSync(this.scopeDir)
634
- .filter(f => f.endsWith(".md") && !f.startsWith("."));
635
-
636
- // Get existing IDs from DB
637
- const existingIds = new Set(
638
- (this.db.prepare("SELECT id FROM memories").all() as MemoryRow[])
639
- .map(r => r.id)
640
- );
641
-
642
- let synced = 0;
643
- for (const file of files) {
644
- const filePath = path.join(this.scopeDir, file);
645
- const record = parseMemoryFile(filePath);
646
- if (!record) continue;
647
-
648
- // New files preserve the authoritative store ID. Legacy files have no
649
- // `id` frontmatter, so retain the historical title-derived fallback.
650
- const id = record.id || record.title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
651
-
652
- if (existingIds.has(id)) continue; // Already in DB
653
-
654
- // Insert into DB
655
- try {
656
- record.id = id;
657
- const tagsJson = JSON.stringify(record.tags);
658
-
659
- this.db.prepare(`
660
- INSERT OR IGNORE INTO memories (id, title, content, tags, project, type, created, updated, embedding)
661
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)
662
- `).run(
663
- id,
664
- record.title,
665
- record.content,
666
- tagsJson,
667
- record.project || this.projectName,
668
- record.type,
669
- record.created,
670
- record.updated
671
- );
672
-
673
- synced++;
674
- // Removed console.warn — orphaned file sync is silent.
675
- } catch (_err) {
676
- // Sync failure — file remains as standalone markdown.
677
- }
678
- }
679
-
680
- return synced;
681
- }
682
-
683
- /**
684
- * Check if a memory with the given title already exists.
685
- */
686
- hasByTitle(title: string): boolean {
687
- if (this.isMempalace()) {
688
- return this.memPalaceCall<boolean>("has_title", {
689
- wing: this.projectName,
690
- title,
691
- }) ?? false;
692
- }
693
- if (!this.db) throw new Error("Storage not initialized");
694
- const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
695
- const row = this.db.prepare("SELECT 1 FROM memories WHERE id = ?").get(id);
696
- return !!row;
386
+ const synced = this.memPalaceCall<number>("sync_orphaned", {
387
+ project_dir: this.scopeDir,
388
+ wing: this.projectName,
389
+ });
390
+ return synced ?? 0;
697
391
  }
698
392
 
699
393
  /**
@@ -701,165 +395,53 @@ export class MemoryStorage {
701
395
  * Returns array of { record, similarity } sorted by similarity desc.
702
396
  */
703
397
  findSimilarByTitle(title: string, threshold = 0.6): Array<{ record: MemoryRecord; similarity: number }> {
704
- if (this.isMempalace()) {
705
- const rows = this.memPalaceCall<Array<{ record: MempalaceRecord; similarity: number }>>(
706
- "find_similar",
707
- { wing: this.projectName, title, threshold },
708
- ) ?? [];
709
- return rows.map((r) => ({ record: toMemoryRecord(r.record), similarity: r.similarity }));
710
- }
711
- if (!this.db) throw new Error("Storage not initialized");
712
-
713
- const allRows = this.db.prepare("SELECT id, title FROM memories").all() as MemoryRow[];
714
- const results: Array<{ record: MemoryRecord; similarity: number }> = [];
715
-
716
- const normalizedTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, " ");
717
- const titleWords = new Set(normalizedTitle.split(/\s+/).filter((w: string) => w.length > 2));
718
-
719
- for (const row of allRows) {
720
- const normalizedRowTitle = row.title.toLowerCase().replace(/[^a-z0-9]+/g, " ");
721
- const rowWords = new Set(normalizedRowTitle.split(/\s+/).filter((w: string) => w.length > 2));
722
-
723
- // Calculate Jaccard similarity
724
- const intersection = new Set([...titleWords].filter((w: string) => rowWords.has(w)));
725
- const union = new Set([...titleWords, ...rowWords]);
726
- const similarity = union.size > 0 ? intersection.size / union.size : 0;
727
-
728
- if (similarity >= threshold) {
729
- const record = this.getById(row.id);
730
- if (record) {
731
- results.push({ record, similarity });
732
- }
733
- }
734
- }
735
-
736
- return results.sort((a, b) => b.similarity - a.similarity);
398
+ const rows = this.memPalaceCall<Array<{ record: MempalaceRecord; similarity: number }>>(
399
+ "find_similar",
400
+ { wing: this.projectName, title, threshold },
401
+ ) ?? [];
402
+ return rows.map((r) => ({ record: toMemoryRecord(r.record), similarity: r.similarity }));
737
403
  }
738
404
 
739
405
  /**
740
406
  * Get a memory record by ID.
741
407
  */
742
408
  getById(id: string): MemoryRecord | null {
743
- if (this.isMempalace()) {
744
- const rec = this.memPalaceCall<MempalaceRecord | null>("get", { id });
745
- return rec ? toMemoryRecord(rec) : null;
746
- }
747
- if (!this.db) throw new Error("Storage not initialized");
748
-
749
- const row = this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id) as MemoryRow | undefined;
750
- if (!row) return null;
751
-
752
- return {
753
- id: row.id,
754
- title: row.title,
755
- content: row.content ?? "",
756
- tags: JSON.parse(row.tags || "[]"),
757
- project: row.project ?? "",
758
- type: (row.type ?? "summary") as MemoryRecord["type"],
759
- created: row.created ?? "",
760
- updated: row.updated ?? "",
761
- embedding: row.embedding ? new Float32Array(row.embedding.buffer) : null,
762
- };
409
+ const rec = this.memPalaceCall<MempalaceRecord | null>("get", { id });
410
+ return rec ? toMemoryRecord(rec) : null;
763
411
  }
764
412
 
765
413
  /**
766
414
  * Get a memory record by title (fuzzy match).
767
415
  */
768
416
  getByTitle(title: string): MemoryRecord | null {
769
- if (this.isMempalace()) {
770
- const rec = this.memPalaceCall<MempalaceRecord | null>("get_by_title", {
771
- wing: this.projectName,
772
- title,
773
- });
774
- return rec ? toMemoryRecord(rec) : null;
775
- }
776
- if (!this.db) throw new Error("Storage not initialized");
777
-
778
- // Try exact match first
779
- const exact = this.db.prepare("SELECT * FROM memories WHERE title = ?").get(title) as MemoryRow | undefined;
780
- if (exact) {
781
- return {
782
- id: exact.id,
783
- title: exact.title,
784
- content: exact.content ?? "",
785
- tags: JSON.parse(exact.tags || "[]"),
786
- project: exact.project ?? "",
787
- type: (exact.type ?? "summary") as MemoryRecord["type"],
788
- created: exact.created ?? "",
789
- updated: exact.updated ?? "",
790
- embedding: exact.embedding ? new Float32Array(exact.embedding.buffer) : null,
791
- };
792
- }
793
-
794
- // Try case-insensitive match
795
- const row = this.db.prepare("SELECT * FROM memories WHERE LOWER(title) = LOWER(?)").get(title) as MemoryRow | undefined;
796
- if (!row) return null;
797
-
798
- return {
799
- id: row.id,
800
- title: row.title,
801
- content: row.content ?? "",
802
- tags: JSON.parse(row.tags || "[]"),
803
- project: row.project ?? "",
804
- type: (row.type ?? "summary") as MemoryRecord["type"],
805
- created: row.created ?? "",
806
- updated: row.updated ?? "",
807
- embedding: row.embedding ? new Float32Array(row.embedding.buffer) : null,
808
- };
417
+ const rec = this.memPalaceCall<MempalaceRecord | null>("get_by_title", {
418
+ wing: this.projectName,
419
+ title,
420
+ });
421
+ return rec ? toMemoryRecord(rec) : null;
809
422
  }
810
423
 
811
424
  /**
812
425
  * List all memories (titles only).
813
426
  */
814
427
  listAll(): Array<{ id: string; title: string; type: string }> {
815
- if (this.isMempalace()) {
816
- const items = this.memPalaceCall<MempalaceListItem[]>("list", {
817
- wing: this.projectName,
818
- }) ?? [];
819
- return items;
820
- }
821
- if (!this.db) throw new Error("Storage not initialized");
822
-
823
- const rows = this.db.prepare("SELECT id, title, type FROM memories ORDER BY updated DESC").all() as MemoryRow[];
824
- return rows.map((r) => ({ id: r.id, title: r.title, type: r.type ?? "" }));
428
+ const items = this.memPalaceCall<MempalaceListItem[]>("list", {
429
+ wing: this.projectName,
430
+ }) ?? [];
431
+ return items;
825
432
  }
826
433
 
827
434
  /**
828
435
  * Delete a memory by ID.
829
436
  */
830
437
  delete(id: string): boolean {
831
- if (this.isMempalace()) {
832
- const ok = this.memPalaceCall<boolean>("delete", { id }) ?? false;
833
- // Also remove the markdown tier if present.
834
- try {
835
- const mdPath = path.join(this.scopeDir, `${id}.md`);
836
- if (fs.existsSync(mdPath)) fs.unlinkSync(mdPath);
837
- } catch { /* ignore */ }
838
- return ok;
839
- }
840
- if (!this.db) throw new Error("Storage not initialized");
841
-
842
- // Delete from vector table
843
- try {
844
- this.db.prepare("DELETE FROM memories_vec WHERE rowid = ?").run(BigInt(this.idToRowid(id)));
845
- } catch {
846
- // Ignore
847
- }
848
-
849
- // Delete from memories table
850
- const result = this.db.prepare("DELETE FROM memories WHERE id = ?").run(id);
851
-
852
- // Delete markdown file
853
- const mdPath = path.join(this.scopeDir, `${id}.md`);
438
+ const ok = this.memPalaceCall<boolean>("delete", { id }) ?? false;
439
+ // Also remove the markdown tier if present.
854
440
  try {
855
- if (fs.existsSync(mdPath)) {
856
- fs.unlinkSync(mdPath);
857
- }
858
- } catch {
859
- // Ignore
860
- }
861
-
862
- return result.changes > 0;
441
+ const mdPath = path.join(this.scopeDir, `${id}.md`);
442
+ if (fs.existsSync(mdPath)) fs.unlinkSync(mdPath);
443
+ } catch { /* ignore */ }
444
+ return ok;
863
445
  }
864
446
 
865
447
  /**
@@ -875,151 +457,18 @@ export class MemoryStorage {
875
457
  * Search memories using hybrid approach.
876
458
  */
877
459
  search(query: string, limit = 10, embedding?: Float32Array | null): SearchResult[] {
878
- if (this.isMempalace()) {
879
- const rows = this.memPalaceCall<MempalaceSearchResult[]>("search", {
880
- query,
881
- wing: this.projectName,
882
- limit,
883
- }) ?? [];
884
- return rows.map((r) => ({
885
- record: toMemoryRecord(r),
886
- score: r.score,
887
- snippet: r.snippet,
888
- }));
889
- }
890
- if (!this.db) throw new Error("Storage not initialized");
891
-
892
- const results: Map<string, SearchResult> = new Map();
893
-
894
- // 1. Vector search (if embedding provided and vec table exists)
895
- if (embedding) {
896
- try {
897
- const vecResults = this.db
898
- .prepare(
899
- `SELECT rowid, distance FROM memories_vec
900
- WHERE embedding MATCH ?
901
- ORDER BY distance
902
- LIMIT ?`
903
- )
904
- .all(Buffer.from(embedding.buffer), limit * 2) as SearchResultRow[];
905
-
906
- for (const vr of vecResults) {
907
- const memoryId = this.rowidToId(Number(vr.rowid));
908
- const record = this.getById(memoryId);
909
- if (record) {
910
- const score = 1 - Math.min(vr.distance, 1); // Normalize to 0-1
911
- const snippet = this.extractSnippet(record.content, query);
912
- results.set(record.id, { record, score, snippet });
913
- }
914
- }
915
- } catch (err) {
916
- // Vector search failed, continue with fuzzy
917
- }
918
- }
919
-
920
- // 2. Fuzzy text search (split query into words)
921
- const queryWords = query.toLowerCase().split(/\s+/).filter(w => w.length > 0);
922
-
923
- // Build conditions: each word must match either title OR content
924
- const wordConditions = queryWords.map(() =>
925
- "(LOWER(title) LIKE LOWER(?) OR LOWER(content) LIKE LOWER(?))"
926
- ).join(" AND ");
927
-
928
- const fuzzyResults = this.db
929
- .prepare(
930
- `SELECT id, title, content,
931
- (CASE WHEN LOWER(title) LIKE LOWER(?) THEN 1 ELSE 0 END) as title_match,
932
- (CASE WHEN LOWER(content) LIKE LOWER(?) THEN 1 ELSE 0 END) as content_match
933
- FROM memories
934
- WHERE ${wordConditions}
935
- LIMIT ?`
936
- )
937
- .all(
938
- `%${query}%`,
939
- `%${query}%`,
940
- ...queryWords.flatMap(w => [`%${w}%`, `%${w}%`]),
941
- limit * 2
942
- ) as SearchResultRow[];
943
-
944
- for (const fr of fuzzyResults) {
945
- const existing = results.get(fr.id);
946
- const fuzzyScore = ((fr.title_match ?? 0) * 0.7 + (fr.content_match ?? 0) * 0.3);
947
- const record = this.getById(fr.id);
948
- if (record) {
949
- const snippet = this.extractSnippet(record.content, query);
950
- if (existing) {
951
- // Boost score if found in both vector and fuzzy
952
- existing.score = Math.min(existing.score + fuzzyScore * 0.3, 1);
953
- } else {
954
- results.set(fr.id, { record, score: fuzzyScore, snippet });
955
- }
956
- }
957
- }
958
-
959
- // 3. Sort by score and return top results
960
- return Array.from(results.values())
961
- .sort((a, b) => b.score - a.score)
962
- .slice(0, limit);
963
- }
964
-
965
- /**
966
- * Get the underlying database for advanced queries.
967
- */
968
- getDb(): Database.Database | null {
969
- return this.db;
970
- }
971
-
972
- /**
973
- * Get the scope directory.
974
- */
975
- getScopeDir(): string {
976
- return this.scopeDir;
977
- }
978
-
979
- /**
980
- * Extract a snippet around the query match.
981
- */
982
- private extractSnippet(content: string, query: string, chars = 100): string {
983
- const lowerContent = content.toLowerCase();
984
- const lowerQuery = query.toLowerCase();
985
- const idx = lowerContent.indexOf(lowerQuery);
986
-
987
- if (idx === -1) {
988
- // No match, return beginning
989
- return content.slice(0, chars) + (content.length > chars ? "..." : "");
990
- }
991
-
992
- const start = Math.max(0, idx - chars / 2);
993
- const end = Math.min(content.length, idx + query.length + chars / 2);
994
- let snippet = content.slice(start, end);
995
-
996
- if (start > 0) snippet = "..." + snippet;
997
- if (end < content.length) snippet = snippet + "...";
998
-
999
- return snippet;
1000
- }
1001
-
1002
- /**
1003
- * Convert string ID to numeric rowid for sqlite-vec.
1004
- */
1005
- private idToRowid(id: string): number {
1006
- // Simple hash: sum of char codes modulo 1M
1007
- let hash = 0;
1008
- for (let i = 0; i < id.length; i++) {
1009
- hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
1010
- }
1011
- return Math.abs(hash) % 1_000_000;
460
+ const rows = this.memPalaceCall<MempalaceSearchResult[]>("search", {
461
+ query,
462
+ wing: this.projectName,
463
+ limit,
464
+ }) ?? [];
465
+ return rows.map((r) => ({
466
+ record: toMemoryRecord(r),
467
+ score: r.score,
468
+ snippet: r.snippet,
469
+ }));
1012
470
  }
1013
471
 
1014
- /**
1015
- * Convert numeric rowid back to string ID.
1016
- */
1017
- private rowidToId(rowid: number): string {
1018
- // Look up ID from memories table by rowid
1019
- if (!this.db) return "";
1020
- const row = this.db.prepare("SELECT id FROM memories LIMIT 1 OFFSET ?").get(rowid) as any;
1021
- return row?.id || "";
1022
- }
1023
472
  }
1024
473
 
1025
474
  /**
@@ -1044,29 +493,8 @@ export function searchAllProjects(
1044
493
  }));
1045
494
  }
1046
495
 
1047
- // SQLite fallback: iterate project directories.
1048
- const projectDirs = getAllProjectDirs();
1049
- const allResults: SearchResult[] = [];
1050
-
1051
- for (const { name: projectName, dir } of projectDirs) {
1052
- const dbPath = path.join(dir, MEMORY_DB_NAME);
1053
- if (!fs.existsSync(dbPath)) continue;
1054
496
 
1055
- try {
1056
- const storage = new MemoryStorage(projectName);
1057
- storage.init();
1058
- const results = storage.search(query, limit);
1059
- allResults.push(...results);
1060
- storage.close();
1061
- } catch {
1062
- // Skip projects with corrupted DB
1063
- }
1064
- }
1065
-
1066
- // Sort by score and return top results
1067
- return allResults
1068
- .sort((a, b) => b.score - a.score)
1069
- .slice(0, limit);
497
+ return [];
1070
498
  }
1071
499
 
1072
500
  /** Result shape shared by listAllProjects and its cached wrapper. */
@@ -1094,7 +522,7 @@ export function invalidateAllProjectsCache(): void {
1094
522
  *
1095
523
  * On a cache miss the MemPalace path spawns Python; doing that synchronously
1096
524
  * froze the UI for ~1.1s. Only the bridge call is async — the SQLite fallback
1097
- * is fast enough to run inline.
525
+ * is async.
1098
526
  */
1099
527
  export async function listAllProjectsCachedAsync(): Promise<AllProjectsEntry[]> {
1100
528
  const now = Date.now();
@@ -1103,13 +531,9 @@ export async function listAllProjectsCachedAsync(): Promise<AllProjectsEntry[]>
1103
531
  }
1104
532
 
1105
533
  const install = ensureMempalace();
1106
- let value: AllProjectsEntry[];
1107
- if (install) {
1108
- const items = (await runBridgeAsync<MempalaceListItemAll[]>(install, DEFAULT_PALACE, "list_all", {})) ?? [];
1109
- value = items.map((m) => ({ project: m.project, id: m.id, title: m.title, type: m.type }));
1110
- } else {
1111
- value = listAllProjects();
1112
- }
534
+ if (!install) return [];
535
+ const items = (await runBridgeAsync<MempalaceListItemAll[]>(install, DEFAULT_PALACE, "list_all", {})) ?? [];
536
+ const value = items.map((m) => ({ project: m.project, id: m.id, title: m.title, type: m.type }));
1113
537
 
1114
538
  allProjectsCache = { at: now, value };
1115
539
  return value;
@@ -1132,128 +556,7 @@ export function listAllProjects(): AllProjectsEntry[] {
1132
556
  }));
1133
557
  }
1134
558
 
1135
- // SQLite fallback: iterate project directories.
1136
- const projectDirs = getAllProjectDirs();
1137
- const allMemories: AllProjectsEntry[] = [];
1138
-
1139
- for (const { name: projectName, dir } of projectDirs) {
1140
- const dbPath = path.join(dir, MEMORY_DB_NAME);
1141
- if (!fs.existsSync(dbPath)) continue;
1142
559
 
1143
- try {
1144
- const storage = new MemoryStorage(projectName);
1145
- storage.init();
1146
- const memories = storage.listAll();
1147
- allMemories.push(
1148
- ...memories.map((m) => ({
1149
- project: projectName,
1150
- id: m.id,
1151
- title: m.title,
1152
- type: m.type,
1153
- }))
1154
- );
1155
- storage.close();
1156
- } catch {
1157
- // Skip projects with corrupted DB
1158
- }
1159
- }
1160
-
1161
- return allMemories;
560
+ return [];
1162
561
  }
1163
562
 
1164
- /**
1165
- * In-memory storage fallback when SQLite is unavailable.
1166
- */
1167
- export class InMemoryStorage {
1168
- private records: Map<string, MemoryRecord> = new Map();
1169
- private projectName: string;
1170
- private globalScope: boolean;
1171
-
1172
- constructor(projectName: string, globalScope = false) {
1173
- this.projectName = projectName;
1174
- this.globalScope = globalScope;
1175
- }
1176
-
1177
- store(record: MemoryRecord): void {
1178
- if (!record.id) {
1179
- record.id = record.title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
1180
- }
1181
- const now = new Date().toISOString();
1182
- if (!record.created) record.created = now;
1183
- record.updated = now;
1184
- if (!record.project) record.project = this.projectName;
1185
- this.records.set(record.id, record);
1186
- }
1187
-
1188
- getById(id: string): MemoryRecord | null {
1189
- return this.records.get(id) || null;
1190
- }
1191
-
1192
- getByTitle(title: string): MemoryRecord | null {
1193
- for (const record of this.records.values()) {
1194
- if (record.title.toLowerCase() === title.toLowerCase()) {
1195
- return record;
1196
- }
1197
- }
1198
- return null;
1199
- }
1200
-
1201
- listAll(): Array<{ id: string; title: string; type: string }> {
1202
- return Array.from(this.records.values()).map((r) => ({
1203
- id: r.id,
1204
- title: r.title,
1205
- type: r.type,
1206
- }));
1207
- }
1208
-
1209
- delete(id: string): boolean {
1210
- return this.records.delete(id);
1211
- }
1212
-
1213
- deleteByTitle(title: string): boolean {
1214
- const record = this.getByTitle(title);
1215
- if (!record) return false;
1216
- return this.delete(record.id);
1217
- }
1218
-
1219
- search(query: string, limit = 10): SearchResult[] {
1220
- const results: SearchResult[] = [];
1221
- const lowerQuery = query.toLowerCase();
1222
-
1223
- for (const record of this.records.values()) {
1224
- const titleMatch = record.title.toLowerCase().includes(lowerQuery);
1225
- const contentMatch = record.content.toLowerCase().includes(lowerQuery);
1226
-
1227
- if (titleMatch || contentMatch) {
1228
- const score = titleMatch ? 0.7 : 0.3;
1229
- const snippet = this.extractSnippet(record.content, query);
1230
- results.push({ record, score, snippet });
1231
- }
1232
- }
1233
-
1234
- return results.sort((a, b) => b.score - a.score).slice(0, limit);
1235
- }
1236
-
1237
- close(): void {
1238
- // No-op for in-memory
1239
- }
1240
-
1241
- private extractSnippet(content: string, query: string, chars = 100): string {
1242
- const lowerContent = content.toLowerCase();
1243
- const lowerQuery = query.toLowerCase();
1244
- const idx = lowerContent.indexOf(lowerQuery);
1245
-
1246
- if (idx === -1) {
1247
- return content.slice(0, chars) + (content.length > chars ? "..." : "");
1248
- }
1249
-
1250
- const start = Math.max(0, idx - chars / 2);
1251
- const end = Math.min(content.length, idx + query.length + chars / 2);
1252
- let snippet = content.slice(start, end);
1253
-
1254
- if (start > 0) snippet = "..." + snippet;
1255
- if (end < content.length) snippet = snippet + "...";
1256
-
1257
- return snippet;
1258
- }
1259
- }