@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 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 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.
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 (legacy + markdown tier)
77
+ ~/.unipi/memory/ # UniPi memory root (markdown tier)
78
78
  ├── .mempalace-install # Cached MemPalace venv detection
79
- ├── .mempalace-migrated # Versioned migration verification state
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
- ├── memory.db # Global vector DB (SQLite fallback)
82
- │ └── *.md # Global memory files
83
+ └── *.md # Global memory files (durable, human-readable)
83
84
  └── <project_name>/
84
- ├── memory.db # Project vector DB (SQLite fallback)
85
- └── *.md # Project memory files
85
+ └── *.md # Project memory files (durable, human-readable)
86
86
 
87
- ~/.mempalace/palace/ # MemPalace palace (primary backend)
87
+ ~/.mempalace/palace/ # MemPalace palace (vector backend)
88
88
  ```
89
89
 
90
90
  ## MemPalace backend
91
91
 
92
- On first load, the memory package:
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. Pings the bridge to confirm the palace is usable.
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.
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
- 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
109
- first MemPalace use on a machine also downloads the default ONNX embedding
110
- model (~80MB, cached at `~/.cache/chroma/onnx_models/`).
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-migrated # force a full verified migration pass next session
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
- for rec in load_sqlite_memories(project, project_dir / "memory.db"):
315
- by_key.setdefault((rec["project"], rec["id"]), rec)
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(self, source_dir: str, project_filter: list[str] | None = None) -> dict[str, Any]:
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
- failed += 1
538
- if len(errors) < 20:
539
- errors.append(
540
- f"{rec['project']}/{rec['id']}: {type(exc).__name__}: {exc}"
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
- /** Read a verified v2 migration state. Legacy timestamp markers return null. */
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.failed !== 0 ||
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
- /** Mark migration complete only after the caller has verified every record. */
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.failed !== 0 || result.verified !== result.discovered) return false;
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. Returns the parsed result, or null
346
- * on any failure (timeout, non-zero exit, bad JSON, ok=false).
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 runBridge<T = unknown>(
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 | null {
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 null;
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 null;
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 null;
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
- if (!out) return null;
378
- try {
379
- const parsed = JSON.parse(out) as BridgeResponse<T>;
380
- if (!parsed.ok) return null;
381
- return (parsed.result ?? null) as T | null;
382
- } catch {
383
- return null;
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
- resolve(null);
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
- resolve(null);
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
- resolve(null);
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 = (value: T | null) => {
924
+ const finish = (outcome: BridgeOutcome<T>): void => {
428
925
  if (settled) return;
429
926
  settled = true;
430
927
  clearTimeout(timer);
431
- resolve(value);
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", (code) => {
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
- if (!trimmed) return finish(null);
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
- finish(parsed.ok ? ((parsed.result ?? null) as T | null) : null);
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",
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.3",
43
- "@pi-unipi/info-screen": "2.20.3",
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
- isMigrated,
19
- markMigrated,
20
- getMemorySourceFingerprint,
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, invalidating the ping-verified flag
214
- * when the call fails. This ensures a palace that breaks after the
215
- * startup ping-skip gets re-verified on the next session.
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 result = runBridge<T>(install, this.palacePath, cmd, args);
221
- if (result === null) {
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 result = await runBridgeAsync<T>(install, this.palacePath, cmd, args);
233
- if (result === null) {
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. Tries MemPalace first (auto-install + one-way
253
- * auto-migration of legacy memories). Throws if MemPalace is unavailable.
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
- if (!this.tryInitMempalace()) {
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
- * Attempt to initialize the MemPalace backend. Returns true on success.
267
- * Handles auto-install and one-way auto-migration of legacy memories.
268
- * Never throws any failure returns false so init() can throw a clear error.
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 tryInitMempalace(): boolean {
271
- let install: MempalaceInstall | null;
272
- try {
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 — if the palace/bridge is broken, fall back.
280
- // Skip the ~0.5s Python cold-start when we ping-verified recently;
281
- // the flag is invalidated on any backend failure so a broken palace
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 = runBridge<string>(install, this.palacePath, "ping");
285
- if (ok !== "pong") return false;
312
+ const ok = await runBridgeAsync<string>(install, this.palacePath, "ping");
313
+ if (ok !== "pong") return;
286
314
  markPingVerified();
287
315
  }
288
316
 
289
- this.mempalaceInstall = install;
290
-
291
- // Idempotent migration + automatic catch-up. The source fingerprint turns
292
- // the old one-shot timestamp into a resumable state: new/changed markdown
293
- // or SQLite sources trigger another verified upsert pass. Never mark a
294
- // failed/partial run complete; it will retry on a later session.
295
- const sourceFingerprint = getMemorySourceFingerprint(getMemoryBaseDir());
296
- if (!isMigrated(sourceFingerprint)) {
297
- try {
298
- // First migrations can embed thousands of records. Give the bridge a
299
- // practical bounded window rather than the normal per-operation 60s.
300
- const result = runBridge<MigrationResult>(install, this.palacePath, "migrate", {
301
- source_dir: getMemoryBaseDir(),
302
- }, 15 * 60_000);
303
- if (
304
- result &&
305
- result.failed === 0 &&
306
- result.verified === result.discovered
307
- ) {
308
- markMigrated(sourceFingerprint, result);
309
- }
310
- } catch {
311
- // Palace remains available for current writes; durable markdown/SQLite
312
- // sources are untouched and migration retries because no state is set.
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
- const mdPath = path.join(this.scopeDir, `${record.id}.md`);
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
  /**