@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/README.md +51 -7
- package/bridge/mempalace_bridge.py +556 -0
- package/index.ts +7 -4
- package/mempalace.ts +260 -0
- package/package.json +8 -5
- package/storage.ts +253 -22
- package/tools.ts +0 -0
package/storage.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @unipi/memory — Storage layer
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
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
|