@pi-unipi/memory 2.4.1 → 2.6.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/README.md +15 -11
- package/bridge/mempalace_bridge.py +84 -4
- package/index.ts +56 -34
- package/mempalace.ts +169 -18
- package/package.json +3 -3
- package/storage.ts +27 -10
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Persistent memory that survives across sessions. Stores facts, preferences, and decisions with semantic vector search, so the agent remembers what you told it last week.
|
|
4
4
|
|
|
5
|
-
**Primary backend: [MemPalace](https://github.com/mempalace/mempalace)** — auto-installed via `uv` on first load, with
|
|
5
|
+
**Primary backend: [MemPalace](https://github.com/mempalace/mempalace)** — auto-installed via `uv` on first load, with verified, resumable migration of existing legacy memories. If MemPalace or `uv` is unavailable, the package transparently falls back to the bundled SQLite + sqlite-vec store, so memory never hard-fails.
|
|
6
6
|
|
|
7
7
|
Two storage tiers: MemPalace (or SQLite) for vector similarity search, markdown files for a durable human-readable copy you can edit by hand. Project-scoped memories stay separate per codebase, global memories are accessible everywhere.
|
|
8
8
|
|
|
@@ -76,7 +76,7 @@ Memory has no configuration file. Storage paths are fixed:
|
|
|
76
76
|
```
|
|
77
77
|
~/.unipi/memory/ # UniPi memory root (legacy + markdown tier)
|
|
78
78
|
├── .mempalace-install # Cached MemPalace venv detection
|
|
79
|
-
├── .mempalace-migrated #
|
|
79
|
+
├── .mempalace-migrated # Versioned migration verification state
|
|
80
80
|
├── global/
|
|
81
81
|
│ ├── memory.db # Global vector DB (SQLite fallback)
|
|
82
82
|
│ └── *.md # Global memory files
|
|
@@ -94,14 +94,18 @@ On first load, the memory package:
|
|
|
94
94
|
`uv tool install mempalace` once (caches the venv python path in
|
|
95
95
|
`~/.unipi/memory/.mempalace-install`).
|
|
96
96
|
2. Pings the bridge to confirm the palace is usable.
|
|
97
|
-
3.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
97
|
+
3. Fingerprints the durable SQLite and markdown sources. If they differ from
|
|
98
|
+
the verified state in `~/.unipi/memory/.mempalace-migrated`, performs an
|
|
99
|
+
idempotent read-only migration into MemPalace drawers. Unchanged drawers are
|
|
100
|
+
skipped, new or changed memories are upserted, and the versioned state is
|
|
101
|
+
written only after every discovered record is verified in MemPalace.
|
|
102
|
+
Failed or partial migrations remain unmarked and retry on a later session.
|
|
103
|
+
Legacy files are never deleted or mutated.
|
|
104
|
+
|
|
105
|
+
Each memory operation invokes the packaged Python bridge
|
|
106
|
+
(`bridge/mempalace_bridge.py`) once via `spawnSync` (~0.5s per call). Both the
|
|
107
|
+
standalone memory package and the all-in-one umbrella tarball ship and resolve
|
|
108
|
+
this bridge. The
|
|
105
109
|
first MemPalace use on a machine also downloads the default ONNX embedding
|
|
106
110
|
model (~80MB, cached at `~/.cache/chroma/onnx_models/`).
|
|
107
111
|
|
|
@@ -109,7 +113,7 @@ model (~80MB, cached at `~/.cache/chroma/onnx_models/`).
|
|
|
109
113
|
|
|
110
114
|
```bash
|
|
111
115
|
rm ~/.unipi/memory/.mempalace-install # re-detect MemPalace next session
|
|
112
|
-
rm ~/.unipi/memory/.mempalace-migrated #
|
|
116
|
+
rm ~/.unipi/memory/.mempalace-migrated # force a full verified migration pass next session
|
|
113
117
|
```
|
|
114
118
|
|
|
115
119
|
### Backend override
|
|
@@ -245,7 +245,13 @@ def parse_markdown_memory(project: str, path: Path) -> dict[str, Any] | None:
|
|
|
245
245
|
if parsed is None:
|
|
246
246
|
return None
|
|
247
247
|
fm, body = parsed
|
|
248
|
-
|
|
248
|
+
# Explicit IDs are authoritative and must not be normalized: UniPi's TS
|
|
249
|
+
# store permits leading/trailing underscores. Legacy files lack `id`, so
|
|
250
|
+
# retain their established filename normalization.
|
|
251
|
+
explicit_id = fm.get("id")
|
|
252
|
+
mid = str(explicit_id) if explicit_id is not None else safe_id_part(path.stem)
|
|
253
|
+
if not mid:
|
|
254
|
+
mid = "unknown"
|
|
249
255
|
return {
|
|
250
256
|
"project": str(fm.get("project") or project),
|
|
251
257
|
"id": mid,
|
|
@@ -487,17 +493,91 @@ class Bridge:
|
|
|
487
493
|
return synced
|
|
488
494
|
|
|
489
495
|
def migrate(self, source_dir: str, project_filter: list[str] | None = None) -> dict[str, Any]:
|
|
496
|
+
"""Idempotently import and then verify every discovered UniPi record.
|
|
497
|
+
|
|
498
|
+
Migration callers must not infer success merely from a bridge process
|
|
499
|
+
exiting cleanly. Return explicit discovery/failure/verification counts
|
|
500
|
+
so UniPi only writes its completion marker after full verification.
|
|
501
|
+
"""
|
|
490
502
|
records = discover_legacy_memories(Path(source_dir), project_filter)
|
|
491
503
|
imported = 0
|
|
504
|
+
skipped = 0
|
|
505
|
+
failed = 0
|
|
506
|
+
errors: list[str] = []
|
|
492
507
|
by_project: dict[str, int] = {}
|
|
508
|
+
expected = {(rec["project"], rec["id"]) for rec in records}
|
|
509
|
+
|
|
510
|
+
# Read once and skip unchanged records. Without this, adding one new
|
|
511
|
+
# markdown memory would re-embed every historical drawer on catch-up.
|
|
512
|
+
got = self.collection.get()
|
|
513
|
+
docs = got.documents if hasattr(got, "documents") else got.get("documents", [])
|
|
514
|
+
metas = got.metadatas if hasattr(got, "metadatas") else got.get("metadatas", [])
|
|
515
|
+
existing_docs: dict[tuple[str, str], str] = {}
|
|
516
|
+
for doc, meta in zip(docs, metas):
|
|
517
|
+
existing = record_from_doc(doc, meta)
|
|
518
|
+
if not existing:
|
|
519
|
+
continue
|
|
520
|
+
existing_docs[(existing["project"], existing["id"])] = doc
|
|
521
|
+
|
|
493
522
|
for rec in records:
|
|
523
|
+
key = (rec["project"], rec["id"])
|
|
524
|
+
expected_doc = build_document(
|
|
525
|
+
rec["title"], rec["content"], rec["tags"], rec["project"],
|
|
526
|
+
rec.get("created", ""), rec.get("updated", ""), rec["type"], rec["id"],
|
|
527
|
+
)
|
|
528
|
+
if existing_docs.get(key) == expected_doc:
|
|
529
|
+
skipped += 1
|
|
530
|
+
continue
|
|
494
531
|
try:
|
|
495
532
|
self._upsert_one(rec)
|
|
496
533
|
imported += 1
|
|
534
|
+
existing_docs[key] = expected_doc
|
|
497
535
|
by_project[rec["project"]] = by_project.get(rec["project"], 0) + 1
|
|
498
|
-
except Exception:
|
|
499
|
-
|
|
500
|
-
|
|
536
|
+
except Exception as exc:
|
|
537
|
+
failed += 1
|
|
538
|
+
if len(errors) < 20:
|
|
539
|
+
errors.append(
|
|
540
|
+
f"{rec['project']}/{rec['id']}: {type(exc).__name__}: {exc}"
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# Re-read after writes and verify exact durable documents, not just
|
|
544
|
+
# optimistic in-memory bookkeeping or collection counts. A palace may
|
|
545
|
+
# also contain drawers created by other harnesses/import recipes.
|
|
546
|
+
verified_get = self.collection.get()
|
|
547
|
+
verified_docs = (
|
|
548
|
+
verified_get.documents
|
|
549
|
+
if hasattr(verified_get, "documents")
|
|
550
|
+
else verified_get.get("documents", [])
|
|
551
|
+
)
|
|
552
|
+
verified_metas = (
|
|
553
|
+
verified_get.metadatas
|
|
554
|
+
if hasattr(verified_get, "metadatas")
|
|
555
|
+
else verified_get.get("metadatas", [])
|
|
556
|
+
)
|
|
557
|
+
persisted: dict[tuple[str, str], str] = {}
|
|
558
|
+
for doc, meta in zip(verified_docs, verified_metas):
|
|
559
|
+
persisted_rec = record_from_doc(doc, meta)
|
|
560
|
+
if persisted_rec:
|
|
561
|
+
persisted[(persisted_rec["project"], persisted_rec["id"])] = doc
|
|
562
|
+
verified = 0
|
|
563
|
+
for rec in records:
|
|
564
|
+
expected_doc = build_document(
|
|
565
|
+
rec["title"], rec["content"], rec["tags"], rec["project"],
|
|
566
|
+
rec.get("created", ""), rec.get("updated", ""), rec["type"], rec["id"],
|
|
567
|
+
)
|
|
568
|
+
if persisted.get((rec["project"], rec["id"])) == expected_doc:
|
|
569
|
+
verified += 1
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
"discovered": len(records),
|
|
573
|
+
"imported": imported,
|
|
574
|
+
"updated": imported,
|
|
575
|
+
"skipped": skipped,
|
|
576
|
+
"failed": failed,
|
|
577
|
+
"verified": verified,
|
|
578
|
+
"projects": by_project,
|
|
579
|
+
"errors": errors,
|
|
580
|
+
}
|
|
501
581
|
|
|
502
582
|
|
|
503
583
|
# ---------------------------------------------------------------------------
|
package/index.ts
CHANGED
|
@@ -35,6 +35,54 @@ import { isEmbeddingReady, hasModelChanged } from "./settings.js";
|
|
|
35
35
|
/** Package version */
|
|
36
36
|
const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
|
|
37
37
|
|
|
38
|
+
interface MemoryReminderInput {
|
|
39
|
+
projectName: string;
|
|
40
|
+
memories: Array<{ title: string }>;
|
|
41
|
+
canSearch: boolean;
|
|
42
|
+
canStore: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the deterministic, model-visible first-turn memory reminder.
|
|
47
|
+
* Exported so provider-payload regressions can exercise the exact production
|
|
48
|
+
* content without initializing a storage backend.
|
|
49
|
+
*/
|
|
50
|
+
export function buildMemoryRecallReminder(input: MemoryReminderInput): string {
|
|
51
|
+
const lines = [
|
|
52
|
+
"## 🧠 Memory System Active",
|
|
53
|
+
"",
|
|
54
|
+
`You have ${input.memories.length} memories stored for project "${input.projectName}".`,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
if (input.canSearch && input.memories.length > 0) {
|
|
58
|
+
const titleList = input.memories.slice(0, 20).map((memory) => `- ${memory.title}`).join("\n");
|
|
59
|
+
const extra = input.memories.length > 20
|
|
60
|
+
? `\n... and ${input.memories.length - 20} more`
|
|
61
|
+
: "";
|
|
62
|
+
lines.push(
|
|
63
|
+
"**BEFORE starting work**, call `memory_search` with relevant keywords to check for existing context.",
|
|
64
|
+
"",
|
|
65
|
+
"Available memories:",
|
|
66
|
+
titleList + extra,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (input.canStore) {
|
|
71
|
+
lines.push(
|
|
72
|
+
"",
|
|
73
|
+
"**AFTER completing the task**, if you learned something non-obvious,",
|
|
74
|
+
"call `memory_store` to save it for future sessions.",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
lines.push(
|
|
79
|
+
"",
|
|
80
|
+
"Guardrails: read max 10 memory results per search. Update existing memories instead of creating duplicates.",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
38
86
|
/** Storage instance for current project */
|
|
39
87
|
let projectStorage: MemoryStorage | null = null;
|
|
40
88
|
|
|
@@ -251,44 +299,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
251
299
|
return;
|
|
252
300
|
}
|
|
253
301
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
"",
|
|
257
|
-
`You have ${projectMemories.length} memories stored for project "${projectName}".`,
|
|
258
|
-
];
|
|
259
|
-
|
|
260
|
-
if (canSearch && projectMemories.length > 0) {
|
|
261
|
-
const titleList = projectMemories.slice(0, 20).map(m => `- ${m.title}`).join("\n");
|
|
262
|
-
const extra = projectMemories.length > 20 ? `\n... and ${projectMemories.length - 20} more` : "";
|
|
263
|
-
lines.push(
|
|
264
|
-
"**BEFORE starting work**, call `memory_search` with relevant keywords to check for existing context.",
|
|
265
|
-
"",
|
|
266
|
-
"Available memories:",
|
|
267
|
-
titleList + extra,
|
|
268
|
-
);
|
|
269
|
-
} else {
|
|
270
|
-
recallDone = true;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
if (canStore) {
|
|
274
|
-
lines.push(
|
|
275
|
-
"",
|
|
276
|
-
"**AFTER completing the task**, if you learned something non-obvious,",
|
|
277
|
-
"call `memory_store` to save it for future sessions.",
|
|
278
|
-
);
|
|
279
|
-
} else {
|
|
280
|
-
storeDone = true;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
lines.push(
|
|
284
|
-
"",
|
|
285
|
-
"Guardrails: read max 10 memory results per search. Update existing memories instead of creating duplicates.",
|
|
286
|
-
);
|
|
302
|
+
if (!canSearch || projectMemories.length === 0) recallDone = true;
|
|
303
|
+
if (!canStore) storeDone = true;
|
|
287
304
|
|
|
288
305
|
return {
|
|
289
306
|
message: {
|
|
290
307
|
customType: "unipi-memory-recall-reminder",
|
|
291
|
-
content:
|
|
308
|
+
content: buildMemoryRecallReminder({
|
|
309
|
+
projectName,
|
|
310
|
+
memories: projectMemories,
|
|
311
|
+
canSearch,
|
|
312
|
+
canStore,
|
|
313
|
+
}),
|
|
292
314
|
display: false,
|
|
293
315
|
},
|
|
294
316
|
};
|
package/mempalace.ts
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { spawnSync, spawn } from "node:child_process";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
15
17
|
import * as fs from "node:fs";
|
|
16
18
|
import * as path from "node:path";
|
|
17
19
|
import * as os from "node:os";
|
|
@@ -27,11 +29,74 @@ const MIGRATED_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-mi
|
|
|
27
29
|
const PING_VERIFIED_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-ping-verified");
|
|
28
30
|
const PING_VERIFIED_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
29
31
|
|
|
30
|
-
/**
|
|
31
|
-
const
|
|
32
|
+
/** Migration marker schema. Increment when migration semantics change. */
|
|
33
|
+
export const MIGRATION_STATE_VERSION = 2;
|
|
34
|
+
|
|
35
|
+
export interface MigrationResult {
|
|
36
|
+
discovered: number;
|
|
37
|
+
imported: number;
|
|
38
|
+
updated: number;
|
|
39
|
+
skipped: number;
|
|
40
|
+
failed: number;
|
|
41
|
+
verified: number;
|
|
42
|
+
errors?: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MigrationState {
|
|
46
|
+
version: number;
|
|
47
|
+
completedAt: string;
|
|
48
|
+
sourceFingerprint: string;
|
|
49
|
+
result: MigrationResult;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let cachedBridgePath: string | null | undefined;
|
|
53
|
+
|
|
54
|
+
function isReadableFile(candidate: string): boolean {
|
|
55
|
+
try {
|
|
56
|
+
return fs.statSync(candidate).isFile() && fs.accessSync(candidate, fs.constants.R_OK) === undefined;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the Python bridge in both supported layouts:
|
|
64
|
+
*
|
|
65
|
+
* - standalone @pi-unipi/memory: <package>/bridge/mempalace_bridge.py
|
|
66
|
+
* - bundled @pi-unipi/unipi: <umbrella>/packages/memory/bridge/...
|
|
67
|
+
*
|
|
68
|
+
* The explicit environment override is useful for custom packagers. The
|
|
69
|
+
* package-resolution fallback handles npm layouts where dependencies are not
|
|
70
|
+
* hoisted beside the umbrella package.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveMempalaceBridgePath(moduleUrl = import.meta.url): string | null {
|
|
73
|
+
const moduleDir = path.dirname(fileURLToPath(moduleUrl));
|
|
74
|
+
const candidates: string[] = [];
|
|
75
|
+
if (process.env.UNIPI_MEMPALACE_BRIDGE) {
|
|
76
|
+
candidates.push(path.resolve(process.env.UNIPI_MEMPALACE_BRIDGE));
|
|
77
|
+
}
|
|
78
|
+
candidates.push(
|
|
79
|
+
path.join(moduleDir, "bridge", "mempalace_bridge.py"),
|
|
80
|
+
path.join(moduleDir, "..", "memory", "bridge", "mempalace_bridge.py"),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const require = createRequire(moduleUrl);
|
|
85
|
+
const memoryPackage = require.resolve("@pi-unipi/memory/package.json");
|
|
86
|
+
candidates.push(path.join(path.dirname(memoryPackage), "bridge", "mempalace_bridge.py"));
|
|
87
|
+
} catch { /* standalone/source layout may not expose package resolution */ }
|
|
88
|
+
|
|
89
|
+
return candidates.find(isReadableFile) ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getBridgePath(): string | null {
|
|
93
|
+
if (cachedBridgePath === undefined) cachedBridgePath = resolveMempalaceBridgePath();
|
|
94
|
+
return cachedBridgePath;
|
|
95
|
+
}
|
|
32
96
|
|
|
33
|
-
|
|
34
|
-
|
|
97
|
+
/** Clear bridge discovery cache (primarily for recovery/tests). */
|
|
98
|
+
export function invalidateBridgePathCache(): void {
|
|
99
|
+
cachedBridgePath = undefined;
|
|
35
100
|
}
|
|
36
101
|
|
|
37
102
|
export interface BridgeResponse<T> {
|
|
@@ -196,22 +261,99 @@ export function invalidatePingVerified(): void {
|
|
|
196
261
|
try { if (fs.existsSync(PING_VERIFIED_FLAG)) fs.unlinkSync(PING_VERIFIED_FLAG); } catch { /* ignore */ }
|
|
197
262
|
}
|
|
198
263
|
|
|
199
|
-
/**
|
|
200
|
-
|
|
201
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Fingerprint all durable legacy sources. This makes migration catch-up
|
|
266
|
+
* automatic for existing installations instead of treating a years-old
|
|
267
|
+
* timestamp flag as permanently complete.
|
|
268
|
+
*/
|
|
269
|
+
export function getMemorySourceFingerprint(
|
|
270
|
+
sourceDir = path.join(os.homedir(), ".unipi", "memory"),
|
|
271
|
+
): string {
|
|
272
|
+
const hash = createHash("sha256");
|
|
273
|
+
if (!fs.existsSync(sourceDir)) return hash.update("missing").digest("hex");
|
|
274
|
+
|
|
275
|
+
const visit = (dir: string): void => {
|
|
276
|
+
let entries: fs.Dirent[];
|
|
277
|
+
try {
|
|
278
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
279
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
280
|
+
} catch {
|
|
281
|
+
hash.update(`unreadable:${dir}`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
if (entry.name.startsWith(".")) continue;
|
|
286
|
+
const full = path.join(dir, entry.name);
|
|
287
|
+
if (entry.isDirectory()) {
|
|
288
|
+
visit(full);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (!entry.isFile() || (entry.name !== "memory.db" && !entry.name.endsWith(".md"))) continue;
|
|
292
|
+
try {
|
|
293
|
+
const stat = fs.statSync(full);
|
|
294
|
+
hash.update(`${path.relative(sourceDir, full)}\0${stat.size}\0${stat.mtimeMs}\n`);
|
|
295
|
+
} catch {
|
|
296
|
+
hash.update(`unreadable:${path.relative(sourceDir, full)}\n`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
visit(sourceDir);
|
|
301
|
+
return hash.digest("hex");
|
|
202
302
|
}
|
|
203
303
|
|
|
204
|
-
/**
|
|
205
|
-
export function
|
|
304
|
+
/** Read a verified v2 migration state. Legacy timestamp markers return null. */
|
|
305
|
+
export function readMigrationState(flagPath = MIGRATED_FLAG): MigrationState | null {
|
|
206
306
|
try {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
307
|
+
const parsed = JSON.parse(fs.readFileSync(flagPath, "utf-8")) as MigrationState;
|
|
308
|
+
if (
|
|
309
|
+
parsed?.version !== MIGRATION_STATE_VERSION ||
|
|
310
|
+
typeof parsed.completedAt !== "string" ||
|
|
311
|
+
typeof parsed.sourceFingerprint !== "string" ||
|
|
312
|
+
!parsed.result ||
|
|
313
|
+
parsed.result.failed !== 0 ||
|
|
314
|
+
parsed.result.verified !== parsed.result.discovered
|
|
315
|
+
) return null;
|
|
316
|
+
return parsed;
|
|
317
|
+
} catch {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Is the palace verified against the current durable source set? */
|
|
323
|
+
export function isMigrated(
|
|
324
|
+
sourceFingerprint = getMemorySourceFingerprint(),
|
|
325
|
+
flagPath = MIGRATED_FLAG,
|
|
326
|
+
): boolean {
|
|
327
|
+
return readMigrationState(flagPath)?.sourceFingerprint === sourceFingerprint;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Mark migration complete only after the caller has verified every record. */
|
|
331
|
+
export function markMigrated(
|
|
332
|
+
sourceFingerprint: string,
|
|
333
|
+
result: MigrationResult,
|
|
334
|
+
flagPath = MIGRATED_FLAG,
|
|
335
|
+
): boolean {
|
|
336
|
+
if (result.failed !== 0 || result.verified !== result.discovered) return false;
|
|
337
|
+
try {
|
|
338
|
+
fs.mkdirSync(path.dirname(flagPath), { recursive: true });
|
|
339
|
+
const state: MigrationState = {
|
|
340
|
+
version: MIGRATION_STATE_VERSION,
|
|
341
|
+
completedAt: new Date().toISOString(),
|
|
342
|
+
sourceFingerprint,
|
|
343
|
+
result,
|
|
344
|
+
};
|
|
345
|
+
const temp = `${flagPath}.${process.pid}.tmp`;
|
|
346
|
+
fs.writeFileSync(temp, JSON.stringify(state, null, 2), "utf-8");
|
|
347
|
+
fs.renameSync(temp, flagPath);
|
|
348
|
+
return true;
|
|
349
|
+
} catch {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
210
352
|
}
|
|
211
353
|
|
|
212
354
|
/** Force re-migration by clearing the flag. */
|
|
213
|
-
export function clearMigratedFlag(): void {
|
|
214
|
-
try { if (fs.existsSync(
|
|
355
|
+
export function clearMigratedFlag(flagPath = MIGRATED_FLAG): void {
|
|
356
|
+
try { if (fs.existsSync(flagPath)) fs.unlinkSync(flagPath); } catch { /* ignore */ }
|
|
215
357
|
}
|
|
216
358
|
|
|
217
359
|
/**
|
|
@@ -223,7 +365,10 @@ export function runBridge<T = unknown>(
|
|
|
223
365
|
palace: string,
|
|
224
366
|
cmd: string,
|
|
225
367
|
args: Record<string, unknown> = {},
|
|
368
|
+
timeoutMs = 60_000,
|
|
226
369
|
): T | null {
|
|
370
|
+
const bridgePath = getBridgePath();
|
|
371
|
+
if (!bridgePath) return null;
|
|
227
372
|
let argsJson: string;
|
|
228
373
|
try {
|
|
229
374
|
argsJson = JSON.stringify(args);
|
|
@@ -232,9 +377,9 @@ export function runBridge<T = unknown>(
|
|
|
232
377
|
}
|
|
233
378
|
let res;
|
|
234
379
|
try {
|
|
235
|
-
res = spawnSync(install.python, [
|
|
380
|
+
res = spawnSync(install.python, [bridgePath, palace, cmd, argsJson], {
|
|
236
381
|
encoding: "utf-8",
|
|
237
|
-
timeout:
|
|
382
|
+
timeout: timeoutMs,
|
|
238
383
|
maxBuffer: 64 * 1024 * 1024,
|
|
239
384
|
});
|
|
240
385
|
} catch {
|
|
@@ -266,8 +411,14 @@ export function runBridgeAsync<T = unknown>(
|
|
|
266
411
|
palace: string,
|
|
267
412
|
cmd: string,
|
|
268
413
|
args: Record<string, unknown> = {},
|
|
414
|
+
timeoutMs = 60_000,
|
|
269
415
|
): Promise<T | null> {
|
|
270
416
|
return new Promise((resolve) => {
|
|
417
|
+
const bridgePath = getBridgePath();
|
|
418
|
+
if (!bridgePath) {
|
|
419
|
+
resolve(null);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
271
422
|
let argsJson: string;
|
|
272
423
|
try {
|
|
273
424
|
argsJson = JSON.stringify(args);
|
|
@@ -278,7 +429,7 @@ export function runBridgeAsync<T = unknown>(
|
|
|
278
429
|
|
|
279
430
|
let child;
|
|
280
431
|
try {
|
|
281
|
-
child = spawn(install.python, [
|
|
432
|
+
child = spawn(install.python, [bridgePath, palace, cmd, argsJson], {
|
|
282
433
|
stdio: ["ignore", "pipe", "ignore"],
|
|
283
434
|
});
|
|
284
435
|
} catch {
|
|
@@ -298,7 +449,7 @@ export function runBridgeAsync<T = unknown>(
|
|
|
298
449
|
const timer = setTimeout(() => {
|
|
299
450
|
try { child.kill(); } catch { /* already gone */ }
|
|
300
451
|
finish(null);
|
|
301
|
-
},
|
|
452
|
+
}, timeoutMs);
|
|
302
453
|
// Do not hold the process open purely for a background bridge call.
|
|
303
454
|
timer.unref?.();
|
|
304
455
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/memory",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.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",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"better-sqlite3": "^12.9.0",
|
|
44
44
|
"sqlite-vec": "^0.1.9",
|
|
45
45
|
"js-yaml": "^4.1.0",
|
|
46
|
-
"@pi-unipi/core": "2.
|
|
47
|
-
"@pi-unipi/info-screen": "2.
|
|
46
|
+
"@pi-unipi/core": "2.6.0",
|
|
47
|
+
"@pi-unipi/info-screen": "2.6.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"@earendil-works/pi-coding-agent": "^0.80.0",
|
package/storage.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
runBridgeAsync,
|
|
21
21
|
isMigrated,
|
|
22
22
|
markMigrated,
|
|
23
|
+
getMemorySourceFingerprint,
|
|
23
24
|
isPingVerified,
|
|
24
25
|
markPingVerified,
|
|
25
26
|
invalidatePingVerified,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
type MempalaceSearchResult,
|
|
30
31
|
type MempalaceListItem,
|
|
31
32
|
type MempalaceListItemAll,
|
|
33
|
+
type MigrationResult,
|
|
32
34
|
} from "./mempalace.js";
|
|
33
35
|
|
|
34
36
|
export type MemoryBackend = "mempalace" | "sqlite";
|
|
@@ -102,6 +104,7 @@ export interface SearchResult {
|
|
|
102
104
|
|
|
103
105
|
/** Memory file frontmatter */
|
|
104
106
|
interface MemoryFrontmatter {
|
|
107
|
+
id?: string;
|
|
105
108
|
title: string;
|
|
106
109
|
tags: string[];
|
|
107
110
|
project: string;
|
|
@@ -201,7 +204,7 @@ export function parseMemoryContent(content: string): MemoryRecord | null {
|
|
|
201
204
|
const frontmatter = yaml.load(frontmatterStr) as MemoryFrontmatter;
|
|
202
205
|
|
|
203
206
|
return {
|
|
204
|
-
id: "",
|
|
207
|
+
id: frontmatter.id || "",
|
|
205
208
|
title: frontmatter.title,
|
|
206
209
|
content: body.trim(),
|
|
207
210
|
tags: frontmatter.tags || [],
|
|
@@ -217,6 +220,7 @@ export function parseMemoryContent(content: string): MemoryRecord | null {
|
|
|
217
220
|
*/
|
|
218
221
|
export function writeMemoryFile(filePath: string, record: MemoryRecord): void {
|
|
219
222
|
const frontmatter: MemoryFrontmatter = {
|
|
223
|
+
id: record.id,
|
|
220
224
|
title: record.title,
|
|
221
225
|
tags: record.tags,
|
|
222
226
|
project: record.project,
|
|
@@ -383,16 +387,28 @@ export class MemoryStorage {
|
|
|
383
387
|
this.mempalaceInstall = install;
|
|
384
388
|
this.backend = "mempalace";
|
|
385
389
|
|
|
386
|
-
//
|
|
387
|
-
|
|
390
|
+
// Idempotent migration + automatic catch-up. The source fingerprint turns
|
|
391
|
+
// the old one-shot timestamp into a resumable state: new/changed markdown
|
|
392
|
+
// or SQLite sources trigger another verified upsert pass. Never mark a
|
|
393
|
+
// failed/partial run complete; it will retry on a later session.
|
|
394
|
+
const sourceFingerprint = getMemorySourceFingerprint(getMemoryBaseDir());
|
|
395
|
+
if (!isMigrated(sourceFingerprint)) {
|
|
388
396
|
try {
|
|
389
|
-
|
|
397
|
+
// First migrations can embed thousands of records. Give the bridge a
|
|
398
|
+
// practical bounded window rather than the normal per-operation 60s.
|
|
399
|
+
const result = runBridge<MigrationResult>(install, this.palacePath, "migrate", {
|
|
390
400
|
source_dir: getMemoryBaseDir(),
|
|
391
|
-
});
|
|
392
|
-
|
|
401
|
+
}, 15 * 60_000);
|
|
402
|
+
if (
|
|
403
|
+
result &&
|
|
404
|
+
result.failed === 0 &&
|
|
405
|
+
result.verified === result.discovered
|
|
406
|
+
) {
|
|
407
|
+
markMigrated(sourceFingerprint, result);
|
|
408
|
+
}
|
|
393
409
|
} catch {
|
|
394
|
-
//
|
|
395
|
-
//
|
|
410
|
+
// Palace remains available for current writes; durable markdown/SQLite
|
|
411
|
+
// sources are untouched and migration retries because no state is set.
|
|
396
412
|
}
|
|
397
413
|
}
|
|
398
414
|
|
|
@@ -629,8 +645,9 @@ export class MemoryStorage {
|
|
|
629
645
|
const record = parseMemoryFile(filePath);
|
|
630
646
|
if (!record) continue;
|
|
631
647
|
|
|
632
|
-
//
|
|
633
|
-
|
|
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, "_");
|
|
634
651
|
|
|
635
652
|
if (existingIds.has(id)) continue; // Already in DB
|
|
636
653
|
|