@remnic/core 9.3.760 → 9.3.762

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.
Files changed (160) hide show
  1. package/dist/access-admin-ops-surface.d.ts +3 -3
  2. package/dist/access-admin-ops-surface.js +11 -10
  3. package/dist/access-boundary.d.ts +3 -3
  4. package/dist/access-boundary.js +12 -11
  5. package/dist/access-cli.js +46 -45
  6. package/dist/access-cli.js.map +1 -1
  7. package/dist/access-http.d.ts +3 -3
  8. package/dist/access-http.js +18 -17
  9. package/dist/access-identity-continuity-surface.d.ts +2 -2
  10. package/dist/access-identity-continuity-surface.js +11 -10
  11. package/dist/access-lcm-surface.d.ts +3 -3
  12. package/dist/access-lcm-surface.js +11 -10
  13. package/dist/access-mcp.d.ts +3 -3
  14. package/dist/access-mcp.js +15 -14
  15. package/dist/access-observe-write-surface.d.ts +3 -3
  16. package/dist/access-observe-write-surface.js +11 -10
  17. package/dist/access-operations-batch.js +13 -12
  18. package/dist/access-operations.d.ts +7 -7
  19. package/dist/access-operations.js +14 -13
  20. package/dist/access-recall-surface.d.ts +3 -3
  21. package/dist/access-recall-surface.js +11 -10
  22. package/dist/access-schema.d.ts +68 -68
  23. package/dist/{access-service-BW-gb3DL.d.ts → access-service-CaWJP_f6.d.ts} +2 -2
  24. package/dist/access-service.d.ts +3 -3
  25. package/dist/access-service.js +11 -10
  26. package/dist/access-surface-catalog.d.ts +3 -3
  27. package/dist/adapters/index.js +4 -4
  28. package/dist/adapters/registry.js +2 -2
  29. package/dist/bootstrap.d.ts +2 -2
  30. package/dist/briefing.js +4 -3
  31. package/dist/{catalog-DaKAFbSJ.d.ts → catalog-bflYNWsM.d.ts} +8 -4
  32. package/dist/causal-consolidation.js +5 -4
  33. package/dist/causal-consolidation.js.map +1 -1
  34. package/dist/{chunk-OLEBPUZ7.js → chunk-2KYXXMTE.js} +6 -6
  35. package/dist/{chunk-6CUUFFU7.js → chunk-2PGB63VU.js} +131 -39
  36. package/dist/chunk-2PGB63VU.js.map +1 -0
  37. package/dist/{chunk-FYJWEGYP.js → chunk-4HYJETVO.js} +2 -2
  38. package/dist/{chunk-APJQ6UEA.js → chunk-AGNBY3VG.js} +4 -4
  39. package/dist/{chunk-W3M34XT4.js → chunk-BAVVI5GP.js} +2 -2
  40. package/dist/{chunk-XQT5GSWU.js → chunk-BRYPUVJU.js} +3 -3
  41. package/dist/{chunk-2XE4F3VZ.js → chunk-DB647OZ4.js} +24 -24
  42. package/dist/{chunk-3JBW3GHN.js → chunk-DPZ5GJMD.js} +2 -2
  43. package/dist/chunk-DQC3ZZYM.js +35 -0
  44. package/dist/chunk-DQC3ZZYM.js.map +1 -0
  45. package/dist/{chunk-SMLMH7S5.js → chunk-EDC7RZCW.js} +24 -24
  46. package/dist/{chunk-ZFXCQPNO.js → chunk-EGGE52UU.js} +2 -2
  47. package/dist/chunk-EGGE52UU.js.map +1 -0
  48. package/dist/{chunk-IOZZG75J.js → chunk-ES3GAMSQ.js} +2 -2
  49. package/dist/{chunk-QUGPXPFJ.js → chunk-ESDBHQY6.js} +5 -5
  50. package/dist/{chunk-ILVF6TIY.js → chunk-HDFS6RWQ.js} +2 -2
  51. package/dist/{chunk-SN2EWIAI.js → chunk-HDYFNXVO.js} +2 -2
  52. package/dist/{chunk-ZEQVS6AZ.js → chunk-HMUEAKSD.js} +2 -2
  53. package/dist/{chunk-P26O6R3Q.js → chunk-IIURIWJG.js} +102 -12
  54. package/dist/chunk-IIURIWJG.js.map +1 -0
  55. package/dist/{chunk-RKNJBZ55.js → chunk-JBPKEARU.js} +4 -4
  56. package/dist/{chunk-QQJMVF6P.js → chunk-JU6BKZHX.js} +3 -3
  57. package/dist/{chunk-SLAZZXBU.js → chunk-KZWUJC7Y.js} +2 -2
  58. package/dist/{chunk-IZLGHKWA.js → chunk-LAJIXABF.js} +10 -10
  59. package/dist/{chunk-3QLPBCFG.js → chunk-NKP6II6G.js} +4 -4
  60. package/dist/{chunk-WIXQ3F5P.js → chunk-OQ6F3WBX.js} +2 -2
  61. package/dist/{chunk-YP23FQPT.js → chunk-P7W4CDX6.js} +92 -41
  62. package/dist/chunk-P7W4CDX6.js.map +1 -0
  63. package/dist/{chunk-EGQ3AVZV.js → chunk-PNFN7SIN.js} +3 -3
  64. package/dist/{chunk-M2AAYJVI.js → chunk-QPFO54VC.js} +4 -4
  65. package/dist/{chunk-7HWOEYHW.js → chunk-QWIE43BS.js} +2 -2
  66. package/dist/chunk-TEKTCF2V.js +118 -0
  67. package/dist/chunk-TEKTCF2V.js.map +1 -0
  68. package/dist/{chunk-QOM3F6YW.js → chunk-TIWRP5NW.js} +5 -5
  69. package/dist/{chunk-CXW6Y7SI.js → chunk-V5RVMULT.js} +4 -4
  70. package/dist/{chunk-DTSOB2YF.js → chunk-VHYFY3JI.js} +2 -2
  71. package/dist/{chunk-AZSSKOG6.js → chunk-WAIMHMO3.js} +3 -3
  72. package/dist/{chunk-PKRRBRY2.js → chunk-ZTBH2OHD.js} +2 -2
  73. package/dist/{cli-D551dnva.d.ts → cli-CvWpIbMD.d.ts} +2 -2
  74. package/dist/cli.d.ts +4 -4
  75. package/dist/cli.js +35 -34
  76. package/dist/compounding/engine.js +4 -3
  77. package/dist/connectors/codex-materialize-runner.js +4 -3
  78. package/dist/connectors/index.js +4 -3
  79. package/dist/entity-retrieval.js +4 -3
  80. package/dist/explicit-capture.d.ts +2 -2
  81. package/dist/{forget-OEE5OELK.js → forget-WYLR4ZFV.js} +2 -2
  82. package/dist/index.d.ts +5 -5
  83. package/dist/index.js +48 -47
  84. package/dist/lcm/engine.js +2 -2
  85. package/dist/lcm/index.js +4 -4
  86. package/dist/maintenance/memory-governance.js +4 -3
  87. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +11 -1
  88. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -3
  89. package/dist/maintenance/rebuild-memory-projection.d.ts +22 -1
  90. package/dist/maintenance/rebuild-memory-projection.js +5 -4
  91. package/dist/mcp-memory-inspector-app.d.ts +3 -3
  92. package/dist/namespaces/migrate.d.ts +1 -1
  93. package/dist/namespaces/migrate.js +9 -8
  94. package/dist/namespaces/search.js +4 -4
  95. package/dist/namespaces/storage.d.ts +1 -1
  96. package/dist/namespaces/storage.js +5 -4
  97. package/dist/operator-toolkit.js +13 -12
  98. package/dist/orchestration/maintenance.d.ts +1 -1
  99. package/dist/orchestration/maintenance.js +8 -7
  100. package/dist/{orchestrator-BnM187cb.d.ts → orchestrator-C04Q4Zao.d.ts} +1 -1
  101. package/dist/orchestrator.d.ts +2 -2
  102. package/dist/orchestrator.js +46 -45
  103. package/dist/schemas.d.ts +84 -84
  104. package/dist/search/factory.js +2 -2
  105. package/dist/search/index.js +4 -4
  106. package/dist/semantic-consolidation.js +5 -4
  107. package/dist/semantic-rule-promotion.js +4 -3
  108. package/dist/semantic-rule-verifier.js +4 -3
  109. package/dist/shared-context/manager.d.ts +8 -8
  110. package/dist/storage-guards.d.ts +9 -0
  111. package/dist/storage-guards.js +11 -0
  112. package/dist/storage-guards.js.map +1 -0
  113. package/dist/storage.js +3 -2
  114. package/dist/transfer/import-sqlite.js +2 -2
  115. package/dist/transfer/types.d.ts +66 -66
  116. package/dist/verified-recall.js +4 -3
  117. package/package.json +2 -2
  118. package/src/maintenance/rebuild-memory-lifecycle-ledger.ts +59 -8
  119. package/src/maintenance/rebuild-memory-projection.ts +149 -9
  120. package/src/namespaces/catalog.test.ts +133 -1
  121. package/src/namespaces/catalog.ts +163 -48
  122. package/src/namespaces/identity.ts +2 -2
  123. package/src/storage/secure-line-reader.ts +79 -0
  124. package/src/storage-guards.ts +30 -0
  125. package/src/storage-memory-lifecycle-ledger.test.ts +130 -0
  126. package/src/storage.ts +24 -41
  127. package/dist/chunk-6CUUFFU7.js.map +0 -1
  128. package/dist/chunk-LLVKDFNU.js +0 -77
  129. package/dist/chunk-LLVKDFNU.js.map +0 -1
  130. package/dist/chunk-P26O6R3Q.js.map +0 -1
  131. package/dist/chunk-YP23FQPT.js.map +0 -1
  132. package/dist/chunk-ZFXCQPNO.js.map +0 -1
  133. /package/dist/{chunk-OLEBPUZ7.js.map → chunk-2KYXXMTE.js.map} +0 -0
  134. /package/dist/{chunk-FYJWEGYP.js.map → chunk-4HYJETVO.js.map} +0 -0
  135. /package/dist/{chunk-APJQ6UEA.js.map → chunk-AGNBY3VG.js.map} +0 -0
  136. /package/dist/{chunk-W3M34XT4.js.map → chunk-BAVVI5GP.js.map} +0 -0
  137. /package/dist/{chunk-XQT5GSWU.js.map → chunk-BRYPUVJU.js.map} +0 -0
  138. /package/dist/{chunk-2XE4F3VZ.js.map → chunk-DB647OZ4.js.map} +0 -0
  139. /package/dist/{chunk-3JBW3GHN.js.map → chunk-DPZ5GJMD.js.map} +0 -0
  140. /package/dist/{chunk-SMLMH7S5.js.map → chunk-EDC7RZCW.js.map} +0 -0
  141. /package/dist/{chunk-IOZZG75J.js.map → chunk-ES3GAMSQ.js.map} +0 -0
  142. /package/dist/{chunk-QUGPXPFJ.js.map → chunk-ESDBHQY6.js.map} +0 -0
  143. /package/dist/{chunk-ILVF6TIY.js.map → chunk-HDFS6RWQ.js.map} +0 -0
  144. /package/dist/{chunk-SN2EWIAI.js.map → chunk-HDYFNXVO.js.map} +0 -0
  145. /package/dist/{chunk-ZEQVS6AZ.js.map → chunk-HMUEAKSD.js.map} +0 -0
  146. /package/dist/{chunk-RKNJBZ55.js.map → chunk-JBPKEARU.js.map} +0 -0
  147. /package/dist/{chunk-QQJMVF6P.js.map → chunk-JU6BKZHX.js.map} +0 -0
  148. /package/dist/{chunk-SLAZZXBU.js.map → chunk-KZWUJC7Y.js.map} +0 -0
  149. /package/dist/{chunk-IZLGHKWA.js.map → chunk-LAJIXABF.js.map} +0 -0
  150. /package/dist/{chunk-3QLPBCFG.js.map → chunk-NKP6II6G.js.map} +0 -0
  151. /package/dist/{chunk-WIXQ3F5P.js.map → chunk-OQ6F3WBX.js.map} +0 -0
  152. /package/dist/{chunk-EGQ3AVZV.js.map → chunk-PNFN7SIN.js.map} +0 -0
  153. /package/dist/{chunk-M2AAYJVI.js.map → chunk-QPFO54VC.js.map} +0 -0
  154. /package/dist/{chunk-7HWOEYHW.js.map → chunk-QWIE43BS.js.map} +0 -0
  155. /package/dist/{chunk-QOM3F6YW.js.map → chunk-TIWRP5NW.js.map} +0 -0
  156. /package/dist/{chunk-CXW6Y7SI.js.map → chunk-V5RVMULT.js.map} +0 -0
  157. /package/dist/{chunk-DTSOB2YF.js.map → chunk-VHYFY3JI.js.map} +0 -0
  158. /package/dist/{chunk-AZSSKOG6.js.map → chunk-WAIMHMO3.js.map} +0 -0
  159. /package/dist/{chunk-PKRRBRY2.js.map → chunk-ZTBH2OHD.js.map} +0 -0
  160. /package/dist/{forget-OEE5OELK.js.map → forget-WYLR4ZFV.js.map} +0 -0
@@ -5,8 +5,8 @@ import {
5
5
  appendFile,
6
6
  lstat,
7
7
  mkdir,
8
+ open,
8
9
  readdir,
9
- readFile,
10
10
  realpath,
11
11
  rename,
12
12
  stat,
@@ -426,6 +426,18 @@ function inferKind(namespace: string, config: PluginConfig): NamespaceKind {
426
426
  return "explicit";
427
427
  }
428
428
 
429
+ interface CatalogFileIdentity {
430
+ size: number;
431
+ mtimeMs: number;
432
+ ctimeMs: number;
433
+ ino: number;
434
+ }
435
+
436
+ interface CompactedCatalogCache {
437
+ identity: CatalogFileIdentity;
438
+ records: Map<string, NamespaceRecord>;
439
+ }
440
+
429
441
  export class NamespaceCatalog {
430
442
  private readonly memoryDir: string;
431
443
  private readonly stateDir: string;
@@ -441,12 +453,19 @@ export class NamespaceCatalog {
441
453
  // neither mistakes the other's lock for self-held (round 6, codex P2 — NBsGP
442
454
  // invariant preserved and tightened).
443
455
  private readonly criticalSection = new MutationSerializer();
456
+ private compactedCache: CompactedCatalogCache | undefined;
444
457
  // Test-only seam (round 7 — NEZkA): fires inside a touch's HELD-lock critical
445
458
  // section, after the lock is acquired but BEFORE the read→merge→append. A
446
459
  // deterministic concurrency test installs a hook here to widen the (otherwise
447
460
  // microscopic) window and prove that a cross-process rebuild CANNOT run its
448
461
  // load→rename while a touch holds the lock. Never set in production code.
449
462
  protected onTouchCriticalSectionForTest?: () => Promise<void>;
463
+ // Test-only observation seam: fires only when the JSONL file is fully read.
464
+ // Cached loads do not invoke it.
465
+ protected onCatalogReadForTest?: () => void;
466
+ // Test-only seam: fires after this process appends but before it stats the
467
+ // file to refresh the cache identity.
468
+ protected onAfterCatalogAppendForTest?: () => Promise<void>;
450
469
  // Test-only seam (round 7 — NEZkA): fires inside a mutating rebuild's HELD-lock
451
470
  // critical section, after the final cross-process re-merge `loadCompacted()` and
452
471
  // BEFORE the atomic `rename()`. This is the EXACT window in which a check-then-
@@ -597,19 +616,20 @@ export class NamespaceCatalog {
597
616
  }
598
617
  return [...byStorageDir.values()];
599
618
  }
600
-
601
- private async loadSanitizedRecords(): Promise<NamespaceRecord[]> {
602
- const records = await this.loadCompacted();
603
- const sanitized = await Promise.all(
604
- [...records.values()].map((r) => this.sanitizeRecordForRead(r)),
605
- );
606
- // Drop unsafe-namespace rows (sanitizer returned null) at the read boundary.
607
- // Then collapse duplicate root aliases so maintenance/QMD see exactly one
608
- // namespace owner for a physical storage root, matching rebuild ownership,
609
- // while preserving touch recency from every alias row.
610
- return this.dropDuplicateStorageRootAliases(
611
- sanitized.filter((r): r is NamespaceRecord => r !== null),
612
- );
619
+ private loadSanitizedRecords(): Promise<NamespaceRecord[]> {
620
+ return this.queueCritical(async () => {
621
+ const records = await this.loadCompacted();
622
+ const sanitized = await Promise.all(
623
+ [...records.values()].map((r) => this.sanitizeRecordForRead(r)),
624
+ );
625
+ // Drop unsafe-namespace rows (sanitizer returned null) at the read boundary.
626
+ // Then collapse duplicate root aliases so maintenance/QMD see exactly one
627
+ // namespace owner for a physical storage root, matching rebuild ownership,
628
+ // while preserving touch recency from every alias row.
629
+ return this.dropDuplicateStorageRootAliases(
630
+ sanitized.filter((r): r is NamespaceRecord => r !== null),
631
+ );
632
+ });
613
633
  }
614
634
 
615
635
  async listNamespaces(filter?: NamespaceCatalogFilter): Promise<NamespaceRecord[]> {
@@ -1701,7 +1721,7 @@ export class NamespaceCatalog {
1701
1721
  // scan, which the in-process `queueCritical` alone cannot see. Only runs
1702
1722
  // when we hold the lock (round 6, codex P2 — NBPmY): an unlocked rebuild
1703
1723
  // must not re-merge then rename, or it races a concurrent lock holder.
1704
- const latest = await this.loadCompacted();
1724
+ const latest = await this.loadCompacted(true);
1705
1725
  for (const [ns, fresh] of latest) {
1706
1726
  const current = rebuilt.get(ns);
1707
1727
  if (!current) {
@@ -1897,53 +1917,96 @@ export class NamespaceCatalog {
1897
1917
  if (prior.parentNamespace !== undefined) merged.parentNamespace = prior.parentNamespace;
1898
1918
  return merged;
1899
1919
  }
1900
-
1901
1920
  // ── Persistence ──────────────────────────────────────────────────────────
1902
1921
 
1903
1922
  /** Load the JSONL log and fold it into current state (last-record-wins). */
1904
- private async loadCompacted(): Promise<Map<string, NamespaceRecord>> {
1923
+ private async loadCompacted(forceFresh = false): Promise<Map<string, NamespaceRecord>> {
1905
1924
  const records = new Map<string, NamespaceRecord>();
1906
- let raw: string;
1925
+ let handle;
1907
1926
  try {
1908
- raw = await readFile(this.catalogPath, "utf8");
1927
+ handle = await open(this.catalogPath, "r");
1909
1928
  } catch {
1929
+ this.compactedCache = undefined;
1910
1930
  return records;
1911
1931
  }
1912
- for (const line of raw.split("\n")) {
1913
- const trimmed = line.trim();
1914
- if (trimmed.length === 0) continue;
1915
- let parsed: unknown;
1916
- try {
1917
- parsed = JSON.parse(trimmed);
1918
- } catch {
1919
- // Skip corrupt lines (CLAUDE.md rule #18 robustness).
1920
- continue;
1932
+
1933
+ let identity: CatalogFileIdentity | undefined;
1934
+ try {
1935
+ const fileStat = await handle.stat();
1936
+ identity = {
1937
+ size: fileStat.size,
1938
+ mtimeMs: fileStat.mtimeMs,
1939
+ ctimeMs: fileStat.ctimeMs,
1940
+ ino: fileStat.ino,
1941
+ };
1942
+ if (
1943
+ !forceFresh &&
1944
+ this.compactedCache &&
1945
+ this.compactedCache.identity.size === identity.size &&
1946
+ this.compactedCache.identity.mtimeMs === identity.mtimeMs &&
1947
+ this.compactedCache.identity.ctimeMs === identity.ctimeMs &&
1948
+ this.compactedCache.identity.ino === identity.ino
1949
+ ) {
1950
+ await handle.close().catch(() => undefined);
1951
+ return this.cloneCompactedRecords(this.compactedCache.records);
1921
1952
  }
1922
- const record = coerceRecord(parsed);
1923
- if (!record) continue;
1924
- // Field-level touch merge during compaction (round 6, codex P2 — ND6Cz).
1925
- // Touches run on PER-PROCESS write chains, so two processes (a gateway write
1926
- // racing a CLI/second-server read or maintenance touch) can each load the
1927
- // same prior record and append a full snapshot. Plain last-record-wins
1928
- // compaction would then discard the earlier snapshot's `lastReadAt` /
1929
- // `lastWriteAt` / `lastMaintenanceAt`, erasing a real touch and skewing
1930
- // `writtenSince`. We instead take the LATER record as the base (most recent
1931
- // identity/disk-derived state) and fold in the MAX of each touch field from
1932
- // both, so no cross-process touch recency is lost without locking the hot
1933
- // touch path. A destructive overwrite of real memory is never at stake here
1934
- // — only best-effort recency metadata.
1935
- const prior = records.get(record.namespace);
1936
- records.set(record.namespace, prior ? mergeNewerTouchFields(record, prior) : record);
1953
+ } catch {
1954
+ // A failed fstat cannot prove freshness. Read and parse the open file, but
1955
+ // do not cache the result without a trustworthy identity.
1937
1956
  }
1938
- return records;
1957
+
1958
+ try {
1959
+ this.onCatalogReadForTest?.();
1960
+ const raw = await handle.readFile("utf8");
1961
+ for (const line of raw.split("\n")) {
1962
+ const trimmed = line.trim();
1963
+ if (trimmed.length === 0) continue;
1964
+ let parsed: unknown;
1965
+ try {
1966
+ parsed = JSON.parse(trimmed);
1967
+ } catch {
1968
+ // Skip corrupt lines (CLAUDE.md rule #18 robustness).
1969
+ continue;
1970
+ }
1971
+ const record = coerceRecord(parsed);
1972
+ if (!record) continue;
1973
+ // Preserve each touch field across cross-process full-snapshot appends.
1974
+ // Field-level touch merge preserves the newest value for every touch
1975
+ // field when full-snapshot appends from separate processes interleave.
1976
+ const prior = records.get(record.namespace);
1977
+ records.set(record.namespace, prior ? mergeNewerTouchFields(record, prior) : record);
1978
+ }
1979
+ } catch {
1980
+ this.compactedCache = undefined;
1981
+ return new Map();
1982
+ } finally {
1983
+ await handle.close().catch(() => undefined);
1984
+ }
1985
+
1986
+ this.compactedCache = identity ? { identity, records } : undefined;
1987
+ return this.cloneCompactedRecords(records);
1988
+ }
1989
+
1990
+ private cloneCompactedRecords(
1991
+ records: Map<string, NamespaceRecord>,
1992
+ ): Map<string, NamespaceRecord> {
1993
+ return new Map(
1994
+ [...records].map(([namespace, record]) => [
1995
+ namespace,
1996
+ {
1997
+ ...record,
1998
+ lastMaintenanceAt: record.lastMaintenanceAt
1999
+ ? { ...record.lastMaintenanceAt }
2000
+ : undefined,
2001
+ },
2002
+ ]),
2003
+ );
1939
2004
  }
1940
2005
 
1941
2006
  /**
1942
2007
  * Serialize an arbitrary read-modify-write critical section through the single
1943
- * per-instance chain. Every catalog mutation (touch read+merge+append, full
1944
- * rewrite) runs through this so they are mutually exclusive: a touch always
1945
- * reads the latest persisted state before appending, and a rebuild rewrite
1946
- * cannot interleave with a touch's append.
2008
+ * per-instance chain. Every catalog mutation and cached catalog read runs
2009
+ * through this queue, so cache validation and updates cannot race in-process.
1947
2010
  *
1948
2011
  * Issue #1524 adoption: delegates to the shared `MutationSerializer` (stored
1949
2012
  * as `criticalSection`). The util owns the rejection-recovery invariant
@@ -1964,8 +2027,13 @@ export class NamespaceCatalog {
1964
2027
  */
1965
2028
  private async appendUnchained(record: NamespaceRecord): Promise<void> {
1966
2029
  const line = serializeRecord(record) + "\n";
2030
+ const lineByteLength = Buffer.byteLength(line, "utf8");
1967
2031
  await mkdir(this.stateDir, { recursive: true });
1968
2032
  await appendFile(this.catalogPath, line, "utf8");
2033
+ if (this.onAfterCatalogAppendForTest) {
2034
+ await this.onAfterCatalogAppendForTest();
2035
+ }
2036
+ await this.refreshCacheAfterAppend(record, lineByteLength);
1969
2037
  }
1970
2038
 
1971
2039
  /**
@@ -1982,6 +2050,53 @@ export class NamespaceCatalog {
1982
2050
  const tmp = `${this.catalogPath}.${process.pid}.${Date.now()}.tmp`;
1983
2051
  await writeFile(tmp, body, "utf8");
1984
2052
  await rename(tmp, this.catalogPath);
2053
+ await this.refreshCacheIdentity(new Map(records.map((record) => [record.namespace, record])));
2054
+ }
2055
+
2056
+ private async refreshCacheAfterAppend(
2057
+ record: NamespaceRecord,
2058
+ lineByteLength: number,
2059
+ ): Promise<void> {
2060
+ const cached = this.compactedCache;
2061
+ if (!cached) return;
2062
+ const expectedSize = cached.identity.size + lineByteLength;
2063
+ try {
2064
+ const fileStat = await stat(this.catalogPath);
2065
+ if (fileStat.size !== expectedSize || fileStat.ino !== cached.identity.ino) {
2066
+ this.compactedCache = undefined;
2067
+ return;
2068
+ }
2069
+ const prior = cached.records.get(record.namespace);
2070
+ cached.records.set(record.namespace, prior ? mergeNewerTouchFields(record, prior) : record);
2071
+ this.compactedCache = {
2072
+ identity: {
2073
+ size: fileStat.size,
2074
+ mtimeMs: fileStat.mtimeMs,
2075
+ ctimeMs: fileStat.ctimeMs,
2076
+ ino: fileStat.ino,
2077
+ },
2078
+ records: cached.records,
2079
+ };
2080
+ } catch {
2081
+ this.compactedCache = undefined;
2082
+ }
2083
+ }
2084
+
2085
+ private async refreshCacheIdentity(records: Map<string, NamespaceRecord>): Promise<void> {
2086
+ try {
2087
+ const fileStat = await stat(this.catalogPath);
2088
+ this.compactedCache = {
2089
+ identity: {
2090
+ size: fileStat.size,
2091
+ mtimeMs: fileStat.mtimeMs,
2092
+ ctimeMs: fileStat.ctimeMs,
2093
+ ino: fileStat.ino,
2094
+ },
2095
+ records,
2096
+ };
2097
+ } catch {
2098
+ this.compactedCache = undefined;
2099
+ }
1985
2100
  }
1986
2101
  }
1987
2102
 
@@ -1,5 +1,5 @@
1
- export function normalizeNamespaceIdentity(namespace: string): string {
2
- return namespace.trim();
1
+ export function normalizeNamespaceIdentity(namespace: string | null | undefined): string {
2
+ return namespace?.trim() ?? "";
3
3
  }
4
4
 
5
5
  export function namespaceIdentityToken(namespace: string): string {
@@ -0,0 +1,79 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ import { createInterface } from "node:readline";
4
+
5
+ import { isEncryptedFile, MAGIC_HEADER_SIZE } from "../secure-store/secure-fs.js";
6
+ import type { MemoryActionEvent } from "../types.js";
7
+
8
+ /**
9
+ * Stream plaintext files by line while preserving authenticated whole-file
10
+ * reads for encrypted files.
11
+ */
12
+ export async function* readMaybeEncryptedLines(
13
+ filePath: string,
14
+ readEncryptedFile: () => Promise<string>,
15
+ ): AsyncGenerator<string> {
16
+ const file = await open(filePath, "r");
17
+ const header = Buffer.alloc(MAGIC_HEADER_SIZE);
18
+ try {
19
+ const { bytesRead } = await file.read(header, 0, header.length, 0);
20
+ if (isEncryptedFile(header.subarray(0, bytesRead))) {
21
+ yield* (await readEncryptedFile()).split("\n");
22
+ return;
23
+ }
24
+
25
+ const input = createReadStream(filePath, {
26
+ autoClose: false,
27
+ encoding: "utf8",
28
+ fd: file.fd,
29
+ start: 0,
30
+ });
31
+ const lines = createInterface({ input, crlfDelay: Infinity });
32
+ try {
33
+ for await (const line of lines) yield line;
34
+ } finally {
35
+ lines.close();
36
+ }
37
+ } finally {
38
+ await file.close();
39
+ }
40
+ }
41
+
42
+ export async function readMemoryActionEventRowsFromLines(
43
+ lines: AsyncIterable<string>,
44
+ limit: number,
45
+ ): Promise<Array<{ line: number; event: MemoryActionEvent }>> {
46
+ const out: Array<{ line: number; event: MemoryActionEvent }> = [];
47
+ let writeIndex = 0;
48
+ let lineNumber = 0;
49
+ for await (const row of lines) {
50
+ lineNumber += 1;
51
+ const line = row.trim();
52
+ if (!line) continue;
53
+ try {
54
+ const parsed = JSON.parse(line) as Partial<MemoryActionEvent>;
55
+ const outcome = parsed.outcome === "applied" || parsed.outcome === "skipped" || parsed.outcome === "failed"
56
+ ? parsed.outcome
57
+ : null;
58
+ if (
59
+ typeof parsed.timestamp === "string" &&
60
+ typeof parsed.action === "string" &&
61
+ outcome !== null
62
+ ) {
63
+ const entry = {
64
+ line: lineNumber,
65
+ event: { ...parsed, outcome } as MemoryActionEvent,
66
+ };
67
+ if (out.length < limit) {
68
+ out.push(entry);
69
+ } else {
70
+ out[writeIndex] = entry;
71
+ writeIndex = (writeIndex + 1) % limit;
72
+ }
73
+ }
74
+ } catch {
75
+ // Ignore malformed rows (fail-open).
76
+ }
77
+ }
78
+ return writeIndex === 0 ? out : [...out.slice(writeIndex), ...out.slice(0, writeIndex)];
79
+ }
@@ -0,0 +1,30 @@
1
+ import { log } from "./logger.js";
2
+
3
+ const PROJECTION_FALLBACK_WARN_INTERVAL_MS = 5 * 60_000;
4
+ const projectionFallbackWarnedAt = new Map<string, number>();
5
+
6
+ export function assertMemoryFrontmatterId(
7
+ fm: { id: string; category?: string; created?: string; entityRef?: string },
8
+ ): void {
9
+ if (typeof fm.id !== "string" || fm.id.trim().length === 0) {
10
+ const context = [
11
+ fm.category ? `category=${fm.category}` : null,
12
+ fm.created ? `created=${fm.created}` : null,
13
+ fm.entityRef ? `entityRef=${fm.entityRef}` : null,
14
+ ].filter(Boolean).join(" ");
15
+ throw new Error(
16
+ `memory frontmatter id must not be blank${context ? ` (${context})` : ""}`,
17
+ );
18
+ }
19
+ }
20
+
21
+ export function warnProjectionFallback(memoryDir: string, consumer: string): null {
22
+ const key = `${memoryDir}\0${consumer}`;
23
+ const now = Date.now();
24
+ const warnedAt = projectionFallbackWarnedAt.get(key) ?? 0;
25
+ if (now - warnedAt >= PROJECTION_FALLBACK_WARN_INTERVAL_MS) {
26
+ projectionFallbackWarnedAt.set(key, now);
27
+ log.warn(`storage.${consumer}: memory projection absent or empty; falling back to full corpus`);
28
+ }
29
+ return null;
30
+ }
@@ -0,0 +1,130 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import { StorageManager } from "./storage.js";
8
+ import { encryptFileBody, filePathAad } from "./secure-store/secure-fs.js";
9
+ import type { MemoryLifecycleEvent } from "./types.js";
10
+
11
+ function lifecycleEvent(
12
+ eventId: string,
13
+ memoryId: string,
14
+ timestamp: string,
15
+ ): MemoryLifecycleEvent {
16
+ return {
17
+ eventId,
18
+ memoryId,
19
+ eventType: "created",
20
+ timestamp,
21
+ actor: "test",
22
+ ruleVersion: "1",
23
+ };
24
+ }
25
+
26
+ async function withLifecycleLedger(
27
+ rows: string[],
28
+ run: (storage: StorageManager, ledgerPath: string) => Promise<void>,
29
+ ): Promise<void> {
30
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-lifecycle-stream-"));
31
+ const ledgerPath = path.join(memoryDir, "state", "memory-lifecycle-ledger.jsonl");
32
+ await mkdir(path.dirname(ledgerPath), { recursive: true });
33
+ await writeFile(ledgerPath, `${rows.join("\n")}\n`, "utf8");
34
+ try {
35
+ await run(new StorageManager(memoryDir), ledgerPath);
36
+ } finally {
37
+ await rm(memoryDir, { recursive: true, force: true });
38
+ }
39
+ }
40
+
41
+ test("readAllMemoryLifecycleEvents streams plaintext rows and preserves fail-open sorting", async () => {
42
+ const later = lifecycleEvent("event-z", "memory-b", "2026-01-03T00:00:00.000Z");
43
+ const earlier = lifecycleEvent("event-a", "memory-a", "2026-01-02T00:00:00.000Z");
44
+ const earliest = lifecycleEvent("event-b", "memory-a", "2026-01-01T00:00:00.000Z");
45
+
46
+ await withLifecycleLedger([
47
+ JSON.stringify(later),
48
+ "{malformed-json",
49
+ JSON.stringify({ ...earlier, actor: 42 }),
50
+ JSON.stringify(earlier),
51
+ " ",
52
+ JSON.stringify(earliest),
53
+ ], async (storage) => {
54
+ let secureWholeFileReads = 0;
55
+ const privateStorage = storage as unknown as {
56
+ readStorageSecureFile(filePath: string): Promise<string>;
57
+ };
58
+ privateStorage.readStorageSecureFile = async () => {
59
+ secureWholeFileReads += 1;
60
+ throw new Error("plaintext lifecycle ledger must not use whole-file reads");
61
+ };
62
+
63
+ const events = await storage.readAllMemoryLifecycleEvents();
64
+
65
+ assert.deepEqual(events, [earliest, earlier, later]);
66
+ assert.equal(secureWholeFileReads, 0);
67
+ });
68
+ });
69
+
70
+ test("readMemoryLifecycleEvents applies its limit after streamed lifecycle sorting", async () => {
71
+ const first = lifecycleEvent("event-1", "memory-a", "2026-01-01T00:00:00.000Z");
72
+ const second = lifecycleEvent("event-2", "memory-a", "2026-01-02T00:00:00.000Z");
73
+ const third = lifecycleEvent("event-3", "memory-b", "2026-01-01T00:00:00.000Z");
74
+
75
+ await withLifecycleLedger([
76
+ JSON.stringify(third),
77
+ JSON.stringify(first),
78
+ JSON.stringify(second),
79
+ ], async (storage) => {
80
+ assert.deepEqual(await storage.readMemoryLifecycleEvents(2), [second, third]);
81
+ });
82
+ });
83
+
84
+ test("readAllMemoryLifecycleEvents keeps the authenticated encrypted-file fallback", async () => {
85
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-lifecycle-encrypted-"));
86
+ const ledgerPath = path.join(memoryDir, "state", "memory-lifecycle-ledger.jsonl");
87
+ const event = lifecycleEvent("event-encrypted", "memory-a", "2026-01-01T00:00:00.000Z");
88
+ const key = Buffer.alloc(32, 7);
89
+ await mkdir(path.dirname(ledgerPath), { recursive: true });
90
+ await writeFile(
91
+ ledgerPath,
92
+ encryptFileBody(`${JSON.stringify(event)}\n`, key, filePathAad(ledgerPath, memoryDir)),
93
+ );
94
+ try {
95
+ const storage = new StorageManager(memoryDir);
96
+ storage.setSecureStoreKey(key);
97
+ assert.deepEqual(await storage.readAllMemoryLifecycleEvents(), [event]);
98
+ } finally {
99
+ await rm(memoryDir, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test("readMemoryActionEventRows streams the action ledger and keeps source line numbers", async () => {
104
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-action-stream-"));
105
+ const ledgerPath = path.join(memoryDir, "state", "memory-actions.jsonl");
106
+ const first = { timestamp: "2026-01-01T00:00:00.000Z", action: "store_note", outcome: "applied" };
107
+ const second = { timestamp: "2026-01-02T00:00:00.000Z", action: "discard", outcome: "skipped" };
108
+ await mkdir(path.dirname(ledgerPath), { recursive: true });
109
+ await writeFile(
110
+ ledgerPath,
111
+ `${JSON.stringify(first)}\n{malformed-json\n${JSON.stringify(second)}\n`,
112
+ "utf8",
113
+ );
114
+ try {
115
+ const storage = new StorageManager(memoryDir);
116
+ let secureWholeFileReads = 0;
117
+ const privateStorage = storage as unknown as {
118
+ readStorageSecureFile(filePath: string): Promise<string>;
119
+ };
120
+ privateStorage.readStorageSecureFile = async () => {
121
+ secureWholeFileReads += 1;
122
+ throw new Error("plaintext action ledger must not use whole-file reads");
123
+ };
124
+
125
+ assert.deepEqual(await storage.readMemoryActionEventRows(1), [{ line: 3, event: second }]);
126
+ assert.equal(secureWholeFileReads, 0);
127
+ } finally {
128
+ await rm(memoryDir, { recursive: true, force: true });
129
+ }
130
+ });
package/src/storage.ts CHANGED
@@ -4,7 +4,12 @@ import { createHash } from "node:crypto"
4
4
  import { normalizeContent, computeContentHash } from "./content-hash.js";;
5
5
  import path from "node:path";
6
6
  import { log } from "./logger.js";
7
+ import { assertMemoryFrontmatterId, warnProjectionFallback } from "./storage-guards.js";
7
8
  import { MemoryReadStore } from "./storage/memory-read-store.js";
9
+ import {
10
+ readMaybeEncryptedLines,
11
+ readMemoryActionEventRowsFromLines,
12
+ } from "./storage/secure-line-reader.js";
8
13
  import { selfDeps } from "./orchestration/self-deps.js";
9
14
  import { EntityStore } from "./storage/entity-store.js";
10
15
  import { IdentityContinuityStore } from "./storage/identity-continuity-store.js";
@@ -304,6 +309,7 @@ function stripCitationMarkersForHashRemoval(value: string, template: string): st
304
309
  }
305
310
 
306
311
  function serializeFrontmatter(fm: MemoryFrontmatter): string {
312
+ assertMemoryFrontmatterId(fm);
307
313
  const lines = [
308
314
  "---",
309
315
  `id: ${fm.id}`,
@@ -5180,38 +5186,16 @@ export class StorageManager {
5180
5186
 
5181
5187
  async readMemoryActionEventRows(limit: number = 200): Promise<Array<{ line: number; event: MemoryActionEvent }>> {
5182
5188
  const cappedLimit = Math.max(0, Math.floor(limit));
5183
- if (cappedLimit === 0) return [];
5189
+ if (cappedLimit === 0 || Number.isNaN(cappedLimit)) return [];
5184
5190
 
5185
5191
  try {
5186
- const raw = await this.readStorageSecureFile(this.memoryActionsPath);
5187
- const out: Array<{ line: number; event: MemoryActionEvent }> = [];
5188
- const lines = raw.split("\n");
5189
- for (let i = lines.length - 1; i >= 0 && out.length < cappedLimit; i -= 1) {
5190
- const line = lines[i]?.trim();
5191
- if (!line) continue;
5192
- try {
5193
- const parsed = JSON.parse(line) as Partial<MemoryActionEvent>;
5194
- const outcome = parsed.outcome === "applied" || parsed.outcome === "skipped" || parsed.outcome === "failed"
5195
- ? parsed.outcome
5196
- : null;
5197
- if (
5198
- typeof parsed.timestamp === "string" &&
5199
- typeof parsed.action === "string" &&
5200
- outcome !== null
5201
- ) {
5202
- out.push({
5203
- line: i + 1,
5204
- event: {
5205
- ...parsed,
5206
- outcome,
5207
- } as MemoryActionEvent,
5208
- });
5209
- }
5210
- } catch {
5211
- // Ignore malformed rows (fail-open).
5212
- }
5213
- }
5214
- return out.reverse();
5192
+ return await readMemoryActionEventRowsFromLines(
5193
+ readMaybeEncryptedLines(
5194
+ this.memoryActionsPath,
5195
+ () => this.readStorageSecureFile(this.memoryActionsPath),
5196
+ ),
5197
+ cappedLimit,
5198
+ );
5215
5199
  } catch (err) {
5216
5200
  if (err instanceof SecureStoreLockedError) throw err;
5217
5201
  if (!isErrnoCode(err, "ENOENT")) throw err;
@@ -5221,10 +5205,11 @@ export class StorageManager {
5221
5205
 
5222
5206
  async readAllMemoryLifecycleEvents(): Promise<MemoryLifecycleEvent[]> {
5223
5207
  try {
5224
- const raw = await this.readStorageSecureFile(this.memoryLifecycleLedgerPath);
5225
5208
  const out: MemoryLifecycleEvent[] = [];
5226
- const lines = raw.split("\n");
5227
- for (const line of lines) {
5209
+ for await (const line of readMaybeEncryptedLines(
5210
+ this.memoryLifecycleLedgerPath,
5211
+ () => this.readStorageSecureFile(this.memoryLifecycleLedgerPath),
5212
+ )) {
5228
5213
  const row = line.trim();
5229
5214
  if (!row) continue;
5230
5215
  try {
@@ -6037,8 +6022,8 @@ export class StorageManager {
6037
6022
  lastAccessed: entry.lastAccessed,
6038
6023
  };
6039
6024
 
6040
- const fileContent = `${serializeFrontmatter(newFm)}\n\n${memory.content}\n`;
6041
6025
  try {
6026
+ const fileContent = `${serializeFrontmatter(newFm)}\n\n${memory.content}\n`;
6042
6027
  await this.writeStorageSecureFile(memory.path, fileContent);
6043
6028
  updated++;
6044
6029
  } catch (err) {
@@ -6087,8 +6072,7 @@ export class StorageManager {
6087
6072
 
6088
6073
  async getProjectedMemoryState(id: string): Promise<MemoryProjectionCurrentState | null> {
6089
6074
  const projected = readProjectedMemoryState(this.baseDir, id);
6090
- if (projected) return projected;
6091
-
6075
+ if (projected) return projected; warnProjectionFallback(this.baseDir, "getProjectedMemoryState");
6092
6076
  const active = await this.getMemoryById(id);
6093
6077
  if (active) return this.toProjectedCurrentState(active, "active");
6094
6078
 
@@ -6101,7 +6085,8 @@ export class StorageManager {
6101
6085
  async browseProjectedMemories(
6102
6086
  options: ProjectedMemoryBrowseOptions,
6103
6087
  ): Promise<ProjectedMemoryBrowsePage | null> {
6104
- return readProjectedMemoryBrowse(this.baseDir, options);
6088
+ return readProjectedMemoryBrowse(this.baseDir, options)
6089
+ ?? warnProjectionFallback(this.baseDir, "browseProjectedMemories");
6105
6090
  }
6106
6091
 
6107
6092
  async getProjectedGovernanceRecord(): Promise<ReturnType<typeof readProjectedGovernanceRecord>> {
@@ -6141,8 +6126,7 @@ export class StorageManager {
6141
6126
  if (cappedLimit === 0) return [];
6142
6127
 
6143
6128
  const projected = readProjectedMemoryTimeline(this.baseDir, memoryId, cappedLimit);
6144
- if (projected && projected.length > 0) return projected;
6145
-
6129
+ if (projected && projected.length > 0) return projected; warnProjectionFallback(this.baseDir, "getMemoryTimeline");
6146
6130
  const events = await this.readAllMemoryLifecycleEvents();
6147
6131
  return events.filter((event) => event.memoryId === memoryId).slice(-cappedLimit);
6148
6132
  }
@@ -6399,9 +6383,8 @@ export class StorageManager {
6399
6383
  updated: now,
6400
6384
  };
6401
6385
 
6402
- const fileContent = `${serializeFrontmatter(updatedFm)}\n\n${memory.content}\n`;
6403
-
6404
6386
  try {
6387
+ const fileContent = `${serializeFrontmatter(updatedFm)}\n\n${memory.content}\n`;
6405
6388
  await this.writeStorageSecureFile(memory.path, fileContent);
6406
6389
  await this.appendGeneratedMemoryLifecycleEventFailOpen("storage.archiveMemories", {
6407
6390
  memoryId: id,