@pi-unipi/memory 2.20.3 → 2.20.5
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 +33 -23
- package/bridge/mempalace_bridge.py +56 -9
- package/mempalace.ts +541 -37
- package/package.json +3 -3
- package/storage.ts +110 -63
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
|
-
**
|
|
5
|
+
**Backend: [MemPalace](https://github.com/mempalace/mempalace)** — auto-installed via `uv` on first load, with verified, resumable, incremental migration of existing markdown memories. Detection, ping, and migration run off the startup path, so loading the package never blocks the first prompt.
|
|
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
|
|
|
@@ -74,46 +74,56 @@ Examples:
|
|
|
74
74
|
Memory has no configuration file. Storage paths are fixed:
|
|
75
75
|
|
|
76
76
|
```
|
|
77
|
-
~/.unipi/memory/ # UniPi memory root (
|
|
77
|
+
~/.unipi/memory/ # UniPi memory root (markdown tier)
|
|
78
78
|
├── .mempalace-install # Cached MemPalace venv detection
|
|
79
|
-
├── .mempalace-migrated #
|
|
79
|
+
├── .mempalace-migrated # Legacy migration marker (seeds the ledger once)
|
|
80
|
+
├── .mempalace-ledger.json # Record-level sync ledger (project/id -> content hash)
|
|
81
|
+
├── .mempalace-ping-verified # Recent-ping cache (skips the cold-start ping)
|
|
80
82
|
├── global/
|
|
81
|
-
│
|
|
82
|
-
│ └── *.md # Global memory files
|
|
83
|
+
│ └── *.md # Global memory files (durable, human-readable)
|
|
83
84
|
└── <project_name>/
|
|
84
|
-
|
|
85
|
-
└── *.md # Project memory files
|
|
85
|
+
└── *.md # Project memory files (durable, human-readable)
|
|
86
86
|
|
|
87
|
-
~/.mempalace/palace/ # MemPalace palace (
|
|
87
|
+
~/.mempalace/palace/ # MemPalace palace (vector backend)
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
## MemPalace backend
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
MemPalace is the sole vector backend; the markdown files are the durable,
|
|
93
|
+
human-readable tier and the migration source. On load, the memory package:
|
|
93
94
|
1. Detects MemPalace; if missing and `uv` is available, runs
|
|
94
95
|
`uv tool install mempalace` once (caches the venv python path in
|
|
95
96
|
`~/.unipi/memory/.mempalace-install`).
|
|
96
|
-
2.
|
|
97
|
-
|
|
98
|
-
the
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
97
|
+
2. Marks the backend active immediately, then does everything else
|
|
98
|
+
**off the startup path** so time-to-first-input is never blocked: a
|
|
99
|
+
background task pings the bridge (skipped when recently ping-verified) and
|
|
100
|
+
runs an incremental catch-up.
|
|
101
|
+
3. Catch-up is driven by a record-level ledger
|
|
102
|
+
(`~/.unipi/memory/.mempalace-ledger.json`) mapping `project/id` to the
|
|
103
|
+
sha256 of the markdown bytes last confirmed in the palace. Only records whose
|
|
104
|
+
bytes differ from the ledger are upserted, so an ordinary write never
|
|
105
|
+
triggers a full re-scan. `store()` updates the ledger only after a confirmed
|
|
106
|
+
upsert; a pre-existing `.mempalace-migrated` marker seeds the ledger once.
|
|
103
107
|
Legacy files are never deleted or mutated.
|
|
104
108
|
|
|
105
|
-
|
|
106
|
-
(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
Records contended by a running MemPalace daemon's mine lock are recorded as
|
|
110
|
+
**deferred** (never as failures) and retried with exponential backoff, so the
|
|
111
|
+
catch-up always converges instead of re-running every boot. When a daemon is
|
|
112
|
+
reachable and actively mining, the background catch-up stands down for the
|
|
113
|
+
session rather than fighting the lock.
|
|
114
|
+
|
|
115
|
+
Memory operations invoke the packaged Python bridge
|
|
116
|
+
(`bridge/mempalace_bridge.py`); startup-path work uses the async, non-blocking
|
|
117
|
+
variant. Both the standalone memory package and the all-in-one umbrella tarball
|
|
118
|
+
ship and resolve this bridge. The first MemPalace use on a machine also
|
|
119
|
+
downloads the default ONNX embedding model (~80MB, cached at
|
|
120
|
+
`~/.cache/chroma/onnx_models/`).
|
|
111
121
|
|
|
112
122
|
### Forcing re-detection / re-migration
|
|
113
123
|
|
|
114
124
|
```bash
|
|
115
125
|
rm ~/.unipi/memory/.mempalace-install # re-detect MemPalace next session
|
|
116
|
-
rm ~/.unipi/memory/.mempalace-
|
|
126
|
+
rm ~/.unipi/memory/.mempalace-ledger.json # force a full verified catch-up pass next session
|
|
117
127
|
```
|
|
118
128
|
|
|
119
129
|
### Backend override
|
|
@@ -46,6 +46,15 @@ except Exception: # pragma: no cover
|
|
|
46
46
|
MEMORY_TYPES = {"preference", "decision", "pattern", "summary"}
|
|
47
47
|
MIGRATION_AGENT = "unipi-memory-bridge"
|
|
48
48
|
|
|
49
|
+
# MemPalace's per-palace mine lock is non-blocking: when a daemon or another
|
|
50
|
+
# writer holds it, upserts raise MineAlreadyRunning (message "... is held by
|
|
51
|
+
# PID ..."). That is transient contention, not a data error.
|
|
52
|
+
_TRANSIENT_LOCK_RE = re.compile(r"MineAlreadyRunning|is held by", re.IGNORECASE)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_transient_lock_error(exc: Exception) -> bool:
|
|
56
|
+
return bool(_TRANSIENT_LOCK_RE.search(f"{type(exc).__name__}: {exc}"))
|
|
57
|
+
|
|
49
58
|
|
|
50
59
|
# ---------------------------------------------------------------------------
|
|
51
60
|
# ID + URI helpers (mirror mempalace.ids when available, else deterministic fallback)
|
|
@@ -311,8 +320,12 @@ def discover_legacy_memories(source_dir: Path, project_filter: list[str] | None
|
|
|
311
320
|
rec = parse_markdown_memory(project, md_path)
|
|
312
321
|
if rec:
|
|
313
322
|
by_key[(rec["project"], rec["id"])] = rec
|
|
314
|
-
|
|
315
|
-
|
|
323
|
+
# Markdown is the sole durable source now (the SQLite fallback was
|
|
324
|
+
# removed). Discovering legacy memory.db here would surface records the
|
|
325
|
+
# TS ledger scanner cannot see, so `--only` targeted retries could not
|
|
326
|
+
# name them and the delta would never converge. `load_sqlite_memories`
|
|
327
|
+
# is retained for any explicit one-off recovery use, but is no longer
|
|
328
|
+
# part of the automatic catch-up corpus.
|
|
316
329
|
return sorted(by_key.values(), key=lambda r: (r["project"], r["type"], r["title"], r["id"]))
|
|
317
330
|
|
|
318
331
|
|
|
@@ -492,18 +505,39 @@ class Bridge:
|
|
|
492
505
|
synced += 1
|
|
493
506
|
return synced
|
|
494
507
|
|
|
495
|
-
def migrate(
|
|
508
|
+
def migrate(
|
|
509
|
+
self,
|
|
510
|
+
source_dir: str,
|
|
511
|
+
project_filter: list[str] | None = None,
|
|
512
|
+
only: list[str] | None = None,
|
|
513
|
+
) -> dict[str, Any]:
|
|
496
514
|
"""Idempotently import and then verify every discovered UniPi record.
|
|
497
515
|
|
|
498
516
|
Migration callers must not infer success merely from a bridge process
|
|
499
517
|
exiting cleanly. Return explicit discovery/failure/verification counts
|
|
500
518
|
so UniPi only writes its completion marker after full verification.
|
|
519
|
+
|
|
520
|
+
Failures are split into two classes:
|
|
521
|
+
- ``deferred``: transient palace-lock contention (``MineAlreadyRunning``)
|
|
522
|
+
while a daemon/other writer holds the mine lock. The record is
|
|
523
|
+
untouched and simply needs retrying later; it is NOT a data error.
|
|
524
|
+
- ``failed``: a genuine per-record error (malformed record, backend
|
|
525
|
+
fault). The completion marker must never advance over these.
|
|
526
|
+
|
|
527
|
+
``only`` optionally restricts the pass to a set of ``"project/id"`` keys
|
|
528
|
+
(targeted retry of previously deferred records) so a catch-up does not
|
|
529
|
+
re-sweep the entire corpus.
|
|
501
530
|
"""
|
|
502
531
|
records = discover_legacy_memories(Path(source_dir), project_filter)
|
|
532
|
+
if only:
|
|
533
|
+
wanted = set(only)
|
|
534
|
+
records = [r for r in records if f"{r['project']}/{r['id']}" in wanted]
|
|
503
535
|
imported = 0
|
|
504
536
|
skipped = 0
|
|
505
537
|
failed = 0
|
|
538
|
+
deferred = 0
|
|
506
539
|
errors: list[str] = []
|
|
540
|
+
deferred_keys: list[str] = []
|
|
507
541
|
by_project: dict[str, int] = {}
|
|
508
542
|
expected = {(rec["project"], rec["id"]) for rec in records}
|
|
509
543
|
|
|
@@ -534,11 +568,19 @@ class Bridge:
|
|
|
534
568
|
existing_docs[key] = expected_doc
|
|
535
569
|
by_project[rec["project"]] = by_project.get(rec["project"], 0) + 1
|
|
536
570
|
except Exception as exc:
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
)
|
|
571
|
+
if _is_transient_lock_error(exc):
|
|
572
|
+
# Palace held by a mine/daemon: the record is untouched and
|
|
573
|
+
# just needs a later retry. Record the key, do not count it
|
|
574
|
+
# as a data failure (which would block the completion marker
|
|
575
|
+
# forever whenever a daemon is running).
|
|
576
|
+
deferred += 1
|
|
577
|
+
deferred_keys.append(f"{rec['project']}/{rec['id']}")
|
|
578
|
+
else:
|
|
579
|
+
failed += 1
|
|
580
|
+
if len(errors) < 20:
|
|
581
|
+
errors.append(
|
|
582
|
+
f"{rec['project']}/{rec['id']}: {type(exc).__name__}: {exc}"
|
|
583
|
+
)
|
|
542
584
|
|
|
543
585
|
# Re-read after writes and verify exact durable documents, not just
|
|
544
586
|
# optimistic in-memory bookkeeping or collection counts. A palace may
|
|
@@ -560,6 +602,7 @@ class Bridge:
|
|
|
560
602
|
if persisted_rec:
|
|
561
603
|
persisted[(persisted_rec["project"], persisted_rec["id"])] = doc
|
|
562
604
|
verified = 0
|
|
605
|
+
verified_keys: list[str] = []
|
|
563
606
|
for rec in records:
|
|
564
607
|
expected_doc = build_document(
|
|
565
608
|
rec["title"], rec["content"], rec["tags"], rec["project"],
|
|
@@ -567,6 +610,7 @@ class Bridge:
|
|
|
567
610
|
)
|
|
568
611
|
if persisted.get((rec["project"], rec["id"])) == expected_doc:
|
|
569
612
|
verified += 1
|
|
613
|
+
verified_keys.append(f"{rec['project']}/{rec['id']}")
|
|
570
614
|
|
|
571
615
|
return {
|
|
572
616
|
"discovered": len(records),
|
|
@@ -574,9 +618,12 @@ class Bridge:
|
|
|
574
618
|
"updated": imported,
|
|
575
619
|
"skipped": skipped,
|
|
576
620
|
"failed": failed,
|
|
621
|
+
"deferred": deferred,
|
|
577
622
|
"verified": verified,
|
|
578
623
|
"projects": by_project,
|
|
579
624
|
"errors": errors,
|
|
625
|
+
"deferred_keys": deferred_keys[:200],
|
|
626
|
+
"verified_keys": verified_keys,
|
|
580
627
|
}
|
|
581
628
|
|
|
582
629
|
|
|
@@ -617,7 +664,7 @@ def main(argv: list[str]) -> int:
|
|
|
617
664
|
"has_title": lambda: bridge.has_title(args["wing"], args["title"]),
|
|
618
665
|
"find_similar": lambda: bridge.find_similar(args["wing"], args["title"], float(args.get("threshold", 0.6))),
|
|
619
666
|
"sync_orphaned": lambda: bridge.sync_orphaned(args["project_dir"], args["wing"]),
|
|
620
|
-
"migrate": lambda: bridge.migrate(args["source_dir"], args.get("projects")),
|
|
667
|
+
"migrate": lambda: bridge.migrate(args["source_dir"], args.get("projects"), args.get("only")),
|
|
621
668
|
}
|
|
622
669
|
handler = handlers.get(cmd)
|
|
623
670
|
if handler is None:
|
package/mempalace.ts
CHANGED
|
@@ -25,6 +25,8 @@ export const DEFAULT_PALACE = path.join(os.homedir(), ".mempalace", "palace");
|
|
|
25
25
|
|
|
26
26
|
const INSTALL_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-install");
|
|
27
27
|
const MIGRATED_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-migrated");
|
|
28
|
+
/** Record-level sync ledger (L2): supersedes the size+mtime fingerprint gate. */
|
|
29
|
+
const LEDGER_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-ledger.json");
|
|
28
30
|
/** Flag written after a successful ping, so subsequent sessions can skip
|
|
29
31
|
* the ~0.5s Python cold-start sanity check. Stale after PING_VERIFIED_TTL_MS. */
|
|
30
32
|
const PING_VERIFIED_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-ping-verified");
|
|
@@ -39,8 +41,14 @@ export interface MigrationResult {
|
|
|
39
41
|
updated: number;
|
|
40
42
|
skipped: number;
|
|
41
43
|
failed: number;
|
|
44
|
+
/** Records left untouched by transient palace-lock contention (retryable). */
|
|
45
|
+
deferred?: number;
|
|
42
46
|
verified: number;
|
|
43
47
|
errors?: string[];
|
|
48
|
+
/** "project/id" keys deferred by lock contention, for a targeted retry. */
|
|
49
|
+
deferredKeys?: string[];
|
|
50
|
+
/** "project/id" keys confirmed durable in the palace after the run. */
|
|
51
|
+
verifiedKeys?: string[];
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export interface MigrationState {
|
|
@@ -48,6 +56,60 @@ export interface MigrationState {
|
|
|
48
56
|
completedAt: string;
|
|
49
57
|
sourceFingerprint: string;
|
|
50
58
|
result: MigrationResult;
|
|
59
|
+
/** Keys still awaiting a retry after transient lock contention. */
|
|
60
|
+
deferredKeys?: string[];
|
|
61
|
+
/** Consecutive deferred-retry rounds, for exponential backoff. */
|
|
62
|
+
attempts?: number;
|
|
63
|
+
/** Epoch ms; do not retry deferred keys before this. */
|
|
64
|
+
retryAfter?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Deferred-retry backoff schedule: 15m → 1h → 6h → 24h (capped). */
|
|
68
|
+
const DEFERRAL_BACKOFF_MS = [15 * 60_000, 60 * 60_000, 6 * 60 * 60_000, 24 * 60 * 60_000];
|
|
69
|
+
|
|
70
|
+
function deferralBackoffMs(attempts: number): number {
|
|
71
|
+
const idx = Math.min(Math.max(attempts, 1), DEFERRAL_BACKOFF_MS.length) - 1;
|
|
72
|
+
return DEFERRAL_BACKOFF_MS[idx] ?? DEFERRAL_BACKOFF_MS[DEFERRAL_BACKOFF_MS.length - 1]!;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A migrate outcome is complete when nothing genuinely failed and every
|
|
76
|
+
* discovered record is either verified or transiently deferred. */
|
|
77
|
+
export function isMigrationComplete(result: MigrationResult): boolean {
|
|
78
|
+
return result.failed === 0 && result.verified + (result.deferred ?? 0) === result.discovered;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Normalize the raw bridge migrate payload (snake_case `deferred_keys`) into a
|
|
83
|
+
* `MigrationResult`. Returns null for a missing/malformed payload so callers
|
|
84
|
+
* never advance the marker on garbage.
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeMigrationResult(raw: unknown): MigrationResult | null {
|
|
87
|
+
if (!raw || typeof raw !== "object") return null;
|
|
88
|
+
const r = raw as Record<string, unknown>;
|
|
89
|
+
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
90
|
+
if (typeof r.discovered !== "number") return null;
|
|
91
|
+
const keys = Array.isArray(r.deferredKeys)
|
|
92
|
+
? r.deferredKeys
|
|
93
|
+
: Array.isArray(r.deferred_keys)
|
|
94
|
+
? r.deferred_keys
|
|
95
|
+
: [];
|
|
96
|
+
const vkeysRaw = Array.isArray(r.verifiedKeys)
|
|
97
|
+
? r.verifiedKeys
|
|
98
|
+
: Array.isArray(r.verified_keys)
|
|
99
|
+
? r.verified_keys
|
|
100
|
+
: [];
|
|
101
|
+
return {
|
|
102
|
+
discovered: num(r.discovered),
|
|
103
|
+
imported: num(r.imported),
|
|
104
|
+
updated: num(r.updated),
|
|
105
|
+
skipped: num(r.skipped),
|
|
106
|
+
failed: num(r.failed),
|
|
107
|
+
deferred: num(r.deferred),
|
|
108
|
+
verified: num(r.verified),
|
|
109
|
+
errors: Array.isArray(r.errors) ? (r.errors as string[]) : undefined,
|
|
110
|
+
deferredKeys: keys.filter((k): k is string => typeof k === "string"),
|
|
111
|
+
verifiedKeys: vkeysRaw.filter((k): k is string => typeof k === "string"),
|
|
112
|
+
};
|
|
51
113
|
}
|
|
52
114
|
|
|
53
115
|
let cachedBridgePath: string | null | undefined;
|
|
@@ -101,6 +163,27 @@ export interface BridgeResponse<T> {
|
|
|
101
163
|
error?: string;
|
|
102
164
|
}
|
|
103
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Full outcome of a bridge call. `result` is the parsed value (which may be a
|
|
168
|
+
* legitimate `null`, e.g. a "not found" lookup). `ok` distinguishes a
|
|
169
|
+
* successful call from a failure; `transient` marks failures that are just
|
|
170
|
+
* MemPalace palace-lock contention (retryable), not real backend errors.
|
|
171
|
+
*/
|
|
172
|
+
export interface BridgeOutcome<T> {
|
|
173
|
+
ok: boolean;
|
|
174
|
+
result: T | null;
|
|
175
|
+
error?: string;
|
|
176
|
+
transient: boolean;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** MemPalace's non-blocking mine lock surfaces as MineAlreadyRunning / "is held by". */
|
|
180
|
+
const TRANSIENT_BRIDGE_ERROR = /MineAlreadyRunning|is held by/i;
|
|
181
|
+
|
|
182
|
+
/** True when a bridge error is transient palace-lock contention, not a real fault. */
|
|
183
|
+
export function isTransientBridgeError(error: string | undefined | null): boolean {
|
|
184
|
+
return typeof error === "string" && TRANSIENT_BRIDGE_ERROR.test(error);
|
|
185
|
+
}
|
|
186
|
+
|
|
104
187
|
export interface MempalaceRecord {
|
|
105
188
|
id: string;
|
|
106
189
|
title: string;
|
|
@@ -291,7 +374,327 @@ export function getMemorySourceFingerprint(
|
|
|
291
374
|
return hash.digest("hex");
|
|
292
375
|
}
|
|
293
376
|
|
|
294
|
-
|
|
377
|
+
// ── Daemon awareness (L3) ───────────────────────────────────────────────────
|
|
378
|
+
//
|
|
379
|
+
// MemPalace can run as a long-lived daemon that holds the per-palace mine lock
|
|
380
|
+
// while it mines. That lock is exactly what makes our direct-bridge upserts
|
|
381
|
+
// defer (L1). The daemon exposes an HTTP control API, but it has NO idempotent
|
|
382
|
+
// record-upsert job — its only generic write (`mcp_tool` -> tool_add_drawer)
|
|
383
|
+
// uses a CONTENT-addressed drawer id, whereas our bridge uses a deterministic
|
|
384
|
+
// SOURCE-URI-addressed id. Routing our writes through the daemon would fork the
|
|
385
|
+
// id scheme and duplicate drawers, so we must NOT do that. Instead we detect a
|
|
386
|
+
// reachable daemon and, when it is actively mining, skip the direct catch-up
|
|
387
|
+
// this session and let the L1/L2 backoff ride it out — avoiding the lock fight
|
|
388
|
+
// rather than joining it. The direct bridge stays the sole write path because
|
|
389
|
+
// it alone produces the correct idempotent ids. When no daemon is running
|
|
390
|
+
// (per-call / MCP-less mode) nothing changes.
|
|
391
|
+
|
|
392
|
+
export interface DaemonStatus {
|
|
393
|
+
/** A daemon endpoint for this palace is reachable and healthy. */
|
|
394
|
+
reachable: boolean;
|
|
395
|
+
/** The daemon is currently running a job (holds the mine lock). */
|
|
396
|
+
busy: boolean;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Replicate the daemon's palace_key: sha256(realpath(palace))[:24] (normcase
|
|
400
|
+
* is a no-op on POSIX). */
|
|
401
|
+
function palaceKey(palacePath: string): string {
|
|
402
|
+
let canonical: string;
|
|
403
|
+
try {
|
|
404
|
+
canonical = fs.realpathSync(palacePath);
|
|
405
|
+
} catch {
|
|
406
|
+
canonical = path.resolve(palacePath);
|
|
407
|
+
}
|
|
408
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 24);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function daemonStateDir(palacePath: string): string {
|
|
412
|
+
const root = process.env.MEMPALACE_DAEMON_STATE_ROOT
|
|
413
|
+
? path.resolve(os.homedir(), process.env.MEMPALACE_DAEMON_STATE_ROOT.replace(/^~(?=$|\/)/, os.homedir()))
|
|
414
|
+
: path.join(os.homedir(), ".mempalace", "daemon");
|
|
415
|
+
return path.join(root, palaceKey(palacePath));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Probe for a reachable MemPalace daemon for `palacePath` via its endpoint.json
|
|
420
|
+
* + token + /health. Never throws; returns `{reachable:false}` when there is no
|
|
421
|
+
* daemon, the endpoint is stale, or the probe errors/times out. `busy` reflects
|
|
422
|
+
* an in-flight job (active_job_id) — i.e. the mine lock is likely held.
|
|
423
|
+
*/
|
|
424
|
+
export async function probeDaemon(palacePath: string, timeoutMs = 300): Promise<DaemonStatus> {
|
|
425
|
+
const down: DaemonStatus = { reachable: false, busy: false };
|
|
426
|
+
try {
|
|
427
|
+
const dir = daemonStateDir(palacePath);
|
|
428
|
+
const endpointRaw = fs.readFileSync(path.join(dir, "endpoint.json"), "utf-8");
|
|
429
|
+
const token = fs.readFileSync(path.join(dir, "token"), "utf-8").trim();
|
|
430
|
+
const endpoint = JSON.parse(endpointRaw) as { host?: string; port?: number };
|
|
431
|
+
if (!endpoint.host || !endpoint.port || !token) return down;
|
|
432
|
+
const controller = new AbortController();
|
|
433
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
434
|
+
timer.unref?.();
|
|
435
|
+
try {
|
|
436
|
+
const resp = await fetch(`http://${endpoint.host}:${String(endpoint.port)}/health`, {
|
|
437
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
438
|
+
signal: controller.signal,
|
|
439
|
+
});
|
|
440
|
+
if (!resp.ok) return down;
|
|
441
|
+
const health = (await resp.json()) as { ok?: boolean; active_job_id?: unknown };
|
|
442
|
+
if (!health.ok) return down;
|
|
443
|
+
return { reachable: true, busy: health.active_job_id != null };
|
|
444
|
+
} finally {
|
|
445
|
+
clearTimeout(timer);
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
return down;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── Record-level sync ledger (L2) ────────────────────────────────────────────
|
|
453
|
+
//
|
|
454
|
+
// The old size+mtime fingerprint invalidated the whole migration marker on any
|
|
455
|
+
// memory write (store() rewrites the .md every time), forcing a full re-sweep.
|
|
456
|
+
// The ledger instead tracks, per record, the content hash last confirmed durable
|
|
457
|
+
// in the palace. Catch-up then touches only records whose file differs from the
|
|
458
|
+
// ledger (out-of-band edits or writes whose palace upsert was deferred), never
|
|
459
|
+
// the whole corpus.
|
|
460
|
+
|
|
461
|
+
export const LEDGER_VERSION = 1;
|
|
462
|
+
|
|
463
|
+
export interface LedgerEntry {
|
|
464
|
+
/** sha256 of the exact .md bytes last confirmed in the palace. */
|
|
465
|
+
hash: string;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export interface Ledger {
|
|
469
|
+
version: number;
|
|
470
|
+
/** "project/id" -> entry. */
|
|
471
|
+
entries: Record<string, LedgerEntry>;
|
|
472
|
+
/** Keys deferred by transient lock contention, awaiting a targeted retry. */
|
|
473
|
+
deferredKeys: string[];
|
|
474
|
+
/** Keys that hit a genuine (non-transient) failure; retried with backoff too. */
|
|
475
|
+
failedKeys: string[];
|
|
476
|
+
/** Consecutive contended/failed rounds, for exponential backoff. */
|
|
477
|
+
attempts: number;
|
|
478
|
+
/** Epoch ms; do not retry deferred/failed keys before this. */
|
|
479
|
+
retryAfter?: number;
|
|
480
|
+
updatedAt: string;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** A record discovered on disk: its key and the hash of its current bytes. */
|
|
484
|
+
export interface ScannedRecord {
|
|
485
|
+
key: string;
|
|
486
|
+
project: string;
|
|
487
|
+
id: string;
|
|
488
|
+
hash: string;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function emptyLedger(): Ledger {
|
|
492
|
+
return { version: LEDGER_VERSION, entries: {}, deferredKeys: [], failedKeys: [], attempts: 0, updatedAt: new Date(0).toISOString() };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** sha256 of raw file bytes — the scanner and store() hash the same bytes so
|
|
496
|
+
* the ledger never drifts through a parse/serialize round-trip. */
|
|
497
|
+
export function hashBytes(text: string): string {
|
|
498
|
+
return createHash("sha256").update(text, "utf-8").digest("hex");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function readLedger(flagPath = LEDGER_FLAG): Ledger {
|
|
502
|
+
try {
|
|
503
|
+
const parsed = JSON.parse(fs.readFileSync(flagPath, "utf-8")) as Partial<Ledger>;
|
|
504
|
+
if (parsed?.version !== LEDGER_VERSION || !parsed.entries || typeof parsed.entries !== "object") {
|
|
505
|
+
return emptyLedger();
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
version: LEDGER_VERSION,
|
|
509
|
+
entries: parsed.entries as Record<string, LedgerEntry>,
|
|
510
|
+
deferredKeys: Array.isArray(parsed.deferredKeys) ? parsed.deferredKeys : [],
|
|
511
|
+
failedKeys: Array.isArray(parsed.failedKeys) ? parsed.failedKeys : [],
|
|
512
|
+
attempts: typeof parsed.attempts === "number" ? parsed.attempts : 0,
|
|
513
|
+
retryAfter: typeof parsed.retryAfter === "number" ? parsed.retryAfter : undefined,
|
|
514
|
+
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date(0).toISOString(),
|
|
515
|
+
};
|
|
516
|
+
} catch {
|
|
517
|
+
return emptyLedger();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function writeLedger(ledger: Ledger, flagPath = LEDGER_FLAG): boolean {
|
|
522
|
+
try {
|
|
523
|
+
fs.mkdirSync(path.dirname(flagPath), { recursive: true });
|
|
524
|
+
const temp = `${flagPath}.${process.pid}.tmp`;
|
|
525
|
+
fs.writeFileSync(temp, JSON.stringify({ ...ledger, version: LEDGER_VERSION }, null, 2), "utf-8");
|
|
526
|
+
fs.renameSync(temp, flagPath);
|
|
527
|
+
return true;
|
|
528
|
+
} catch {
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Scan the durable markdown tier and return one record per `.md` file with the
|
|
535
|
+
* hash of its exact bytes. Only markdown is a migration source now (the SQLite
|
|
536
|
+
* fallback was removed), so `memory.db` is deliberately ignored — its churn was
|
|
537
|
+
* a major cause of needless re-sweeps.
|
|
538
|
+
*/
|
|
539
|
+
export function scanMemorySources(sourceDir = path.join(os.homedir(), ".unipi", "memory")): ScannedRecord[] {
|
|
540
|
+
const out: ScannedRecord[] = [];
|
|
541
|
+
if (!fs.existsSync(sourceDir)) return out;
|
|
542
|
+
let projects: fs.Dirent[];
|
|
543
|
+
try {
|
|
544
|
+
projects = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
545
|
+
} catch {
|
|
546
|
+
return out;
|
|
547
|
+
}
|
|
548
|
+
for (const projEntry of projects) {
|
|
549
|
+
if (!projEntry.isDirectory() || projEntry.name.startsWith(".")) continue;
|
|
550
|
+
const project = projEntry.name;
|
|
551
|
+
const projDir = path.join(sourceDir, project);
|
|
552
|
+
let files: fs.Dirent[];
|
|
553
|
+
try {
|
|
554
|
+
files = fs.readdirSync(projDir, { withFileTypes: true });
|
|
555
|
+
} catch {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
for (const f of files) {
|
|
559
|
+
if (!f.isFile() || f.name.startsWith(".") || !f.name.endsWith(".md")) continue;
|
|
560
|
+
const full = path.join(projDir, f.name);
|
|
561
|
+
let text: string;
|
|
562
|
+
try {
|
|
563
|
+
text = fs.readFileSync(full, "utf-8");
|
|
564
|
+
} catch {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
// Key must match the bridge exactly (parse_markdown_memory):
|
|
568
|
+
// - id: explicit frontmatter `id`, else the filename stem normalized
|
|
569
|
+
// ([^A-Za-z0-9]+ -> _, trimmed, lowercased).
|
|
570
|
+
// - project: frontmatter `project` if present, else the directory name.
|
|
571
|
+
// A mismatch would make targeted `--only` retries silently no-op.
|
|
572
|
+
const fmMatch = /^---\n([\s\S]*?)\n---/.exec(text);
|
|
573
|
+
const fm = fmMatch ? fmMatch[1]! : "";
|
|
574
|
+
const readField = (name: string): string => {
|
|
575
|
+
const m = new RegExp(`(^|\\n)${name}:\\s*(.+)`).exec(fm);
|
|
576
|
+
return m ? m[2]!.trim().replace(/^["']|["']$/g, "") : "";
|
|
577
|
+
};
|
|
578
|
+
const explicitId = readField("id");
|
|
579
|
+
const id = explicitId
|
|
580
|
+
|| (f.name.replace(/\.md$/, "").replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase() || "unknown");
|
|
581
|
+
const recProject = readField("project") || project;
|
|
582
|
+
out.push({ key: `${recProject}/${id}`, project: recProject, id, hash: hashBytes(text) });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Compute which record keys still need a migrate pass: those whose current file
|
|
590
|
+
* hash differs from the ledger, plus any deferred/failed keys whose backoff has
|
|
591
|
+
* elapsed. Returns the keys and whether the ledger has never been populated
|
|
592
|
+
* (→ a first full pass is warranted).
|
|
593
|
+
*/
|
|
594
|
+
export function ledgerDelta(
|
|
595
|
+
scanned: ScannedRecord[],
|
|
596
|
+
ledger: Ledger,
|
|
597
|
+
now = Date.now(),
|
|
598
|
+
): { keys: string[]; firstRun: boolean } {
|
|
599
|
+
const firstRun = Object.keys(ledger.entries).length === 0;
|
|
600
|
+
const due = new Set<string>();
|
|
601
|
+
for (const rec of scanned) {
|
|
602
|
+
if (ledger.entries[rec.key]?.hash !== rec.hash) due.add(rec.key);
|
|
603
|
+
}
|
|
604
|
+
const backoffElapsed = typeof ledger.retryAfter !== "number" || now >= ledger.retryAfter;
|
|
605
|
+
if (backoffElapsed) {
|
|
606
|
+
for (const k of ledger.deferredKeys) due.add(k);
|
|
607
|
+
for (const k of ledger.failedKeys) due.add(k);
|
|
608
|
+
}
|
|
609
|
+
return { keys: [...due], firstRun };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Fold a migrate result into the ledger: advance the content hash for every
|
|
614
|
+
* verified key, and re-track deferred/failed keys (with backoff) so they retry
|
|
615
|
+
* later. `scannedByKey` gives the on-disk hash to record for a verified key.
|
|
616
|
+
*/
|
|
617
|
+
export function applyMigrationToLedger(
|
|
618
|
+
ledger: Ledger,
|
|
619
|
+
result: MigrationResult,
|
|
620
|
+
scannedByKey: Map<string, string>,
|
|
621
|
+
now = Date.now(),
|
|
622
|
+
): Ledger {
|
|
623
|
+
const entries = { ...ledger.entries };
|
|
624
|
+
for (const key of result.verifiedKeys ?? []) {
|
|
625
|
+
const hash = scannedByKey.get(key);
|
|
626
|
+
if (hash) entries[key] = { hash };
|
|
627
|
+
}
|
|
628
|
+
const deferredKeys = [...new Set(result.deferredKeys ?? [])];
|
|
629
|
+
// Genuine failures are surfaced via error strings; derive their keys from the
|
|
630
|
+
// error prefix "project/id: ..." so they, too, are retried (not silently
|
|
631
|
+
// dropped) but never recorded as synced.
|
|
632
|
+
const failedKeys = [...new Set((result.errors ?? [])
|
|
633
|
+
.map((e) => e.split(":")[0]?.trim())
|
|
634
|
+
.filter((k): k is string => !!k && k.includes("/")))];
|
|
635
|
+
const hadBacklog = deferredKeys.length > 0 || failedKeys.length > 0;
|
|
636
|
+
const attempts = hadBacklog ? ledger.attempts + 1 : 0;
|
|
637
|
+
return {
|
|
638
|
+
version: LEDGER_VERSION,
|
|
639
|
+
entries,
|
|
640
|
+
deferredKeys,
|
|
641
|
+
failedKeys,
|
|
642
|
+
attempts,
|
|
643
|
+
...(hadBacklog ? { retryAfter: now + deferralBackoffMs(attempts) } : {}),
|
|
644
|
+
updatedAt: new Date(now).toISOString(),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Record a single successful store() upsert in the ledger. */
|
|
649
|
+
export function ledgerRecordStore(key: string, fileText: string, flagPath = LEDGER_FLAG): void {
|
|
650
|
+
const ledger = readLedger(flagPath);
|
|
651
|
+
ledger.entries[key] = { hash: hashBytes(fileText) };
|
|
652
|
+
// A fresh successful write clears any pending retry state for this key.
|
|
653
|
+
ledger.deferredKeys = ledger.deferredKeys.filter((k) => k !== key);
|
|
654
|
+
ledger.failedKeys = ledger.failedKeys.filter((k) => k !== key);
|
|
655
|
+
ledger.updatedAt = new Date().toISOString();
|
|
656
|
+
writeLedger(ledger, flagPath);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* One-time bootstrap: if there is no ledger yet but a completed legacy
|
|
661
|
+
* `.mempalace-migrated` marker exists for the current corpus, seed the ledger
|
|
662
|
+
* from the current on-disk hashes so we do not re-migrate everything once.
|
|
663
|
+
* Deferred keys from the old marker are carried over for targeted retry.
|
|
664
|
+
*/
|
|
665
|
+
export function bootstrapLedgerFromLegacyMarker(
|
|
666
|
+
sourceDir = path.join(os.homedir(), ".unipi", "memory"),
|
|
667
|
+
ledgerPath = LEDGER_FLAG,
|
|
668
|
+
markerPath = MIGRATED_FLAG,
|
|
669
|
+
): Ledger | null {
|
|
670
|
+
if (fs.existsSync(ledgerPath)) return null;
|
|
671
|
+
const marker = readMigrationState(markerPath);
|
|
672
|
+
if (!marker) return null;
|
|
673
|
+
const scanned = scanMemorySources(sourceDir);
|
|
674
|
+
const deferred = new Set(marker.deferredKeys ?? []);
|
|
675
|
+
const entries: Record<string, LedgerEntry> = {};
|
|
676
|
+
for (const rec of scanned) {
|
|
677
|
+
// A record known-deferred under the old marker is NOT yet durable — leave
|
|
678
|
+
// it out of entries so the delta re-attempts it.
|
|
679
|
+
if (deferred.has(rec.key)) continue;
|
|
680
|
+
entries[rec.key] = { hash: rec.hash };
|
|
681
|
+
}
|
|
682
|
+
const ledger: Ledger = {
|
|
683
|
+
version: LEDGER_VERSION,
|
|
684
|
+
entries,
|
|
685
|
+
deferredKeys: [...deferred],
|
|
686
|
+
failedKeys: [],
|
|
687
|
+
attempts: marker.attempts ?? 0,
|
|
688
|
+
...(marker.retryAfter ? { retryAfter: marker.retryAfter } : {}),
|
|
689
|
+
updatedAt: new Date().toISOString(),
|
|
690
|
+
};
|
|
691
|
+
writeLedger(ledger, ledgerPath);
|
|
692
|
+
return ledger;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Read a verified migration state. Legacy timestamp markers, wrong-version,
|
|
696
|
+
* malformed, or non-complete states all return null (→ treated as not
|
|
697
|
+
* migrated). A state with transiently-deferred records is still complete. */
|
|
295
698
|
export function readMigrationState(flagPath = MIGRATED_FLAG): MigrationState | null {
|
|
296
699
|
try {
|
|
297
700
|
const parsed = JSON.parse(fs.readFileSync(flagPath, "utf-8")) as MigrationState;
|
|
@@ -300,8 +703,7 @@ export function readMigrationState(flagPath = MIGRATED_FLAG): MigrationState | n
|
|
|
300
703
|
typeof parsed.completedAt !== "string" ||
|
|
301
704
|
typeof parsed.sourceFingerprint !== "string" ||
|
|
302
705
|
!parsed.result ||
|
|
303
|
-
parsed.result
|
|
304
|
-
parsed.result.verified !== parsed.result.discovered
|
|
706
|
+
!isMigrationComplete(parsed.result)
|
|
305
707
|
) return null;
|
|
306
708
|
return parsed;
|
|
307
709
|
} catch {
|
|
@@ -309,6 +711,46 @@ export function readMigrationState(flagPath = MIGRATED_FLAG): MigrationState | n
|
|
|
309
711
|
}
|
|
310
712
|
}
|
|
311
713
|
|
|
714
|
+
/**
|
|
715
|
+
* The deferred "project/id" keys due for a targeted retry now, or null when
|
|
716
|
+
* there is nothing to retry (no complete marker for this fingerprint, no
|
|
717
|
+
* deferred keys, or the backoff window has not elapsed).
|
|
718
|
+
*/
|
|
719
|
+
export function deferredRetryDue(
|
|
720
|
+
sourceFingerprint = getMemorySourceFingerprint(),
|
|
721
|
+
flagPath = MIGRATED_FLAG,
|
|
722
|
+
now = Date.now(),
|
|
723
|
+
): string[] | null {
|
|
724
|
+
const state = readMigrationState(flagPath);
|
|
725
|
+
if (!state || state.sourceFingerprint !== sourceFingerprint) return null;
|
|
726
|
+
const keys = state.deferredKeys ?? [];
|
|
727
|
+
if (keys.length === 0) return null;
|
|
728
|
+
if (typeof state.retryAfter === "number" && now < state.retryAfter) return null;
|
|
729
|
+
return keys;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Push the deferred-retry schedule forward without changing completion — used
|
|
734
|
+
* when a targeted retry could not be recorded as complete (e.g. it surfaced a
|
|
735
|
+
* genuine failure) so we do not re-attempt it on every boot.
|
|
736
|
+
*/
|
|
737
|
+
export function bumpDeferredRetry(
|
|
738
|
+
sourceFingerprint: string,
|
|
739
|
+
flagPath = MIGRATED_FLAG,
|
|
740
|
+
now = Date.now(),
|
|
741
|
+
): void {
|
|
742
|
+
const state = readMigrationState(flagPath);
|
|
743
|
+
if (!state || state.sourceFingerprint !== sourceFingerprint) return;
|
|
744
|
+
const attempts = (state.attempts ?? 0) + 1;
|
|
745
|
+
const next: MigrationState = { ...state, attempts, retryAfter: now + deferralBackoffMs(attempts) };
|
|
746
|
+
try {
|
|
747
|
+
fs.mkdirSync(path.dirname(flagPath), { recursive: true });
|
|
748
|
+
const temp = `${flagPath}.${process.pid}.tmp`;
|
|
749
|
+
fs.writeFileSync(temp, JSON.stringify(next, null, 2), "utf-8");
|
|
750
|
+
fs.renameSync(temp, flagPath);
|
|
751
|
+
} catch { /* best effort */ }
|
|
752
|
+
}
|
|
753
|
+
|
|
312
754
|
/** Is the palace verified against the current durable source set? */
|
|
313
755
|
export function isMigrated(
|
|
314
756
|
sourceFingerprint = getMemorySourceFingerprint(),
|
|
@@ -317,20 +759,35 @@ export function isMigrated(
|
|
|
317
759
|
return readMigrationState(flagPath)?.sourceFingerprint === sourceFingerprint;
|
|
318
760
|
}
|
|
319
761
|
|
|
320
|
-
/**
|
|
762
|
+
/**
|
|
763
|
+
* Mark migration complete. Accepts a run where every discovered record is
|
|
764
|
+
* verified or transiently deferred (lock contention) and nothing genuinely
|
|
765
|
+
* failed. When records are deferred, persist their keys plus an exponential
|
|
766
|
+
* backoff `retryAfter` so a later session retries only those keys instead of
|
|
767
|
+
* re-sweeping the corpus. A genuine failure (`failed > 0`) or an incomplete
|
|
768
|
+
* run is refused, so the marker never advances over lost data.
|
|
769
|
+
*/
|
|
321
770
|
export function markMigrated(
|
|
322
771
|
sourceFingerprint: string,
|
|
323
772
|
result: MigrationResult,
|
|
324
773
|
flagPath = MIGRATED_FLAG,
|
|
774
|
+
now = Date.now(),
|
|
325
775
|
): boolean {
|
|
326
|
-
if (result
|
|
776
|
+
if (!isMigrationComplete(result)) return false;
|
|
777
|
+
const deferred = result.deferred ?? 0;
|
|
778
|
+
const prev = readMigrationState(flagPath);
|
|
779
|
+
const prevAttempts = prev?.sourceFingerprint === sourceFingerprint ? prev.attempts ?? 0 : 0;
|
|
780
|
+
const attempts = deferred > 0 ? prevAttempts + 1 : 0;
|
|
327
781
|
try {
|
|
328
782
|
fs.mkdirSync(path.dirname(flagPath), { recursive: true });
|
|
329
783
|
const state: MigrationState = {
|
|
330
784
|
version: MIGRATION_STATE_VERSION,
|
|
331
|
-
completedAt: new Date().toISOString(),
|
|
785
|
+
completedAt: new Date(now).toISOString(),
|
|
332
786
|
sourceFingerprint,
|
|
333
787
|
result,
|
|
788
|
+
deferredKeys: deferred > 0 ? (result.deferredKeys ?? []) : [],
|
|
789
|
+
attempts,
|
|
790
|
+
...(deferred > 0 ? { retryAfter: now + deferralBackoffMs(attempts) } : {}),
|
|
334
791
|
};
|
|
335
792
|
const temp = `${flagPath}.${process.pid}.tmp`;
|
|
336
793
|
fs.writeFileSync(temp, JSON.stringify(state, null, 2), "utf-8");
|
|
@@ -342,23 +799,30 @@ export function markMigrated(
|
|
|
342
799
|
}
|
|
343
800
|
|
|
344
801
|
/**
|
|
345
|
-
* Run one bridge command synchronously
|
|
346
|
-
*
|
|
802
|
+
* Run one bridge command synchronously, returning the full outcome so callers
|
|
803
|
+
* can tell a successful `null` result (e.g. "not found") apart from a failure,
|
|
804
|
+
* and transient palace-lock contention apart from a real backend error.
|
|
347
805
|
*/
|
|
348
|
-
export function
|
|
806
|
+
export function runBridgeOutcome<T = unknown>(
|
|
349
807
|
install: MempalaceInstall,
|
|
350
808
|
palace: string,
|
|
351
809
|
cmd: string,
|
|
352
810
|
args: Record<string, unknown> = {},
|
|
353
811
|
timeoutMs = 60_000,
|
|
354
|
-
): T
|
|
812
|
+
): BridgeOutcome<T> {
|
|
813
|
+
const fail = (error?: string): BridgeOutcome<T> => ({
|
|
814
|
+
ok: false,
|
|
815
|
+
result: null,
|
|
816
|
+
error,
|
|
817
|
+
transient: isTransientBridgeError(error),
|
|
818
|
+
});
|
|
355
819
|
const bridgePath = getBridgePath();
|
|
356
|
-
if (!bridgePath) return
|
|
820
|
+
if (!bridgePath) return fail("bridge script not found");
|
|
357
821
|
let argsJson: string;
|
|
358
822
|
try {
|
|
359
823
|
argsJson = JSON.stringify(args);
|
|
360
824
|
} catch {
|
|
361
|
-
return
|
|
825
|
+
return fail("args not serializable");
|
|
362
826
|
}
|
|
363
827
|
let res;
|
|
364
828
|
try {
|
|
@@ -367,21 +831,38 @@ export function runBridge<T = unknown>(
|
|
|
367
831
|
timeout: timeoutMs,
|
|
368
832
|
maxBuffer: 64 * 1024 * 1024,
|
|
369
833
|
});
|
|
370
|
-
} catch {
|
|
371
|
-
return
|
|
372
|
-
}
|
|
373
|
-
if (res.error || res.status !== 0) {
|
|
374
|
-
return null;
|
|
834
|
+
} catch (err) {
|
|
835
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
375
836
|
}
|
|
837
|
+
if (res.error) return fail(res.error.message);
|
|
376
838
|
const out = (res.stdout || "").trim();
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
839
|
+
// A non-zero exit may still carry a structured {ok:false,error} on stdout
|
|
840
|
+
// (the bridge prints that then exits 1) — parse it so we can classify.
|
|
841
|
+
if (out) {
|
|
842
|
+
try {
|
|
843
|
+
const parsed = JSON.parse(out) as BridgeResponse<T>;
|
|
844
|
+
if (parsed.ok) return { ok: true, result: (parsed.result ?? null) as T | null, transient: false };
|
|
845
|
+
return fail(parsed.error);
|
|
846
|
+
} catch {
|
|
847
|
+
return fail("bad json from bridge");
|
|
848
|
+
}
|
|
384
849
|
}
|
|
850
|
+
return fail(res.status === 0 ? "empty output" : `bridge exited ${String(res.status)}`);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Run one bridge command synchronously. Returns the parsed result, or null
|
|
855
|
+
* on any failure. Prefer {@link runBridgeOutcome} when you need to distinguish
|
|
856
|
+
* a "not found" null from a failure, or transient contention from a real error.
|
|
857
|
+
*/
|
|
858
|
+
export function runBridge<T = unknown>(
|
|
859
|
+
install: MempalaceInstall,
|
|
860
|
+
palace: string,
|
|
861
|
+
cmd: string,
|
|
862
|
+
args: Record<string, unknown> = {},
|
|
863
|
+
timeoutMs = 60_000,
|
|
864
|
+
): T | null {
|
|
865
|
+
return runBridgeOutcome<T>(install, palace, cmd, args, timeoutMs).result;
|
|
385
866
|
}
|
|
386
867
|
|
|
387
868
|
/**
|
|
@@ -398,17 +879,30 @@ export function runBridgeAsync<T = unknown>(
|
|
|
398
879
|
args: Record<string, unknown> = {},
|
|
399
880
|
timeoutMs = 60_000,
|
|
400
881
|
): Promise<T | null> {
|
|
882
|
+
return runBridgeAsyncOutcome<T>(install, palace, cmd, args, timeoutMs).then((o) => o.result);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** Async twin of {@link runBridgeOutcome}. Never rejects. */
|
|
886
|
+
export function runBridgeAsyncOutcome<T = unknown>(
|
|
887
|
+
install: MempalaceInstall,
|
|
888
|
+
palace: string,
|
|
889
|
+
cmd: string,
|
|
890
|
+
args: Record<string, unknown> = {},
|
|
891
|
+
timeoutMs = 60_000,
|
|
892
|
+
): Promise<BridgeOutcome<T>> {
|
|
401
893
|
return new Promise((resolve) => {
|
|
894
|
+
const fail = (error?: string): void =>
|
|
895
|
+
resolve({ ok: false, result: null, error, transient: isTransientBridgeError(error) });
|
|
402
896
|
const bridgePath = getBridgePath();
|
|
403
897
|
if (!bridgePath) {
|
|
404
|
-
|
|
898
|
+
fail("bridge script not found");
|
|
405
899
|
return;
|
|
406
900
|
}
|
|
407
901
|
let argsJson: string;
|
|
408
902
|
try {
|
|
409
903
|
argsJson = JSON.stringify(args);
|
|
410
904
|
} catch {
|
|
411
|
-
|
|
905
|
+
fail("args not serializable");
|
|
412
906
|
return;
|
|
413
907
|
}
|
|
414
908
|
|
|
@@ -417,39 +911,49 @@ export function runBridgeAsync<T = unknown>(
|
|
|
417
911
|
child = spawn(install.python, [bridgePath, palace, cmd, argsJson], {
|
|
418
912
|
stdio: ["ignore", "pipe", "ignore"],
|
|
419
913
|
});
|
|
420
|
-
} catch {
|
|
421
|
-
|
|
914
|
+
} catch (err) {
|
|
915
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
422
916
|
return;
|
|
423
917
|
}
|
|
918
|
+
// Fire-and-forget callers (e.g. the L0 background migrate) must never keep
|
|
919
|
+
// the process alive; the promise still resolves on close for awaiters.
|
|
920
|
+
child.unref?.();
|
|
424
921
|
|
|
425
922
|
let out = "";
|
|
426
923
|
let settled = false;
|
|
427
|
-
const finish = (
|
|
924
|
+
const finish = (outcome: BridgeOutcome<T>): void => {
|
|
428
925
|
if (settled) return;
|
|
429
926
|
settled = true;
|
|
430
927
|
clearTimeout(timer);
|
|
431
|
-
resolve(
|
|
928
|
+
resolve(outcome);
|
|
432
929
|
};
|
|
433
930
|
|
|
434
931
|
const timer = setTimeout(() => {
|
|
435
932
|
try { child.kill(); } catch { /* already gone */ }
|
|
436
|
-
finish(null);
|
|
933
|
+
finish({ ok: false, result: null, error: "bridge timed out", transient: false });
|
|
437
934
|
}, timeoutMs);
|
|
438
935
|
// Do not hold the process open purely for a background bridge call.
|
|
439
936
|
timer.unref?.();
|
|
440
937
|
|
|
441
938
|
child.stdout?.setEncoding("utf-8");
|
|
442
939
|
child.stdout?.on("data", (chunk) => { out += chunk; });
|
|
443
|
-
child.on("error", () => finish(null));
|
|
444
|
-
child.on("close", (
|
|
445
|
-
if (code !== 0) return finish(null);
|
|
940
|
+
child.on("error", (err) => finish({ ok: false, result: null, error: err.message, transient: false }));
|
|
941
|
+
child.on("close", () => {
|
|
446
942
|
const trimmed = out.trim();
|
|
447
|
-
|
|
943
|
+
// A non-zero exit still carries {ok:false,error} on stdout; parse first.
|
|
944
|
+
if (!trimmed) {
|
|
945
|
+
finish({ ok: false, result: null, error: "empty output", transient: false });
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
448
948
|
try {
|
|
449
949
|
const parsed = JSON.parse(trimmed) as BridgeResponse<T>;
|
|
450
|
-
|
|
950
|
+
if (parsed.ok) {
|
|
951
|
+
finish({ ok: true, result: (parsed.result ?? null) as T | null, transient: false });
|
|
952
|
+
} else {
|
|
953
|
+
finish({ ok: false, result: null, error: parsed.error, transient: isTransientBridgeError(parsed.error) });
|
|
954
|
+
}
|
|
451
955
|
} catch {
|
|
452
|
-
finish(null);
|
|
956
|
+
finish({ ok: false, result: null, error: "bad json from bridge", transient: false });
|
|
453
957
|
}
|
|
454
958
|
});
|
|
455
959
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/memory",
|
|
3
|
-
"version": "2.20.
|
|
3
|
+
"version": "2.20.5",
|
|
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",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"README.md"
|
|
40
40
|
],
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@pi-unipi/core": "2.20.
|
|
43
|
-
"@pi-unipi/info-screen": "2.20.
|
|
42
|
+
"@pi-unipi/core": "2.20.5",
|
|
43
|
+
"@pi-unipi/info-screen": "2.20.5",
|
|
44
44
|
"js-yaml": "^4.1.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
package/storage.ts
CHANGED
|
@@ -15,9 +15,17 @@ import {
|
|
|
15
15
|
ensureMempalace,
|
|
16
16
|
runBridge,
|
|
17
17
|
runBridgeAsync,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
runBridgeOutcome,
|
|
19
|
+
runBridgeAsyncOutcome,
|
|
20
|
+
normalizeMigrationResult,
|
|
21
|
+
probeDaemon,
|
|
22
|
+
readLedger,
|
|
23
|
+
writeLedger,
|
|
24
|
+
scanMemorySources,
|
|
25
|
+
ledgerDelta,
|
|
26
|
+
applyMigrationToLedger,
|
|
27
|
+
ledgerRecordStore,
|
|
28
|
+
bootstrapLedgerFromLegacyMarker,
|
|
21
29
|
isPingVerified,
|
|
22
30
|
markPingVerified,
|
|
23
31
|
invalidatePingVerified,
|
|
@@ -27,7 +35,6 @@ import {
|
|
|
27
35
|
type MempalaceSearchResult,
|
|
28
36
|
type MempalaceListItem,
|
|
29
37
|
type MempalaceListItemAll,
|
|
30
|
-
type MigrationResult,
|
|
31
38
|
} from "./mempalace.js";
|
|
32
39
|
|
|
33
40
|
|
|
@@ -198,6 +205,8 @@ export class MemoryStorage {
|
|
|
198
205
|
private scopeDir: string;
|
|
199
206
|
private mempalaceInstall: MempalaceInstall | null = null;
|
|
200
207
|
private palacePath: string = DEFAULT_PALACE;
|
|
208
|
+
/** Single-flight guard for the background ping+migrate task. */
|
|
209
|
+
private bgVerifyStarted = false;
|
|
201
210
|
|
|
202
211
|
constructor(projectName: string) {
|
|
203
212
|
this.projectName = projectName;
|
|
@@ -210,30 +219,34 @@ export class MemoryStorage {
|
|
|
210
219
|
}
|
|
211
220
|
|
|
212
221
|
/**
|
|
213
|
-
* Run a MemPalace bridge command
|
|
214
|
-
*
|
|
215
|
-
*
|
|
222
|
+
* Run a MemPalace bridge command. Invalidates the ping-verified flag only on
|
|
223
|
+
* a genuine backend failure (bad process, corrupt palace, protocol error) so
|
|
224
|
+
* a broken palace gets re-verified next session.
|
|
225
|
+
*
|
|
226
|
+
* L4: a successful call that legitimately returns `null` (e.g. a "not found"
|
|
227
|
+
* lookup) and transient palace-lock contention (`MineAlreadyRunning`, common
|
|
228
|
+
* while the daemon mines) must NOT invalidate the flag — doing so forced a
|
|
229
|
+
* ~0.5s cold-start ping on every boot.
|
|
216
230
|
*/
|
|
217
231
|
private memPalaceCall<T>(cmd: string, args: Record<string, unknown> = {}): T | null {
|
|
218
232
|
const install = this.mempalaceInstall;
|
|
219
233
|
if (!install) return null;
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
222
|
-
// Backend didn't respond — force a real ping next session.
|
|
234
|
+
const outcome = runBridgeOutcome<T>(install, this.palacePath, cmd, args);
|
|
235
|
+
if (!outcome.ok && !outcome.transient) {
|
|
223
236
|
invalidatePingVerified();
|
|
224
237
|
}
|
|
225
|
-
return result;
|
|
238
|
+
return outcome.result;
|
|
226
239
|
}
|
|
227
240
|
|
|
228
241
|
/** Async twin of memPalaceCall, for paths that must not block the UI. */
|
|
229
242
|
private async memPalaceCallAsync<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T | null> {
|
|
230
243
|
const install = this.mempalaceInstall;
|
|
231
244
|
if (!install) return null;
|
|
232
|
-
const
|
|
233
|
-
if (
|
|
245
|
+
const outcome = await runBridgeAsyncOutcome<T>(install, this.palacePath, cmd, args);
|
|
246
|
+
if (!outcome.ok && !outcome.transient) {
|
|
234
247
|
invalidatePingVerified();
|
|
235
248
|
}
|
|
236
|
-
return result;
|
|
249
|
+
return outcome.result;
|
|
237
250
|
}
|
|
238
251
|
|
|
239
252
|
/**
|
|
@@ -249,71 +262,94 @@ export class MemoryStorage {
|
|
|
249
262
|
}
|
|
250
263
|
|
|
251
264
|
/**
|
|
252
|
-
* Initialize storage.
|
|
253
|
-
* auto-
|
|
265
|
+
* Initialize storage. Requires the MemPalace backend to be installable
|
|
266
|
+
* (auto-install via uv); throws only when it is genuinely unavailable.
|
|
267
|
+
*
|
|
268
|
+
* L0: this is deliberately NON-BLOCKING. The synchronous path does only the
|
|
269
|
+
* cheap install check (a cached flag on normal boots) and then hands the
|
|
270
|
+
* ~0.5s ping and the potentially multi-second migrate/catch-up to a
|
|
271
|
+
* fire-and-forget background task. `mempalaceInstall` is set optimistically
|
|
272
|
+
* so tools work immediately; the migrate no longer sits on `session_start`
|
|
273
|
+
* and never blocks time-to-first-input.
|
|
254
274
|
*/
|
|
255
275
|
init(): void {
|
|
256
276
|
if (!fs.existsSync(this.scopeDir)) {
|
|
257
277
|
fs.mkdirSync(this.scopeDir, { recursive: true });
|
|
258
278
|
}
|
|
259
279
|
|
|
260
|
-
|
|
280
|
+
let install: MempalaceInstall | null;
|
|
281
|
+
try {
|
|
282
|
+
install = ensureMempalace();
|
|
283
|
+
} catch {
|
|
284
|
+
install = null;
|
|
285
|
+
}
|
|
286
|
+
if (!install) {
|
|
261
287
|
throw new Error("MemPalace backend unavailable. Ensure uv is installed.");
|
|
262
288
|
}
|
|
289
|
+
|
|
290
|
+
// Optimistic: mark the backend active now so memory tools are usable from
|
|
291
|
+
// the first turn. A palace that turns out to be broken simply returns null
|
|
292
|
+
// from bridge calls (and re-verifies next session) rather than blocking.
|
|
293
|
+
this.mempalaceInstall = install;
|
|
294
|
+
|
|
295
|
+
// Ping + migration/catch-up run off the startup path.
|
|
296
|
+
void this.backgroundVerifyAndMigrate(install);
|
|
263
297
|
}
|
|
264
298
|
|
|
265
299
|
/**
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
* Never throws
|
|
300
|
+
* Verify the palace is reachable (ping) and run the idempotent migration
|
|
301
|
+
* catch-up — entirely off the synchronous startup path. Single-flight per
|
|
302
|
+
* instance. Never throws into the caller.
|
|
269
303
|
*/
|
|
270
|
-
private
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
install = ensureMempalace();
|
|
274
|
-
} catch {
|
|
275
|
-
return false;
|
|
276
|
-
}
|
|
277
|
-
if (!install) return false;
|
|
304
|
+
private async backgroundVerifyAndMigrate(install: MempalaceInstall): Promise<void> {
|
|
305
|
+
if (this.bgVerifyStarted) return;
|
|
306
|
+
this.bgVerifyStarted = true;
|
|
278
307
|
|
|
279
|
-
// Sanity ping —
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
// is re-checked on the next session.
|
|
308
|
+
// Sanity ping — skip the ~0.5s Python cold-start when we ping-verified
|
|
309
|
+
// recently. A failed ping leaves the flag unset so it re-checks next
|
|
310
|
+
// session; it does not disable the optimistically-active backend.
|
|
283
311
|
if (!isPingVerified()) {
|
|
284
|
-
const ok =
|
|
285
|
-
if (ok !== "pong") return
|
|
312
|
+
const ok = await runBridgeAsync<string>(install, this.palacePath, "ping");
|
|
313
|
+
if (ok !== "pong") return;
|
|
286
314
|
markPingVerified();
|
|
287
315
|
}
|
|
288
316
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
317
|
+
// Incremental catch-up driven by the record-level ledger (L2). Ordinary
|
|
318
|
+
// store() writes update the ledger inline, so the only work here is
|
|
319
|
+
// out-of-band .md edits and records whose earlier upsert was deferred by
|
|
320
|
+
// lock contention. The whole-corpus fingerprint sweep is gone.
|
|
321
|
+
const source = getMemoryBaseDir();
|
|
322
|
+
try {
|
|
323
|
+
// One-time: seed the ledger from a completed legacy marker so existing
|
|
324
|
+
// installs do not re-migrate everything on the first ledger-era boot.
|
|
325
|
+
bootstrapLedgerFromLegacyMarker(source);
|
|
326
|
+
|
|
327
|
+
const ledger = readLedger();
|
|
328
|
+
const scanned = scanMemorySources(source);
|
|
329
|
+
const scannedByKey = new Map(scanned.map((r) => [r.key, r.hash]));
|
|
330
|
+
const { keys, firstRun } = ledgerDelta(scanned, ledger);
|
|
331
|
+
if (keys.length === 0) return; // fully in sync
|
|
332
|
+
|
|
333
|
+
// L3: if a MemPalace daemon is actively mining, it holds the palace lock,
|
|
334
|
+
// so a direct catch-up would just defer every contended upsert. Skip this
|
|
335
|
+
// session and let the L1/L2 backoff retry once the lock frees, rather than
|
|
336
|
+
// fighting the daemon. (There is no idempotent record-upsert daemon job to
|
|
337
|
+
// route through — its generic write path uses an incompatible drawer id.)
|
|
338
|
+
const daemon = await probeDaemon(this.palacePath);
|
|
339
|
+
if (daemon.reachable && daemon.busy) return;
|
|
340
|
+
|
|
341
|
+
// First ever ledger population runs a full pass (source_dir only);
|
|
342
|
+
// otherwise target just the delta so we never re-sweep the corpus.
|
|
343
|
+
const args: Record<string, unknown> = firstRun
|
|
344
|
+
? { source_dir: source }
|
|
345
|
+
: { source_dir: source, only: keys };
|
|
346
|
+
const raw = await runBridgeAsync<unknown>(install, this.palacePath, "migrate", args, 15 * 60_000);
|
|
347
|
+
const result = normalizeMigrationResult(raw);
|
|
348
|
+
if (result) writeLedger(applyMigrationToLedger(ledger, result, scannedByKey));
|
|
349
|
+
} catch {
|
|
350
|
+
// Palace remains available for current writes; durable markdown sources
|
|
351
|
+
// are untouched and catch-up retries on a later session.
|
|
314
352
|
}
|
|
315
|
-
|
|
316
|
-
return true;
|
|
317
353
|
}
|
|
318
354
|
|
|
319
355
|
|
|
@@ -352,7 +388,7 @@ export class MemoryStorage {
|
|
|
352
388
|
* consistent and durable as a fallback source.
|
|
353
389
|
*/
|
|
354
390
|
private storeMempalace(record: MemoryRecord): void {
|
|
355
|
-
this.memPalaceCall("store", {
|
|
391
|
+
const stored = this.memPalaceCall("store", {
|
|
356
392
|
record: {
|
|
357
393
|
id: record.id,
|
|
358
394
|
title: record.title,
|
|
@@ -366,14 +402,25 @@ export class MemoryStorage {
|
|
|
366
402
|
},
|
|
367
403
|
});
|
|
368
404
|
// Markdown tier (durable human copy + fallback source).
|
|
405
|
+
let mdPath: string | null = null;
|
|
369
406
|
try {
|
|
370
|
-
|
|
407
|
+
mdPath = path.join(this.scopeDir, `${record.id}.md`);
|
|
371
408
|
const dir = path.dirname(mdPath);
|
|
372
409
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
373
410
|
writeMemoryFile(mdPath, record);
|
|
374
411
|
} catch {
|
|
375
412
|
// Palace write succeeded; markdown is best-effort.
|
|
376
413
|
}
|
|
414
|
+
// L2: only record the ledger when the palace upsert actually succeeded
|
|
415
|
+
// (the bridge returns an object on success, null on transient/failed). A
|
|
416
|
+
// skipped ledger write means the incremental catch-up re-attempts this
|
|
417
|
+
// record later — we never claim an unsynced record as synced.
|
|
418
|
+
if (stored !== null && mdPath !== null) {
|
|
419
|
+
try {
|
|
420
|
+
const bytes = fs.readFileSync(mdPath, "utf-8");
|
|
421
|
+
ledgerRecordStore(`${record.project}/${record.id}`, bytes);
|
|
422
|
+
} catch { /* ledger is best-effort; delta will catch it next boot */ }
|
|
423
|
+
}
|
|
377
424
|
}
|
|
378
425
|
|
|
379
426
|
/**
|