@pi-unipi/memory 2.0.13 → 2.1.1

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/storage.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * @unipi/memory — Storage layer
3
3
  *
4
- * Two-tier storage: SQLite + sqlite-vec for vector search,
5
- * markdown files for human-readable memory.
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
7
+ * durable human-readable tier and the migration source.
6
8
  */
7
9
 
8
10
  import Database from "better-sqlite3";
@@ -12,6 +14,47 @@ import * as fs from "node:fs";
12
14
  import * as path from "node:path";
13
15
  import * as os from "node:os";
14
16
  import { randomUUID } from "node:crypto";
17
+ import {
18
+ ensureMempalace,
19
+ runBridge,
20
+ isMigrated,
21
+ markMigrated,
22
+ isPingVerified,
23
+ markPingVerified,
24
+ invalidatePingVerified,
25
+ DEFAULT_PALACE,
26
+ type MempalaceInstall,
27
+ type MempalaceRecord,
28
+ type MempalaceSearchResult,
29
+ type MempalaceListItem,
30
+ type MempalaceListItemAll,
31
+ } from "./mempalace.js";
32
+
33
+ export type MemoryBackend = "mempalace" | "sqlite";
34
+
35
+ /** Convert a MemPalace record (plain JSON) into a MemoryRecord. */
36
+ function toMemoryRecord(r: MempalaceRecord): MemoryRecord {
37
+ return {
38
+ id: r.id,
39
+ title: r.title,
40
+ content: r.content,
41
+ tags: Array.isArray(r.tags) ? r.tags : [],
42
+ project: r.project,
43
+ type: (r.type as MemoryRecord["type"]) || "summary",
44
+ created: r.created || "",
45
+ updated: r.updated || "",
46
+ embedding: null,
47
+ };
48
+ }
49
+
50
+ /** Whether MemPalace is detectable on this machine (for status display). */
51
+ export function isMempalaceAvailable(): boolean {
52
+ try {
53
+ return ensureMempalace() !== null;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
15
58
 
16
59
  /** Memory row from SQLite queries */
17
60
  interface MemoryRow {
@@ -203,27 +246,59 @@ export class MemoryStorage {
203
246
  private db: Database.Database | null = null;
204
247
  private projectName: string;
205
248
  private scopeDir: string;
249
+ private backend: MemoryBackend = "sqlite";
250
+ private mempalaceInstall: MempalaceInstall | null = null;
251
+ private palacePath: string = DEFAULT_PALACE;
206
252
 
207
253
  constructor(projectName: string) {
208
254
  this.projectName = projectName;
209
255
  this.scopeDir = getProjectDir(projectName);
210
256
  }
211
257
 
258
+ /** Active backend ("mempalace" when available, else "sqlite"). */
259
+ getBackend(): MemoryBackend {
260
+ return this.backend;
261
+ }
262
+
263
+ /** True when the MemPalace backend is active for this instance. */
264
+ isMempalace(): boolean {
265
+ return this.backend === "mempalace" && this.mempalaceInstall !== null;
266
+ }
267
+
268
+ /**
269
+ * Run a MemPalace bridge command, invalidating the ping-verified flag
270
+ * when the call fails. This ensures a palace that breaks after the
271
+ * startup ping-skip gets re-verified on the next session.
272
+ */
273
+ private memPalaceCall<T>(cmd: string, args: Record<string, unknown> = {}): T | null {
274
+ const install = this.mempalaceInstall;
275
+ if (!install) return null;
276
+ const result = runBridge<T>(install, this.palacePath, cmd, args);
277
+ if (result === null) {
278
+ // Backend didn't respond — force a real ping next session.
279
+ invalidatePingVerified();
280
+ }
281
+ return result;
282
+ }
283
+
212
284
  /**
213
- * Initialize the storage (create DB, tables, load extension).
214
- *
215
- * Uses retry logic to handle concurrent access from multiple Pi sessions,
216
- * especially on WSL/Windows filesystem where SQLite locking can be flaky.
217
- *
218
- * IMPORTANT: We never delete the DB here — another session may have it open.
219
- * If all retries fail, we throw and let this session run without memory.
285
+ * Initialize storage. Tries MemPalace first (auto-install + one-way
286
+ * auto-migration of legacy memories); falls back to SQLite if MemPalace
287
+ * is unavailable. Never throws for backend unavailability only throws
288
+ * if the SQLite fallback itself fails to open.
220
289
  */
221
290
  init(): void {
222
- // Ensure directory exists
291
+ // Ensure directory exists (used by both backends for markdown tier).
223
292
  if (!fs.existsSync(this.scopeDir)) {
224
293
  fs.mkdirSync(this.scopeDir, { recursive: true });
225
294
  }
226
295
 
296
+ if (this.tryInitMempalace()) {
297
+ return;
298
+ }
299
+
300
+ // Fallback: SQLite + sqlite-vec.
301
+ this.backend = "sqlite";
227
302
  const dbPath = path.join(this.scopeDir, MEMORY_DB_NAME);
228
303
  const maxRetries = 5;
229
304
 
@@ -243,27 +318,60 @@ export class MemoryStorage {
243
318
  this.close();
244
319
 
245
320
  if (isTransient && attempt < maxRetries) {
246
- // Likely concurrent access — back off and retry.
247
- // Do NOT delete the DB: another session may have it open
248
- // and deleting open files on WSL/Windows is unsafe.
249
321
  const delayMs = 50 * Math.pow(2, attempt - 1); // 50, 100, 200, 400
250
- // Removed console.warn — transient retries are normal during concurrent access.
251
- // Memory availability visible via info-screen memory group.
252
322
  const end = Date.now() + delayMs;
253
323
  while (Date.now() < end) { /* busy wait */ }
254
324
  continue;
255
325
  }
256
326
 
257
- // Either non-transient error, or retries exhausted.
258
- // Log and throw — this session will run without memory.
259
- if (isTransient) {
260
- // Removed console.warn — memory unavailable status visible via info-screen.
261
- }
262
327
  throw err;
263
328
  }
264
329
  }
265
330
  }
266
331
 
332
+ /**
333
+ * Attempt to initialize the MemPalace backend. Returns true on success.
334
+ * Handles auto-install and one-way auto-migration of legacy memories.
335
+ * Never throws — any failure returns false so the SQLite fallback runs.
336
+ */
337
+ private tryInitMempalace(): boolean {
338
+ let install: MempalaceInstall | null;
339
+ try {
340
+ install = ensureMempalace();
341
+ } catch {
342
+ return false;
343
+ }
344
+ if (!install) return false;
345
+
346
+ // Sanity ping — if the palace/bridge is broken, fall back.
347
+ // Skip the ~0.5s Python cold-start when we ping-verified recently;
348
+ // the flag is invalidated on any backend failure so a broken palace
349
+ // is re-checked on the next session.
350
+ if (!isPingVerified()) {
351
+ const ok = runBridge<string>(install, this.palacePath, "ping");
352
+ if (ok !== "pong") return false;
353
+ markPingVerified();
354
+ }
355
+
356
+ this.mempalaceInstall = install;
357
+ this.backend = "mempalace";
358
+
359
+ // One-way auto-migration of legacy memories (idempotent).
360
+ if (!isMigrated()) {
361
+ try {
362
+ runBridge(install, this.palacePath, "migrate", {
363
+ source_dir: getMemoryBaseDir(),
364
+ });
365
+ markMigrated();
366
+ } catch {
367
+ // Migration failed — palace still usable for new stores; legacy
368
+ // memories can be re-migrated later. Do not block.
369
+ }
370
+ }
371
+
372
+ return true;
373
+ }
374
+
267
375
  /**
268
376
  * Open database and set up schema. Called by init() with retry logic.
269
377
  */
@@ -341,6 +449,7 @@ export class MemoryStorage {
341
449
  * Check if database is healthy.
342
450
  */
343
451
  isHealthy(): boolean {
452
+ if (this.isMempalace()) return true;
344
453
  if (!this.db) return false;
345
454
  try {
346
455
  this.db.prepare("SELECT 1").get();
@@ -355,8 +464,6 @@ export class MemoryStorage {
355
464
  * Uses transaction to ensure atomicity — either all writes succeed or none do.
356
465
  */
357
466
  store(record: MemoryRecord): void {
358
- if (!this.db) throw new Error("Storage not initialized");
359
-
360
467
  // Generate ID from title if not provided
361
468
  if (!record.id) {
362
469
  record.id = record.title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
@@ -370,6 +477,13 @@ export class MemoryStorage {
370
477
  // Set project if not provided
371
478
  if (!record.project) record.project = this.projectName;
372
479
 
480
+ if (this.isMempalace()) {
481
+ this.storeMempalace(record);
482
+ return;
483
+ }
484
+
485
+ if (!this.db) throw new Error("Storage not initialized");
486
+
373
487
  // Prepare markdown content BEFORE transaction (fail fast)
374
488
  const mdPath = path.join(this.scopeDir, `${record.id}.md`);
375
489
  const frontmatter: MemoryFrontmatter = {
@@ -444,6 +558,36 @@ export class MemoryStorage {
444
558
  }
445
559
  }
446
560
 
561
+ /**
562
+ * Store a record via the MemPalace backend. Also writes the markdown
563
+ * tier so the human-readable file and legacy migration source stay
564
+ * consistent and durable as a fallback source.
565
+ */
566
+ private storeMempalace(record: MemoryRecord): void {
567
+ this.memPalaceCall("store", {
568
+ record: {
569
+ id: record.id,
570
+ title: record.title,
571
+ content: record.content,
572
+ tags: record.tags,
573
+ project: record.project,
574
+ type: record.type,
575
+ created: record.created,
576
+ updated: record.updated,
577
+ source_kind: "markdown",
578
+ },
579
+ });
580
+ // Markdown tier (durable human copy + fallback source).
581
+ try {
582
+ const mdPath = path.join(this.scopeDir, `${record.id}.md`);
583
+ const dir = path.dirname(mdPath);
584
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
585
+ writeMemoryFile(mdPath, record);
586
+ } catch {
587
+ // Palace write succeeded; markdown is best-effort.
588
+ }
589
+ }
590
+
447
591
  /**
448
592
  * Sync orphaned markdown files into the database.
449
593
  * Reads all .md files in the project dir, parses frontmatter,
@@ -451,6 +595,14 @@ export class MemoryStorage {
451
595
  * Returns count of synced files.
452
596
  */
453
597
  syncOrphanedFiles(): number {
598
+ if (this.isMempalace()) {
599
+ const synced = this.memPalaceCall<number>("sync_orphaned", {
600
+ project_dir: this.scopeDir,
601
+ wing: this.projectName,
602
+ });
603
+ return synced ?? 0;
604
+ }
605
+
454
606
  if (!this.db) throw new Error("Storage not initialized");
455
607
 
456
608
  const files = fs.readdirSync(this.scopeDir)
@@ -506,6 +658,12 @@ export class MemoryStorage {
506
658
  * Check if a memory with the given title already exists.
507
659
  */
508
660
  hasByTitle(title: string): boolean {
661
+ if (this.isMempalace()) {
662
+ return this.memPalaceCall<boolean>("has_title", {
663
+ wing: this.projectName,
664
+ title,
665
+ }) ?? false;
666
+ }
509
667
  if (!this.db) throw new Error("Storage not initialized");
510
668
  const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "_");
511
669
  const row = this.db.prepare("SELECT 1 FROM memories WHERE id = ?").get(id);
@@ -517,6 +675,13 @@ export class MemoryStorage {
517
675
  * Returns array of { record, similarity } sorted by similarity desc.
518
676
  */
519
677
  findSimilarByTitle(title: string, threshold = 0.6): Array<{ record: MemoryRecord; similarity: number }> {
678
+ if (this.isMempalace()) {
679
+ const rows = this.memPalaceCall<Array<{ record: MempalaceRecord; similarity: number }>>(
680
+ "find_similar",
681
+ { wing: this.projectName, title, threshold },
682
+ ) ?? [];
683
+ return rows.map((r) => ({ record: toMemoryRecord(r.record), similarity: r.similarity }));
684
+ }
520
685
  if (!this.db) throw new Error("Storage not initialized");
521
686
 
522
687
  const allRows = this.db.prepare("SELECT id, title FROM memories").all() as MemoryRow[];
@@ -549,6 +714,10 @@ export class MemoryStorage {
549
714
  * Get a memory record by ID.
550
715
  */
551
716
  getById(id: string): MemoryRecord | null {
717
+ if (this.isMempalace()) {
718
+ const rec = this.memPalaceCall<MempalaceRecord | null>("get", { id });
719
+ return rec ? toMemoryRecord(rec) : null;
720
+ }
552
721
  if (!this.db) throw new Error("Storage not initialized");
553
722
 
554
723
  const row = this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id) as MemoryRow | undefined;
@@ -571,6 +740,13 @@ export class MemoryStorage {
571
740
  * Get a memory record by title (fuzzy match).
572
741
  */
573
742
  getByTitle(title: string): MemoryRecord | null {
743
+ if (this.isMempalace()) {
744
+ const rec = this.memPalaceCall<MempalaceRecord | null>("get_by_title", {
745
+ wing: this.projectName,
746
+ title,
747
+ });
748
+ return rec ? toMemoryRecord(rec) : null;
749
+ }
574
750
  if (!this.db) throw new Error("Storage not initialized");
575
751
 
576
752
  // Try exact match first
@@ -610,6 +786,12 @@ export class MemoryStorage {
610
786
  * List all memories (titles only).
611
787
  */
612
788
  listAll(): Array<{ id: string; title: string; type: string }> {
789
+ if (this.isMempalace()) {
790
+ const items = this.memPalaceCall<MempalaceListItem[]>("list", {
791
+ wing: this.projectName,
792
+ }) ?? [];
793
+ return items;
794
+ }
613
795
  if (!this.db) throw new Error("Storage not initialized");
614
796
 
615
797
  const rows = this.db.prepare("SELECT id, title, type FROM memories ORDER BY updated DESC").all() as MemoryRow[];
@@ -620,6 +802,15 @@ export class MemoryStorage {
620
802
  * Delete a memory by ID.
621
803
  */
622
804
  delete(id: string): boolean {
805
+ if (this.isMempalace()) {
806
+ const ok = this.memPalaceCall<boolean>("delete", { id }) ?? false;
807
+ // Also remove the markdown tier if present.
808
+ try {
809
+ const mdPath = path.join(this.scopeDir, `${id}.md`);
810
+ if (fs.existsSync(mdPath)) fs.unlinkSync(mdPath);
811
+ } catch { /* ignore */ }
812
+ return ok;
813
+ }
623
814
  if (!this.db) throw new Error("Storage not initialized");
624
815
 
625
816
  // Delete from vector table
@@ -658,6 +849,18 @@ export class MemoryStorage {
658
849
  * Search memories using hybrid approach.
659
850
  */
660
851
  search(query: string, limit = 10, embedding?: Float32Array | null): SearchResult[] {
852
+ if (this.isMempalace()) {
853
+ const rows = this.memPalaceCall<MempalaceSearchResult[]>("search", {
854
+ query,
855
+ wing: this.projectName,
856
+ limit,
857
+ }) ?? [];
858
+ return rows.map((r) => ({
859
+ record: toMemoryRecord(r),
860
+ score: r.score,
861
+ snippet: r.snippet,
862
+ }));
863
+ }
661
864
  if (!this.db) throw new Error("Storage not initialized");
662
865
 
663
866
  const results: Map<string, SearchResult> = new Map();
@@ -801,6 +1004,21 @@ export function searchAllProjects(
801
1004
  query: string,
802
1005
  limit = 10
803
1006
  ): SearchResult[] {
1007
+ // MemPalace global path: query across all wings in one call.
1008
+ const install = ensureMempalace();
1009
+ if (install) {
1010
+ const rows = runBridge<MempalaceSearchResult[]>(install, DEFAULT_PALACE, "search", {
1011
+ query,
1012
+ limit,
1013
+ }) ?? [];
1014
+ return rows.map((r) => ({
1015
+ record: toMemoryRecord(r),
1016
+ score: r.score,
1017
+ snippet: r.snippet,
1018
+ }));
1019
+ }
1020
+
1021
+ // SQLite fallback: iterate project directories.
804
1022
  const projectDirs = getAllProjectDirs();
805
1023
  const allResults: SearchResult[] = [];
806
1024
 
@@ -835,6 +1053,19 @@ export function listAllProjects(): Array<{
835
1053
  title: string;
836
1054
  type: string;
837
1055
  }> {
1056
+ // MemPalace global path: list all drawers across wings.
1057
+ const install = ensureMempalace();
1058
+ if (install) {
1059
+ const items = runBridge<MempalaceListItemAll[]>(install, DEFAULT_PALACE, "list_all", {}) ?? [];
1060
+ return items.map((m) => ({
1061
+ project: m.project,
1062
+ id: m.id,
1063
+ title: m.title,
1064
+ type: m.type,
1065
+ }));
1066
+ }
1067
+
1068
+ // SQLite fallback: iterate project directories.
838
1069
  const projectDirs = getAllProjectDirs();
839
1070
  const allMemories: Array<{
840
1071
  project: string;
package/tools.ts CHANGED
File without changes