@remnic/core 9.35.2 → 9.35.3

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 (203) hide show
  1. package/dist/access-admin-ops-surface.d.ts +5 -5
  2. package/dist/access-admin-ops-surface.js +19 -18
  3. package/dist/access-authorization-probe.d.ts +5 -5
  4. package/dist/access-authorization-probe.js +20 -19
  5. package/dist/access-boundary.d.ts +5 -5
  6. package/dist/access-boundary.js +19 -18
  7. package/dist/access-cli.js +39 -38
  8. package/dist/access-cli.js.map +1 -1
  9. package/dist/access-extraction-force-flush.d.ts +5 -5
  10. package/dist/access-extraction-force-flush.js +19 -18
  11. package/dist/access-http-lcm-compaction.d.ts +5 -5
  12. package/dist/access-http-lifecycle-flush.d.ts +5 -5
  13. package/dist/access-http.d.ts +5 -5
  14. package/dist/access-http.js +24 -23
  15. package/dist/access-identity-continuity-surface.d.ts +4 -4
  16. package/dist/access-identity-continuity-surface.js +19 -18
  17. package/dist/access-lcm-surface.d.ts +5 -5
  18. package/dist/access-lcm-surface.js +19 -18
  19. package/dist/access-mcp.d.ts +5 -5
  20. package/dist/access-mcp.js +22 -21
  21. package/dist/access-namespace-preflight.js +19 -18
  22. package/dist/access-observe-write-surface.d.ts +5 -5
  23. package/dist/access-observe-write-surface.js +19 -18
  24. package/dist/access-operations-batch.js +20 -19
  25. package/dist/access-operations.d.ts +5 -5
  26. package/dist/access-operations.js +21 -20
  27. package/dist/access-recall-concurrency.d.ts +5 -5
  28. package/dist/access-recall-concurrency.js +19 -18
  29. package/dist/access-recall-response.d.ts +5 -5
  30. package/dist/access-recall-response.js +19 -18
  31. package/dist/access-recall-surface.d.ts +5 -5
  32. package/dist/access-recall-surface.js +19 -18
  33. package/dist/access-schema.d.ts +4 -4
  34. package/dist/{access-service-D9zIBlpp.d.ts → access-service-C2WYT1pO.d.ts} +3 -3
  35. package/dist/access-service-helpers.d.ts +5 -5
  36. package/dist/access-service.d.ts +5 -5
  37. package/dist/access-service.js +19 -18
  38. package/dist/access-surface-catalog.d.ts +5 -5
  39. package/dist/access-wearables-meetings-surface.d.ts +2 -2
  40. package/dist/bootstrap.d.ts +4 -4
  41. package/dist/briefing.d.ts +1 -1
  42. package/dist/briefing.js +10 -9
  43. package/dist/buffer.d.ts +1 -1
  44. package/dist/{capsule-merge-2H2IUTBT.js → capsule-merge-IDWJPCWK.js} +77 -8
  45. package/dist/capsule-merge-IDWJPCWK.js.map +1 -0
  46. package/dist/causal-consolidation.js +11 -10
  47. package/dist/causal-consolidation.js.map +1 -1
  48. package/dist/{chunk-65ZPVYTA.js → chunk-3CE77VAP.js} +55 -5
  49. package/dist/chunk-3CE77VAP.js.map +1 -0
  50. package/dist/chunk-4J7G5SY4.js +338 -0
  51. package/dist/chunk-4J7G5SY4.js.map +1 -0
  52. package/dist/{chunk-EO7X2LDK.js → chunk-4TAD3B56.js} +2 -2
  53. package/dist/{chunk-QHYB4MJV.js → chunk-5ZGHUWTT.js} +2 -2
  54. package/dist/{chunk-TCIWHK3N.js → chunk-6D5D5A4M.js} +5 -5
  55. package/dist/{chunk-BIXWNHIL.js → chunk-6TF4M2LB.js} +5 -5
  56. package/dist/{chunk-FHYGLITW.js → chunk-AKELMJO2.js} +217 -45
  57. package/dist/{chunk-FHYGLITW.js.map → chunk-AKELMJO2.js.map} +1 -1
  58. package/dist/{chunk-FBUE6PD3.js → chunk-BEQOZOYZ.js} +21 -4
  59. package/dist/chunk-BEQOZOYZ.js.map +1 -0
  60. package/dist/{chunk-SA3WJJF2.js → chunk-BGLDKAYY.js} +2 -2
  61. package/dist/{chunk-OPRP6Q4Z.js → chunk-DKKZ6SO2.js} +3 -3
  62. package/dist/{chunk-6TAUHL33.js → chunk-DLVPUX7U.js} +10 -2
  63. package/dist/chunk-DLVPUX7U.js.map +1 -0
  64. package/dist/{chunk-QNWXCJ76.js → chunk-ENTCVTQR.js} +18 -1
  65. package/dist/{chunk-QNWXCJ76.js.map → chunk-ENTCVTQR.js.map} +1 -1
  66. package/dist/{chunk-AEPYA55F.js → chunk-FE7MUX7M.js} +2 -2
  67. package/dist/{chunk-GHR7WHZX.js → chunk-GORHSN5C.js} +2 -2
  68. package/dist/{chunk-7TEZUUGM.js → chunk-HUS2XN3T.js} +2 -2
  69. package/dist/{chunk-XWDZQIYU.js → chunk-IJAQCBGV.js} +2 -2
  70. package/dist/{chunk-TJLLYV2X.js → chunk-IMEXE4XS.js} +7 -7
  71. package/dist/{chunk-IZXTZ7BF.js → chunk-JYLN6KHH.js} +6 -6
  72. package/dist/{chunk-HEVWWPTL.js → chunk-LH3KXIBJ.js} +17 -17
  73. package/dist/{chunk-EH5U2YCX.js → chunk-M3DBB2ND.js} +2 -2
  74. package/dist/{chunk-XABS455Y.js → chunk-NBAEGFXT.js} +5 -5
  75. package/dist/{chunk-T4HH5Q3U.js → chunk-OJRLTZQM.js} +3 -3
  76. package/dist/{chunk-7ZHGPX56.js → chunk-PRP3M5Y3.js} +2 -2
  77. package/dist/{chunk-Y2ZD2RVG.js → chunk-PYTUBWAS.js} +2 -2
  78. package/dist/{chunk-4TGZCYHH.js → chunk-Q3CVY7LV.js} +1954 -1642
  79. package/dist/chunk-Q3CVY7LV.js.map +1 -0
  80. package/dist/{chunk-PH3ZWL3W.js → chunk-Q53RLEW7.js} +2 -2
  81. package/dist/{chunk-Y6OWJ3V5.js → chunk-RY2XZTFN.js} +2 -2
  82. package/dist/{chunk-2SHIWGY7.js → chunk-RY7CHVGE.js} +2 -2
  83. package/dist/{chunk-7VUAG236.js → chunk-T3FVAHM7.js} +4 -4
  84. package/dist/{chunk-2LTDWSR6.js → chunk-TSH56VPU.js} +2 -2
  85. package/dist/{chunk-J6SLNQRO.js → chunk-UBWOCQ3I.js} +6 -2
  86. package/dist/chunk-UBWOCQ3I.js.map +1 -0
  87. package/dist/{chunk-5FHE4JI7.js → chunk-VEUVON5I.js} +2 -2
  88. package/dist/{chunk-KEWUNL7P.js → chunk-WET4M3R6.js} +2 -2
  89. package/dist/{chunk-SLPLPGJQ.js → chunk-WL2S2UBB.js} +5 -5
  90. package/dist/{chunk-AHZYNPLX.js → chunk-YVMVVE4B.js} +2 -2
  91. package/dist/{chunk-5HTAJCTO.js → chunk-ZB3YODID.js} +15 -15
  92. package/dist/{cli-mFX-QQvx.d.ts → cli-jkmzdT8P.d.ts} +3 -3
  93. package/dist/cli.d.ts +6 -6
  94. package/dist/cli.js +34 -33
  95. package/dist/compounding/engine.d.ts +1 -1
  96. package/dist/compounding/engine.js +10 -9
  97. package/dist/connectors/codex-materialize-runner.js +10 -9
  98. package/dist/connectors/index.js +10 -9
  99. package/dist/consolidation-provenance-check.d.ts +1 -1
  100. package/dist/consolidation-provenance-check.js +8 -2
  101. package/dist/consolidation-undo.d.ts +1 -1
  102. package/dist/consolidation-undo.js +10 -1
  103. package/dist/consolidation-undo.js.map +1 -1
  104. package/dist/contradiction/index.d.ts +1 -1
  105. package/dist/corpus-watermark.js +12 -11
  106. package/dist/entity-retrieval.d.ts +1 -1
  107. package/dist/entity-retrieval.js +10 -9
  108. package/dist/explicit-capture.d.ts +4 -4
  109. package/dist/explicit-capture.js +6 -3
  110. package/dist/extraction.js +10 -9
  111. package/dist/{forget-4UY2EOKH.js → forget-DNAJ66ZD.js} +2 -2
  112. package/dist/index.d.ts +10 -10
  113. package/dist/index.js +39 -38
  114. package/dist/maintenance/memory-governance.js +10 -9
  115. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +1 -1
  116. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +10 -9
  117. package/dist/maintenance/rebuild-memory-projection.d.ts +1 -1
  118. package/dist/maintenance/rebuild-memory-projection.js +11 -10
  119. package/dist/{maintenance-70Wq118b.d.ts → maintenance-Ha9Dg93M.d.ts} +1 -1
  120. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  121. package/dist/memory-worth-outcomes.d.ts +1 -1
  122. package/dist/namespaces/migrate.d.ts +1 -1
  123. package/dist/namespaces/migrate.js +11 -10
  124. package/dist/namespaces/storage.d.ts +1 -1
  125. package/dist/namespaces/storage.js +10 -9
  126. package/dist/offline-sync-impression-drain.js +3 -3
  127. package/dist/operator-doctor-corpus.js +13 -12
  128. package/dist/operator-toolkit.d.ts +1 -1
  129. package/dist/operator-toolkit.js +17 -16
  130. package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
  131. package/dist/orchestration/maintenance.d.ts +2 -2
  132. package/dist/orchestration/maintenance.js +15 -14
  133. package/dist/{orchestrator-BH8LT-NX.d.ts → orchestrator-DV_u9bfx.d.ts} +3 -3
  134. package/dist/orchestrator.d.ts +4 -4
  135. package/dist/orchestrator.js +39 -38
  136. package/dist/page-versioning.js +7 -1
  137. package/dist/semantic-consolidation.js +11 -10
  138. package/dist/semantic-rule-promotion.js +10 -9
  139. package/dist/semantic-rule-verifier.js +10 -9
  140. package/dist/{service-CrxXHz5T.d.ts → service-JxXXY-Eh.d.ts} +1 -1
  141. package/dist/source-agent-qualifier.js +10 -9
  142. package/dist/{storage-CyWa1Jm6.d.ts → storage-BbH4HDUS.d.ts} +30 -26
  143. package/dist/storage.d.ts +1 -1
  144. package/dist/storage.js +9 -8
  145. package/dist/temporal-supersession.d.ts +1 -1
  146. package/dist/tier-migration.d.ts +1 -1
  147. package/dist/transfer/capsule-import.js +8 -2
  148. package/dist/verified-recall.js +10 -9
  149. package/package.json +2 -2
  150. package/src/binary-lifecycle/pipeline.ts +52 -1
  151. package/src/consolidation-undo.ts +4 -0
  152. package/src/curation/index.ts +41 -5
  153. package/src/lifecycle/tombstones.ts +40 -0
  154. package/src/maintenance/memory-governance.ts +4 -0
  155. package/src/page-versioning.ts +16 -1
  156. package/src/review/index.ts +97 -13
  157. package/src/spaces/index.ts +127 -6
  158. package/src/storage/citation-hash-source.ts +97 -0
  159. package/src/storage/entity-canonical-id-lock.ts +33 -0
  160. package/src/storage/entity-canonical-id-migration-adapter.ts +8 -4
  161. package/src/storage/entity-canonical-id-migration.ts +129 -77
  162. package/src/storage/entity-canonical-id-references.ts +557 -0
  163. package/src/storage/entity-ref-repair.ts +171 -0
  164. package/src/storage/entity-store.ts +163 -2
  165. package/src/storage/tombstone-blocked-capture-sync.ts +18 -0
  166. package/src/storage.ts +363 -396
  167. package/src/transfer/capsule-import.ts +91 -3
  168. package/src/transfer/capsule-merge.ts +128 -10
  169. package/dist/capsule-merge-2H2IUTBT.js.map +0 -1
  170. package/dist/chunk-4TGZCYHH.js.map +0 -1
  171. package/dist/chunk-65ZPVYTA.js.map +0 -1
  172. package/dist/chunk-6TAUHL33.js.map +0 -1
  173. package/dist/chunk-FBUE6PD3.js.map +0 -1
  174. package/dist/chunk-J6SLNQRO.js.map +0 -1
  175. /package/dist/{chunk-EO7X2LDK.js.map → chunk-4TAD3B56.js.map} +0 -0
  176. /package/dist/{chunk-QHYB4MJV.js.map → chunk-5ZGHUWTT.js.map} +0 -0
  177. /package/dist/{chunk-TCIWHK3N.js.map → chunk-6D5D5A4M.js.map} +0 -0
  178. /package/dist/{chunk-BIXWNHIL.js.map → chunk-6TF4M2LB.js.map} +0 -0
  179. /package/dist/{chunk-SA3WJJF2.js.map → chunk-BGLDKAYY.js.map} +0 -0
  180. /package/dist/{chunk-OPRP6Q4Z.js.map → chunk-DKKZ6SO2.js.map} +0 -0
  181. /package/dist/{chunk-AEPYA55F.js.map → chunk-FE7MUX7M.js.map} +0 -0
  182. /package/dist/{chunk-GHR7WHZX.js.map → chunk-GORHSN5C.js.map} +0 -0
  183. /package/dist/{chunk-7TEZUUGM.js.map → chunk-HUS2XN3T.js.map} +0 -0
  184. /package/dist/{chunk-XWDZQIYU.js.map → chunk-IJAQCBGV.js.map} +0 -0
  185. /package/dist/{chunk-TJLLYV2X.js.map → chunk-IMEXE4XS.js.map} +0 -0
  186. /package/dist/{chunk-IZXTZ7BF.js.map → chunk-JYLN6KHH.js.map} +0 -0
  187. /package/dist/{chunk-HEVWWPTL.js.map → chunk-LH3KXIBJ.js.map} +0 -0
  188. /package/dist/{chunk-EH5U2YCX.js.map → chunk-M3DBB2ND.js.map} +0 -0
  189. /package/dist/{chunk-XABS455Y.js.map → chunk-NBAEGFXT.js.map} +0 -0
  190. /package/dist/{chunk-T4HH5Q3U.js.map → chunk-OJRLTZQM.js.map} +0 -0
  191. /package/dist/{chunk-7ZHGPX56.js.map → chunk-PRP3M5Y3.js.map} +0 -0
  192. /package/dist/{chunk-Y2ZD2RVG.js.map → chunk-PYTUBWAS.js.map} +0 -0
  193. /package/dist/{chunk-PH3ZWL3W.js.map → chunk-Q53RLEW7.js.map} +0 -0
  194. /package/dist/{chunk-Y6OWJ3V5.js.map → chunk-RY2XZTFN.js.map} +0 -0
  195. /package/dist/{chunk-2SHIWGY7.js.map → chunk-RY7CHVGE.js.map} +0 -0
  196. /package/dist/{chunk-7VUAG236.js.map → chunk-T3FVAHM7.js.map} +0 -0
  197. /package/dist/{chunk-2LTDWSR6.js.map → chunk-TSH56VPU.js.map} +0 -0
  198. /package/dist/{chunk-5FHE4JI7.js.map → chunk-VEUVON5I.js.map} +0 -0
  199. /package/dist/{chunk-KEWUNL7P.js.map → chunk-WET4M3R6.js.map} +0 -0
  200. /package/dist/{chunk-SLPLPGJQ.js.map → chunk-WL2S2UBB.js.map} +0 -0
  201. /package/dist/{chunk-AHZYNPLX.js.map → chunk-YVMVVE4B.js.map} +0 -0
  202. /package/dist/{chunk-5HTAJCTO.js.map → chunk-ZB3YODID.js.map} +0 -0
  203. /package/dist/{forget-4UY2EOKH.js.map → forget-DNAJ66ZD.js.map} +0 -0
@@ -11,14 +11,10 @@ import {
11
11
  } from "./chunk-UHVXE24Y.js";
12
12
  import {
13
13
  TombstoneStore,
14
+ applyTombstoneResurrectionGate,
14
15
  buildRetiredFactTombstoneInputs,
15
16
  collectRetiredMemoriesForRebuild
16
- } from "./chunk-QNWXCJ76.js";
17
- import {
18
- computeSupersessionKey,
19
- normalizeSupersessionKey,
20
- supersessionKeysForFact
21
- } from "./chunk-DNHVPZOD.js";
17
+ } from "./chunk-ENTCVTQR.js";
22
18
  import {
23
19
  assertMemoryFrontmatterId,
24
20
  warnProjectionFallback
@@ -42,7 +38,7 @@ import {
42
38
  readMaybeEncryptedLines,
43
39
  readMemoryActionEventRowsFromLines,
44
40
  serializeLifecycleAppendPayload
45
- } from "./chunk-7VUAG236.js";
41
+ } from "./chunk-T3FVAHM7.js";
46
42
  import {
47
43
  namespaceIdentityFromToken
48
44
  } from "./chunk-EGGE52UU.js";
@@ -82,7 +78,7 @@ import {
82
78
  TombstoneBlockedCaptureIndexHost,
83
79
  buildExplicitCaptureDedupKey,
84
80
  parseTombstoneBlockedOfflineSyncMemory
85
- } from "./chunk-FBUE6PD3.js";
81
+ } from "./chunk-BEQOZOYZ.js";
86
82
  import {
87
83
  composeMemoryEnvelope,
88
84
  isSealedMemoryEnvelope,
@@ -92,9 +88,6 @@ import {
92
88
  assemblePersistedBody,
93
89
  stripAttributesSuffix
94
90
  } from "./chunk-R3DJVOSM.js";
95
- import {
96
- withHeldFileLock
97
- } from "./chunk-FFAZ2XFU.js";
98
91
  import {
99
92
  markProjectedMemoryPathInvalid,
100
93
  openProjectionReadonly,
@@ -117,7 +110,7 @@ import {
117
110
  } from "./chunk-G7D6GZ5J.js";
118
111
  import {
119
112
  createVersion
120
- } from "./chunk-6TAUHL33.js";
113
+ } from "./chunk-DLVPUX7U.js";
121
114
  import {
122
115
  clearInFlightReads,
123
116
  deleteInFlightRead,
@@ -125,6 +118,27 @@ import {
125
118
  getInFlightRead,
126
119
  setInFlightRead
127
120
  } from "./chunk-PDJTHRAW.js";
121
+ import {
122
+ ENTITY_CANONICAL_ID_MIGRATION_FILE,
123
+ ENTITY_CANONICAL_ID_RECONCILE_CONSUMING_MARKER,
124
+ ENTITY_CANONICAL_ID_RECONCILE_MARKER,
125
+ HistoricalEntityCanonicalIdCache,
126
+ appendCanonicalizedTombstone,
127
+ canonicalizeEntityRefOption,
128
+ reconcileIfJournalMovedSync,
129
+ repairEntityRefAfterJournalMove,
130
+ requestEntityCanonicalIdReconcileSync,
131
+ resolveHistoricalEntityCanonicalId,
132
+ withEntityCanonicalMutationLock
133
+ } from "./chunk-4J7G5SY4.js";
134
+ import {
135
+ computeSupersessionKey,
136
+ normalizeSupersessionKey,
137
+ supersessionKeysForFact
138
+ } from "./chunk-DNHVPZOD.js";
139
+ import {
140
+ withHeldFileLock
141
+ } from "./chunk-FFAZ2XFU.js";
128
142
  import {
129
143
  clearMemoryCache,
130
144
  getCachedEntities,
@@ -214,11 +228,11 @@ import {
214
228
  stat as stat2,
215
229
  writeFile as writeFile2,
216
230
  mkdir,
217
- unlink as unlink3,
231
+ unlink as unlink4,
218
232
  appendFile,
219
233
  open
220
234
  } from "fs/promises";
221
- import { appendFileSync, createReadStream, mkdirSync, readFileSync as readFileSync2, statSync } from "fs";
235
+ import { appendFileSync, createReadStream, mkdirSync, readFileSync, statSync } from "fs";
222
236
  import { createHash as createHash5 } from "crypto";
223
237
  import path9 from "path";
224
238
 
@@ -1420,1442 +1434,1641 @@ function selfDeps(self) {
1420
1434
  }
1421
1435
 
1422
1436
  // src/storage/entity-store.ts
1423
- import { readdir as readdir3, unlink as unlink2 } from "fs/promises";
1424
- import path4 from "path";
1425
-
1426
- // src/storage/entity-canonical-id-migration.ts
1427
- import { randomUUID } from "crypto";
1428
- import {
1429
- access,
1430
- lstat as lstat2,
1431
- readFile,
1432
- readdir as readdir2,
1433
- realpath as realpath2,
1434
- rename,
1435
- stat as statFile,
1436
- unlink,
1437
- writeFile
1438
- } from "fs/promises";
1437
+ import { readdir as readdir2, unlink } from "fs/promises";
1439
1438
  import path3 from "path";
1440
- import { readFileSync } from "fs";
1441
- import { z } from "zod";
1442
- var ENTITY_CANONICAL_ID_MIGRATION_VERSION = 1;
1443
- var ENTITY_CANONICAL_ID_MIGRATION_FILE = "entity-canonical-id-migration-v1.json";
1444
- var ENTITY_CANONICAL_ID_MIGRATION_LOCK_STALE_MS = 6e4;
1445
- var ENTITY_CANONICAL_ID_MIGRATION_LOCK_MAX_WAIT_MS = 3e5;
1446
- var TOMBSTONE_LOCK_STALE_MS = 3e4;
1447
- var TOMBSTONE_LOCK_MAX_WAIT_MS = 5e3;
1448
- var ENTITY_CANONICAL_ID_MUTATION_LOCK_STALE_MS = 6e4;
1449
- var ENTITY_CANONICAL_ID_MUTATION_LOCK_MAX_WAIT_MS = 3e5;
1450
- var MEMORY_REWRITE_MAX_PASSES = 3;
1451
- var ENTITY_MAPPING_RESCAN_MAX_PASSES = 3;
1452
- function loadHistoricalEntityCanonicalIds(stateDir) {
1453
- const statePath2 = path3.join(stateDir, ENTITY_CANONICAL_ID_MIGRATION_FILE);
1454
- try {
1455
- const parsed = JSON.parse(readFileSync(statePath2, "utf-8"));
1456
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
1457
- const mappings = parsed.mappings;
1458
- if (!mappings || typeof mappings !== "object" || Array.isArray(mappings)) return {};
1459
- const cleaned = {};
1460
- for (const [legacyId, canonicalId] of Object.entries(mappings)) {
1461
- if (legacyId.length > 0 && typeof canonicalId === "string" && canonicalId.length > 0) {
1462
- cleaned[legacyId] = canonicalId;
1463
- }
1464
- }
1465
- return cleaned;
1466
- } catch {
1467
- return {};
1439
+ var EntityStore = class {
1440
+ constructor(deps) {
1441
+ this.deps = deps;
1468
1442
  }
1469
- }
1470
- function resolveHistoricalEntityCanonicalId(normalized, mappings) {
1471
- let current = normalized;
1472
- const seen = /* @__PURE__ */ new Set();
1473
- while (!seen.has(current)) {
1474
- seen.add(current);
1475
- const next = mappings[current];
1476
- if (!next || next === current) break;
1477
- current = next;
1443
+ deps;
1444
+ /** Normalize an entity name using this store's alias table. */
1445
+ normalizeEntityName(raw, type) {
1446
+ return normalizeEntityName(raw, type, this.deps.userAliases);
1478
1447
  }
1479
- return current;
1480
- }
1481
- var EntityCanonicalIdMigrationStateSchema = z.object({
1482
- version: z.literal(ENTITY_CANONICAL_ID_MIGRATION_VERSION),
1483
- complete: z.boolean(),
1484
- mappings: z.record(z.string().min(1)),
1485
1448
  /**
1486
- * Pairs the migration refused to resolve, PARKED rather than dropped.
1487
- * Deleting the record would strand the collision: once an operator removes
1488
- * the legacy file, no later scan can rediscover the mapping, so references
1489
- * still naming the legacy id would never be rewritten. Optional so a journal
1490
- * written by an older build still parses.
1449
+ * Read an entity file by id, resolving through the migration journal with a
1450
+ * fallback to the caller's original id (issue #2213): mid-migration, the
1451
+ * journal maps legacy canonical BEFORE the file moves writing the
1452
+ * still-existing legacy file is safe (the rename retry carries it), while
1453
+ * resolving only the canonical path silently drops the mutation.
1491
1454
  */
1492
- blocked: z.record(z.string().min(1)).optional()
1493
- });
1494
- async function fingerprintPath(filePath) {
1495
- let fileStat;
1496
- try {
1497
- fileStat = await lstat2(filePath);
1498
- } catch (error) {
1499
- if (!isErrnoCode(error, "ENOENT")) throw error;
1455
+ async readEntityForMutation(name, method, historicalIds) {
1456
+ const canonical = resolveHistoricalEntityCanonicalId(name, historicalIds);
1457
+ for (const candidate of canonical === name ? [name] : [canonical, name]) {
1458
+ const filePath = path3.join(this.deps.entitiesDir, `${candidate}.md`);
1459
+ try {
1460
+ const entity = parseEntityFile(await this.deps.readStorageSecureFile(filePath), this.deps.entitySchemas);
1461
+ return { filePath, entity };
1462
+ } catch (err) {
1463
+ if (err instanceof SecureStoreLockedError) throw err;
1464
+ if (!isErrnoCode(err, "ENOENT")) throw err;
1465
+ }
1466
+ }
1467
+ log.debug(`${method}: entity file ${canonical}.md not found`);
1468
+ return null;
1500
1469
  }
1501
- return fileStat ? `${fileStat.dev}:${fileStat.ino}:${fileStat.mtimeMs}:${fileStat.ctimeMs}:${fileStat.size}` : "missing";
1502
- }
1503
- async function getFingerprint(baseDir, entitiesDir, getCorpusScanVersion) {
1504
- const [entityFingerprint, aliasFingerprint] = await Promise.all([
1505
- fingerprintPath(entitiesDir),
1506
- fingerprintPath(path3.join(baseDir, "config", "aliases.json"))
1507
- ]);
1508
- return `${entityFingerprint}:${aliasFingerprint}:${getCorpusScanVersion()}`;
1509
- }
1510
- async function validateRoots(baseDir, entitiesDir, stateDir, archiveDir) {
1511
- let baseStat;
1512
- try {
1513
- baseStat = await lstat2(baseDir);
1514
- } catch (error) {
1515
- if (isErrnoCode(error, "ENOENT")) return;
1516
- throw error;
1470
+ /**
1471
+ * Locked read-modify-write for the entity-file mutators (issue #2213):
1472
+ * holding the entity-mutation lock keeps `migrateEntityFilePair` (which
1473
+ * takes the same lock) from moving the file between the fallback read and
1474
+ * this write — an unlocked write could recreate a just-renamed legacy file.
1475
+ * The journal itself is NOT serialized by this lock (`pruneBlocked` can
1476
+ * park a mapping mid-mutation), so the journal identity is REVALIDATED
1477
+ * just before committing — a moved journal restarts resolution against the
1478
+ * fresh table instead of writing into a since-contested claimant — and
1479
+ * checked once more after the write for a move across the write itself.
1480
+ * `mutate` returns false to skip the write. Resolves true when written.
1481
+ */
1482
+ async mutateEntityFile(name, method, mutate) {
1483
+ const stateDir = path3.join(this.deps.baseDir, "state");
1484
+ return withEntityCanonicalMutationLock(stateDir, async () => {
1485
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1486
+ const idsAtResolve = this.deps.currentHistoricalIds();
1487
+ const located = await this.readEntityForMutation(name, method, idsAtResolve);
1488
+ const mutated = located !== null && mutate(located.entity, idsAtResolve);
1489
+ if (this.deps.currentHistoricalIds() !== idsAtResolve) continue;
1490
+ if (!located || !mutated) return false;
1491
+ located.entity.updated = (/* @__PURE__ */ new Date()).toISOString();
1492
+ await this.deps.writeStorageSecureFile(
1493
+ located.filePath,
1494
+ serializeEntityFile(located.entity, this.deps.entitySchemas)
1495
+ );
1496
+ reconcileIfJournalMovedSync(stateDir, idsAtResolve, this.deps.currentHistoricalIds());
1497
+ this.deps.invalidateKnowledgeIndexCache();
1498
+ this.deps.bumpEntityMutationVersion();
1499
+ return true;
1500
+ }
1501
+ requestEntityCanonicalIdReconcileSync(stateDir);
1502
+ throw new Error(
1503
+ `${method}: entity canonical-id journal kept changing; mutation not applied \u2014 retry`
1504
+ );
1505
+ });
1517
1506
  }
1518
- if (baseStat.isSymbolicLink() || !baseStat.isDirectory()) {
1519
- throw new Error(`Refusing entity canonical-id migration through unsafe memory root: ${baseDir}.`);
1507
+ /** Add a relationship to an entity file. Deduplicates by target+label. */
1508
+ async addEntityRelationship(name, rel) {
1509
+ await this.mutateEntityFile(name, "addEntityRelationship", (entity, historicalIds) => {
1510
+ const target = resolveHistoricalEntityCanonicalId(rel.target, historicalIds);
1511
+ const duplicate = entity.relationships.some(
1512
+ (r) => resolveHistoricalEntityCanonicalId(r.target, historicalIds) === target && r.label === rel.label
1513
+ );
1514
+ if (duplicate) return false;
1515
+ entity.relationships.push({ ...rel, target });
1516
+ return true;
1517
+ });
1520
1518
  }
1521
- const memoryRoot = await realpath2(baseDir);
1522
- const roots = [
1523
- entitiesDir,
1524
- stateDir,
1525
- ...RECALL_FALLBACK_DIRS.map((directory) => path3.join(baseDir, directory)),
1526
- path3.join(baseDir, "cold"),
1527
- archiveDir
1528
- ];
1529
- for (const root of roots) {
1530
- let rootStat;
1531
- try {
1532
- rootStat = await lstat2(root);
1533
- } catch (error) {
1534
- if (isErrnoCode(error, "ENOENT")) continue;
1535
- throw error;
1536
- }
1537
- if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
1538
- throw new Error(`Refusing entity canonical-id migration through unsafe memory root: ${root}.`);
1539
- }
1540
- assertPathInsideRoot(memoryRoot, await realpath2(root), root);
1519
+ /** Add an activity entry to an entity file; prunes oldest beyond maxEntries. */
1520
+ async addEntityActivity(name, entry, maxEntries) {
1521
+ await this.mutateEntityFile(name, "addEntityActivity", (entity) => {
1522
+ entity.activity.unshift(entry);
1523
+ if (entity.activity.length > maxEntries) entity.activity = entity.activity.slice(0, maxEntries);
1524
+ return true;
1525
+ });
1541
1526
  }
1542
- }
1543
- function statePath(deps) {
1544
- return path3.join(deps.stateDir, ENTITY_CANONICAL_ID_MIGRATION_FILE);
1545
- }
1546
- async function assertSafeMigrationStateFile(deps) {
1547
- const migrationStatePath = statePath(deps);
1548
- let fileStat;
1549
- try {
1550
- fileStat = await lstat2(migrationStatePath);
1551
- } catch (error) {
1552
- if (isErrnoCode(error, "ENOENT")) return;
1553
- throw error;
1527
+ async addEntityAlias(name, alias) {
1528
+ const wrote = await this.mutateEntityFile(name, "addEntityAlias", (entity) => {
1529
+ if (entity.aliases.includes(alias)) return false;
1530
+ entity.aliases.push(alias);
1531
+ return true;
1532
+ });
1533
+ if (wrote) this.deps.bumpMemoryStatusVersion();
1554
1534
  }
1555
- if (fileStat.isSymbolicLink() || !fileStat.isFile()) {
1556
- throw new Error(
1557
- `Refusing entity canonical-id migration through unsafe migration state file: ${migrationStatePath}.`
1535
+ async writeEntity(name, type, facts, options = {}) {
1536
+ await this.deps.ensureDirectories();
1537
+ return withEntityCanonicalMutationLock(
1538
+ path3.join(this.deps.baseDir, "state"),
1539
+ () => this.writeEntityUnlocked(name, type, facts, options)
1558
1540
  );
1559
1541
  }
1560
- const [stateRoot, stateFile] = await Promise.all([
1561
- realpath2(deps.stateDir),
1562
- realpath2(migrationStatePath)
1563
- ]);
1564
- assertPathInsideRoot(stateRoot, stateFile, migrationStatePath);
1565
- }
1566
- async function assertSafeTombstoneFile(deps, tombstonePath) {
1567
- let fileStat;
1568
- try {
1569
- fileStat = await lstat2(tombstonePath);
1570
- } catch (error) {
1571
- if (isErrnoCode(error, "ENOENT")) return false;
1572
- throw error;
1573
- }
1574
- if (fileStat.isSymbolicLink() || !fileStat.isFile()) {
1575
- throw new Error(
1576
- `Refusing entity canonical-id migration through unsafe tombstone ledger: ${tombstonePath}.`
1542
+ async writeEntityUnlocked(name, type, facts, options = {}) {
1543
+ if (typeof name !== "string" || !name.trim() || typeof type !== "string" || !type.trim()) {
1544
+ log.warn("writeEntity: invalid entity payload, skipping", {
1545
+ nameType: typeof name,
1546
+ typeType: typeof type
1547
+ });
1548
+ return "";
1549
+ }
1550
+ const safeFacts = Array.isArray(facts) ? [...new Set(
1551
+ facts.filter((fact) => typeof fact === "string").map((fact) => fact.trim()).filter((fact) => fact.length > 0)
1552
+ )] : [];
1553
+ let normalized = this.deps.normalizeEntityName(name, type);
1554
+ const match = await this.deps.findMatchingEntity(name, type);
1555
+ if (match && match !== normalized) {
1556
+ log.debug(`fuzzy match: "${normalized}" \u2192 existing "${match}"`);
1557
+ normalized = match;
1558
+ }
1559
+ const filePath = path3.join(this.deps.entitiesDir, `${normalized}.md`);
1560
+ let entity = {
1561
+ name,
1562
+ type,
1563
+ created: "",
1564
+ updated: (/* @__PURE__ */ new Date()).toISOString(),
1565
+ facts: [],
1566
+ summary: void 0,
1567
+ synthesis: void 0,
1568
+ synthesisUpdatedAt: void 0,
1569
+ synthesisVersion: void 0,
1570
+ synthesisStructuredFactCount: void 0,
1571
+ synthesisStructuredFactDigest: void 0,
1572
+ timeline: [],
1573
+ relationships: [],
1574
+ activity: [],
1575
+ aliases: []
1576
+ };
1577
+ try {
1578
+ const existing = await this.deps.readStorageSecureFile(filePath);
1579
+ entity = parseEntityFile(existing, this.deps.entitySchemas);
1580
+ } catch (err) {
1581
+ if (err instanceof SecureStoreLockedError) throw err;
1582
+ if (!isErrnoCode(err, "ENOENT")) throw err;
1583
+ }
1584
+ const timestamp = options.timestamp?.trim() || (/* @__PURE__ */ new Date()).toISOString();
1585
+ const source = options.source?.trim() || void 0;
1586
+ const sessionKey = options.sessionKey?.trim() || void 0;
1587
+ const principal = options.principal?.trim() || void 0;
1588
+ const structuredSectionMap = new Map(
1589
+ (entity.structuredSections ?? []).map((section) => [section.key, {
1590
+ ...section,
1591
+ facts: [...section.facts]
1592
+ }])
1577
1593
  );
1578
- }
1579
- const [stateRoot, tombstoneFile] = await Promise.all([
1580
- realpath2(deps.stateDir),
1581
- realpath2(tombstonePath)
1582
- ]);
1583
- assertPathInsideRoot(stateRoot, tombstoneFile, tombstonePath);
1584
- return true;
1585
- }
1586
- function lockPath(deps) {
1587
- return path3.join(deps.stateDir, "entity-canonical-id-migration.lock");
1588
- }
1589
- async function withEntityCanonicalMutationLock(stateDir, task) {
1590
- return withHeldFileLock(
1591
- path3.join(stateDir, "entity-canonical-id-mutation.lock"),
1592
- {
1593
- staleMs: ENTITY_CANONICAL_ID_MUTATION_LOCK_STALE_MS,
1594
- maxWaitMs: ENTITY_CANONICAL_ID_MUTATION_LOCK_MAX_WAIT_MS
1595
- },
1596
- async (acquired, lock) => {
1597
- if (!acquired) throw new Error("Timed out waiting for entity mutation lock.");
1598
- return task(() => lock.refresh());
1594
+ for (const section of options.structuredSections ?? []) {
1595
+ const normalizedSection = normalizeEntityStructuredSection(type, section, this.deps.entitySchemas);
1596
+ const normalizedFacts = normalizeStructuredSectionFacts(section.facts);
1597
+ if (normalizedFacts.length === 0) continue;
1598
+ const existingSection = structuredSectionMap.get(normalizedSection.key);
1599
+ if (!existingSection) {
1600
+ structuredSectionMap.set(normalizedSection.key, {
1601
+ key: normalizedSection.key,
1602
+ title: normalizedSection.title,
1603
+ facts: normalizedFacts
1604
+ });
1605
+ continue;
1606
+ }
1607
+ existingSection.facts = normalizeStructuredSectionFacts([...existingSection.facts, ...normalizedFacts]);
1608
+ if (!existingSection.title.trim() && normalizedSection.title.trim()) {
1609
+ existingSection.title = normalizedSection.title;
1610
+ }
1599
1611
  }
1600
- );
1601
- }
1602
- async function readState(deps) {
1603
- await assertSafeMigrationStateFile(deps);
1604
- let raw;
1605
- try {
1606
- raw = await readFile(statePath(deps), "utf-8");
1607
- } catch (error) {
1608
- if (isErrnoCode(error, "ENOENT")) return null;
1609
- throw error;
1610
- }
1611
- let parsed;
1612
- try {
1613
- parsed = JSON.parse(raw);
1614
- } catch {
1615
- throw new Error("Invalid entity canonical-id migration state.");
1616
- }
1617
- const result = EntityCanonicalIdMigrationStateSchema.safeParse(parsed);
1618
- if (!result.success) throw new Error("Invalid entity canonical-id migration state.");
1619
- return result.data;
1620
- }
1621
- async function writeState(deps, state) {
1622
- const destination = statePath(deps);
1623
- const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
1624
- try {
1625
- await writeFile(temporary, `${JSON.stringify(state, null, 2)}
1626
- `, "utf-8");
1627
- await rename(temporary, destination);
1628
- } finally {
1629
- await unlink(temporary).catch(() => void 0);
1630
- }
1631
- }
1632
- async function fileExists(filePath) {
1633
- try {
1634
- await access(filePath);
1635
- return true;
1636
- } catch (error) {
1637
- if (isErrnoCode(error, "ENOENT")) return false;
1638
- throw error;
1639
- }
1640
- }
1641
- async function sameFileIdentity(leftPath, rightPath) {
1642
- try {
1643
- const [left, right] = await Promise.all([statFile(leftPath), statFile(rightPath)]);
1644
- return left.dev === right.dev && left.ino === right.ino;
1645
- } catch (error) {
1646
- if (isErrnoCode(error, "ENOENT")) return false;
1647
- throw error;
1648
- }
1649
- }
1650
- function assertSafeEntityId(entityId) {
1651
- if (/[\/\\\0]/.test(entityId)) {
1652
- throw new Error(`Refusing entity canonical-id migration through unsafe entity id: ${entityId}.`);
1653
- }
1654
- }
1655
- async function assertNotSymlink(filePath, label) {
1656
- let stat3;
1657
- try {
1658
- stat3 = await lstat2(filePath);
1659
- } catch (error) {
1660
- if (isErrnoCode(error, "ENOENT")) return;
1661
- throw error;
1662
- }
1663
- if (stat3.isSymbolicLink()) {
1664
- throw new Error(`Refusing entity canonical-id migration through symlink: ${label}.`);
1665
- }
1666
- }
1667
- async function assertNotMemorySymlink(filePath) {
1668
- let stat3;
1669
- try {
1670
- stat3 = await lstat2(filePath);
1671
- } catch (error) {
1672
- if (isErrnoCode(error, "ENOENT")) return;
1673
- throw error;
1674
- }
1675
- if (stat3.isSymbolicLink()) {
1676
- throw new Error(`Refusing entity canonical-id migration through symlinked memory: ${filePath}.`);
1677
- }
1678
- }
1679
- async function assertEntityContentUnchanged(deps, filePath, expectedContent, legacyId) {
1680
- const latestContent = await deps.readStorageSecureFile(filePath);
1681
- if (latestContent !== expectedContent) {
1682
- throw new Error(`Legacy entity id ${legacyId} changed while migration was running; retry migration.`);
1683
- }
1684
- }
1685
- async function readSafeEntityEntries(entitiesDir) {
1686
- const rootStat = await lstat2(entitiesDir);
1687
- if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
1688
- throw new Error("Refusing entity canonical-id migration through a symlinked or non-directory entities root.");
1689
- }
1690
- const entries = await readdir2(entitiesDir);
1691
- for (const entry of entries) {
1692
- await assertNotSymlink(path3.join(entitiesDir, entry), entry);
1693
- }
1694
- return entries;
1695
- }
1696
- async function discoverMappings(deps, knownMappings = {}, blocked = /* @__PURE__ */ new Map()) {
1697
- const mappings = {};
1698
- const canonicalOwners = /* @__PURE__ */ new Map();
1699
- blocked.clear();
1700
- const entityEntries = await readSafeEntityEntries(deps.entitiesDir);
1701
- for (const entry of entityEntries) {
1702
- if (!entry.endsWith(".md")) continue;
1703
- const legacyId = entry.slice(0, -".md".length);
1704
- assertSafeEntityId(legacyId);
1705
- const legacyPath = deps.resolveEntityFilePath(legacyId);
1706
- if (legacyPath === null) continue;
1707
- const entityContent = await deps.readStorageSecureFile(legacyPath);
1708
- if (!/^#[ \t]+[^\r\n \t]/m.test(entityContent) || !/^\*\*Type:\*\*[ \t]*[^\r\n \t]/m.test(entityContent)) {
1709
- throw new Error(`Cannot migrate malformed entity file ${entry}: missing name heading or type.`);
1612
+ for (const fact of safeFacts) {
1613
+ const nextEntry = {
1614
+ timestamp,
1615
+ text: fact,
1616
+ ...source ? { source } : {},
1617
+ ...sessionKey ? { sessionKey } : {},
1618
+ ...principal ? { principal } : {}
1619
+ };
1620
+ const alreadyPresent = entity.timeline.some(
1621
+ (entry) => entry.timestamp === nextEntry.timestamp && entry.text === nextEntry.text && entry.source === nextEntry.source && entry.sessionKey === nextEntry.sessionKey && entry.principal === nextEntry.principal
1622
+ );
1623
+ if (alreadyPresent) continue;
1624
+ entity.timeline.push(nextEntry);
1710
1625
  }
1711
- const entity = deps.parseEntityFile(entityContent);
1712
- if (!entity.name.trim() || !entity.type.trim()) {
1713
- throw new Error(`Cannot migrate malformed entity file ${entry}: missing name or type.`);
1626
+ entity.structuredSections = sortStructuredSectionsBySchema(
1627
+ type,
1628
+ Array.from(structuredSectionMap.values()).filter((section) => section.facts.length > 0),
1629
+ this.deps.entitySchemas
1630
+ );
1631
+ entity.facts = compileEntityFacts(entity.timeline, entity.structuredSections);
1632
+ entity.summary = entity.synthesis || entity.summary;
1633
+ entity.name = name;
1634
+ entity.type = type;
1635
+ entity.created = entity.created || timestamp;
1636
+ entity.updated = (/* @__PURE__ */ new Date()).toISOString();
1637
+ await this.deps.snapshotBeforeWrite(filePath, "write");
1638
+ await this.deps.writeStorageSecureFile(filePath, serializeEntityFile(entity, this.deps.entitySchemas));
1639
+ this.deps.invalidateKnowledgeIndexCache();
1640
+ this.deps.bumpMemoryStatusVersion();
1641
+ this.deps.bumpEntityMutationVersion();
1642
+ log.debug(`wrote entity ${normalized}`);
1643
+ return normalized;
1644
+ }
1645
+ async readEntity(name) {
1646
+ const filePath = this.deps.resolveEntityFilePath(name);
1647
+ if (filePath === null) {
1648
+ return "";
1714
1649
  }
1715
- const canonicalId = deps.normalizeEntityName(entity.name, entity.type);
1716
- assertSafeEntityId(canonicalId);
1717
- if (legacyId === canonicalId) continue;
1718
- const canonicalPath = deps.resolveEntityFilePath(canonicalId);
1719
- if (canonicalPath === null) continue;
1720
- const canonicalExists = await fileExists(canonicalPath);
1721
- if (canonicalExists && await sameFileIdentity(legacyPath, canonicalPath) && knownMappings[legacyId] === canonicalId) continue;
1722
- if (canonicalExists) {
1723
- const canonicalContent2 = await deps.readStorageSecureFile(canonicalPath);
1724
- if (entityContent !== canonicalContent2) {
1725
- blocked.set(legacyId, canonicalId);
1726
- continue;
1727
- }
1650
+ try {
1651
+ return await this.deps.readStorageSecureFile(filePath);
1652
+ } catch (err) {
1653
+ if (err instanceof SecureStoreLockedError) throw err;
1654
+ if (!isErrnoCode(err, "ENOENT")) throw err;
1655
+ return "";
1728
1656
  }
1729
- canonicalOwners.set(canonicalId, [...canonicalOwners.get(canonicalId) ?? [], legacyId]);
1730
- mappings[legacyId] = canonicalId;
1731
1657
  }
1732
- for (const [canonicalId, claimants] of canonicalOwners) {
1733
- if (claimants.length < 2) continue;
1734
- for (const legacyId of claimants) {
1735
- blocked.set(legacyId, canonicalId);
1736
- delete mappings[legacyId];
1658
+ /** Return sorted list of entity filenames (without .md extension) */
1659
+ async listEntityNames() {
1660
+ try {
1661
+ const entries = await readdir2(this.deps.entitiesDir);
1662
+ return entries.filter((e) => e.endsWith(".md")).map((e) => e.replace(".md", "")).sort();
1663
+ } catch {
1664
+ return [];
1737
1665
  }
1738
1666
  }
1739
- return mappings;
1740
- }
1741
- async function rewriteReferences(deps, mappings, refreshLock) {
1742
- let rewroteColdMemory = false;
1743
- for (let pass = 0; pass < MEMORY_REWRITE_MAX_PASSES; pass += 1) {
1744
- deps.invalidateAllMemoriesCache();
1745
- deps.invalidateColdMemoriesCache();
1746
- const [hotMemories, coldMemories, archivedMemories] = await Promise.all([
1747
- deps.readAllMemories(),
1748
- deps.readAllColdMemories(),
1749
- deps.readArchivedMemories()
1750
- ]);
1751
- const seenPaths = /* @__PURE__ */ new Set();
1752
- let rewroteAny = false;
1753
- for (const memory of [...hotMemories, ...coldMemories, ...archivedMemories]) {
1754
- const memoryPath = path3.resolve(memory.path);
1755
- if (seenPaths.has(memoryPath)) continue;
1756
- seenPaths.add(memoryPath);
1757
- const previousEntityRef = memory.frontmatter.entityRef;
1758
- const canonicalId = previousEntityRef ? mappings[previousEntityRef] : void 0;
1759
- if (!previousEntityRef || !canonicalId) continue;
1760
- await assertNotMemorySymlink(memory.path);
1761
- const observedMemoryFingerprint = await fingerprintPath(memory.path);
1762
- if (observedMemoryFingerprint === "missing") continue;
1763
- if (!await refreshLock()) throw new Error("Lost entity canonical-id migration lock.");
1764
- const latestMemory = await deps.readMemoryByPath(memory.path);
1765
- if (!latestMemory) continue;
1766
- const latestMemoryFingerprint = await fingerprintPath(latestMemory.path);
1767
- if (latestMemoryFingerprint !== observedMemoryFingerprint) continue;
1768
- const latestPreviousEntityRef = latestMemory.frontmatter.entityRef;
1769
- const latestCanonicalId = latestPreviousEntityRef ? mappings[latestPreviousEntityRef] : void 0;
1770
- if (!latestPreviousEntityRef || !latestCanonicalId) continue;
1771
- const memoryEncrypted = await deps.isEncryptedStorageFile(latestMemory.path);
1772
- if (await fingerprintPath(latestMemory.path) !== latestMemoryFingerprint) continue;
1773
- await deps.snapshotBeforeWrite(latestMemory.path, "write");
1774
- if (await fingerprintPath(latestMemory.path) !== latestMemoryFingerprint) continue;
1775
- await deps.writeStorageSecureFile(
1776
- latestMemory.path,
1777
- deps.serializeMemoryWithEntityRef(latestMemory, latestCanonicalId),
1778
- memoryEncrypted
1779
- );
1780
- await deps.rewriteProjectedMemoryEntityReference(
1781
- latestMemory.frontmatter.id,
1782
- latestPreviousEntityRef,
1783
- latestCanonicalId
1784
- );
1785
- rewroteAny = true;
1786
- rewroteColdMemory ||= latestMemory.path.includes(`${path3.sep}cold${path3.sep}`);
1667
+ /** Re-serialize every entity file into the compiled-truth timeline shape. */
1668
+ async migrateEntityFilesToCompiledTruthTimeline() {
1669
+ const entityNames = await this.listEntityNames();
1670
+ const stateDir = path3.join(this.deps.baseDir, "state");
1671
+ let migrated = 0;
1672
+ for (const entityName of entityNames) {
1673
+ const wrote = await withEntityCanonicalMutationLock(stateDir, async () => {
1674
+ const raw = await this.readEntity(entityName);
1675
+ if (!raw) return false;
1676
+ const serialized = serializeEntityFile(parseEntityFile(raw, this.deps.entitySchemas), this.deps.entitySchemas);
1677
+ if (raw.trimEnd() === serialized.trimEnd()) return false;
1678
+ const filePath = this.deps.resolveEntityFilePath(entityName);
1679
+ if (filePath === null) return false;
1680
+ await this.deps.writeStorageSecureFile(filePath, serialized);
1681
+ this.deps.bumpEntityMutationVersion();
1682
+ return true;
1683
+ });
1684
+ if (wrote) migrated += 1;
1787
1685
  }
1788
- if (!rewroteAny) return { rewroteColdMemory };
1686
+ if (migrated > 0) {
1687
+ this.deps.invalidateKnowledgeIndexCache();
1688
+ this.deps.bumpMemoryStatusVersion();
1689
+ }
1690
+ return { total: entityNames.length, migrated };
1789
1691
  }
1790
- throw new Error("Memory references changed while entity canonical-id migration was running; retry migration.");
1791
- }
1792
- function rewriteTombstoneLine(line, mappings, normalizedMappings) {
1793
- let parsed;
1794
- try {
1795
- parsed = JSON.parse(line);
1796
- } catch {
1797
- return line;
1692
+ /**
1693
+ * Set or rewrite the synthesis layer of an entity file.
1694
+ */
1695
+ async updateEntitySynthesis(name, synthesis, options = {}) {
1696
+ return withEntityCanonicalMutationLock(
1697
+ path3.join(this.deps.baseDir, "state"),
1698
+ () => this.updateEntitySynthesisUnlocked(name, synthesis, options)
1699
+ );
1798
1700
  }
1799
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return line;
1800
- const tombstone = parsed;
1801
- let changed = false;
1802
- if (typeof tombstone.entityRef === "string") {
1803
- const canonicalId = mappings[tombstone.entityRef];
1804
- if (canonicalId) {
1805
- tombstone.entityRef = canonicalId;
1806
- changed = true;
1701
+ async updateEntitySynthesisUnlocked(name, synthesis, options = {}) {
1702
+ const filePath = path3.join(this.deps.entitiesDir, `${name}.md`);
1703
+ let entity;
1704
+ try {
1705
+ const content = await this.deps.readStorageSecureFile(filePath);
1706
+ entity = parseEntityFile(content, this.deps.entitySchemas);
1707
+ } catch (err) {
1708
+ if (err instanceof SecureStoreLockedError) throw err;
1709
+ if (!isErrnoCode(err, "ENOENT")) throw err;
1710
+ log.debug(`updateEntitySynthesis: entity file ${name}.md not found`);
1711
+ return;
1807
1712
  }
1808
- }
1809
- if (typeof tombstone.supersessionKey === "string") {
1810
- const separator = tombstone.supersessionKey.indexOf("::");
1811
- if (separator < 0) return changed ? JSON.stringify(tombstone) : line;
1812
- const normalizedLegacyId = normalizeSupersessionKey(tombstone.supersessionKey.slice(0, separator));
1813
- const canonicalId = normalizedMappings.get(normalizedLegacyId);
1814
- if (canonicalId) {
1815
- const canonicalKey = computeSupersessionKey(canonicalId, tombstone.supersessionKey.slice(separator + 2));
1816
- if (canonicalKey) {
1817
- tombstone.supersessionKey = canonicalKey;
1818
- changed = true;
1713
+ const updatedAt = options.updatedAt?.trim() || entity.synthesisUpdatedAt?.trim() || void 0;
1714
+ const entityUpdatedAt = options.entityUpdatedAt?.trim() || updatedAt || entity.updated || (/* @__PURE__ */ new Date()).toISOString();
1715
+ const synthesisTimelineCount = Number.isInteger(options.synthesisTimelineCount) && (options.synthesisTimelineCount ?? 0) >= 0 ? options.synthesisTimelineCount : void 0;
1716
+ const synthesisStructuredFactCount = Number.isInteger(options.synthesisStructuredFactCount) && (options.synthesisStructuredFactCount ?? 0) >= 0 ? options.synthesisStructuredFactCount : countEntityStructuredFacts(entity);
1717
+ const synthesisStructuredFactDigest = options.synthesisStructuredFactDigest?.trim() || fingerprintEntityStructuredFacts(entity);
1718
+ entity.synthesis = synthesis.trim();
1719
+ entity.summary = entity.synthesis;
1720
+ entity.synthesisUpdatedAt = updatedAt;
1721
+ entity.synthesisTimelineCount = synthesisTimelineCount;
1722
+ entity.synthesisStructuredFactCount = synthesisStructuredFactCount;
1723
+ entity.synthesisStructuredFactDigest = synthesisStructuredFactDigest;
1724
+ entity.synthesisVersion = Math.max(0, entity.synthesisVersion ?? 0) + (options.incrementVersion === false ? 0 : 1);
1725
+ entity.updated = entityUpdatedAt;
1726
+ await this.deps.writeStorageSecureFile(filePath, serializeEntityFile(entity, this.deps.entitySchemas));
1727
+ await this.deps.removeEntitySynthesisQueueEntries([
1728
+ .../* @__PURE__ */ new Set([name, this.deps.normalizeEntityName(entity.name, entity.type)])
1729
+ ]);
1730
+ this.deps.invalidateKnowledgeIndexCache();
1731
+ this.deps.bumpMemoryStatusVersion();
1732
+ this.deps.bumpEntityMutationVersion();
1733
+ }
1734
+ /**
1735
+ * Read all entity files and return lightweight EntityFile objects.
1736
+ * Parsing is fast (~50-100ms for ~1,800 files) since entity files are small.
1737
+ */
1738
+ async readAllEntityFiles() {
1739
+ const currentVersion = this.deps.getMemoryStatusVersion();
1740
+ const schemaCacheKey = buildEntitySchemaCacheKey(this.deps.entitySchemas);
1741
+ const cacheKey = `${this.deps.getEntityCacheSecureStoreKey()}\0${schemaCacheKey}`;
1742
+ const cached = getCachedEntities(this.deps.baseDir, currentVersion, cacheKey);
1743
+ if (cached) return cached;
1744
+ try {
1745
+ const entries = await readdir2(this.deps.entitiesDir);
1746
+ const mdFiles = entries.filter((e) => e.endsWith(".md"));
1747
+ if (mdFiles.length === 0) return [];
1748
+ const BATCH_SIZE = 100;
1749
+ const entities = [];
1750
+ for (let i = 0; i < mdFiles.length; i += BATCH_SIZE) {
1751
+ const batch = mdFiles.slice(i, i + BATCH_SIZE);
1752
+ const results = await Promise.all(
1753
+ batch.map(async (entry) => {
1754
+ try {
1755
+ return await this.deps.readStorageSecureFile(path3.join(this.deps.entitiesDir, entry));
1756
+ } catch (err) {
1757
+ if (err instanceof SecureStoreLockedError) throw err;
1758
+ if (!isErrnoCode(err, "ENOENT")) throw err;
1759
+ return null;
1760
+ }
1761
+ })
1762
+ );
1763
+ for (const content of results) {
1764
+ if (content !== null) entities.push(parseEntityFile(content, this.deps.entitySchemas));
1765
+ }
1819
1766
  }
1767
+ setCachedEntities(this.deps.baseDir, entities, currentVersion, cacheKey);
1768
+ return entities;
1769
+ } catch (err) {
1770
+ if (err instanceof SecureStoreLockedError || !isErrnoCode(err, "ENOENT")) throw err;
1771
+ return [];
1820
1772
  }
1821
1773
  }
1822
- return changed ? JSON.stringify(tombstone) : line;
1823
- }
1824
- async function rewriteTombstoneReferences(deps, mappings, refreshLock) {
1825
- const tombstonePath = path3.join(deps.stateDir, "tombstones.jsonl");
1826
- const normalizedMappings = /* @__PURE__ */ new Map();
1827
- for (const [legacyId, canonicalId] of Object.entries(mappings)) {
1828
- const normalizedLegacyId = normalizeSupersessionKey(legacyId);
1829
- if (normalizedLegacyId && !normalizedMappings.has(normalizedLegacyId)) {
1830
- normalizedMappings.set(normalizedLegacyId, canonicalId);
1774
+ /**
1775
+ * Build the Knowledge Index: a compact markdown table of top-scored entities.
1776
+ * Respects maxEntities and maxChars limits from config.
1777
+ */
1778
+ async buildKnowledgeIndex(config, overrides) {
1779
+ const useDefaultLimits = overrides?.maxEntities === void 0 && overrides?.maxChars === void 0;
1780
+ if (useDefaultLimits && this.deps.knowledgeIndexCache && Date.now() - this.deps.knowledgeIndexCache.builtAt < this.deps.storageManagerClass.KNOWLEDGE_INDEX_CACHE_TTL_MS) {
1781
+ return { result: this.deps.knowledgeIndexCache.result, cached: true };
1831
1782
  }
1832
- }
1833
- await withHeldFileLock(
1834
- path3.join(deps.stateDir, "tombstones.lock"),
1835
- {
1836
- staleMs: TOMBSTONE_LOCK_STALE_MS,
1837
- maxWaitMs: TOMBSTONE_LOCK_MAX_WAIT_MS
1838
- },
1839
- async (acquired, tombstoneLock) => {
1840
- if (!acquired) throw new Error("Timed out waiting for active tombstone migration.");
1841
- if (!await assertSafeTombstoneFile(deps, tombstonePath)) return;
1842
- const tombstoneEncrypted = await deps.isEncryptedStorageFile(tombstonePath);
1843
- const original = await deps.readStorageSecureFile(tombstonePath);
1844
- let changed = false;
1845
- const rewritten = original.split("\n").map((line) => {
1846
- const next = rewriteTombstoneLine(line, mappings, normalizedMappings);
1847
- changed ||= next !== line;
1848
- return next;
1849
- }).join("\n");
1850
- if (!changed) return;
1851
- if (!await refreshLock()) throw new Error("Lost entity canonical-id migration lock.");
1852
- if (!await tombstoneLock.refresh()) throw new Error("Lost tombstone migration lock.");
1853
- await deps.snapshotBeforeWrite(tombstonePath, "write");
1854
- await deps.writeStorageSecureFile(tombstonePath, rewritten, tombstoneEncrypted);
1783
+ const entities = await this.deps.readAllEntityFiles();
1784
+ if (entities.length === 0) {
1785
+ if (useDefaultLimits) this.deps.knowledgeIndexCache = { result: "", builtAt: Date.now() };
1786
+ return { result: "", cached: false };
1855
1787
  }
1856
- );
1857
- }
1858
- async function rewriteRelationshipTargets(deps, mappings, refreshLock) {
1859
- let rewritten = false;
1860
- const entries = await readSafeEntityEntries(deps.entitiesDir);
1861
- for (const entry of entries) {
1862
- if (!entry.endsWith(".md")) continue;
1863
- const entityPath = deps.resolveEntityFilePath(entry.slice(0, -".md".length));
1864
- if (entityPath === null) continue;
1865
- const observedEntityFingerprint = await fingerprintPath(entityPath);
1866
- if (observedEntityFingerprint === "missing") continue;
1867
- const entity = deps.parseEntityFile(await deps.readStorageSecureFile(entityPath));
1868
- const relationships = entity.relationships.map((relationship) => {
1869
- const target = mappings[relationship.target];
1870
- return target ? { ...relationship, target } : relationship;
1871
- });
1872
- if (relationships.every((relationship, index) => relationship === entity.relationships[index])) continue;
1873
- const rewroteEntity = await withEntityCanonicalMutationLock(
1874
- deps.stateDir,
1875
- async (refreshEntityLock) => {
1876
- if (!await refreshLock()) throw new Error("Lost entity canonical-id migration lock.");
1877
- const latestEntity = deps.parseEntityFile(await deps.readStorageSecureFile(entityPath));
1878
- if (await fingerprintPath(entityPath) !== observedEntityFingerprint) return false;
1879
- const latestRelationships = latestEntity.relationships.map((relationship) => {
1880
- const target = mappings[relationship.target];
1881
- return target ? { ...relationship, target } : relationship;
1882
- });
1883
- if (latestRelationships.every((relationship, index) => relationship === latestEntity.relationships[index])) {
1884
- return false;
1885
- }
1886
- const entityEncrypted = await deps.isEncryptedStorageFile(entityPath);
1887
- if (!await refreshEntityLock()) throw new Error("Lost entity mutation lock.");
1888
- if (await fingerprintPath(entityPath) !== observedEntityFingerprint) return false;
1889
- await deps.snapshotBeforeWrite(entityPath, "write");
1890
- if (await fingerprintPath(entityPath) !== observedEntityFingerprint) return false;
1891
- await deps.writeStorageSecureFile(
1892
- entityPath,
1893
- deps.serializeEntityFile({ ...latestEntity, relationships: latestRelationships }),
1894
- entityEncrypted
1895
- );
1896
- return true;
1897
- }
1898
- );
1899
- rewritten ||= rewroteEntity;
1900
- }
1901
- return rewritten;
1902
- }
1903
- async function rewriteKnownReferences(deps, mappings, refreshLock) {
1904
- await rewriteRelationshipTargets(deps, mappings, refreshLock);
1905
- let rewroteColdMemory = (await rewriteReferences(deps, mappings, refreshLock)).rewroteColdMemory;
1906
- await rewriteTombstoneReferences(deps, mappings, refreshLock);
1907
- await deps.rewriteProjectedEntityReferences(mappings);
1908
- const finalMemoryPass = await rewriteReferences(deps, mappings, refreshLock);
1909
- rewroteColdMemory ||= finalMemoryPass.rewroteColdMemory;
1910
- return rewroteColdMemory;
1911
- }
1912
- function wouldCreateMappingCycle(mappings, legacyId, canonicalId) {
1913
- let current = canonicalId;
1914
- const seen = /* @__PURE__ */ new Set();
1915
- while (true) {
1916
- if (current === legacyId) return true;
1917
- if (seen.has(current)) return false;
1918
- seen.add(current);
1919
- const next = mappings[current];
1920
- if (!next || next === current) return false;
1921
- current = next;
1922
- }
1923
- }
1924
- function rejectMappingCycles(existing, discovered) {
1925
- const accepted = {};
1926
- const merged = { ...existing };
1927
- for (const [legacyId, canonicalId] of Object.entries(discovered)) {
1928
- if (merged[legacyId] === void 0 && wouldCreateMappingCycle(merged, legacyId, canonicalId)) continue;
1929
- accepted[legacyId] = canonicalId;
1930
- merged[legacyId] = canonicalId;
1931
- }
1932
- return accepted;
1933
- }
1934
- function mergeDiscoveredMappings(existing, discovered) {
1935
- const merged = { ...existing };
1936
- const canonicalOwners = /* @__PURE__ */ new Map();
1937
- for (const [legacyId, canonicalId] of Object.entries(discovered)) {
1938
- const previousMapping = merged[legacyId];
1939
- if (previousMapping !== void 0 && previousMapping !== canonicalId) {
1940
- let hop = canonicalId;
1941
- const seen = /* @__PURE__ */ new Set();
1942
- while (hop !== void 0 && hop !== previousMapping && !seen.has(hop)) {
1943
- seen.add(hop);
1944
- hop = merged[hop];
1945
- }
1946
- if (hop !== previousMapping) {
1947
- throw new Error(`Legacy entity id ${legacyId} changed canonical target during migration.`);
1948
- }
1949
- continue;
1788
+ const now = /* @__PURE__ */ new Date();
1789
+ const scored = entities.map((e) => ({
1790
+ name: e.name,
1791
+ type: e.type,
1792
+ score: this.deps.storageManagerClass.scoreEntity(e, now),
1793
+ factCount: e.facts.length,
1794
+ summary: e.synthesis ?? e.summary,
1795
+ topRelationships: e.relationships.slice(0, 3).map((r) => r.target)
1796
+ }));
1797
+ scored.sort((a, b) => b.score - a.score);
1798
+ const maxEntities = typeof overrides?.maxEntities === "number" ? Math.max(0, Math.floor(overrides.maxEntities)) : config.knowledgeIndexMaxEntities;
1799
+ const topN = scored.slice(0, maxEntities);
1800
+ if (topN.length === 0) {
1801
+ if (useDefaultLimits) this.deps.knowledgeIndexCache = { result: "", builtAt: Date.now() };
1802
+ return { result: "", cached: false };
1950
1803
  }
1951
- if (previousMapping === void 0 && wouldCreateMappingCycle(merged, legacyId, canonicalId)) continue;
1952
- const previousOwner = canonicalOwners.get(canonicalId);
1953
- if (previousOwner !== void 0 && previousOwner !== legacyId) {
1954
- throw new Error(
1955
- `Cannot migrate legacy entity ids ${previousOwner} and ${legacyId}: both normalize to ${canonicalId}.`
1956
- );
1804
+ const header = "## Knowledge Index\n\n| Entity | Type | Summary | Connected to |\n|--------|------|---------|-------------|";
1805
+ const rows = [];
1806
+ let totalChars = header.length;
1807
+ const maxChars = typeof overrides?.maxChars === "number" ? Math.max(0, Math.floor(overrides.maxChars)) : config.knowledgeIndexMaxChars;
1808
+ for (const entity of topN) {
1809
+ const summary = entity.summary || `${entity.factCount} facts`;
1810
+ const connected = entity.topRelationships.length > 0 ? entity.topRelationships.join(", ") : "\u2014";
1811
+ const row = `| ${entity.name} | ${entity.type} | ${summary} | ${connected} |`;
1812
+ if (totalChars + row.length + 1 > maxChars) break;
1813
+ rows.push(row);
1814
+ totalChars += row.length + 1;
1957
1815
  }
1958
- merged[legacyId] = canonicalId;
1959
- canonicalOwners.set(canonicalId, legacyId);
1816
+ const result = rows.length === 0 ? "" : `${header}
1817
+ ${rows.join("\n")}
1818
+ `;
1819
+ if (useDefaultLimits) this.deps.knowledgeIndexCache = { result, builtAt: Date.now() };
1820
+ return { result, cached: false };
1960
1821
  }
1961
- return merged;
1962
- }
1963
- function collapseMappings(mappings) {
1964
- const collapsed = {};
1965
- for (const legacyId of Object.keys(mappings)) {
1966
- let current = legacyId;
1967
- const seen = /* @__PURE__ */ new Set();
1968
- while (true) {
1969
- const next = mappings[current];
1970
- if (!next || next === current) break;
1971
- if (seen.has(current)) {
1972
- throw new Error(`Entity canonical-id migration mapping cycle includes ${legacyId}.`);
1973
- }
1974
- seen.add(current);
1975
- current = next;
1976
- }
1977
- if (current !== legacyId) collapsed[legacyId] = current;
1822
+ /**
1823
+ * Merge fragmented entity files that resolve to the same canonical name.
1824
+ * Preserves relationships, activity, aliases, and summary from all fragments.
1825
+ * Returns count of files merged.
1826
+ */
1827
+ async mergeFragmentedEntities() {
1828
+ return withEntityCanonicalMutationLock(
1829
+ path3.join(this.deps.baseDir, "state"),
1830
+ () => this.mergeFragmentedEntitiesUnlocked()
1831
+ );
1978
1832
  }
1979
- return collapsed;
1980
- }
1981
- async function migrateEntityFilePair(deps, legacyId, canonicalId, refreshMigrationLock) {
1982
- return withEntityCanonicalMutationLock(deps.stateDir, async (refreshEntityLock) => {
1983
- assertSafeEntityId(legacyId);
1984
- assertSafeEntityId(canonicalId);
1985
- const legacyPath = deps.resolveEntityFilePath(legacyId);
1986
- const canonicalPath = deps.resolveEntityFilePath(canonicalId);
1987
- if (legacyPath === null || canonicalPath === null) {
1988
- throw new Error("Invalid entity canonical-id migration path.");
1989
- }
1990
- await assertNotSymlink(legacyPath, legacyId);
1991
- await assertNotSymlink(canonicalPath, canonicalId);
1992
- const legacyExists = await fileExists(legacyPath);
1993
- const canonicalExists = await fileExists(canonicalPath);
1994
- if (!legacyExists && !canonicalExists) return { deferred: true, progressed: false };
1995
- if (legacyExists && canonicalExists) {
1996
- if (await sameFileIdentity(legacyPath, canonicalPath)) return { deferred: false, progressed: false };
1997
- const [legacyContent, canonicalContent2, legacyEncrypted, canonicalEncrypted] = await Promise.all([
1998
- deps.readStorageSecureFile(legacyPath),
1999
- deps.readStorageSecureFile(canonicalPath),
2000
- deps.isEncryptedStorageFile(legacyPath),
2001
- deps.isEncryptedStorageFile(canonicalPath)
2002
- ]);
2003
- if (legacyContent !== canonicalContent2) {
2004
- return { deferred: false, progressed: false, blocked: true };
1833
+ async mergeFragmentedEntitiesUnlocked() {
1834
+ let merged = 0;
1835
+ try {
1836
+ const entries = await readdir2(this.deps.entitiesDir);
1837
+ const mdFiles = entries.filter((e) => e.endsWith(".md"));
1838
+ const groups = /* @__PURE__ */ new Map();
1839
+ for (const file of mdFiles) {
1840
+ const baseName = file.replace(".md", "");
1841
+ const dashIdx = baseName.indexOf("-");
1842
+ if (dashIdx === -1) continue;
1843
+ const type = baseName.slice(0, dashIdx);
1844
+ const restOfName = baseName.slice(dashIdx + 1);
1845
+ const canonical = this.deps.normalizeEntityName(restOfName, type);
1846
+ if (!groups.has(canonical)) groups.set(canonical, []);
1847
+ groups.get(canonical).push(file);
2005
1848
  }
2006
- if (legacyEncrypted && !canonicalEncrypted) {
2007
- if (!await refreshMigrationLock() || !await refreshEntityLock()) {
2008
- throw new Error("Lost entity canonical-id migration lock.");
1849
+ for (const [canonical, files] of groups) {
1850
+ if (files.length <= 1) continue;
1851
+ const mergedEntity = {
1852
+ name: "",
1853
+ type: "other",
1854
+ created: "",
1855
+ updated: "",
1856
+ extraFrontmatterLines: [],
1857
+ preSectionLines: [],
1858
+ facts: [],
1859
+ summary: void 0,
1860
+ synthesis: void 0,
1861
+ synthesisUpdatedAt: void 0,
1862
+ synthesisTimelineCount: void 0,
1863
+ synthesisStructuredFactCount: void 0,
1864
+ synthesisStructuredFactDigest: void 0,
1865
+ synthesisVersion: void 0,
1866
+ timeline: [],
1867
+ relationships: [],
1868
+ activity: [],
1869
+ aliases: [],
1870
+ structuredSections: [],
1871
+ extraSections: []
1872
+ };
1873
+ for (const file of files) {
1874
+ const filePath = path3.join(this.deps.entitiesDir, file);
1875
+ try {
1876
+ const content = await this.deps.readStorageSecureFile(filePath);
1877
+ const parsed = parseEntityFile(content, this.deps.entitySchemas);
1878
+ if (!mergedEntity.type || mergedEntity.type === "other") {
1879
+ mergedEntity.type = parsed.type;
1880
+ }
1881
+ if (!mergedEntity.updated || compareEntityTimestamps(parsed.updated, mergedEntity.updated) > 0) {
1882
+ mergedEntity.updated = parsed.updated;
1883
+ }
1884
+ const parsedCreated = parsed.created || parsed.updated;
1885
+ const mergedCreated = mergedEntity.created?.trim() || "";
1886
+ const parsedCreatedMs = parsedCreated ? Date.parse(parsedCreated) : Number.NaN;
1887
+ const mergedCreatedMs = mergedCreated ? Date.parse(mergedCreated) : Number.NaN;
1888
+ const parsedCreatedIsValid = Number.isFinite(parsedCreatedMs);
1889
+ const mergedCreatedIsValid = Number.isFinite(mergedCreatedMs);
1890
+ if (parsedCreated && (!mergedCreated || parsedCreatedIsValid && !mergedCreatedIsValid || parsedCreatedIsValid && mergedCreatedIsValid && parsedCreatedMs < mergedCreatedMs || !parsedCreatedIsValid && !mergedCreatedIsValid && compareEntityTimestamps(parsedCreated, mergedCreated) < 0)) {
1891
+ mergedEntity.created = parsedCreated;
1892
+ }
1893
+ if (parsed.name.length > mergedEntity.name.length) {
1894
+ mergedEntity.name = parsed.name;
1895
+ }
1896
+ const parsedSynthesisUpdatedAt = parsed.synthesisUpdatedAt?.trim() || void 0;
1897
+ const mergedSynthesisUpdatedAt = mergedEntity.synthesisUpdatedAt?.trim() || void 0;
1898
+ if (parsed.synthesis && (!mergedEntity.synthesis || !mergedSynthesisUpdatedAt && Boolean(parsedSynthesisUpdatedAt) || Boolean(mergedSynthesisUpdatedAt) && Boolean(parsedSynthesisUpdatedAt) && compareEntityTimestamps(parsedSynthesisUpdatedAt, mergedSynthesisUpdatedAt) > 0)) {
1899
+ mergedEntity.synthesis = parsed.synthesis;
1900
+ mergedEntity.summary = parsed.synthesis;
1901
+ mergedEntity.synthesisUpdatedAt = parsedSynthesisUpdatedAt;
1902
+ mergedEntity.synthesisTimelineCount = parsed.synthesisTimelineCount;
1903
+ mergedEntity.synthesisStructuredFactCount = parsed.synthesisStructuredFactCount;
1904
+ mergedEntity.synthesisStructuredFactDigest = parsed.synthesisStructuredFactDigest;
1905
+ mergedEntity.synthesisVersion = parsed.synthesisVersion;
1906
+ } else if (!mergedEntity.summary && parsed.summary) {
1907
+ mergedEntity.summary = parsed.summary;
1908
+ mergedEntity.synthesis = parsed.summary;
1909
+ mergedEntity.synthesisUpdatedAt = parsedSynthesisUpdatedAt;
1910
+ mergedEntity.synthesisTimelineCount = parsed.synthesisTimelineCount;
1911
+ mergedEntity.synthesisStructuredFactCount = parsed.synthesisStructuredFactCount;
1912
+ mergedEntity.synthesisStructuredFactDigest = parsed.synthesisStructuredFactDigest;
1913
+ mergedEntity.synthesisVersion = parsed.synthesisVersion;
1914
+ }
1915
+ mergedEntity.timeline.push(...parsed.timeline);
1916
+ mergedEntity.relationships.push(...parsed.relationships);
1917
+ mergedEntity.activity.push(...parsed.activity);
1918
+ mergedEntity.aliases.push(...parsed.aliases);
1919
+ const mergedStructuredSectionMap = new Map(
1920
+ (mergedEntity.structuredSections ?? []).map((section) => [section.key, {
1921
+ ...section,
1922
+ facts: [...section.facts]
1923
+ }])
1924
+ );
1925
+ for (const section of parsed.structuredSections ?? []) {
1926
+ const existingSection = mergedStructuredSectionMap.get(section.key);
1927
+ if (!existingSection) {
1928
+ mergedStructuredSectionMap.set(section.key, {
1929
+ key: section.key,
1930
+ title: section.title,
1931
+ facts: [...new Set(section.facts.map((fact) => fact.trim()).filter((fact) => fact.length > 0))]
1932
+ });
1933
+ continue;
1934
+ }
1935
+ const mergedFacts = new Set(existingSection.facts.map((fact) => fact.trim()));
1936
+ for (const fact of section.facts) {
1937
+ const trimmed = fact.trim();
1938
+ if (!trimmed) continue;
1939
+ mergedFacts.add(trimmed);
1940
+ }
1941
+ existingSection.facts = Array.from(mergedFacts);
1942
+ if (!existingSection.title.trim() && section.title.trim()) {
1943
+ existingSection.title = section.title;
1944
+ }
1945
+ }
1946
+ mergedEntity.structuredSections = Array.from(mergedStructuredSectionMap.values());
1947
+ mergedEntity.extraFrontmatterLines.push(...parsed.extraFrontmatterLines ?? []);
1948
+ mergedEntity.preSectionLines.push(...parsed.preSectionLines ?? []);
1949
+ mergedEntity.extraSections.push(...(parsed.extraSections ?? []).map((section) => ({
1950
+ title: section.title,
1951
+ lines: [...section.lines]
1952
+ })));
1953
+ } catch (err) {
1954
+ if (err instanceof SecureStoreLockedError) throw err;
1955
+ if (!isErrnoCode(err, "ENOENT")) throw err;
1956
+ }
2009
1957
  }
2010
- await deps.snapshotBeforeWrite(canonicalPath, "write");
2011
- await deps.writeStorageSecureFile(canonicalPath, legacyContent, true);
2012
- if (await deps.readStorageSecureFile(canonicalPath) !== legacyContent) {
2013
- throw new Error(`Cannot verify migrated entity id ${canonicalId}.`);
1958
+ const timelineKeys = /* @__PURE__ */ new Set();
1959
+ mergedEntity.timeline = mergedEntity.timeline.filter((entry) => {
1960
+ const key = JSON.stringify([
1961
+ entry.timestamp,
1962
+ entry.source ?? "",
1963
+ entry.sessionKey ?? "",
1964
+ entry.principal ?? "",
1965
+ entry.text
1966
+ ]);
1967
+ if (timelineKeys.has(key)) return false;
1968
+ timelineKeys.add(key);
1969
+ return true;
1970
+ });
1971
+ const relKeys = /* @__PURE__ */ new Set();
1972
+ mergedEntity.relationships = mergedEntity.relationships.filter((r) => {
1973
+ const key = `${r.target}::${r.label}`;
1974
+ if (relKeys.has(key)) return false;
1975
+ relKeys.add(key);
1976
+ return true;
1977
+ });
1978
+ const actKeys = /* @__PURE__ */ new Set();
1979
+ mergedEntity.activity = mergedEntity.activity.filter((a) => {
1980
+ const key = `${a.date}::${a.note}`;
1981
+ if (actKeys.has(key)) return false;
1982
+ actKeys.add(key);
1983
+ return true;
1984
+ }).sort((a, b) => b.date.localeCompare(a.date));
1985
+ mergedEntity.aliases = [...new Set(mergedEntity.aliases)];
1986
+ mergedEntity.structuredSections = sortStructuredSectionsBySchema(
1987
+ mergedEntity.type,
1988
+ mergedEntity.structuredSections ?? [],
1989
+ this.deps.entitySchemas
1990
+ );
1991
+ mergedEntity.facts = compileEntityFacts(mergedEntity.timeline, mergedEntity.structuredSections);
1992
+ const extraSectionKeys = /* @__PURE__ */ new Set();
1993
+ mergedEntity.extraSections = (mergedEntity.extraSections ?? []).filter((section) => {
1994
+ const key = `${section.title}::${section.lines.join("\n")}`;
1995
+ if (extraSectionKeys.has(key)) return false;
1996
+ extraSectionKeys.add(key);
1997
+ return true;
1998
+ });
1999
+ if (!mergedEntity.name) {
2000
+ const dashIdx = canonical.indexOf("-");
2001
+ mergedEntity.name = dashIdx !== -1 ? canonical.slice(dashIdx + 1) : canonical;
2014
2002
  }
2015
- }
2016
- if (!await refreshMigrationLock() || !await refreshEntityLock()) {
2017
- throw new Error("Lost entity canonical-id migration lock.");
2018
- }
2019
- await deps.snapshotBeforeWrite(legacyPath, "write");
2020
- await assertNotSymlink(legacyPath, legacyId);
2021
- await assertEntityContentUnchanged(deps, legacyPath, legacyContent, legacyId);
2022
- await unlink(legacyPath);
2023
- return { deferred: false, progressed: true };
2024
- }
2025
- if (!legacyExists) return { deferred: false, progressed: false };
2026
- if (!await refreshMigrationLock() || !await refreshEntityLock()) {
2027
- throw new Error("Lost entity canonical-id migration lock.");
2028
- }
2029
- const entityEncrypted = await deps.isEncryptedStorageFile(legacyPath);
2030
- const entityContent = await deps.readStorageSecureFile(legacyPath);
2031
- await assertNotSymlink(legacyPath, legacyId);
2032
- await assertEntityContentUnchanged(deps, legacyPath, entityContent, legacyId);
2033
- let canonicalFingerprintAfterWrite = null;
2034
- try {
2035
- await deps.writeStorageSecureFile(canonicalPath, entityContent, entityEncrypted);
2036
- canonicalFingerprintAfterWrite = await fingerprintPath(canonicalPath);
2037
- if (await deps.readStorageSecureFile(canonicalPath) !== entityContent) {
2038
- throw new Error(`Cannot verify migrated entity id ${canonicalId}.`);
2039
- }
2040
- if (!await refreshMigrationLock() || !await refreshEntityLock()) {
2041
- throw new Error("Lost entity canonical-id migration lock.");
2042
- }
2043
- await deps.snapshotBeforeWrite(legacyPath, "write");
2044
- await assertNotSymlink(legacyPath, legacyId);
2045
- await assertEntityContentUnchanged(deps, legacyPath, entityContent, legacyId);
2046
- await unlink(legacyPath);
2047
- } catch (error) {
2048
- if (!canonicalExists && canonicalFingerprintAfterWrite !== null) {
2049
- try {
2050
- if (await fingerprintPath(canonicalPath) === canonicalFingerprintAfterWrite) {
2051
- await assertNotSymlink(canonicalPath, canonicalId);
2052
- await unlink(canonicalPath);
2003
+ mergedEntity.created = mergedEntity.created || mergedEntity.updated || (/* @__PURE__ */ new Date()).toISOString();
2004
+ mergedEntity.updated = mergedEntity.updated || (/* @__PURE__ */ new Date()).toISOString();
2005
+ const canonicalPath = path3.join(this.deps.entitiesDir, `${canonical}.md`);
2006
+ await this.deps.writeStorageSecureFile(canonicalPath, serializeEntityFile(mergedEntity, this.deps.entitySchemas));
2007
+ this.deps.bumpEntityMutationVersion();
2008
+ for (const file of files) {
2009
+ const filePath = path3.join(this.deps.entitiesDir, file);
2010
+ if (filePath !== canonicalPath) {
2011
+ try {
2012
+ await unlink(filePath);
2013
+ merged++;
2014
+ log.debug(`merged entity ${file} \u2192 ${canonical}.md`);
2015
+ } catch {
2016
+ }
2053
2017
  }
2054
- } catch {
2055
2018
  }
2056
2019
  }
2057
- throw error;
2058
- }
2059
- return { deferred: false, progressed: true };
2060
- });
2061
- }
2062
- async function migrateLegacyEntityCanonicalIds(deps) {
2063
- return withHeldFileLock(
2064
- lockPath(deps),
2065
- {
2066
- staleMs: ENTITY_CANONICAL_ID_MIGRATION_LOCK_STALE_MS,
2067
- maxWaitMs: ENTITY_CANONICAL_ID_MIGRATION_LOCK_MAX_WAIT_MS
2068
- },
2069
- async (acquired, lock) => {
2070
- if (!acquired) throw new Error("Timed out waiting for active entity canonical-id migration.");
2071
- await deps.validateMemoryScanRoots?.();
2072
- await readSafeEntityEntries(deps.entitiesDir);
2073
- let state = await readState(deps);
2074
- if (!state) {
2075
- state = {
2076
- version: ENTITY_CANONICAL_ID_MIGRATION_VERSION,
2077
- complete: false,
2078
- mappings: {}
2079
- };
2080
- }
2081
- const blocked = /* @__PURE__ */ new Map();
2082
- const finish = () => {
2083
- if (blocked.size > 0) {
2084
- const pairs = [...blocked].map(([legacyId, canonicalId]) => `${legacyId} -> ${canonicalId}`).join(", ");
2085
- log.warn(
2086
- `entity canonical-id migration skipped ${blocked.size} unresolvable pair(s): ${pairs}. Both files were kept and the legacy id still resolves; merge or delete one side to migrate it.`
2087
- );
2088
- }
2089
- return deps.readMigrationFingerprint?.();
2090
- };
2091
- const pruneBlocked = async () => {
2092
- const current = state;
2093
- if (!current) return false;
2094
- let revived = false;
2095
- const parked = { ...current.blocked ?? {} };
2096
- const active = {};
2097
- for (const [legacyId, canonicalId] of Object.entries(current.mappings)) {
2098
- if (!blocked.has(legacyId)) active[legacyId] = canonicalId;
2099
- }
2100
- for (const [legacyId, canonicalId] of blocked) parked[legacyId] = canonicalId;
2101
- for (const [legacyId, canonicalId] of Object.entries(parked)) {
2102
- if (blocked.has(legacyId)) continue;
2103
- delete parked[legacyId];
2104
- if (active[legacyId] !== void 0) continue;
2105
- const legacyPath = deps.resolveEntityFilePath(legacyId);
2106
- if (legacyPath !== null && await fileExists(legacyPath)) continue;
2107
- active[legacyId] = canonicalId;
2108
- revived = true;
2109
- }
2110
- const collapsed = collapseMappings(active);
2111
- for (const [legacyId, canonicalId] of Object.entries(parked)) {
2112
- const moved = collapsed[canonicalId];
2113
- if (moved !== void 0 && moved !== legacyId) parked[legacyId] = moved;
2114
- }
2115
- const same = (a, b) => Object.keys(a).length === Object.keys(b).length && Object.entries(a).every(([key, value]) => b[key] === value);
2116
- if (same(collapsed, current.mappings) && same(parked, current.blocked ?? {})) return revived;
2117
- state = { ...current, mappings: collapsed, blocked: parked };
2118
- await writeState(deps, state);
2119
- return revived;
2120
- };
2121
- const previousMappings = state.mappings;
2122
- const collapsedMappings = collapseMappings(previousMappings);
2123
- const mappingsChanged = Object.keys(collapsedMappings).length !== Object.keys(previousMappings).length || Object.entries(collapsedMappings).some(
2124
- ([legacyId, canonicalId]) => previousMappings[legacyId] !== canonicalId
2125
- );
2126
- state = { ...state, mappings: collapsedMappings };
2127
- for (let migrationPass = 0; migrationPass < ENTITY_MAPPING_RESCAN_MAX_PASSES; migrationPass += 1) {
2128
- if (!await lock.refresh()) throw new Error("Lost entity canonical-id migration lock.");
2129
- const discoveredBefore = rejectMappingCycles(
2130
- state.mappings,
2131
- await discoverMappings(deps, state.mappings, blocked)
2132
- );
2133
- await pruneBlocked();
2134
- const mergedBefore = collapseMappings(mergeDiscoveredMappings(state.mappings, discoveredBefore));
2135
- if (state.complete && Object.keys(discoveredBefore).length === 0 && !mappingsChanged) {
2136
- if (Object.keys(state.mappings).length === 0) return finish();
2137
- const rewroteColdMemory2 = await rewriteKnownReferences(
2138
- deps,
2139
- state.mappings,
2140
- () => lock.refresh()
2141
- );
2142
- deps.invalidateKnowledgeIndexCache();
2143
- deps.invalidateAllMemoriesCache();
2144
- if (rewroteColdMemory2) deps.invalidateColdMemoriesCache();
2145
- deps.bumpMemoryStatusVersion();
2146
- return finish();
2147
- }
2148
- if (state.complete || Object.keys(discoveredBefore).length > 0 || Object.keys(mergedBefore).length !== Object.keys(state.mappings).length) {
2149
- state = { ...state, complete: false, mappings: mergedBefore };
2150
- await writeState(deps, state);
2151
- }
2152
- if (Object.keys(state.mappings).length === 0) {
2153
- if (!await lock.refresh()) throw new Error("Lost entity canonical-id migration lock.");
2154
- const discoveredAfter2 = rejectMappingCycles(
2155
- state.mappings,
2156
- await discoverMappings(deps, state.mappings, blocked)
2157
- );
2158
- const revivedInRescan = await pruneBlocked();
2159
- if (Object.keys(discoveredAfter2).length === 0 && !revivedInRescan) {
2160
- await writeState(deps, { ...state, complete: true });
2161
- return finish();
2162
- }
2163
- state = {
2164
- ...state,
2165
- complete: false,
2166
- mappings: collapseMappings(mergeDiscoveredMappings(state.mappings, discoveredAfter2))
2167
- };
2168
- await writeState(deps, state);
2169
- continue;
2170
- }
2171
- let pendingMappings = Object.entries(state.mappings);
2172
- while (pendingMappings.length > 0) {
2173
- const deferredMappings = [];
2174
- let progressed = false;
2175
- for (const [legacyId, canonicalId] of pendingMappings) {
2176
- const result = await migrateEntityFilePair(
2177
- deps,
2178
- legacyId,
2179
- canonicalId,
2180
- () => lock.refresh()
2181
- );
2182
- if (result.blocked) blocked.set(legacyId, canonicalId);
2183
- else if (result.deferred) deferredMappings.push([legacyId, canonicalId]);
2184
- progressed ||= result.progressed;
2185
- }
2186
- if (deferredMappings.length === 0) break;
2187
- if (!progressed) {
2188
- const [legacyId, canonicalId] = deferredMappings[0];
2189
- throw new Error(
2190
- `Cannot migrate legacy entity id ${legacyId}: both files are missing and no intermediate mapping can advance migration.`
2191
- );
2192
- }
2193
- pendingMappings = deferredMappings;
2194
- }
2195
- await pruneBlocked();
2196
- const rewroteColdMemory = await rewriteKnownReferences(
2197
- deps,
2198
- state.mappings,
2199
- () => lock.refresh()
2200
- );
2201
- deps.invalidateKnowledgeIndexCache();
2202
- deps.invalidateAllMemoriesCache();
2203
- if (rewroteColdMemory) deps.invalidateColdMemoriesCache();
2204
- deps.bumpMemoryStatusVersion();
2205
- if (!await lock.refresh()) throw new Error("Lost entity canonical-id migration lock.");
2206
- const discoveredAfter = rejectMappingCycles(
2207
- state.mappings,
2208
- await discoverMappings(deps, state.mappings, blocked)
2209
- );
2210
- const revivedAfterRewrite = await pruneBlocked();
2211
- if (Object.keys(discoveredAfter).length === 0 && !revivedAfterRewrite) {
2212
- await writeState(deps, { ...state, complete: true });
2213
- return finish();
2214
- }
2215
- state = {
2216
- ...state,
2217
- complete: false,
2218
- mappings: collapseMappings(mergeDiscoveredMappings(state.mappings, discoveredAfter))
2219
- };
2220
- await writeState(deps, state);
2221
- }
2222
- throw new Error("Entity canonical-id migration mappings changed while migration was running; retry migration.");
2020
+ } catch (err) {
2021
+ if (err instanceof SecureStoreLockedError || !isErrnoCode(err, "ENOENT")) throw err;
2223
2022
  }
2224
- );
2225
- }
2023
+ return merged;
2024
+ }
2025
+ };
2226
2026
 
2227
- // src/storage/entity-store.ts
2228
- var EntityStore = class {
2027
+ // src/storage/identity-continuity-store.ts
2028
+ import { readdir as readdir3 } from "fs/promises";
2029
+ import path4 from "path";
2030
+ var IdentityContinuityStore = class {
2229
2031
  constructor(deps) {
2230
2032
  this.deps = deps;
2231
2033
  }
2232
2034
  deps;
2233
- /** Normalize an entity name using this store's alias table. */
2234
- normalizeEntityName(raw, type) {
2235
- return normalizeEntityName(raw, type, this.deps.userAliases);
2236
- }
2237
- async writeEntity(name, type, facts, options = {}) {
2035
+ async writeIdentityAnchor(content) {
2238
2036
  await this.deps.ensureDirectories();
2239
- return withEntityCanonicalMutationLock(
2240
- path4.join(this.deps.baseDir, "state"),
2241
- () => this.writeEntityUnlocked(name, type, facts, options)
2242
- );
2037
+ await this.deps.writeStorageSecureFile(this.deps.identityAnchorPath, content);
2243
2038
  }
2244
- async writeEntityUnlocked(name, type, facts, options = {}) {
2245
- if (typeof name !== "string" || !name.trim() || typeof type !== "string" || !type.trim()) {
2246
- log.warn("writeEntity: invalid entity payload, skipping", {
2247
- nameType: typeof name,
2248
- typeType: typeof type
2249
- });
2250
- return "";
2251
- }
2252
- const safeFacts = Array.isArray(facts) ? [...new Set(
2253
- facts.filter((fact) => typeof fact === "string").map((fact) => fact.trim()).filter((fact) => fact.length > 0)
2254
- )] : [];
2255
- let normalized = this.deps.normalizeEntityName(name, type);
2256
- const match = await this.deps.findMatchingEntity(name, type);
2257
- if (match && match !== normalized) {
2258
- log.debug(`fuzzy match: "${normalized}" \u2192 existing "${match}"`);
2259
- normalized = match;
2039
+ async readIdentityAnchor() {
2040
+ try {
2041
+ return await this.deps.readStorageSecureFile(this.deps.identityAnchorPath);
2042
+ } catch (err) {
2043
+ if (!isErrnoCode(err, "ENOENT")) throw err;
2044
+ return null;
2260
2045
  }
2261
- const filePath = path4.join(this.deps.entitiesDir, `${normalized}.md`);
2262
- let entity = {
2263
- name,
2264
- type,
2265
- created: "",
2266
- updated: (/* @__PURE__ */ new Date()).toISOString(),
2267
- facts: [],
2268
- summary: void 0,
2269
- synthesis: void 0,
2270
- synthesisUpdatedAt: void 0,
2271
- synthesisVersion: void 0,
2272
- synthesisStructuredFactCount: void 0,
2273
- synthesisStructuredFactDigest: void 0,
2274
- timeline: [],
2275
- relationships: [],
2276
- activity: [],
2277
- aliases: []
2278
- };
2046
+ }
2047
+ async appendContinuityIncident(input) {
2048
+ await this.deps.ensureDirectories();
2049
+ const now = /* @__PURE__ */ new Date();
2050
+ const nowIso = now.toISOString();
2051
+ const date = nowIso.slice(0, 10);
2052
+ const id = this.deps.generateId("incident");
2053
+ const incident = createContinuityIncidentRecord(id, input, nowIso);
2054
+ const filePath = path4.join(this.deps.identityIncidentsDir, `${date}-${id}.md`);
2055
+ await this.deps.writeStorageSecureFile(filePath, serializeContinuityIncident(incident));
2056
+ return { ...incident, filePath };
2057
+ }
2058
+ async readContinuityIncidents(limit = 200, state = "all") {
2059
+ const normalizedLimit = Number.isFinite(limit) ? Math.floor(limit) : 0;
2060
+ const cappedLimit = Math.max(0, normalizedLimit);
2061
+ if (cappedLimit === 0) return [];
2279
2062
  try {
2280
- const existing = await this.deps.readStorageSecureFile(filePath);
2281
- entity = parseEntityFile(existing, this.deps.entitySchemas);
2063
+ const candidates = await this.readContinuityIncidentFileNames();
2064
+ const incidents = [];
2065
+ for (const file of candidates) {
2066
+ if (incidents.length >= cappedLimit) break;
2067
+ const filePath = path4.join(this.deps.identityIncidentsDir, file);
2068
+ try {
2069
+ const raw = await this.deps.readStorageSecureFile(filePath);
2070
+ const parsed = parseContinuityIncident(raw);
2071
+ if (!parsed) continue;
2072
+ if (state !== "all" && parsed.state !== state) continue;
2073
+ incidents.push({ ...parsed, filePath });
2074
+ } catch (err) {
2075
+ if (err instanceof SecureStoreLockedError) throw err;
2076
+ }
2077
+ }
2078
+ return incidents;
2282
2079
  } catch (err) {
2283
2080
  if (err instanceof SecureStoreLockedError) throw err;
2284
2081
  if (!isErrnoCode(err, "ENOENT")) throw err;
2082
+ return [];
2285
2083
  }
2286
- const timestamp = options.timestamp?.trim() || (/* @__PURE__ */ new Date()).toISOString();
2287
- const source = options.source?.trim() || void 0;
2288
- const sessionKey = options.sessionKey?.trim() || void 0;
2289
- const principal = options.principal?.trim() || void 0;
2290
- const structuredSectionMap = new Map(
2291
- (entity.structuredSections ?? []).map((section) => [section.key, {
2292
- ...section,
2293
- facts: [...section.facts]
2294
- }])
2295
- );
2296
- for (const section of options.structuredSections ?? []) {
2297
- const normalizedSection = normalizeEntityStructuredSection(type, section, this.deps.entitySchemas);
2298
- const normalizedFacts = normalizeStructuredSectionFacts(section.facts);
2299
- if (normalizedFacts.length === 0) continue;
2300
- const existingSection = structuredSectionMap.get(normalizedSection.key);
2301
- if (!existingSection) {
2302
- structuredSectionMap.set(normalizedSection.key, {
2303
- key: normalizedSection.key,
2304
- title: normalizedSection.title,
2305
- facts: normalizedFacts
2306
- });
2307
- continue;
2308
- }
2309
- existingSection.facts = normalizeStructuredSectionFacts([...existingSection.facts, ...normalizedFacts]);
2310
- if (!existingSection.title.trim() && normalizedSection.title.trim()) {
2311
- existingSection.title = normalizedSection.title;
2312
- }
2313
- }
2314
- for (const fact of safeFacts) {
2315
- const nextEntry = {
2316
- timestamp,
2317
- text: fact,
2318
- ...source ? { source } : {},
2319
- ...sessionKey ? { sessionKey } : {},
2320
- ...principal ? { principal } : {}
2321
- };
2322
- const alreadyPresent = entity.timeline.some(
2323
- (entry) => entry.timestamp === nextEntry.timestamp && entry.text === nextEntry.text && entry.source === nextEntry.source && entry.sessionKey === nextEntry.sessionKey && entry.principal === nextEntry.principal
2324
- );
2325
- if (alreadyPresent) continue;
2326
- entity.timeline.push(nextEntry);
2327
- }
2328
- entity.structuredSections = sortStructuredSectionsBySchema(
2329
- type,
2330
- Array.from(structuredSectionMap.values()).filter((section) => section.facts.length > 0),
2331
- this.deps.entitySchemas
2332
- );
2333
- entity.facts = compileEntityFacts(entity.timeline, entity.structuredSections);
2334
- entity.summary = entity.synthesis || entity.summary;
2335
- entity.name = name;
2336
- entity.type = type;
2337
- entity.created = entity.created || timestamp;
2338
- entity.updated = (/* @__PURE__ */ new Date()).toISOString();
2339
- await this.deps.snapshotBeforeWrite(filePath, "write");
2340
- await this.deps.writeStorageSecureFile(filePath, serializeEntityFile(entity, this.deps.entitySchemas));
2341
- this.deps.invalidateKnowledgeIndexCache();
2342
- this.deps.bumpMemoryStatusVersion();
2343
- log.debug(`wrote entity ${normalized}`);
2344
- return normalized;
2345
2084
  }
2346
- async readEntity(name) {
2347
- const filePath = this.deps.resolveEntityFilePath(name);
2348
- if (filePath === null) {
2349
- return "";
2350
- }
2085
+ async closeContinuityIncident(id, closure) {
2086
+ const directFilePath = await this.findContinuityIncidentFilePathById(id);
2087
+ const target = directFilePath ? await this.readContinuityIncidentFile(directFilePath) : null;
2088
+ if (!target || !directFilePath) return null;
2089
+ if (target.state === "closed") return target;
2090
+ const closed = closeContinuityIncidentRecord(target, closure, (/* @__PURE__ */ new Date()).toISOString());
2091
+ await this.deps.writeStorageSecureFile(directFilePath, serializeContinuityIncident(closed));
2092
+ return { ...closed, filePath: directFilePath };
2093
+ }
2094
+ async writeIdentityAudit(period, key, content) {
2095
+ await this.deps.ensureDirectories();
2096
+ const safeKey = this.sanitizeIdentityAuditKey(key);
2097
+ const dir = period === "weekly" ? this.deps.identityAuditsWeeklyDir : this.deps.identityAuditsMonthlyDir;
2098
+ const filePath = path4.join(dir, `${safeKey}.md`);
2099
+ await this.deps.writeStorageSecureFile(filePath, content);
2100
+ return filePath;
2101
+ }
2102
+ async readIdentityAudit(period, key) {
2351
2103
  try {
2352
- return await this.deps.readStorageSecureFile(filePath);
2104
+ const safeKey = this.sanitizeIdentityAuditKey(key);
2105
+ const dir = period === "weekly" ? this.deps.identityAuditsWeeklyDir : this.deps.identityAuditsMonthlyDir;
2106
+ return await this.deps.readStorageSecureFile(path4.join(dir, `${safeKey}.md`));
2353
2107
  } catch (err) {
2354
- if (err instanceof SecureStoreLockedError) throw err;
2108
+ if (err instanceof Error && err.message === "Invalid identity audit key") return null;
2355
2109
  if (!isErrnoCode(err, "ENOENT")) throw err;
2356
- return "";
2357
- }
2358
- }
2359
- /** Return sorted list of entity filenames (without .md extension) */
2360
- async listEntityNames() {
2361
- try {
2362
- const entries = await readdir3(this.deps.entitiesDir);
2363
- return entries.filter((e) => e.endsWith(".md")).map((e) => e.replace(".md", "")).sort();
2364
- } catch {
2365
- return [];
2110
+ return null;
2366
2111
  }
2367
2112
  }
2368
- /**
2369
- * Set or rewrite the synthesis layer of an entity file.
2370
- */
2371
- async updateEntitySynthesis(name, synthesis, options = {}) {
2372
- return withEntityCanonicalMutationLock(
2373
- path4.join(this.deps.baseDir, "state"),
2374
- () => this.updateEntitySynthesisUnlocked(name, synthesis, options)
2375
- );
2113
+ async writeIdentityImprovementLoops(content) {
2114
+ await this.deps.ensureDirectories();
2115
+ await this.deps.writeStorageSecureFile(this.deps.identityImprovementLoopsPath, content);
2376
2116
  }
2377
- async updateEntitySynthesisUnlocked(name, synthesis, options = {}) {
2378
- const filePath = path4.join(this.deps.entitiesDir, `${name}.md`);
2379
- let entity;
2117
+ async readIdentityImprovementLoops() {
2380
2118
  try {
2381
- const content = await this.deps.readStorageSecureFile(filePath);
2382
- entity = parseEntityFile(content, this.deps.entitySchemas);
2119
+ return await this.deps.readStorageSecureFile(this.deps.identityImprovementLoopsPath);
2383
2120
  } catch (err) {
2384
- if (err instanceof SecureStoreLockedError) throw err;
2385
2121
  if (!isErrnoCode(err, "ENOENT")) throw err;
2386
- log.debug(`updateEntitySynthesis: entity file ${name}.md not found`);
2387
- return;
2122
+ return null;
2388
2123
  }
2389
- const updatedAt = options.updatedAt?.trim() || entity.synthesisUpdatedAt?.trim() || void 0;
2390
- const entityUpdatedAt = options.entityUpdatedAt?.trim() || updatedAt || entity.updated || (/* @__PURE__ */ new Date()).toISOString();
2391
- const synthesisTimelineCount = Number.isInteger(options.synthesisTimelineCount) && (options.synthesisTimelineCount ?? 0) >= 0 ? options.synthesisTimelineCount : void 0;
2392
- const synthesisStructuredFactCount = Number.isInteger(options.synthesisStructuredFactCount) && (options.synthesisStructuredFactCount ?? 0) >= 0 ? options.synthesisStructuredFactCount : countEntityStructuredFacts(entity);
2393
- const synthesisStructuredFactDigest = options.synthesisStructuredFactDigest?.trim() || fingerprintEntityStructuredFacts(entity);
2394
- entity.synthesis = synthesis.trim();
2395
- entity.summary = entity.synthesis;
2396
- entity.synthesisUpdatedAt = updatedAt;
2397
- entity.synthesisTimelineCount = synthesisTimelineCount;
2398
- entity.synthesisStructuredFactCount = synthesisStructuredFactCount;
2399
- entity.synthesisStructuredFactDigest = synthesisStructuredFactDigest;
2400
- entity.synthesisVersion = Math.max(0, entity.synthesisVersion ?? 0) + (options.incrementVersion === false ? 0 : 1);
2401
- entity.updated = entityUpdatedAt;
2402
- await this.deps.writeStorageSecureFile(filePath, serializeEntityFile(entity, this.deps.entitySchemas));
2403
- await this.deps.removeEntitySynthesisQueueEntries([
2404
- .../* @__PURE__ */ new Set([name, this.deps.normalizeEntityName(entity.name, entity.type)])
2405
- ]);
2406
- this.deps.invalidateKnowledgeIndexCache();
2407
- this.deps.bumpMemoryStatusVersion();
2408
2124
  }
2409
- /**
2410
- * Read all entity files and return lightweight EntityFile objects.
2411
- * Parsing is fast (~50-100ms for ~1,800 files) since entity files are small.
2412
- */
2413
- async readAllEntityFiles() {
2414
- const currentVersion = this.deps.getMemoryStatusVersion();
2415
- const schemaCacheKey = buildEntitySchemaCacheKey(this.deps.entitySchemas);
2416
- const cacheKey = `${this.deps.getEntityCacheSecureStoreKey()}\0${schemaCacheKey}`;
2417
- const cached = getCachedEntities(this.deps.baseDir, currentVersion, cacheKey);
2418
- if (cached) return cached;
2125
+ async readIdentityImprovementLoopRegister() {
2126
+ const raw = await this.readIdentityImprovementLoops();
2127
+ if (!raw) return [];
2128
+ return parseContinuityImprovementLoops(raw);
2129
+ }
2130
+ async upsertIdentityImprovementLoop(input) {
2131
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
2132
+ const raw = await this.readIdentityImprovementLoops();
2133
+ const { markdown, loop } = upsertContinuityLoopInMarkdown(raw, input, nowIso);
2134
+ await this.writeIdentityImprovementLoops(markdown);
2135
+ return loop;
2136
+ }
2137
+ async reviewIdentityImprovementLoop(id, input) {
2138
+ const raw = await this.readIdentityImprovementLoops();
2139
+ const { markdown, loop } = reviewContinuityLoopInMarkdown(raw, id, input, (/* @__PURE__ */ new Date()).toISOString());
2140
+ if (!loop) return null;
2141
+ await this.writeIdentityImprovementLoops(markdown);
2142
+ return loop;
2143
+ }
2144
+ async readContinuityIncidentFileNames() {
2145
+ const files = await readdir3(this.deps.identityIncidentsDir);
2146
+ return files.filter((file) => file.endsWith(".md")).sort().reverse();
2147
+ }
2148
+ async readContinuityIncidentFile(filePath) {
2419
2149
  try {
2420
- const entries = await readdir3(this.deps.entitiesDir);
2421
- const mdFiles = entries.filter((e) => e.endsWith(".md"));
2422
- if (mdFiles.length === 0) return [];
2423
- const BATCH_SIZE = 100;
2424
- const entities = [];
2425
- for (let i = 0; i < mdFiles.length; i += BATCH_SIZE) {
2426
- const batch = mdFiles.slice(i, i + BATCH_SIZE);
2427
- const results = await Promise.all(
2428
- batch.map(async (entry) => {
2429
- try {
2430
- return await this.deps.readStorageSecureFile(path4.join(this.deps.entitiesDir, entry));
2431
- } catch (err) {
2432
- if (err instanceof SecureStoreLockedError) throw err;
2433
- if (!isErrnoCode(err, "ENOENT")) throw err;
2434
- return null;
2435
- }
2436
- })
2437
- );
2438
- for (const content of results) {
2439
- if (content !== null) entities.push(parseEntityFile(content, this.deps.entitySchemas));
2440
- }
2441
- }
2442
- setCachedEntities(this.deps.baseDir, entities, currentVersion, cacheKey);
2443
- return entities;
2150
+ const raw = await this.deps.readStorageSecureFile(filePath);
2151
+ const parsed = parseContinuityIncident(raw);
2152
+ return parsed ? { ...parsed, filePath } : null;
2444
2153
  } catch (err) {
2445
- if (err instanceof SecureStoreLockedError || !isErrnoCode(err, "ENOENT")) throw err;
2446
- return [];
2154
+ if (err instanceof SecureStoreLockedError) throw err;
2155
+ return null;
2447
2156
  }
2448
2157
  }
2449
- /**
2450
- * Build the Knowledge Index: a compact markdown table of top-scored entities.
2451
- * Respects maxEntities and maxChars limits from config.
2452
- */
2453
- async buildKnowledgeIndex(config, overrides) {
2454
- const useDefaultLimits = overrides?.maxEntities === void 0 && overrides?.maxChars === void 0;
2455
- if (useDefaultLimits && this.deps.knowledgeIndexCache && Date.now() - this.deps.knowledgeIndexCache.builtAt < this.deps.storageManagerClass.KNOWLEDGE_INDEX_CACHE_TTL_MS) {
2456
- return { result: this.deps.knowledgeIndexCache.result, cached: true };
2457
- }
2458
- const entities = await this.deps.readAllEntityFiles();
2459
- if (entities.length === 0) {
2460
- if (useDefaultLimits) this.deps.knowledgeIndexCache = { result: "", builtAt: Date.now() };
2461
- return { result: "", cached: false };
2462
- }
2463
- const now = /* @__PURE__ */ new Date();
2464
- const scored = entities.map((e) => ({
2465
- name: e.name,
2466
- type: e.type,
2467
- score: this.deps.storageManagerClass.scoreEntity(e, now),
2468
- factCount: e.facts.length,
2469
- summary: e.synthesis ?? e.summary,
2470
- topRelationships: e.relationships.slice(0, 3).map((r) => r.target)
2471
- }));
2472
- scored.sort((a, b) => b.score - a.score);
2473
- const maxEntities = typeof overrides?.maxEntities === "number" ? Math.max(0, Math.floor(overrides.maxEntities)) : config.knowledgeIndexMaxEntities;
2474
- const topN = scored.slice(0, maxEntities);
2475
- if (topN.length === 0) {
2476
- if (useDefaultLimits) this.deps.knowledgeIndexCache = { result: "", builtAt: Date.now() };
2477
- return { result: "", cached: false };
2158
+ async findContinuityIncidentFilePathById(id) {
2159
+ const fileNames = await this.readContinuityIncidentFileNames();
2160
+ const directMatch = fileNames.find((name) => name.endsWith(`-${id}.md`));
2161
+ if (directMatch) {
2162
+ const directPath = path4.join(this.deps.identityIncidentsDir, directMatch);
2163
+ const parsed = await this.readContinuityIncidentFile(directPath);
2164
+ if (parsed?.id === id) return directPath;
2478
2165
  }
2479
- const header = "## Knowledge Index\n\n| Entity | Type | Summary | Connected to |\n|--------|------|---------|-------------|";
2480
- const rows = [];
2481
- let totalChars = header.length;
2482
- const maxChars = typeof overrides?.maxChars === "number" ? Math.max(0, Math.floor(overrides.maxChars)) : config.knowledgeIndexMaxChars;
2483
- for (const entity of topN) {
2484
- const summary = entity.summary || `${entity.factCount} facts`;
2485
- const connected = entity.topRelationships.length > 0 ? entity.topRelationships.join(", ") : "\u2014";
2486
- const row = `| ${entity.name} | ${entity.type} | ${summary} | ${connected} |`;
2487
- if (totalChars + row.length + 1 > maxChars) break;
2488
- rows.push(row);
2489
- totalChars += row.length + 1;
2166
+ for (const fileName of fileNames) {
2167
+ const filePath = path4.join(this.deps.identityIncidentsDir, fileName);
2168
+ const parsed = await this.readContinuityIncidentFile(filePath);
2169
+ if (parsed?.id === id) return filePath;
2490
2170
  }
2491
- const result = rows.length === 0 ? "" : `${header}
2492
- ${rows.join("\n")}
2493
- `;
2494
- if (useDefaultLimits) this.deps.knowledgeIndexCache = { result, builtAt: Date.now() };
2495
- return { result, cached: false };
2171
+ return null;
2496
2172
  }
2497
- /**
2498
- * Merge fragmented entity files that resolve to the same canonical name.
2499
- * Preserves relationships, activity, aliases, and summary from all fragments.
2500
- * Returns count of files merged.
2501
- */
2502
- async mergeFragmentedEntities() {
2503
- return withEntityCanonicalMutationLock(
2504
- path4.join(this.deps.baseDir, "state"),
2505
- () => this.mergeFragmentedEntitiesUnlocked()
2506
- );
2173
+ sanitizeIdentityAuditKey(key) {
2174
+ const trimmed = key.trim();
2175
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed) || trimmed.includes("..")) {
2176
+ throw new Error("Invalid identity audit key");
2177
+ }
2178
+ return trimmed;
2507
2179
  }
2508
- async mergeFragmentedEntitiesUnlocked() {
2509
- let merged = 0;
2510
- try {
2511
- const entries = await readdir3(this.deps.entitiesDir);
2512
- const mdFiles = entries.filter((e) => e.endsWith(".md"));
2513
- const groups = /* @__PURE__ */ new Map();
2514
- for (const file of mdFiles) {
2515
- const baseName = file.replace(".md", "");
2516
- const dashIdx = baseName.indexOf("-");
2517
- if (dashIdx === -1) continue;
2518
- const type = baseName.slice(0, dashIdx);
2519
- const restOfName = baseName.slice(dashIdx + 1);
2520
- const canonical = this.deps.normalizeEntityName(restOfName, type);
2521
- if (!groups.has(canonical)) groups.set(canonical, []);
2522
- groups.get(canonical).push(file);
2180
+ };
2181
+
2182
+ // src/storage/entity-canonical-id-migration.ts
2183
+ import { randomUUID } from "crypto";
2184
+ import {
2185
+ access,
2186
+ lstat as lstat2,
2187
+ readFile,
2188
+ readdir as readdir4,
2189
+ realpath as realpath2,
2190
+ rename,
2191
+ stat as statFile,
2192
+ unlink as unlink2,
2193
+ writeFile
2194
+ } from "fs/promises";
2195
+ import path5 from "path";
2196
+ import { z } from "zod";
2197
+ var ENTITY_CANONICAL_ID_MIGRATION_VERSION = 1;
2198
+ var ENTITY_CANONICAL_ID_MIGRATION_LOCK_STALE_MS = 6e4;
2199
+ var ENTITY_CANONICAL_ID_MIGRATION_LOCK_MAX_WAIT_MS = 3e5;
2200
+ var TOMBSTONE_LOCK_STALE_MS = 3e4;
2201
+ var TOMBSTONE_LOCK_MAX_WAIT_MS = 5e3;
2202
+ var MEMORY_REWRITE_MAX_PASSES = 3;
2203
+ var ENTITY_MAPPING_RESCAN_MAX_PASSES = 3;
2204
+ var EntityCanonicalIdMigrationStateSchema = z.object({
2205
+ version: z.literal(ENTITY_CANONICAL_ID_MIGRATION_VERSION),
2206
+ complete: z.boolean(),
2207
+ mappings: z.record(z.string().min(1)),
2208
+ /**
2209
+ * Pairs the migration refused to resolve, PARKED rather than dropped.
2210
+ * Deleting the record would strand the collision: once an operator removes
2211
+ * the legacy file, no later scan can rediscover the mapping, so references
2212
+ * still naming the legacy id would never be rewritten. Optional so a journal
2213
+ * written by an older build still parses.
2214
+ */
2215
+ blocked: z.record(z.string().min(1)).optional()
2216
+ });
2217
+ async function fingerprintPath(filePath) {
2218
+ let fileStat;
2219
+ try {
2220
+ fileStat = await lstat2(filePath);
2221
+ } catch (error) {
2222
+ if (!isErrnoCode(error, "ENOENT")) throw error;
2223
+ }
2224
+ return fileStat ? `${fileStat.dev}:${fileStat.ino}:${fileStat.mtimeMs}:${fileStat.ctimeMs}:${fileStat.size}` : "missing";
2225
+ }
2226
+ async function getFingerprint(baseDir, entitiesDir, getEntityMutationVersion) {
2227
+ const [entityFingerprint, aliasFingerprint, reconcileFingerprint, consumingFingerprint] = await Promise.all([
2228
+ fingerprintPath(entitiesDir),
2229
+ fingerprintPath(path5.join(baseDir, "config", "aliases.json")),
2230
+ fingerprintPath(path5.join(baseDir, "state", ENTITY_CANONICAL_ID_RECONCILE_MARKER)),
2231
+ // A `.consuming` generation stranded by a crashed PEER process must also
2232
+ // re-invoke a live runner whose cached fingerprint predates it.
2233
+ fingerprintPath(path5.join(baseDir, "state", ENTITY_CANONICAL_ID_RECONCILE_CONSUMING_MARKER))
2234
+ ]);
2235
+ return [entityFingerprint, aliasFingerprint, reconcileFingerprint, consumingFingerprint, getEntityMutationVersion()].join(":");
2236
+ }
2237
+ async function validateRoots(baseDir, entitiesDir, stateDir, archiveDir) {
2238
+ let baseStat;
2239
+ try {
2240
+ baseStat = await lstat2(baseDir);
2241
+ } catch (error) {
2242
+ if (isErrnoCode(error, "ENOENT")) return;
2243
+ throw error;
2244
+ }
2245
+ if (baseStat.isSymbolicLink() || !baseStat.isDirectory()) {
2246
+ throw new Error(`Refusing entity canonical-id migration through unsafe memory root: ${baseDir}.`);
2247
+ }
2248
+ const memoryRoot = await realpath2(baseDir);
2249
+ const roots = [
2250
+ entitiesDir,
2251
+ stateDir,
2252
+ ...RECALL_FALLBACK_DIRS.map((directory) => path5.join(baseDir, directory)),
2253
+ path5.join(baseDir, "cold"),
2254
+ archiveDir
2255
+ ];
2256
+ for (const root of roots) {
2257
+ let rootStat;
2258
+ try {
2259
+ rootStat = await lstat2(root);
2260
+ } catch (error) {
2261
+ if (isErrnoCode(error, "ENOENT")) continue;
2262
+ throw error;
2263
+ }
2264
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
2265
+ throw new Error(`Refusing entity canonical-id migration through unsafe memory root: ${root}.`);
2266
+ }
2267
+ assertPathInsideRoot(memoryRoot, await realpath2(root), root);
2268
+ }
2269
+ }
2270
+ function statePath(deps) {
2271
+ return path5.join(deps.stateDir, ENTITY_CANONICAL_ID_MIGRATION_FILE);
2272
+ }
2273
+ async function assertSafeMigrationStateFile(deps) {
2274
+ const migrationStatePath = statePath(deps);
2275
+ let fileStat;
2276
+ try {
2277
+ fileStat = await lstat2(migrationStatePath);
2278
+ } catch (error) {
2279
+ if (isErrnoCode(error, "ENOENT")) return;
2280
+ throw error;
2281
+ }
2282
+ if (fileStat.isSymbolicLink() || !fileStat.isFile()) {
2283
+ throw new Error(
2284
+ `Refusing entity canonical-id migration through unsafe migration state file: ${migrationStatePath}.`
2285
+ );
2286
+ }
2287
+ const [stateRoot, stateFile] = await Promise.all([
2288
+ realpath2(deps.stateDir),
2289
+ realpath2(migrationStatePath)
2290
+ ]);
2291
+ assertPathInsideRoot(stateRoot, stateFile, migrationStatePath);
2292
+ }
2293
+ async function assertSafeTombstoneFile(deps, tombstonePath) {
2294
+ let fileStat;
2295
+ try {
2296
+ fileStat = await lstat2(tombstonePath);
2297
+ } catch (error) {
2298
+ if (isErrnoCode(error, "ENOENT")) return false;
2299
+ throw error;
2300
+ }
2301
+ if (fileStat.isSymbolicLink() || !fileStat.isFile()) {
2302
+ throw new Error(
2303
+ `Refusing entity canonical-id migration through unsafe tombstone ledger: ${tombstonePath}.`
2304
+ );
2305
+ }
2306
+ const [stateRoot, tombstoneFile] = await Promise.all([
2307
+ realpath2(deps.stateDir),
2308
+ realpath2(tombstonePath)
2309
+ ]);
2310
+ assertPathInsideRoot(stateRoot, tombstoneFile, tombstonePath);
2311
+ return true;
2312
+ }
2313
+ function lockPath(deps) {
2314
+ return path5.join(deps.stateDir, "entity-canonical-id-migration.lock");
2315
+ }
2316
+ async function readState(deps) {
2317
+ await assertSafeMigrationStateFile(deps);
2318
+ let raw;
2319
+ try {
2320
+ raw = await readFile(statePath(deps), "utf-8");
2321
+ } catch (error) {
2322
+ if (isErrnoCode(error, "ENOENT")) return null;
2323
+ throw error;
2324
+ }
2325
+ let parsed;
2326
+ try {
2327
+ parsed = JSON.parse(raw);
2328
+ } catch {
2329
+ throw new Error("Invalid entity canonical-id migration state.");
2330
+ }
2331
+ const result = EntityCanonicalIdMigrationStateSchema.safeParse(parsed);
2332
+ if (!result.success) throw new Error("Invalid entity canonical-id migration state.");
2333
+ return result.data;
2334
+ }
2335
+ async function writeState(deps, state) {
2336
+ const destination = statePath(deps);
2337
+ const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
2338
+ try {
2339
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}
2340
+ `, "utf-8");
2341
+ await rename(temporary, destination);
2342
+ } finally {
2343
+ await unlink2(temporary).catch(() => void 0);
2344
+ }
2345
+ }
2346
+ async function publishState(deps, state) {
2347
+ await withEntityCanonicalMutationLock(deps.stateDir, async () => {
2348
+ await writeState(deps, state);
2349
+ });
2350
+ }
2351
+ async function fileExists(filePath) {
2352
+ try {
2353
+ await access(filePath);
2354
+ return true;
2355
+ } catch (error) {
2356
+ if (isErrnoCode(error, "ENOENT")) return false;
2357
+ throw error;
2358
+ }
2359
+ }
2360
+ async function sameFileIdentity(leftPath, rightPath) {
2361
+ try {
2362
+ const [left, right] = await Promise.all([statFile(leftPath), statFile(rightPath)]);
2363
+ return left.dev === right.dev && left.ino === right.ino;
2364
+ } catch (error) {
2365
+ if (isErrnoCode(error, "ENOENT")) return false;
2366
+ throw error;
2367
+ }
2368
+ }
2369
+ function assertSafeEntityId(entityId) {
2370
+ if (/[\/\\\0]/.test(entityId)) {
2371
+ throw new Error(`Refusing entity canonical-id migration through unsafe entity id: ${entityId}.`);
2372
+ }
2373
+ }
2374
+ async function assertNotSymlink(filePath, label) {
2375
+ let stat3;
2376
+ try {
2377
+ stat3 = await lstat2(filePath);
2378
+ } catch (error) {
2379
+ if (isErrnoCode(error, "ENOENT")) return;
2380
+ throw error;
2381
+ }
2382
+ if (stat3.isSymbolicLink()) {
2383
+ throw new Error(`Refusing entity canonical-id migration through symlink: ${label}.`);
2384
+ }
2385
+ }
2386
+ async function assertNotMemorySymlink(filePath) {
2387
+ let stat3;
2388
+ try {
2389
+ stat3 = await lstat2(filePath);
2390
+ } catch (error) {
2391
+ if (isErrnoCode(error, "ENOENT")) return;
2392
+ throw error;
2393
+ }
2394
+ if (stat3.isSymbolicLink()) {
2395
+ throw new Error(`Refusing entity canonical-id migration through symlinked memory: ${filePath}.`);
2396
+ }
2397
+ }
2398
+ async function assertEntityContentUnchanged(deps, filePath, expectedContent, legacyId) {
2399
+ const latestContent = await deps.readStorageSecureFile(filePath);
2400
+ if (latestContent !== expectedContent) {
2401
+ throw new Error(`Legacy entity id ${legacyId} changed while migration was running; retry migration.`);
2402
+ }
2403
+ }
2404
+ async function readSafeEntityEntries(entitiesDir) {
2405
+ const rootStat = await lstat2(entitiesDir);
2406
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
2407
+ throw new Error("Refusing entity canonical-id migration through a symlinked or non-directory entities root.");
2408
+ }
2409
+ const entries = await readdir4(entitiesDir);
2410
+ for (const entry of entries) {
2411
+ await assertNotSymlink(path5.join(entitiesDir, entry), entry);
2412
+ }
2413
+ return entries;
2414
+ }
2415
+ async function discoverMappings(deps, knownMappings = {}, blocked = /* @__PURE__ */ new Map()) {
2416
+ const mappings = {};
2417
+ const canonicalOwners = /* @__PURE__ */ new Map();
2418
+ blocked.clear();
2419
+ const entityEntries = await readSafeEntityEntries(deps.entitiesDir);
2420
+ for (const entry of entityEntries) {
2421
+ if (!entry.endsWith(".md")) continue;
2422
+ const legacyId = entry.slice(0, -".md".length);
2423
+ assertSafeEntityId(legacyId);
2424
+ const legacyPath = deps.resolveEntityFilePath(legacyId);
2425
+ if (legacyPath === null) continue;
2426
+ const entityContent = await deps.readStorageSecureFile(legacyPath);
2427
+ if (!/^#[ \t]+[^\r\n \t]/m.test(entityContent) || !/^\*\*Type:\*\*[ \t]*[^\r\n \t]/m.test(entityContent)) {
2428
+ throw new Error(`Cannot migrate malformed entity file ${entry}: missing name heading or type.`);
2429
+ }
2430
+ const entity = deps.parseEntityFile(entityContent);
2431
+ if (!entity.name.trim() || !entity.type.trim()) {
2432
+ throw new Error(`Cannot migrate malformed entity file ${entry}: missing name or type.`);
2433
+ }
2434
+ const canonicalId = deps.normalizeEntityName(entity.name, entity.type);
2435
+ assertSafeEntityId(canonicalId);
2436
+ if (legacyId === canonicalId) continue;
2437
+ const canonicalPath = deps.resolveEntityFilePath(canonicalId);
2438
+ if (canonicalPath === null) continue;
2439
+ const canonicalExists = await fileExists(canonicalPath);
2440
+ if (canonicalExists && await sameFileIdentity(legacyPath, canonicalPath) && knownMappings[legacyId] === canonicalId) continue;
2441
+ if (canonicalExists) {
2442
+ const canonicalContent2 = await deps.readStorageSecureFile(canonicalPath);
2443
+ if (entityContent !== canonicalContent2) {
2444
+ blocked.set(legacyId, canonicalId);
2445
+ continue;
2446
+ }
2447
+ }
2448
+ canonicalOwners.set(canonicalId, [...canonicalOwners.get(canonicalId) ?? [], legacyId]);
2449
+ mappings[legacyId] = canonicalId;
2450
+ }
2451
+ for (const [canonicalId, claimants] of canonicalOwners) {
2452
+ if (claimants.length < 2) continue;
2453
+ for (const legacyId of claimants) {
2454
+ blocked.set(legacyId, canonicalId);
2455
+ delete mappings[legacyId];
2456
+ }
2457
+ }
2458
+ return mappings;
2459
+ }
2460
+ async function rewriteReferences(deps, mappings, refreshLock) {
2461
+ let rewroteColdMemory = false;
2462
+ for (let pass = 0; pass < MEMORY_REWRITE_MAX_PASSES; pass += 1) {
2463
+ deps.invalidateAllMemoriesCache();
2464
+ deps.invalidateColdMemoriesCache();
2465
+ const [hotMemories, coldMemories, archivedMemories] = await Promise.all([
2466
+ deps.readAllMemories(),
2467
+ deps.readAllColdMemories(),
2468
+ deps.readArchivedMemories()
2469
+ ]);
2470
+ const seenPaths = /* @__PURE__ */ new Set();
2471
+ let rewroteAny = false;
2472
+ for (const memory of [...hotMemories, ...coldMemories, ...archivedMemories]) {
2473
+ const memoryPath = path5.resolve(memory.path);
2474
+ if (seenPaths.has(memoryPath)) continue;
2475
+ seenPaths.add(memoryPath);
2476
+ const previousEntityRef = memory.frontmatter.entityRef;
2477
+ const canonicalId = previousEntityRef ? mappings[previousEntityRef] : void 0;
2478
+ if (!previousEntityRef || !canonicalId) continue;
2479
+ await assertNotMemorySymlink(memory.path);
2480
+ const observedMemoryFingerprint = await fingerprintPath(memory.path);
2481
+ if (observedMemoryFingerprint === "missing") continue;
2482
+ if (!await refreshLock()) throw new Error("Lost entity canonical-id migration lock.");
2483
+ const latestMemory = await deps.readMemoryByPath(memory.path);
2484
+ if (!latestMemory) continue;
2485
+ const latestMemoryFingerprint = await fingerprintPath(latestMemory.path);
2486
+ if (latestMemoryFingerprint !== observedMemoryFingerprint) continue;
2487
+ const latestPreviousEntityRef = latestMemory.frontmatter.entityRef;
2488
+ const latestCanonicalId = latestPreviousEntityRef ? mappings[latestPreviousEntityRef] : void 0;
2489
+ if (!latestPreviousEntityRef || !latestCanonicalId) continue;
2490
+ const memoryEncrypted = await deps.isEncryptedStorageFile(latestMemory.path);
2491
+ if (await fingerprintPath(latestMemory.path) !== latestMemoryFingerprint) continue;
2492
+ await deps.snapshotBeforeWrite(latestMemory.path, "write");
2493
+ if (await fingerprintPath(latestMemory.path) !== latestMemoryFingerprint) continue;
2494
+ await deps.writeStorageSecureFile(
2495
+ latestMemory.path,
2496
+ deps.serializeMemoryWithEntityRef(latestMemory, latestCanonicalId),
2497
+ memoryEncrypted
2498
+ );
2499
+ await deps.rewriteProjectedMemoryEntityReference(
2500
+ latestMemory.frontmatter.id,
2501
+ latestPreviousEntityRef,
2502
+ latestCanonicalId
2503
+ );
2504
+ rewroteAny = true;
2505
+ rewroteColdMemory ||= latestMemory.path.includes(`${path5.sep}cold${path5.sep}`);
2506
+ }
2507
+ if (!rewroteAny) return { rewroteColdMemory };
2508
+ }
2509
+ throw new Error("Memory references changed while entity canonical-id migration was running; retry migration.");
2510
+ }
2511
+ function rewriteTombstoneLine(line, mappings, normalizedMappings) {
2512
+ let parsed;
2513
+ try {
2514
+ parsed = JSON.parse(line);
2515
+ } catch {
2516
+ return line;
2517
+ }
2518
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return line;
2519
+ const tombstone = parsed;
2520
+ let changed = false;
2521
+ if (typeof tombstone.entityRef === "string") {
2522
+ const canonicalId = mappings[tombstone.entityRef];
2523
+ if (canonicalId) {
2524
+ tombstone.entityRef = canonicalId;
2525
+ changed = true;
2526
+ }
2527
+ }
2528
+ if (typeof tombstone.supersessionKey === "string") {
2529
+ const separator = tombstone.supersessionKey.indexOf("::");
2530
+ if (separator < 0) return changed ? JSON.stringify(tombstone) : line;
2531
+ const normalizedLegacyId = normalizeSupersessionKey(tombstone.supersessionKey.slice(0, separator));
2532
+ const canonicalId = normalizedMappings.get(normalizedLegacyId);
2533
+ if (canonicalId) {
2534
+ const canonicalKey = computeSupersessionKey(canonicalId, tombstone.supersessionKey.slice(separator + 2));
2535
+ if (canonicalKey) {
2536
+ tombstone.supersessionKey = canonicalKey;
2537
+ changed = true;
2538
+ }
2539
+ }
2540
+ }
2541
+ return changed ? JSON.stringify(tombstone) : line;
2542
+ }
2543
+ async function rewriteTombstoneReferences(deps, mappings, refreshLock) {
2544
+ const tombstonePath = path5.join(deps.stateDir, "tombstones.jsonl");
2545
+ const normalizedMappings = /* @__PURE__ */ new Map();
2546
+ for (const [legacyId, canonicalId] of Object.entries(mappings)) {
2547
+ const normalizedLegacyId = normalizeSupersessionKey(legacyId);
2548
+ if (normalizedLegacyId && !normalizedMappings.has(normalizedLegacyId)) {
2549
+ normalizedMappings.set(normalizedLegacyId, canonicalId);
2550
+ }
2551
+ }
2552
+ await withHeldFileLock(
2553
+ path5.join(deps.stateDir, "tombstones.lock"),
2554
+ {
2555
+ staleMs: TOMBSTONE_LOCK_STALE_MS,
2556
+ maxWaitMs: TOMBSTONE_LOCK_MAX_WAIT_MS
2557
+ },
2558
+ async (acquired, tombstoneLock) => {
2559
+ if (!acquired) throw new Error("Timed out waiting for active tombstone migration.");
2560
+ if (!await assertSafeTombstoneFile(deps, tombstonePath)) return;
2561
+ const tombstoneEncrypted = await deps.isEncryptedStorageFile(tombstonePath);
2562
+ const original = await deps.readStorageSecureFile(tombstonePath);
2563
+ let changed = false;
2564
+ const rewritten = original.split("\n").map((line) => {
2565
+ const next = rewriteTombstoneLine(line, mappings, normalizedMappings);
2566
+ changed ||= next !== line;
2567
+ return next;
2568
+ }).join("\n");
2569
+ if (!changed) return;
2570
+ if (!await refreshLock()) throw new Error("Lost entity canonical-id migration lock.");
2571
+ if (!await tombstoneLock.refresh()) throw new Error("Lost tombstone migration lock.");
2572
+ await deps.snapshotBeforeWrite(tombstonePath, "write");
2573
+ await deps.writeStorageSecureFile(tombstonePath, rewritten, tombstoneEncrypted);
2574
+ }
2575
+ );
2576
+ }
2577
+ async function rewriteRelationshipTargets(deps, mappings, refreshLock) {
2578
+ let rewritten = false;
2579
+ const entries = await readSafeEntityEntries(deps.entitiesDir);
2580
+ for (const entry of entries) {
2581
+ if (!entry.endsWith(".md")) continue;
2582
+ const entityPath = deps.resolveEntityFilePath(entry.slice(0, -".md".length));
2583
+ if (entityPath === null) continue;
2584
+ const observedEntityFingerprint = await fingerprintPath(entityPath);
2585
+ if (observedEntityFingerprint === "missing") continue;
2586
+ const entity = deps.parseEntityFile(await deps.readStorageSecureFile(entityPath));
2587
+ const relationships = entity.relationships.map((relationship) => {
2588
+ const target = mappings[relationship.target];
2589
+ return target ? { ...relationship, target } : relationship;
2590
+ });
2591
+ if (relationships.every((relationship, index) => relationship === entity.relationships[index])) continue;
2592
+ const rewroteEntity = await withEntityCanonicalMutationLock(
2593
+ deps.stateDir,
2594
+ async (refreshEntityLock) => {
2595
+ if (!await refreshLock()) throw new Error("Lost entity canonical-id migration lock.");
2596
+ const latestEntity = deps.parseEntityFile(await deps.readStorageSecureFile(entityPath));
2597
+ if (await fingerprintPath(entityPath) !== observedEntityFingerprint) return false;
2598
+ const latestRelationships = latestEntity.relationships.map((relationship) => {
2599
+ const target = mappings[relationship.target];
2600
+ return target ? { ...relationship, target } : relationship;
2601
+ });
2602
+ if (latestRelationships.every((relationship, index) => relationship === latestEntity.relationships[index])) {
2603
+ return false;
2604
+ }
2605
+ const entityEncrypted = await deps.isEncryptedStorageFile(entityPath);
2606
+ if (!await refreshEntityLock()) throw new Error("Lost entity mutation lock.");
2607
+ if (await fingerprintPath(entityPath) !== observedEntityFingerprint) return false;
2608
+ await deps.snapshotBeforeWrite(entityPath, "write");
2609
+ if (await fingerprintPath(entityPath) !== observedEntityFingerprint) return false;
2610
+ await deps.writeStorageSecureFile(
2611
+ entityPath,
2612
+ deps.serializeEntityFile({ ...latestEntity, relationships: latestRelationships }),
2613
+ entityEncrypted
2614
+ );
2615
+ return true;
2616
+ }
2617
+ );
2618
+ rewritten ||= rewroteEntity;
2619
+ }
2620
+ return rewritten;
2621
+ }
2622
+ async function rewriteKnownReferences(deps, mappings, refreshLock) {
2623
+ await rewriteRelationshipTargets(deps, mappings, refreshLock);
2624
+ let rewroteColdMemory = (await rewriteReferences(deps, mappings, refreshLock)).rewroteColdMemory;
2625
+ await rewriteTombstoneReferences(deps, mappings, refreshLock);
2626
+ await deps.rewriteProjectedEntityReferences(mappings);
2627
+ const finalMemoryPass = await rewriteReferences(deps, mappings, refreshLock);
2628
+ rewroteColdMemory ||= finalMemoryPass.rewroteColdMemory;
2629
+ return rewroteColdMemory;
2630
+ }
2631
+ function wouldCreateMappingCycle(mappings, legacyId, canonicalId) {
2632
+ let current = canonicalId;
2633
+ const seen = /* @__PURE__ */ new Set();
2634
+ while (true) {
2635
+ if (current === legacyId) return true;
2636
+ if (seen.has(current)) return false;
2637
+ seen.add(current);
2638
+ const next = mappings[current];
2639
+ if (!next || next === current) return false;
2640
+ current = next;
2641
+ }
2642
+ }
2643
+ function rejectMappingCycles(existing, discovered) {
2644
+ const accepted = {};
2645
+ const merged = { ...existing };
2646
+ for (const [legacyId, canonicalId] of Object.entries(discovered)) {
2647
+ if (merged[legacyId] === void 0 && wouldCreateMappingCycle(merged, legacyId, canonicalId)) continue;
2648
+ accepted[legacyId] = canonicalId;
2649
+ merged[legacyId] = canonicalId;
2650
+ }
2651
+ return accepted;
2652
+ }
2653
+ function mergeDiscoveredMappings(existing, discovered) {
2654
+ const merged = { ...existing };
2655
+ const canonicalOwners = /* @__PURE__ */ new Map();
2656
+ for (const [legacyId, canonicalId] of Object.entries(discovered)) {
2657
+ const previousMapping = merged[legacyId];
2658
+ if (previousMapping !== void 0 && previousMapping !== canonicalId) {
2659
+ let hop = canonicalId;
2660
+ const seen = /* @__PURE__ */ new Set();
2661
+ while (hop !== void 0 && hop !== previousMapping && !seen.has(hop)) {
2662
+ seen.add(hop);
2663
+ hop = merged[hop];
2664
+ }
2665
+ if (hop !== previousMapping) {
2666
+ throw new Error(`Legacy entity id ${legacyId} changed canonical target during migration.`);
2667
+ }
2668
+ continue;
2669
+ }
2670
+ if (previousMapping === void 0 && wouldCreateMappingCycle(merged, legacyId, canonicalId)) continue;
2671
+ const previousOwner = canonicalOwners.get(canonicalId);
2672
+ if (previousOwner !== void 0 && previousOwner !== legacyId) {
2673
+ throw new Error(
2674
+ `Cannot migrate legacy entity ids ${previousOwner} and ${legacyId}: both normalize to ${canonicalId}.`
2675
+ );
2676
+ }
2677
+ merged[legacyId] = canonicalId;
2678
+ canonicalOwners.set(canonicalId, legacyId);
2679
+ }
2680
+ return merged;
2681
+ }
2682
+ function collapseMappings(mappings) {
2683
+ const collapsed = {};
2684
+ for (const legacyId of Object.keys(mappings)) {
2685
+ let current = legacyId;
2686
+ const seen = /* @__PURE__ */ new Set();
2687
+ while (true) {
2688
+ const next = mappings[current];
2689
+ if (!next || next === current) break;
2690
+ if (seen.has(current)) {
2691
+ throw new Error(`Entity canonical-id migration mapping cycle includes ${legacyId}.`);
2692
+ }
2693
+ seen.add(current);
2694
+ current = next;
2695
+ }
2696
+ if (current !== legacyId) collapsed[legacyId] = current;
2697
+ }
2698
+ return collapsed;
2699
+ }
2700
+ async function migrateEntityFilePair(deps, legacyId, canonicalId, refreshMigrationLock) {
2701
+ return withEntityCanonicalMutationLock(deps.stateDir, async (refreshEntityLock) => {
2702
+ assertSafeEntityId(legacyId);
2703
+ assertSafeEntityId(canonicalId);
2704
+ const legacyPath = deps.resolveEntityFilePath(legacyId);
2705
+ const canonicalPath = deps.resolveEntityFilePath(canonicalId);
2706
+ if (legacyPath === null || canonicalPath === null) {
2707
+ throw new Error("Invalid entity canonical-id migration path.");
2708
+ }
2709
+ await assertNotSymlink(legacyPath, legacyId);
2710
+ await assertNotSymlink(canonicalPath, canonicalId);
2711
+ const legacyExists = await fileExists(legacyPath);
2712
+ const canonicalExists = await fileExists(canonicalPath);
2713
+ if (!legacyExists && !canonicalExists) return { deferred: true, progressed: false };
2714
+ if (legacyExists && canonicalExists) {
2715
+ if (await sameFileIdentity(legacyPath, canonicalPath)) return { deferred: false, progressed: false };
2716
+ const [legacyContent, canonicalContent2, legacyEncrypted, canonicalEncrypted] = await Promise.all([
2717
+ deps.readStorageSecureFile(legacyPath),
2718
+ deps.readStorageSecureFile(canonicalPath),
2719
+ deps.isEncryptedStorageFile(legacyPath),
2720
+ deps.isEncryptedStorageFile(canonicalPath)
2721
+ ]);
2722
+ if (legacyContent !== canonicalContent2) {
2723
+ return { deferred: false, progressed: false, blocked: true };
2724
+ }
2725
+ if (legacyEncrypted && !canonicalEncrypted) {
2726
+ if (!await refreshMigrationLock() || !await refreshEntityLock()) {
2727
+ throw new Error("Lost entity canonical-id migration lock.");
2728
+ }
2729
+ await deps.snapshotBeforeWrite(canonicalPath, "write");
2730
+ await deps.writeStorageSecureFile(canonicalPath, legacyContent, true);
2731
+ if (await deps.readStorageSecureFile(canonicalPath) !== legacyContent) {
2732
+ throw new Error(`Cannot verify migrated entity id ${canonicalId}.`);
2733
+ }
2734
+ }
2735
+ if (!await refreshMigrationLock() || !await refreshEntityLock()) {
2736
+ throw new Error("Lost entity canonical-id migration lock.");
2737
+ }
2738
+ await deps.snapshotBeforeWrite(legacyPath, "write");
2739
+ await assertNotSymlink(legacyPath, legacyId);
2740
+ await assertEntityContentUnchanged(deps, legacyPath, legacyContent, legacyId);
2741
+ await unlink2(legacyPath);
2742
+ return { deferred: false, progressed: true };
2743
+ }
2744
+ if (!legacyExists) return { deferred: false, progressed: false };
2745
+ if (!await refreshMigrationLock() || !await refreshEntityLock()) {
2746
+ throw new Error("Lost entity canonical-id migration lock.");
2747
+ }
2748
+ const entityEncrypted = await deps.isEncryptedStorageFile(legacyPath);
2749
+ const entityContent = await deps.readStorageSecureFile(legacyPath);
2750
+ await assertNotSymlink(legacyPath, legacyId);
2751
+ await assertEntityContentUnchanged(deps, legacyPath, entityContent, legacyId);
2752
+ let canonicalFingerprintAfterWrite = null;
2753
+ try {
2754
+ await deps.writeStorageSecureFile(canonicalPath, entityContent, entityEncrypted);
2755
+ canonicalFingerprintAfterWrite = await fingerprintPath(canonicalPath);
2756
+ if (await deps.readStorageSecureFile(canonicalPath) !== entityContent) {
2757
+ throw new Error(`Cannot verify migrated entity id ${canonicalId}.`);
2758
+ }
2759
+ if (!await refreshMigrationLock() || !await refreshEntityLock()) {
2760
+ throw new Error("Lost entity canonical-id migration lock.");
2761
+ }
2762
+ await deps.snapshotBeforeWrite(legacyPath, "write");
2763
+ await assertNotSymlink(legacyPath, legacyId);
2764
+ await assertEntityContentUnchanged(deps, legacyPath, entityContent, legacyId);
2765
+ await unlink2(legacyPath);
2766
+ } catch (error) {
2767
+ if (!canonicalExists && canonicalFingerprintAfterWrite !== null) {
2768
+ try {
2769
+ if (await fingerprintPath(canonicalPath) === canonicalFingerprintAfterWrite) {
2770
+ await assertNotSymlink(canonicalPath, canonicalId);
2771
+ await unlink2(canonicalPath);
2772
+ }
2773
+ } catch {
2774
+ }
2775
+ }
2776
+ throw error;
2777
+ }
2778
+ return { deferred: false, progressed: true };
2779
+ });
2780
+ }
2781
+ async function migrateLegacyEntityCanonicalIds(deps) {
2782
+ return withHeldFileLock(
2783
+ lockPath(deps),
2784
+ {
2785
+ staleMs: ENTITY_CANONICAL_ID_MIGRATION_LOCK_STALE_MS,
2786
+ maxWaitMs: ENTITY_CANONICAL_ID_MIGRATION_LOCK_MAX_WAIT_MS
2787
+ },
2788
+ async (acquired, lock) => {
2789
+ if (!acquired) throw new Error("Timed out waiting for active entity canonical-id migration.");
2790
+ await deps.validateMemoryScanRoots?.();
2791
+ await readSafeEntityEntries(deps.entitiesDir);
2792
+ const reconcileMarkerPath = path5.join(deps.stateDir, ENTITY_CANONICAL_ID_RECONCILE_MARKER);
2793
+ const consumingMarkerPath = path5.join(deps.stateDir, ENTITY_CANONICAL_ID_RECONCILE_CONSUMING_MARKER);
2794
+ let reconcileRequested = await fileExists(consumingMarkerPath);
2795
+ if (await fileExists(reconcileMarkerPath)) {
2796
+ try {
2797
+ await rename(reconcileMarkerPath, consumingMarkerPath);
2798
+ reconcileRequested = true;
2799
+ } catch (error) {
2800
+ if (!isErrnoCode(error, "ENOENT")) throw error;
2801
+ }
2523
2802
  }
2524
- for (const [canonical, files] of groups) {
2525
- if (files.length <= 1) continue;
2526
- const mergedEntity = {
2527
- name: "",
2528
- type: "other",
2529
- created: "",
2530
- updated: "",
2531
- extraFrontmatterLines: [],
2532
- preSectionLines: [],
2533
- facts: [],
2534
- summary: void 0,
2535
- synthesis: void 0,
2536
- synthesisUpdatedAt: void 0,
2537
- synthesisTimelineCount: void 0,
2538
- synthesisStructuredFactCount: void 0,
2539
- synthesisStructuredFactDigest: void 0,
2540
- synthesisVersion: void 0,
2541
- timeline: [],
2542
- relationships: [],
2543
- activity: [],
2544
- aliases: [],
2545
- structuredSections: [],
2546
- extraSections: []
2803
+ let state = await readState(deps);
2804
+ if (!state) {
2805
+ state = {
2806
+ version: ENTITY_CANONICAL_ID_MIGRATION_VERSION,
2807
+ complete: false,
2808
+ mappings: {}
2547
2809
  };
2548
- for (const file of files) {
2549
- const filePath = path4.join(this.deps.entitiesDir, file);
2550
- try {
2551
- const content = await this.deps.readStorageSecureFile(filePath);
2552
- const parsed = parseEntityFile(content, this.deps.entitySchemas);
2553
- if (!mergedEntity.type || mergedEntity.type === "other") {
2554
- mergedEntity.type = parsed.type;
2555
- }
2556
- if (!mergedEntity.updated || compareEntityTimestamps(parsed.updated, mergedEntity.updated) > 0) {
2557
- mergedEntity.updated = parsed.updated;
2558
- }
2559
- const parsedCreated = parsed.created || parsed.updated;
2560
- const mergedCreated = mergedEntity.created?.trim() || "";
2561
- const parsedCreatedMs = parsedCreated ? Date.parse(parsedCreated) : Number.NaN;
2562
- const mergedCreatedMs = mergedCreated ? Date.parse(mergedCreated) : Number.NaN;
2563
- const parsedCreatedIsValid = Number.isFinite(parsedCreatedMs);
2564
- const mergedCreatedIsValid = Number.isFinite(mergedCreatedMs);
2565
- if (parsedCreated && (!mergedCreated || parsedCreatedIsValid && !mergedCreatedIsValid || parsedCreatedIsValid && mergedCreatedIsValid && parsedCreatedMs < mergedCreatedMs || !parsedCreatedIsValid && !mergedCreatedIsValid && compareEntityTimestamps(parsedCreated, mergedCreated) < 0)) {
2566
- mergedEntity.created = parsedCreated;
2567
- }
2568
- if (parsed.name.length > mergedEntity.name.length) {
2569
- mergedEntity.name = parsed.name;
2570
- }
2571
- const parsedSynthesisUpdatedAt = parsed.synthesisUpdatedAt?.trim() || void 0;
2572
- const mergedSynthesisUpdatedAt = mergedEntity.synthesisUpdatedAt?.trim() || void 0;
2573
- if (parsed.synthesis && (!mergedEntity.synthesis || !mergedSynthesisUpdatedAt && Boolean(parsedSynthesisUpdatedAt) || Boolean(mergedSynthesisUpdatedAt) && Boolean(parsedSynthesisUpdatedAt) && compareEntityTimestamps(parsedSynthesisUpdatedAt, mergedSynthesisUpdatedAt) > 0)) {
2574
- mergedEntity.synthesis = parsed.synthesis;
2575
- mergedEntity.summary = parsed.synthesis;
2576
- mergedEntity.synthesisUpdatedAt = parsedSynthesisUpdatedAt;
2577
- mergedEntity.synthesisTimelineCount = parsed.synthesisTimelineCount;
2578
- mergedEntity.synthesisStructuredFactCount = parsed.synthesisStructuredFactCount;
2579
- mergedEntity.synthesisStructuredFactDigest = parsed.synthesisStructuredFactDigest;
2580
- mergedEntity.synthesisVersion = parsed.synthesisVersion;
2581
- } else if (!mergedEntity.summary && parsed.summary) {
2582
- mergedEntity.summary = parsed.summary;
2583
- mergedEntity.synthesis = parsed.summary;
2584
- mergedEntity.synthesisUpdatedAt = parsedSynthesisUpdatedAt;
2585
- mergedEntity.synthesisTimelineCount = parsed.synthesisTimelineCount;
2586
- mergedEntity.synthesisStructuredFactCount = parsed.synthesisStructuredFactCount;
2587
- mergedEntity.synthesisStructuredFactDigest = parsed.synthesisStructuredFactDigest;
2588
- mergedEntity.synthesisVersion = parsed.synthesisVersion;
2589
- }
2590
- mergedEntity.timeline.push(...parsed.timeline);
2591
- mergedEntity.relationships.push(...parsed.relationships);
2592
- mergedEntity.activity.push(...parsed.activity);
2593
- mergedEntity.aliases.push(...parsed.aliases);
2594
- const mergedStructuredSectionMap = new Map(
2595
- (mergedEntity.structuredSections ?? []).map((section) => [section.key, {
2596
- ...section,
2597
- facts: [...section.facts]
2598
- }])
2810
+ }
2811
+ const blocked = /* @__PURE__ */ new Map();
2812
+ const finish = async () => {
2813
+ if (blocked.size > 0) {
2814
+ const pairs = [...blocked].map(([legacyId, canonicalId]) => `${legacyId} -> ${canonicalId}`).join(", ");
2815
+ log.warn(
2816
+ `entity canonical-id migration skipped ${blocked.size} unresolvable pair(s): ${pairs}. Both files were kept and the legacy id still resolves; merge or delete one side to migrate it.`
2817
+ );
2818
+ }
2819
+ if (reconcileRequested) {
2820
+ await unlink2(consumingMarkerPath).catch((error) => {
2821
+ if (!isErrnoCode(error, "ENOENT")) throw error;
2822
+ });
2823
+ }
2824
+ const completionFingerprint = await deps.readMigrationFingerprint?.();
2825
+ if (await fileExists(reconcileMarkerPath)) return void 0;
2826
+ return completionFingerprint;
2827
+ };
2828
+ const pruneBlocked = async () => {
2829
+ const current = state;
2830
+ if (!current) return false;
2831
+ let revived = false;
2832
+ const parked = { ...current.blocked ?? {} };
2833
+ const active = {};
2834
+ for (const [legacyId, canonicalId] of Object.entries(current.mappings)) {
2835
+ if (!blocked.has(legacyId)) active[legacyId] = canonicalId;
2836
+ }
2837
+ for (const [legacyId, canonicalId] of blocked) parked[legacyId] = canonicalId;
2838
+ for (const [legacyId, canonicalId] of Object.entries(parked)) {
2839
+ if (blocked.has(legacyId)) continue;
2840
+ delete parked[legacyId];
2841
+ if (active[legacyId] !== void 0) continue;
2842
+ const legacyPath = deps.resolveEntityFilePath(legacyId);
2843
+ if (legacyPath !== null && await fileExists(legacyPath)) continue;
2844
+ active[legacyId] = canonicalId;
2845
+ revived = true;
2846
+ }
2847
+ const collapsed = collapseMappings(active);
2848
+ for (const [legacyId, canonicalId] of Object.entries(parked)) {
2849
+ const moved = collapsed[canonicalId];
2850
+ if (moved !== void 0 && moved !== legacyId) parked[legacyId] = moved;
2851
+ }
2852
+ const same = (a, b) => Object.keys(a).length === Object.keys(b).length && Object.entries(a).every(([key, value]) => b[key] === value);
2853
+ if (same(collapsed, current.mappings) && same(parked, current.blocked ?? {})) return revived;
2854
+ state = { ...current, mappings: collapsed, blocked: parked };
2855
+ await publishState(deps, state);
2856
+ return revived;
2857
+ };
2858
+ const previousMappings = state.mappings;
2859
+ const collapsedMappings = collapseMappings(previousMappings);
2860
+ const mappingsChanged = Object.keys(collapsedMappings).length !== Object.keys(previousMappings).length || Object.entries(collapsedMappings).some(
2861
+ ([legacyId, canonicalId]) => previousMappings[legacyId] !== canonicalId
2862
+ );
2863
+ state = { ...state, mappings: collapsedMappings };
2864
+ for (let migrationPass = 0; migrationPass < ENTITY_MAPPING_RESCAN_MAX_PASSES; migrationPass += 1) {
2865
+ if (!await lock.refresh()) throw new Error("Lost entity canonical-id migration lock.");
2866
+ const discoveredBefore = rejectMappingCycles(
2867
+ state.mappings,
2868
+ await discoverMappings(deps, state.mappings, blocked)
2869
+ );
2870
+ const revivedBefore = await pruneBlocked();
2871
+ const mergedBefore = collapseMappings(mergeDiscoveredMappings(state.mappings, discoveredBefore));
2872
+ if (state.complete && Object.keys(discoveredBefore).length === 0 && !mappingsChanged && !revivedBefore) {
2873
+ if (reconcileRequested && Object.keys(state.mappings).length > 0) {
2874
+ const rewroteColdMemory2 = await rewriteKnownReferences(
2875
+ deps,
2876
+ state.mappings,
2877
+ () => lock.refresh()
2599
2878
  );
2600
- for (const section of parsed.structuredSections ?? []) {
2601
- const existingSection = mergedStructuredSectionMap.get(section.key);
2602
- if (!existingSection) {
2603
- mergedStructuredSectionMap.set(section.key, {
2604
- key: section.key,
2605
- title: section.title,
2606
- facts: [...new Set(section.facts.map((fact) => fact.trim()).filter((fact) => fact.length > 0))]
2607
- });
2608
- continue;
2609
- }
2610
- const mergedFacts = new Set(existingSection.facts.map((fact) => fact.trim()));
2611
- for (const fact of section.facts) {
2612
- const trimmed = fact.trim();
2613
- if (!trimmed) continue;
2614
- mergedFacts.add(trimmed);
2615
- }
2616
- existingSection.facts = Array.from(mergedFacts);
2617
- if (!existingSection.title.trim() && section.title.trim()) {
2618
- existingSection.title = section.title;
2619
- }
2620
- }
2621
- mergedEntity.structuredSections = Array.from(mergedStructuredSectionMap.values());
2622
- mergedEntity.extraFrontmatterLines.push(...parsed.extraFrontmatterLines ?? []);
2623
- mergedEntity.preSectionLines.push(...parsed.preSectionLines ?? []);
2624
- mergedEntity.extraSections.push(...(parsed.extraSections ?? []).map((section) => ({
2625
- title: section.title,
2626
- lines: [...section.lines]
2627
- })));
2628
- } catch (err) {
2629
- if (err instanceof SecureStoreLockedError) throw err;
2630
- if (!isErrnoCode(err, "ENOENT")) throw err;
2879
+ deps.invalidateKnowledgeIndexCache();
2880
+ deps.invalidateAllMemoriesCache();
2881
+ if (rewroteColdMemory2) deps.invalidateColdMemoriesCache();
2882
+ deps.bumpMemoryStatusVersion();
2631
2883
  }
2884
+ return finish();
2632
2885
  }
2633
- const timelineKeys = /* @__PURE__ */ new Set();
2634
- mergedEntity.timeline = mergedEntity.timeline.filter((entry) => {
2635
- const key = JSON.stringify([
2636
- entry.timestamp,
2637
- entry.source ?? "",
2638
- entry.sessionKey ?? "",
2639
- entry.principal ?? "",
2640
- entry.text
2641
- ]);
2642
- if (timelineKeys.has(key)) return false;
2643
- timelineKeys.add(key);
2644
- return true;
2645
- });
2646
- const relKeys = /* @__PURE__ */ new Set();
2647
- mergedEntity.relationships = mergedEntity.relationships.filter((r) => {
2648
- const key = `${r.target}::${r.label}`;
2649
- if (relKeys.has(key)) return false;
2650
- relKeys.add(key);
2651
- return true;
2652
- });
2653
- const actKeys = /* @__PURE__ */ new Set();
2654
- mergedEntity.activity = mergedEntity.activity.filter((a) => {
2655
- const key = `${a.date}::${a.note}`;
2656
- if (actKeys.has(key)) return false;
2657
- actKeys.add(key);
2658
- return true;
2659
- }).sort((a, b) => b.date.localeCompare(a.date));
2660
- mergedEntity.aliases = [...new Set(mergedEntity.aliases)];
2661
- mergedEntity.structuredSections = sortStructuredSectionsBySchema(
2662
- mergedEntity.type,
2663
- mergedEntity.structuredSections ?? [],
2664
- this.deps.entitySchemas
2665
- );
2666
- mergedEntity.facts = compileEntityFacts(mergedEntity.timeline, mergedEntity.structuredSections);
2667
- const extraSectionKeys = /* @__PURE__ */ new Set();
2668
- mergedEntity.extraSections = (mergedEntity.extraSections ?? []).filter((section) => {
2669
- const key = `${section.title}::${section.lines.join("\n")}`;
2670
- if (extraSectionKeys.has(key)) return false;
2671
- extraSectionKeys.add(key);
2672
- return true;
2673
- });
2674
- if (!mergedEntity.name) {
2675
- const dashIdx = canonical.indexOf("-");
2676
- mergedEntity.name = dashIdx !== -1 ? canonical.slice(dashIdx + 1) : canonical;
2886
+ if (state.complete || Object.keys(discoveredBefore).length > 0 || Object.keys(mergedBefore).length !== Object.keys(state.mappings).length) {
2887
+ state = { ...state, complete: false, mappings: mergedBefore };
2888
+ await publishState(deps, state);
2677
2889
  }
2678
- mergedEntity.created = mergedEntity.created || mergedEntity.updated || (/* @__PURE__ */ new Date()).toISOString();
2679
- mergedEntity.updated = mergedEntity.updated || (/* @__PURE__ */ new Date()).toISOString();
2680
- const canonicalPath = path4.join(this.deps.entitiesDir, `${canonical}.md`);
2681
- await this.deps.writeStorageSecureFile(canonicalPath, serializeEntityFile(mergedEntity, this.deps.entitySchemas));
2682
- for (const file of files) {
2683
- const filePath = path4.join(this.deps.entitiesDir, file);
2684
- if (filePath !== canonicalPath) {
2685
- try {
2686
- await unlink2(filePath);
2687
- merged++;
2688
- log.debug(`merged entity ${file} \u2192 ${canonical}.md`);
2689
- } catch {
2690
- }
2890
+ if (Object.keys(state.mappings).length === 0) {
2891
+ if (!await lock.refresh()) throw new Error("Lost entity canonical-id migration lock.");
2892
+ const discoveredAfter2 = rejectMappingCycles(
2893
+ state.mappings,
2894
+ await discoverMappings(deps, state.mappings, blocked)
2895
+ );
2896
+ const revivedInRescan = await pruneBlocked();
2897
+ if (Object.keys(discoveredAfter2).length === 0 && !revivedInRescan) {
2898
+ await publishState(deps, { ...state, complete: true });
2899
+ return finish();
2900
+ }
2901
+ state = {
2902
+ ...state,
2903
+ complete: false,
2904
+ mappings: collapseMappings(mergeDiscoveredMappings(state.mappings, discoveredAfter2))
2905
+ };
2906
+ await publishState(deps, state);
2907
+ continue;
2908
+ }
2909
+ let pendingMappings = Object.entries(state.mappings);
2910
+ while (pendingMappings.length > 0) {
2911
+ const deferredMappings = [];
2912
+ let progressed = false;
2913
+ for (const [legacyId, canonicalId] of pendingMappings) {
2914
+ const result = await migrateEntityFilePair(
2915
+ deps,
2916
+ legacyId,
2917
+ canonicalId,
2918
+ () => lock.refresh()
2919
+ );
2920
+ if (result.blocked) blocked.set(legacyId, canonicalId);
2921
+ else if (result.deferred) deferredMappings.push([legacyId, canonicalId]);
2922
+ progressed ||= result.progressed;
2923
+ }
2924
+ if (deferredMappings.length === 0) break;
2925
+ if (!progressed) {
2926
+ const [legacyId, canonicalId] = deferredMappings[0];
2927
+ throw new Error(
2928
+ `Cannot migrate legacy entity id ${legacyId}: both files are missing and no intermediate mapping can advance migration.`
2929
+ );
2691
2930
  }
2931
+ pendingMappings = deferredMappings;
2932
+ }
2933
+ await pruneBlocked();
2934
+ const rewroteColdMemory = await rewriteKnownReferences(
2935
+ deps,
2936
+ state.mappings,
2937
+ () => lock.refresh()
2938
+ );
2939
+ deps.invalidateKnowledgeIndexCache();
2940
+ deps.invalidateAllMemoriesCache();
2941
+ if (rewroteColdMemory) deps.invalidateColdMemoriesCache();
2942
+ deps.bumpMemoryStatusVersion();
2943
+ if (!await lock.refresh()) throw new Error("Lost entity canonical-id migration lock.");
2944
+ const discoveredAfter = rejectMappingCycles(
2945
+ state.mappings,
2946
+ await discoverMappings(deps, state.mappings, blocked)
2947
+ );
2948
+ const revivedAfterRewrite = await pruneBlocked();
2949
+ if (Object.keys(discoveredAfter).length === 0 && !revivedAfterRewrite) {
2950
+ await publishState(deps, { ...state, complete: true });
2951
+ return finish();
2692
2952
  }
2953
+ state = {
2954
+ ...state,
2955
+ complete: false,
2956
+ mappings: collapseMappings(mergeDiscoveredMappings(state.mappings, discoveredAfter))
2957
+ };
2958
+ await publishState(deps, state);
2693
2959
  }
2694
- } catch (err) {
2695
- if (err instanceof SecureStoreLockedError || !isErrnoCode(err, "ENOENT")) throw err;
2960
+ throw new Error("Entity canonical-id migration mappings changed while migration was running; retry migration.");
2696
2961
  }
2697
- return merged;
2698
- }
2699
- };
2962
+ );
2963
+ }
2700
2964
 
2701
- // src/storage/identity-continuity-store.ts
2702
- import { readdir as readdir4 } from "fs/promises";
2703
- import path5 from "path";
2704
- var IdentityContinuityStore = class {
2965
+ // src/storage/entity-ref-repair.ts
2966
+ import { unlink as unlink3 } from "fs/promises";
2967
+ var EntityRefRepair = class {
2705
2968
  constructor(deps) {
2706
2969
  this.deps = deps;
2707
2970
  }
2708
2971
  deps;
2709
- async writeIdentityAnchor(content) {
2710
- await this.deps.ensureDirectories();
2711
- await this.deps.writeStorageSecureFile(this.deps.identityAnchorPath, content);
2712
- }
2713
- async readIdentityAnchor() {
2972
+ /**
2973
+ * Shared lookup+apply core for the write-time resurrection gate (#1579 /
2974
+ * #2213): fail-open, add-only; writeMemory's gate + chunk-repair re-gate
2975
+ * consult this one implementation (rule 43).
2976
+ */
2977
+ async gate(fm, hashSource, structuredAttributes) {
2978
+ if (!this.deps.tombstonesEnabled()) return null;
2714
2979
  try {
2715
- return await this.deps.readStorageSecureFile(this.deps.identityAnchorPath);
2980
+ const store = await this.deps.getTombstoneStore();
2981
+ const supersessionKeys = fm.entityRef && structuredAttributes ? supersessionKeysForFact({ entityRef: fm.entityRef, structuredAttributes }) : [];
2982
+ const match = applyTombstoneResurrectionGate(store, fm, {
2983
+ normalizedText: ContentHashIndex.normalizeContent(hashSource),
2984
+ supersessionKeys,
2985
+ namespace: this.deps.tombstonesNamespace()
2986
+ });
2987
+ if (match) {
2988
+ log.info(
2989
+ `tombstone: blocked resurrection of fact ${fm.id} (tier=${match.matchedTier}, tombstone=${match.tombstoneId}, reason=${match.reason})`
2990
+ );
2991
+ }
2992
+ return match;
2716
2993
  } catch (err) {
2717
- if (!isErrnoCode(err, "ENOENT")) throw err;
2994
+ log.warn(`tombstone lookup failed for fact ${fm.id} (fail-open): ${err}`);
2718
2995
  return null;
2719
2996
  }
2720
2997
  }
2721
- async appendContinuityIncident(input) {
2722
- await this.deps.ensureDirectories();
2723
- const now = /* @__PURE__ */ new Date();
2724
- const nowIso = now.toISOString();
2725
- const date = nowIso.slice(0, 10);
2726
- const id = this.deps.generateId("incident");
2727
- const incident = createContinuityIncidentRecord(id, input, nowIso);
2728
- const filePath = path5.join(this.deps.identityIncidentsDir, `${date}-${id}.md`);
2729
- await this.deps.writeStorageSecureFile(filePath, serializeContinuityIncident(incident));
2730
- return { ...incident, filePath };
2731
- }
2732
- async readContinuityIncidents(limit = 200, state = "all") {
2733
- const normalizedLimit = Number.isFinite(limit) ? Math.floor(limit) : 0;
2734
- const cappedLimit = Math.max(0, normalizedLimit);
2735
- if (cappedLimit === 0) return [];
2998
+ /** Post-persist repair delegate — see the references module for semantics. */
2999
+ async repair(filePath, fm, rawRef, refIds, body, opts = {}) {
3000
+ const refBeforeRepair = fm.entityRef;
2736
3001
  try {
2737
- const candidates = await this.readContinuityIncidentFileNames();
2738
- const incidents = [];
2739
- for (const file of candidates) {
2740
- if (incidents.length >= cappedLimit) break;
2741
- const filePath = path5.join(this.deps.identityIncidentsDir, file);
2742
- try {
2743
- const raw = await this.deps.readStorageSecureFile(filePath);
2744
- const parsed = parseContinuityIncident(raw);
2745
- if (!parsed) continue;
2746
- if (state !== "all" && parsed.state !== state) continue;
2747
- incidents.push({ ...parsed, filePath });
2748
- } catch (err) {
2749
- if (err instanceof SecureStoreLockedError) throw err;
3002
+ await repairEntityRefAfterJournalMove({
3003
+ stateDir: this.deps.stateDir,
3004
+ currentIds: () => this.deps.currentHistoricalIds(),
3005
+ idsAtResolve: refIds,
3006
+ rawRef,
3007
+ frontmatter: fm,
3008
+ rewrite: async () => {
3009
+ if (opts.regateFact && fm.category === "fact") {
3010
+ await this.gate(fm, body, void 0);
3011
+ }
3012
+ await this.deps.writeTombstoneBlockedMemory(
3013
+ filePath,
3014
+ `${this.deps.serializeFrontmatter(fm)}
3015
+
3016
+ ${body}
3017
+ `,
3018
+ fm,
3019
+ body
3020
+ );
3021
+ this.deps.invalidateAllMemoriesCache();
2750
3022
  }
2751
- }
2752
- return incidents;
2753
- } catch (err) {
2754
- if (err instanceof SecureStoreLockedError) throw err;
2755
- if (!isErrnoCode(err, "ENOENT")) throw err;
2756
- return [];
2757
- }
2758
- }
2759
- async closeContinuityIncident(id, closure) {
2760
- const directFilePath = await this.findContinuityIncidentFilePathById(id);
2761
- const target = directFilePath ? await this.readContinuityIncidentFile(directFilePath) : null;
2762
- if (!target || !directFilePath) return null;
2763
- if (target.state === "closed") return target;
2764
- const closed = closeContinuityIncidentRecord(target, closure, (/* @__PURE__ */ new Date()).toISOString());
2765
- await this.deps.writeStorageSecureFile(directFilePath, serializeContinuityIncident(closed));
2766
- return { ...closed, filePath: directFilePath };
2767
- }
2768
- async writeIdentityAudit(period, key, content) {
2769
- await this.deps.ensureDirectories();
2770
- const safeKey = this.sanitizeIdentityAuditKey(key);
2771
- const dir = period === "weekly" ? this.deps.identityAuditsWeeklyDir : this.deps.identityAuditsMonthlyDir;
2772
- const filePath = path5.join(dir, `${safeKey}.md`);
2773
- await this.deps.writeStorageSecureFile(filePath, content);
2774
- return filePath;
2775
- }
2776
- async readIdentityAudit(period, key) {
2777
- try {
2778
- const safeKey = this.sanitizeIdentityAuditKey(key);
2779
- const dir = period === "weekly" ? this.deps.identityAuditsWeeklyDir : this.deps.identityAuditsMonthlyDir;
2780
- return await this.deps.readStorageSecureFile(path5.join(dir, `${safeKey}.md`));
3023
+ });
2781
3024
  } catch (err) {
2782
- if (err instanceof Error && err.message === "Invalid identity audit key") return null;
2783
- if (!isErrnoCode(err, "ENOENT")) throw err;
2784
- return null;
3025
+ if (opts.onFailRestore) {
3026
+ await this.restore(opts.onFailRestore);
3027
+ } else if (opts.onFailRemove) {
3028
+ await unlink3(opts.onFailRemove).catch(() => void 0);
3029
+ this.deps.invalidateAllMemoriesCache();
3030
+ }
3031
+ throw err;
2785
3032
  }
3033
+ await this.syncProjection(fm.id, refBeforeRepair, fm.entityRef);
2786
3034
  }
2787
- async writeIdentityImprovementLoops(content) {
2788
- await this.deps.ensureDirectories();
2789
- await this.deps.writeStorageSecureFile(this.deps.identityImprovementLoopsPath, content);
2790
- }
2791
- async readIdentityImprovementLoops() {
3035
+ /**
3036
+ * Restore a record's pre-mutation bytes through the blocked-capture
3037
+ * surface when a post-persist repair fails (§14).
3038
+ */
3039
+ async restore(before) {
2792
3040
  try {
2793
- return await this.deps.readStorageSecureFile(this.deps.identityImprovementLoopsPath);
2794
- } catch (err) {
2795
- if (!isErrnoCode(err, "ENOENT")) throw err;
2796
- return null;
3041
+ await this.deps.writeTombstoneBlockedMemory(
3042
+ before.path,
3043
+ `${this.deps.serializeFrontmatter(before.frontmatter)}
3044
+
3045
+ ${before.content}
3046
+ `,
3047
+ before.frontmatter,
3048
+ before.content
3049
+ );
3050
+ } catch (restoreErr) {
3051
+ log.warn(`failed to restore ${before.frontmatter.id} after repair failure: ${restoreErr}`);
2797
3052
  }
3053
+ this.deps.invalidateAllMemoriesCache();
2798
3054
  }
2799
- async readIdentityImprovementLoopRegister() {
2800
- const raw = await this.readIdentityImprovementLoops();
2801
- if (!raw) return [];
2802
- return parseContinuityImprovementLoops(raw);
2803
- }
2804
- async upsertIdentityImprovementLoop(input) {
2805
- const nowIso = (/* @__PURE__ */ new Date()).toISOString();
2806
- const raw = await this.readIdentityImprovementLoops();
2807
- const { markdown, loop } = upsertContinuityLoopInMarkdown(raw, input, nowIso);
2808
- await this.writeIdentityImprovementLoops(markdown);
2809
- return loop;
2810
- }
2811
- async reviewIdentityImprovementLoop(id, input) {
2812
- const raw = await this.readIdentityImprovementLoops();
2813
- const { markdown, loop } = reviewContinuityLoopInMarkdown(raw, id, input, (/* @__PURE__ */ new Date()).toISOString());
2814
- if (!loop) return null;
2815
- await this.writeIdentityImprovementLoops(markdown);
2816
- return loop;
2817
- }
2818
- async readContinuityIncidentFileNames() {
2819
- const files = await readdir4(this.deps.identityIncidentsDir);
2820
- return files.filter((file) => file.endsWith(".md")).sort().reverse();
2821
- }
2822
- async readContinuityIncidentFile(filePath) {
3055
+ /**
3056
+ * Projection rows written under the pre-repair claimant cannot be restored
3057
+ * once the mapping is parked (Codex P1). Fail-open: projection is rebuildable.
3058
+ */
3059
+ async syncProjection(memoryId, refBefore, refAfter) {
3060
+ if (typeof refBefore !== "string" || typeof refAfter !== "string" || refBefore === refAfter) return;
2823
3061
  try {
2824
- const raw = await this.deps.readStorageSecureFile(filePath);
2825
- const parsed = parseContinuityIncident(raw);
2826
- return parsed ? { ...parsed, filePath } : null;
3062
+ await rewriteProjectedMemoryEntityReference(this.deps.baseDir, memoryId, refBefore, refAfter);
2827
3063
  } catch (err) {
2828
- if (err instanceof SecureStoreLockedError) throw err;
2829
- return null;
2830
- }
2831
- }
2832
- async findContinuityIncidentFilePathById(id) {
2833
- const fileNames = await this.readContinuityIncidentFileNames();
2834
- const directMatch = fileNames.find((name) => name.endsWith(`-${id}.md`));
2835
- if (directMatch) {
2836
- const directPath = path5.join(this.deps.identityIncidentsDir, directMatch);
2837
- const parsed = await this.readContinuityIncidentFile(directPath);
2838
- if (parsed?.id === id) return directPath;
2839
- }
2840
- for (const fileName of fileNames) {
2841
- const filePath = path5.join(this.deps.identityIncidentsDir, fileName);
2842
- const parsed = await this.readContinuityIncidentFile(filePath);
2843
- if (parsed?.id === id) return filePath;
2844
- }
2845
- return null;
2846
- }
2847
- sanitizeIdentityAuditKey(key) {
2848
- const trimmed = key.trim();
2849
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed) || trimmed.includes("..")) {
2850
- throw new Error("Invalid identity audit key");
3064
+ log.warn(`projection entityRef sync failed for ${memoryId}: ${err}`);
2851
3065
  }
2852
- return trimmed;
2853
3066
  }
2854
3067
  };
2855
3068
 
2856
3069
  // src/storage/entity-canonical-id-migration-adapter.ts
2857
3070
  async function runLegacyEntityCanonicalIdMigration(host, parseEntityFile2, serializeEntityFile2, serializeMemoryWithEntityRef2) {
2858
- const readMigrationFingerprint = () => getFingerprint(host.baseDir, host.entitiesDir, () => host.getCorpusScanVersion());
3071
+ const readMigrationFingerprint = () => getFingerprint(host.baseDir, host.entitiesDir, () => String(host.getEntityMutationVersion()));
2859
3072
  const completionFingerprint = await migrateLegacyEntityCanonicalIds({
2860
3073
  stateDir: host.stateDir,
2861
3074
  entitiesDir: host.entitiesDir,
@@ -2881,7 +3094,7 @@ async function runLegacyEntityCanonicalIdMigration(host, parseEntityFile2, seria
2881
3094
  bumpMemoryStatusVersion: host.bumpMemoryStatusVersion.bind(host),
2882
3095
  readMigrationFingerprint
2883
3096
  });
2884
- return completionFingerprint ?? readMigrationFingerprint();
3097
+ return completionFingerprint;
2885
3098
  }
2886
3099
 
2887
3100
  // src/storage/entity-canonical-id-migration-runner.ts
@@ -4630,22 +4843,103 @@ var MeetingRecordStore = class {
4630
4843
  `meetings ${kind} '${label}' is a symbolic link \u2014 refusing to follow it even when it resolves inside the memory dir (AGENTS.md pattern #3)`
4631
4844
  );
4632
4845
  }
4633
- }
4634
- async assertNoEscape(rootReal, candidate) {
4635
- let candidateReal;
4636
- try {
4637
- candidateReal = await this.io.realpath(candidate);
4638
- } catch {
4639
- return;
4846
+ }
4847
+ async assertNoEscape(rootReal, candidate) {
4848
+ let candidateReal;
4849
+ try {
4850
+ candidateReal = await this.io.realpath(candidate);
4851
+ } catch {
4852
+ return;
4853
+ }
4854
+ if (!pathIsInside(rootReal, candidateReal)) {
4855
+ throw new Error(
4856
+ "meeting record path resolves outside the memory dir \u2014 refusing to follow a symlink/traversal (AGENTS.md pattern #3)"
4857
+ );
4858
+ }
4859
+ }
4860
+ };
4861
+ var SORT_STR = (a, b) => a < b ? -1 : a > b ? 1 : 0;
4862
+
4863
+ // src/storage/citation-hash-source.ts
4864
+ function trimTrailingSpacesAndTabs(value) {
4865
+ let end = value.length;
4866
+ while (end > 0 && (value[end - 1] === " " || value[end - 1] === " ")) {
4867
+ end -= 1;
4868
+ }
4869
+ return end === value.length ? value : value.slice(0, end);
4870
+ }
4871
+ function trimLeadingSpacesAndTabs(value) {
4872
+ let start = 0;
4873
+ while (start < value.length && (value[start] === " " || value[start] === " ")) {
4874
+ start += 1;
4875
+ }
4876
+ return start === 0 ? value : value.slice(start);
4877
+ }
4878
+ function stripDefaultCitationMarkersWithoutRegex(value) {
4879
+ return stripCitationMarkersForHashRemoval(value, DEFAULT_CITATION_FORMAT);
4880
+ }
4881
+ function citationTemplateLiteralParts(template) {
4882
+ const parts = [];
4883
+ let cursor = 0;
4884
+ while (cursor < template.length) {
4885
+ const open2 = template.indexOf("{", cursor);
4886
+ if (open2 === -1) {
4887
+ parts.push(template.slice(cursor));
4888
+ break;
4889
+ }
4890
+ parts.push(template.slice(cursor, open2));
4891
+ const close = template.indexOf("}", open2 + 1);
4892
+ if (close === -1) {
4893
+ cursor = open2 + 1;
4894
+ } else {
4895
+ cursor = close + 1;
4896
+ }
4897
+ }
4898
+ return parts.filter((part) => part.length > 0);
4899
+ }
4900
+ function stripCitationMarkersForHashRemoval(value, template) {
4901
+ const parts = citationTemplateLiteralParts(template);
4902
+ if (parts.length === 0) return value;
4903
+ const first = parts[0];
4904
+ const lowerValue = value.toLowerCase();
4905
+ const lowerFirst = first.toLowerCase();
4906
+ const lowerParts = parts.map((part) => part.toLowerCase());
4907
+ if (!lowerValue.includes(lowerFirst)) return value;
4908
+ let result = "";
4909
+ let cursor = 0;
4910
+ let removed = false;
4911
+ while (cursor < value.length) {
4912
+ const markerStart = lowerValue.indexOf(lowerFirst, cursor);
4913
+ if (markerStart === -1) {
4914
+ result += value.slice(cursor);
4915
+ break;
4916
+ }
4917
+ const boundedEnd = first.startsWith("[") ? value.indexOf("]", markerStart + first.length) : -1;
4918
+ if (first.startsWith("[") && boundedEnd === -1) {
4919
+ result += value.slice(cursor);
4920
+ break;
4921
+ }
4922
+ const searchLimit = boundedEnd === -1 ? value.length : boundedEnd + 1;
4923
+ let markerEnd = markerStart + first.length;
4924
+ let matched = true;
4925
+ for (let i = 1; i < lowerParts.length; i += 1) {
4926
+ const partIndex = lowerValue.indexOf(lowerParts[i], markerEnd);
4927
+ if (partIndex === -1 || partIndex + parts[i].length > searchLimit) {
4928
+ matched = false;
4929
+ break;
4930
+ }
4931
+ markerEnd = partIndex + parts[i].length;
4640
4932
  }
4641
- if (!pathIsInside(rootReal, candidateReal)) {
4642
- throw new Error(
4643
- "meeting record path resolves outside the memory dir \u2014 refusing to follow a symlink/traversal (AGENTS.md pattern #3)"
4644
- );
4933
+ if (!matched) {
4934
+ result += value.slice(cursor);
4935
+ break;
4645
4936
  }
4937
+ result += trimTrailingSpacesAndTabs(value.slice(cursor, markerStart));
4938
+ cursor = markerEnd;
4939
+ removed = true;
4646
4940
  }
4647
- };
4648
- var SORT_STR = (a, b) => a < b ? -1 : a > b ? 1 : 0;
4941
+ return removed ? trimLeadingSpacesAndTabs(result) : value;
4942
+ }
4649
4943
 
4650
4944
  // src/storage/entity-timeline.ts
4651
4945
  import { createHash as createHash4 } from "crypto";
@@ -5097,85 +5391,6 @@ function normalizeMemoryWriteTimestamp(field, value) {
5097
5391
  }
5098
5392
  return new Date(parsed).toISOString();
5099
5393
  }
5100
- function trimTrailingSpacesAndTabs(value) {
5101
- let end = value.length;
5102
- while (end > 0 && (value[end - 1] === " " || value[end - 1] === " ")) {
5103
- end -= 1;
5104
- }
5105
- return end === value.length ? value : value.slice(0, end);
5106
- }
5107
- function trimLeadingSpacesAndTabs(value) {
5108
- let start = 0;
5109
- while (start < value.length && (value[start] === " " || value[start] === " ")) {
5110
- start += 1;
5111
- }
5112
- return start === 0 ? value : value.slice(start);
5113
- }
5114
- function stripDefaultCitationMarkersWithoutRegex(value) {
5115
- return stripCitationMarkersForHashRemoval(value, DEFAULT_CITATION_FORMAT);
5116
- }
5117
- function citationTemplateLiteralParts(template) {
5118
- const parts = [];
5119
- let cursor = 0;
5120
- while (cursor < template.length) {
5121
- const open2 = template.indexOf("{", cursor);
5122
- if (open2 === -1) {
5123
- parts.push(template.slice(cursor));
5124
- break;
5125
- }
5126
- parts.push(template.slice(cursor, open2));
5127
- const close = template.indexOf("}", open2 + 1);
5128
- if (close === -1) {
5129
- cursor = open2 + 1;
5130
- } else {
5131
- cursor = close + 1;
5132
- }
5133
- }
5134
- return parts.filter((part) => part.length > 0);
5135
- }
5136
- function stripCitationMarkersForHashRemoval(value, template) {
5137
- const parts = citationTemplateLiteralParts(template);
5138
- if (parts.length === 0) return value;
5139
- const first = parts[0];
5140
- const lowerValue = value.toLowerCase();
5141
- const lowerFirst = first.toLowerCase();
5142
- const lowerParts = parts.map((part) => part.toLowerCase());
5143
- if (!lowerValue.includes(lowerFirst)) return value;
5144
- let result = "";
5145
- let cursor = 0;
5146
- let removed = false;
5147
- while (cursor < value.length) {
5148
- const markerStart = lowerValue.indexOf(lowerFirst, cursor);
5149
- if (markerStart === -1) {
5150
- result += value.slice(cursor);
5151
- break;
5152
- }
5153
- const boundedEnd = first.startsWith("[") ? value.indexOf("]", markerStart + first.length) : -1;
5154
- if (first.startsWith("[") && boundedEnd === -1) {
5155
- result += value.slice(cursor);
5156
- break;
5157
- }
5158
- const searchLimit = boundedEnd === -1 ? value.length : boundedEnd + 1;
5159
- let markerEnd = markerStart + first.length;
5160
- let matched = true;
5161
- for (let i = 1; i < lowerParts.length; i += 1) {
5162
- const partIndex = lowerValue.indexOf(lowerParts[i], markerEnd);
5163
- if (partIndex === -1 || partIndex + parts[i].length > searchLimit) {
5164
- matched = false;
5165
- break;
5166
- }
5167
- markerEnd = partIndex + parts[i].length;
5168
- }
5169
- if (!matched) {
5170
- result += value.slice(cursor);
5171
- break;
5172
- }
5173
- result += trimTrailingSpacesAndTabs(value.slice(cursor, markerStart));
5174
- cursor = markerEnd;
5175
- removed = true;
5176
- }
5177
- return removed ? trimLeadingSpacesAndTabs(result) : value;
5178
- }
5179
5394
  function serializeFrontmatter(fm) {
5180
5395
  assertMemoryFrontmatterId(fm);
5181
5396
  const lines = [
@@ -6142,7 +6357,6 @@ var StorageManager3 = class _StorageManager extends TombstoneBlockedCaptureIndex
6142
6357
  this.entitySchemas = entitySchemas;
6143
6358
  this.hotMemoriesCacheEnabled = hotMemoriesCacheEnabledOverride ?? _StorageManager.hotMemoriesCacheDefaultByDir.get(baseDir) ?? _StorageManager.hotMemoriesCacheDefault;
6144
6359
  this.loadAliasesSync();
6145
- this.loadHistoricalEntityCanonicalIdsSync();
6146
6360
  }
6147
6361
  baseDir;
6148
6362
  entitySchemas;
@@ -6162,6 +6376,8 @@ var StorageManager3 = class _StorageManager extends TombstoneBlockedCaptureIndex
6162
6376
  // every plain create). In-process fallback when the on-disk sentinel is
6163
6377
  // unavailable; canonical source is state/.memory-corpus-version.log.
6164
6378
  static memoryCorpusVersionByDir = /* @__PURE__ */ new Map();
6379
+ // Entity-mutation sentinel: see getEntityMutationVersion.
6380
+ static entityMutationVersionByDir = /* @__PURE__ */ new Map();
6165
6381
  static secureStoreEntityCacheKeyIds = /* @__PURE__ */ new WeakMap();
6166
6382
  static nextSecureStoreEntityCacheKeyId = 1;
6167
6383
  // In-process fallback for the cold-write sentinel (used when the disk file
@@ -6613,8 +6829,7 @@ var StorageManager3 = class _StorageManager extends TombstoneBlockedCaptureIndex
6613
6829
  return path9.join(workspaceDir, `IDENTITY.${safeNamespace}.md`);
6614
6830
  }
6615
6831
  versionFilePath(kind) {
6616
- const fileName = kind === "memory-status" ? ".memory-status-version.log" : kind === "artifact-write" ? ".artifact-write-version.log" : kind === "cold-write" ? ".cold-write-version.log" : ".memory-corpus-version.log";
6617
- return path9.join(this.stateDir, fileName);
6832
+ return path9.join(this.stateDir, `.${kind}-version.log`);
6618
6833
  }
6619
6834
  bumpSharedVersion(kind, fallbackMap) {
6620
6835
  const filePath = this.versionFilePath(kind);
@@ -6647,13 +6862,21 @@ var StorageManager3 = class _StorageManager extends TombstoneBlockedCaptureIndex
6647
6862
  return this.readSharedVersion("memory-status", _StorageManager.memoryStatusVersionByDir);
6648
6863
  }
6649
6864
  /**
6650
- * Corpus version sentinel for the hot-memories result cache (issue #1902).
6651
- * Distinct from memory-status: it bumps on EVERY memory-file mutation
6652
- * (create/update/delete/bulk) so the version-keyed hot cache stays coherent
6653
- * across processes, WITHOUT bumping memory-status — which would needlessly
6654
- * invalidate the version-keyed entity cache on every plain fact create (the
6655
- * ki-spike regression the sibling perf work removes). memory-status keeps its
6656
- * lifecycle/status/entity semantics unchanged.
6865
+ * Entity-mutation sentinel: advanced only by entity-store content writes.
6866
+ * The migration fingerprint keys on this instead of memory-status, so
6867
+ * supersede/archive/delete no longer re-trigger discovery scans (#2213).
6868
+ */
6869
+ getEntityMutationVersion() {
6870
+ return this.readSharedVersion("entity-mutation", _StorageManager.entityMutationVersionByDir);
6871
+ }
6872
+ bumpEntityMutationVersion() {
6873
+ this.bumpSharedVersion("entity-mutation", _StorageManager.entityMutationVersionByDir);
6874
+ }
6875
+ /**
6876
+ * Corpus version sentinel for the hot-memories result cache (#1902).
6877
+ * Distinct from memory-status: bumps on EVERY memory-file mutation so the
6878
+ * version-keyed hot cache stays coherent across processes, WITHOUT
6879
+ * invalidating the version-keyed entity cache on every plain fact create.
6657
6880
  */
6658
6881
  getMemoryCorpusVersion() {
6659
6882
  return this.readSharedVersion("memory-corpus", _StorageManager.memoryCorpusVersionByDir);
@@ -6663,12 +6886,10 @@ var StorageManager3 = class _StorageManager extends TombstoneBlockedCaptureIndex
6663
6886
  deleteInFlightReadsForDir(this.baseDir);
6664
6887
  }
6665
6888
  /**
6666
- * Corpus bump that reports whether THIS process's append was the only one
6667
- * since the pre-bump size (issue #1902, Codex P1). appendFileSync is atomic
6668
- * per write, but a peer process can append concurrently; the `exclusive`
6669
- * flag lets patchHotMemoriesCache refuse to re-key its locally patched
6670
- * (peer-incomplete) corpus at a version that already reflects a peer's
6671
- * still-unread write.
6889
+ * Corpus bump reporting whether THIS process's append was the only one
6890
+ * since the pre-bump size (#1902, Codex P1): the `exclusive` flag lets
6891
+ * patchHotMemoriesCache refuse to re-key its locally patched corpus at a
6892
+ * version already reflecting a peer's still-unread concurrent append.
6672
6893
  */
6673
6894
  bumpMemoryCorpusVersionExclusive() {
6674
6895
  const filePath = this.versionFilePath("memory-corpus");
@@ -6749,7 +6970,7 @@ var StorageManager3 = class _StorageManager extends TombstoneBlockedCaptureIndex
6749
6970
  writeFile: (p, c) => this.writeStorageSecureFile(p, c),
6750
6971
  readFile: (p) => readMaybeEncryptedFile(p, this._secureStoreKey, this.baseDir),
6751
6972
  readDir: (d) => readdir5(d),
6752
- deleteFile: (p) => unlink3(p),
6973
+ deleteFile: (p) => unlink4(p),
6753
6974
  realpath: (p) => realpath3(p),
6754
6975
  lstat: (p) => lstat3(p).then((st) => ({ isSymbolicLink: st.isSymbolicLink() }))
6755
6976
  });
@@ -6767,7 +6988,7 @@ var StorageManager3 = class _StorageManager extends TombstoneBlockedCaptureIndex
6767
6988
  writeFile: (p, c) => this.writeStorageSecureFile(p, c),
6768
6989
  readFile: (p) => readMaybeEncryptedFile(p, this._secureStoreKey, this.baseDir),
6769
6990
  readDir: (d) => readdir5(d),
6770
- deleteFile: (p) => unlink3(p),
6991
+ deleteFile: (p) => unlink4(p),
6771
6992
  realpath: (p) => realpath3(p),
6772
6993
  lstat: (p) => lstat3(p).then((st) => ({ isSymbolicLink: st.isSymbolicLink() }))
6773
6994
  });
@@ -7204,7 +7425,14 @@ var StorageManager3 = class _StorageManager extends TombstoneBlockedCaptureIndex
7204
7425
  try {
7205
7426
  const store = await this.getTombstoneStore();
7206
7427
  const strippedRawContent = stripCitationForTemplate(input.rawContent, this.citationTemplate);
7207
- return await store.appendTombstone({ ...input, rawContent: strippedRawContent });
7428
+ return await appendCanonicalizedTombstone(
7429
+ this.stateDir,
7430
+ input,
7431
+ () => this.currentHistoricalIds(),
7432
+ (identity) => store.appendTombstone({ ...input, rawContent: strippedRawContent, ...identity }),
7433
+ (tombstoneId) => store.revoke(tombstoneId, input.createdBy),
7434
+ input.sourceMemoryId
7435
+ );
7208
7436
  } catch (err) {
7209
7437
  log.warn(`tombstone append failed (memory=${input.sourceMemoryId}): ${err}`);
7210
7438
  return null;
@@ -7500,16 +7728,37 @@ var StorageManager3 = class _StorageManager extends TombstoneBlockedCaptureIndex
7500
7728
  * store loaded last rewrite every other store's canonical entity ids.
7501
7729
  */
7502
7730
  userAliases = {};
7503
- historicalEntityCanonicalIds = {};
7731
+ historicalEntityCanonicalIds = new HistoricalEntityCanonicalIdCache();
7732
+ currentHistoricalIds() {
7733
+ return this.historicalEntityCanonicalIds.get(this.stateDir);
7734
+ }
7735
+ /** Post-persist repair subsystem (issue #2213) — storage/entity-ref-repair.ts. */
7736
+ _entityRefRepair;
7737
+ get entityRefRepair() {
7738
+ if (!this._entityRefRepair) {
7739
+ this._entityRefRepair = new EntityRefRepair({
7740
+ stateDir: this.stateDir,
7741
+ baseDir: this.baseDir,
7742
+ currentHistoricalIds: () => this.currentHistoricalIds(),
7743
+ serializeFrontmatter,
7744
+ writeTombstoneBlockedMemory: (pathname, fileContent, frontmatter, content) => this.writeTombstoneBlockedMemory(pathname, fileContent, frontmatter, content),
7745
+ invalidateAllMemoriesCache: () => this.invalidateAllMemoriesCache(),
7746
+ getTombstoneStore: () => this.getTombstoneStore(),
7747
+ tombstonesEnabled: () => this.tombstonesConfig.enabled,
7748
+ tombstonesNamespace: () => this.tombstonesConfig.namespace
7749
+ });
7750
+ }
7751
+ return this._entityRefRepair;
7752
+ }
7504
7753
  entityCanonicalIdMigration = new EntityCanonicalIdMigrationRunner(
7505
7754
  () => !(this._secureStoreRequired && !this.isSecureStoreUnlocked()),
7506
7755
  () => this.runLegacyEntityCanonicalIdMigration(),
7507
- () => getFingerprint(this.baseDir, this.entitiesDir, () => this.getCorpusScanVersion())
7756
+ () => getFingerprint(this.baseDir, this.entitiesDir, () => String(this.getEntityMutationVersion()))
7508
7757
  );
7509
7758
  normalizeEntityName(raw, type) {
7510
7759
  return resolveHistoricalEntityCanonicalId(
7511
7760
  this.entityStore.normalizeEntityName(raw, type),
7512
- this.historicalEntityCanonicalIds
7761
+ this.currentHistoricalIds()
7513
7762
  );
7514
7763
  }
7515
7764
  /**
@@ -7533,7 +7782,7 @@ var StorageManager3 = class _StorageManager extends TombstoneBlockedCaptureIndex
7533
7782
  const aliasPath = path9.join(this.baseDir, "config", "aliases.json");
7534
7783
  this.userAliases = {};
7535
7784
  try {
7536
- const raw = readFileSync2(aliasPath, "utf-8");
7785
+ const raw = readFileSync(aliasPath, "utf-8");
7537
7786
  const parsed = JSON.parse(raw);
7538
7787
  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
7539
7788
  const cleaned = {};
@@ -7551,9 +7800,6 @@ var StorageManager3 = class _StorageManager extends TombstoneBlockedCaptureIndex
7551
7800
  log.debug("no config/aliases.json found \u2014 using built-in aliases only");
7552
7801
  }
7553
7802
  }
7554
- loadHistoricalEntityCanonicalIdsSync() {
7555
- this.historicalEntityCanonicalIds = loadHistoricalEntityCanonicalIds(this.stateDir);
7556
- }
7557
7803
  async runLegacyEntityCanonicalIdMigration() {
7558
7804
  const completionFingerprint = await runLegacyEntityCanonicalIdMigration(
7559
7805
  this,
@@ -7561,7 +7807,6 @@ var StorageManager3 = class _StorageManager extends TombstoneBlockedCaptureIndex
7561
7807
  (entity) => serializeEntityFile(entity, this.entitySchemas),
7562
7808
  createMemoryEntityRefSerializer(serializeFrontmatter)
7563
7809
  );
7564
- this.loadHistoricalEntityCanonicalIdsSync();
7565
7810
  return completionFingerprint;
7566
7811
  }
7567
7812
  async ensureDirectories() {
@@ -7622,6 +7867,9 @@ var StorageManager3 = class _StorageManager extends TombstoneBlockedCaptureIndex
7622
7867
  }
7623
7868
  async writeMemory(category, content, options = {}) {
7624
7869
  await this.ensureDirectories();
7870
+ const rawEntityRef = options.entityRef;
7871
+ let refIds = typeof options.entityRef === "string" ? this.currentHistoricalIds() : null;
7872
+ if (refIds) options = canonicalizeEntityRefOption(options, refIds);
7625
7873
  const now = /* @__PURE__ */ new Date();
7626
7874
  const today = now.toISOString().slice(0, 10);
7627
7875
  const id = `${category}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
@@ -7695,40 +7943,31 @@ var StorageManager3 = class _StorageManager extends TombstoneBlockedCaptureIndex
7695
7943
  fm.contentHash = ContentHashIndex.computeHash(hashSource);
7696
7944
  factHashSourceForTombstone = hashSource;
7697
7945
  }
7946
+ if (refIds && typeof rawEntityRef === "string" && this.currentHistoricalIds() !== refIds) {
7947
+ refIds = this.currentHistoricalIds();
7948
+ fm.entityRef = resolveHistoricalEntityCanonicalId(rawEntityRef, refIds);
7949
+ }
7698
7950
  let tombstoneBlocked = false;
7699
- if (category === "fact" && this.tombstonesConfig.enabled && factHashSourceForTombstone !== null && // Block facts that would become ACTIVE or are already pending_review
7700
- // (issue #1579 thread ObteQ: wearable/native imports write fact
7701
- // candidates with status: pending_review; without checking them here,
7702
- // promoteWearableMemory could later flip a retired fact active without
7703
- // a tombstone check). A caller that explicitly requests a terminal
7704
- // non-active status (rejected/retracted/superseded) is respected.
7705
- (fm.status === void 0 || fm.status === "active" || fm.status === "pending_review")) {
7706
- try {
7707
- const tombstoneStore = await this.getTombstoneStore();
7708
- const keyedSupersession = options.entityRef && options.structuredAttributes ? supersessionKeysForFact({
7709
- entityRef: options.entityRef,
7710
- structuredAttributes: options.structuredAttributes
7711
- }) : [];
7712
- const match = tombstoneStore.lookup({
7713
- contentHash: fm.contentHash,
7714
- normalizedText: ContentHashIndex.normalizeContent(factHashSourceForTombstone),
7715
- ...options.entityRef ? { entityRef: options.entityRef } : {},
7716
- ...keyedSupersession.length > 0 ? { supersessionKeys: keyedSupersession } : {},
7717
- namespace: this.tombstonesConfig.namespace
7718
- });
7719
- if (match) {
7720
- tombstoneBlocked = true;
7721
- fm.status = "pending_review";
7722
- fm.blockedBy = match.tombstoneId;
7723
- fm.tombstoneBlockTier = match.matchedTier;
7724
- log.info(
7725
- `tombstone: blocked resurrection of fact ${id} (tier=${match.matchedTier}, tombstone=${match.tombstoneId}, reason=${match.reason})`
7726
- );
7727
- }
7728
- } catch (err) {
7729
- log.warn(`tombstone lookup failed for fact ${id} (fail-open): ${err}`);
7951
+ let gateRef;
7952
+ let statusBeforeBlock;
7953
+ const applyTombstoneGate = async () => {
7954
+ if (tombstoneBlocked) {
7955
+ if (fm.entityRef === gateRef) return;
7956
+ tombstoneBlocked = false;
7957
+ fm.status = statusBeforeBlock;
7958
+ delete fm.blockedBy;
7959
+ delete fm.tombstoneBlockTier;
7960
+ }
7961
+ if (category !== "fact" || factHashSourceForTombstone === null) return;
7962
+ const statusBefore = fm.status;
7963
+ const match = await this.entityRefRepair.gate(fm, factHashSourceForTombstone, options.structuredAttributes);
7964
+ if (match) {
7965
+ gateRef = fm.entityRef;
7966
+ statusBeforeBlock = statusBefore;
7967
+ tombstoneBlocked = true;
7730
7968
  }
7731
- }
7969
+ };
7970
+ await applyTombstoneGate();
7732
7971
  const fileContent = `${serializeFrontmatter(fm)}
7733
7972
 
7734
7973
  ${sanitized.text}
@@ -7757,6 +7996,37 @@ ${sanitized.text}
7757
7996
  duplicateOf: duplicateBlocked.frontmatter.id
7758
7997
  };
7759
7998
  }
7999
+ if (refIds && typeof rawEntityRef === "string") {
8000
+ const refBeforeRepair = fm.entityRef;
8001
+ try {
8002
+ await repairEntityRefAfterJournalMove({
8003
+ stateDir: this.stateDir,
8004
+ currentIds: () => this.currentHistoricalIds(),
8005
+ idsAtResolve: refIds,
8006
+ rawRef: rawEntityRef,
8007
+ frontmatter: fm,
8008
+ rewrite: async () => {
8009
+ await applyTombstoneGate();
8010
+ await this.writeTombstoneBlockedMemory(
8011
+ filePath,
8012
+ `${serializeFrontmatter(fm)}
8013
+
8014
+ ${sanitized.text}
8015
+ `,
8016
+ fm,
8017
+ sanitized.text,
8018
+ async () => this.invalidateAllMemoriesCache()
8019
+ );
8020
+ this.invalidateAllMemoriesCache();
8021
+ }
8022
+ });
8023
+ } catch (err) {
8024
+ await unlink4(filePath).catch(() => void 0);
8025
+ this.invalidateAllMemoriesCache();
8026
+ throw err;
8027
+ }
8028
+ await this.entityRefRepair.syncProjection(id, refBeforeRepair, fm.entityRef);
8029
+ }
7760
8030
  await this.patchHotMemoriesCache({ addedPath: filePath }, "memory-create");
7761
8031
  this.notifyMemoryWrite(filePath);
7762
8032
  await this.appendGeneratedMemoryLifecycleEventFailOpen("storage.writeMemory", {
@@ -8472,11 +8742,46 @@ ${sanitized.text}
8472
8742
  return path9.join(root, dir, this.resolveMemoryDateDir(memory), `${memory.frontmatter.id}.md`);
8473
8743
  }
8474
8744
  async writeMemoryFileAtomic(targetPath, memory) {
8475
- const fileContent = `${serializeFrontmatter(memory.frontmatter)}
8745
+ const refIdsAtWrite = this.currentHistoricalIds();
8746
+ const rawRef = memory.frontmatter.entityRef;
8747
+ const frontmatter = canonicalizeEntityRefOption(memory.frontmatter, refIdsAtWrite);
8748
+ const persist = () => writeMaybeEncryptedFile(
8749
+ targetPath,
8750
+ `${serializeFrontmatter(frontmatter)}
8476
8751
 
8477
8752
  ${memory.content}
8478
- `;
8479
- await writeMaybeEncryptedFile(targetPath, fileContent, this.resolveWriteKey(), {}, this.baseDir);
8753
+ `,
8754
+ this.resolveWriteKey(),
8755
+ {},
8756
+ this.baseDir
8757
+ );
8758
+ const priorDest = await readFile2(targetPath).catch(() => null);
8759
+ await persist();
8760
+ if (typeof rawRef === "string") {
8761
+ const refBeforeRepair = frontmatter.entityRef;
8762
+ try {
8763
+ await repairEntityRefAfterJournalMove({
8764
+ stateDir: this.stateDir,
8765
+ currentIds: () => this.currentHistoricalIds(),
8766
+ idsAtResolve: refIdsAtWrite,
8767
+ rawRef,
8768
+ frontmatter,
8769
+ rewrite: persist
8770
+ });
8771
+ } catch (err) {
8772
+ if (priorDest === null) {
8773
+ await unlink4(targetPath).catch(() => void 0);
8774
+ } else {
8775
+ await writeFile2(targetPath, priorDest).catch(() => void 0);
8776
+ }
8777
+ this.invalidateAllMemoriesCache();
8778
+ throw err;
8779
+ }
8780
+ await this.entityRefRepair.syncProjection(memory.frontmatter.id, refBeforeRepair, frontmatter.entityRef);
8781
+ }
8782
+ if (memory.frontmatter.entityRef !== frontmatter.entityRef) {
8783
+ memory.frontmatter.entityRef = frontmatter.entityRef;
8784
+ }
8480
8785
  this.invalidateAllMemoriesCache();
8481
8786
  this.notifyCatalogWrite();
8482
8787
  }
@@ -8486,7 +8791,7 @@ ${memory.content}
8486
8791
  const destPath = path9.resolve(targetPath);
8487
8792
  if (sourcePath !== destPath) {
8488
8793
  try {
8489
- await unlink3(memory.path);
8794
+ await unlink4(memory.path);
8490
8795
  } catch (err) {
8491
8796
  const message = err instanceof Error ? err.message : String(err);
8492
8797
  if (!message.includes("ENOENT")) {
@@ -8507,7 +8812,7 @@ ${memory.content}
8507
8812
  const existing = await this.readMemoryByPath(targetPath);
8508
8813
  if (existing?.frontmatter.id === memory.frontmatter.id) {
8509
8814
  try {
8510
- await unlink3(memory.path);
8815
+ await unlink4(memory.path);
8511
8816
  } catch (err) {
8512
8817
  const message = err instanceof Error ? err.message : String(err);
8513
8818
  if (!message.includes("ENOENT")) {
@@ -8541,19 +8846,42 @@ ${memory.content}
8541
8846
  const today = now.toISOString().slice(0, 10);
8542
8847
  const destDir = path9.join(this.archiveDir, today);
8543
8848
  await mkdir(destDir, { recursive: true });
8544
- const updatedFm = {
8545
- ...current.frontmatter,
8546
- status: "archived",
8547
- archivedAt: now.toISOString(),
8548
- updated: now.toISOString()
8549
- };
8849
+ const refIdsAtWrite = this.currentHistoricalIds();
8850
+ const updatedFm = canonicalizeEntityRefOption(
8851
+ {
8852
+ ...current.frontmatter,
8853
+ status: "archived",
8854
+ archivedAt: now.toISOString(),
8855
+ updated: now.toISOString()
8856
+ },
8857
+ refIdsAtWrite
8858
+ );
8550
8859
  const fileContent = `${serializeFrontmatter(updatedFm)}
8551
8860
 
8552
8861
  ${current.content}
8553
8862
  `;
8554
8863
  const destPath = path9.join(destDir, path9.basename(current.path));
8864
+ const priorDest = await this.readStorageSecureFile(destPath).catch(() => null);
8555
8865
  await this.writeStorageSecureFile(destPath, fileContent);
8556
- await unlink3(current.path);
8866
+ if (typeof current.frontmatter.entityRef === "string") {
8867
+ try {
8868
+ await this.entityRefRepair.repair(
8869
+ destPath,
8870
+ updatedFm,
8871
+ current.frontmatter.entityRef,
8872
+ refIdsAtWrite,
8873
+ current.content
8874
+ );
8875
+ } catch (err) {
8876
+ if (priorDest === null) {
8877
+ await unlink4(destPath).catch(() => void 0);
8878
+ } else {
8879
+ await this.writeStorageSecureFile(destPath, priorDest).catch(() => void 0);
8880
+ }
8881
+ throw err;
8882
+ }
8883
+ }
8884
+ await unlink4(current.path);
8557
8885
  markDurable();
8558
8886
  markProjectedMemoryPathInvalid(this.baseDir, current.frontmatter.id);
8559
8887
  this.invalidateAllMemoriesCache();
@@ -8582,13 +8910,9 @@ ${current.content}
8582
8910
  };
8583
8911
  return await this.runTombstoneBlockedArchive(memory, archiveCurrent);
8584
8912
  }
8913
+ /** Alias of listEntityNames (kept for legacy callers; sorted). */
8585
8914
  async readEntities() {
8586
- try {
8587
- const entries = await readdir5(this.entitiesDir);
8588
- return entries.filter((e) => e.endsWith(".md")).map((e) => e.replace(".md", ""));
8589
- } catch {
8590
- return [];
8591
- }
8915
+ return this.entityStore.listEntityNames();
8592
8916
  }
8593
8917
  async readEntity(name) {
8594
8918
  return this.entityStore.readEntity(name);
@@ -8638,7 +8962,7 @@ ${current.content}
8638
8962
  return await this.runTombstoneBlockedInvalidation(
8639
8963
  memory,
8640
8964
  async (current, rebuildMarker, markDurable) => {
8641
- await unlink3(current.path);
8965
+ await unlink4(current.path);
8642
8966
  markDurable();
8643
8967
  markProjectedMemoryPathInvalid(this.baseDir, id);
8644
8968
  this.invalidateAllMemoriesCache();
@@ -8656,13 +8980,17 @@ ${current.content}
8656
8980
  const mergedLineage = [...memory.frontmatter.lineage ?? [], ...options?.lineage ?? []].filter(
8657
8981
  (v, i, a) => a.indexOf(v) === i
8658
8982
  );
8659
- const updated = {
8660
- ...memory.frontmatter,
8661
- updated: (/* @__PURE__ */ new Date()).toISOString(),
8662
- supersedes: options?.supersedes ?? memory.frontmatter.supersedes,
8663
- lineage: mergedLineage.length > 0 ? mergedLineage : void 0,
8664
- ...memory.frontmatter.sourceConnector === void 0 && options?.sourceConnector ? { sourceConnector: options.sourceConnector } : {}
8665
- };
8983
+ const refIdsAtWrite = this.currentHistoricalIds();
8984
+ const updated = canonicalizeEntityRefOption(
8985
+ {
8986
+ ...memory.frontmatter,
8987
+ updated: (/* @__PURE__ */ new Date()).toISOString(),
8988
+ supersedes: options?.supersedes ?? memory.frontmatter.supersedes,
8989
+ lineage: mergedLineage.length > 0 ? mergedLineage : void 0,
8990
+ ...memory.frontmatter.sourceConnector === void 0 && options?.sourceConnector ? { sourceConnector: options.sourceConnector } : {}
8991
+ },
8992
+ refIdsAtWrite
8993
+ );
8666
8994
  const sanitized = sanitizeMemoryContent(newContent);
8667
8995
  if (!sanitized.clean) {
8668
8996
  log.warn(`updated memory content sanitized for ${id}; violations=${sanitized.violations.join(", ")}`);
@@ -8674,6 +9002,16 @@ ${sanitized.text}
8674
9002
  await this.writeTombstoneBlockedUpdate(memory, fileContent, updated, sanitized.text, async () => {
8675
9003
  this.invalidateAllMemoriesCache();
8676
9004
  });
9005
+ if (typeof memory.frontmatter.entityRef === "string") {
9006
+ await this.entityRefRepair.repair(
9007
+ memory.path,
9008
+ updated,
9009
+ memory.frontmatter.entityRef,
9010
+ refIdsAtWrite,
9011
+ sanitized.text,
9012
+ { onFailRestore: memory }
9013
+ );
9014
+ }
8677
9015
  await this.patchHotMemoriesCache({ addedPath: memory.path });
8678
9016
  await this.appendGeneratedMemoryLifecycleEventFailOpen("storage.updateMemory", {
8679
9017
  memoryId: id,
@@ -8696,10 +9034,12 @@ ${sanitized.text}
8696
9034
  */
8697
9035
  async writeMemoryFrontmatter(memory, patch, lifecycle) {
8698
9036
  const beforeStatus = memory.frontmatter.status ?? "active";
8699
- const updated = {
8700
- ...memory.frontmatter,
8701
- ...patch
8702
- };
9037
+ const resolveIds = this.currentHistoricalIds();
9038
+ const updated = canonicalizeEntityRefOption(
9039
+ { ...memory.frontmatter, ...patch },
9040
+ resolveIds
9041
+ );
9042
+ const refIds = typeof updated.entityRef === "string" ? resolveIds : null;
8703
9043
  const afterStatus = updated.status ?? "active";
8704
9044
  const fileContent = `${serializeFrontmatter(updated)}
8705
9045
 
@@ -8711,6 +9051,17 @@ ${memory.content}
8711
9051
  this.invalidateColdMemoriesCache();
8712
9052
  }
8713
9053
  });
9054
+ const rawMergedRef = typeof patch.entityRef === "string" ? patch.entityRef : memory.frontmatter.entityRef;
9055
+ if (refIds && typeof rawMergedRef === "string") {
9056
+ await this.entityRefRepair.repair(
9057
+ memory.path,
9058
+ updated,
9059
+ rawMergedRef,
9060
+ refIds,
9061
+ memory.content,
9062
+ { onFailRestore: memory }
9063
+ );
9064
+ }
8714
9065
  await this.patchHotMemoriesCache({ addedPath: memory.path });
8715
9066
  if (memory.path.includes(`${path9.sep}cold${path9.sep}`)) {
8716
9067
  this.invalidateColdMemoriesCache();
@@ -9164,8 +9515,8 @@ ${memory.content}
9164
9515
  activationState: "active"
9165
9516
  });
9166
9517
  await Promise.all([
9167
- unlink3(this.compressionGuidelineDraftPath).catch(() => void 0),
9168
- unlink3(this.compressionGuidelineDraftStatePath).catch(() => void 0)
9518
+ unlink4(this.compressionGuidelineDraftPath).catch(() => void 0),
9519
+ unlink4(this.compressionGuidelineDraftStatePath).catch(() => void 0)
9169
9520
  ]);
9170
9521
  return true;
9171
9522
  }
@@ -9399,96 +9750,27 @@ ${reflection}
9399
9750
  await this.writeStorageSecureFile(this.identityReflectionsPath, `${existing.trimEnd()}${section}`);
9400
9751
  log.debug(`appended namespace-local reflection to ${this.identityReflectionsPath}`);
9401
9752
  }
9402
- // ---------------------------------------------------------------------------
9403
- // Entity mutation helpers (Knowledge Graph v7.0)
9404
- // ---------------------------------------------------------------------------
9405
- /**
9406
- * Add a relationship to an entity file.
9407
- * Deduplicates by target+label.
9408
- */
9753
+ // Entity-file mutators live on EntityStore (issue #2213): journal-resolved
9754
+ // with legacy-file fallback, under the entity-mutation lock.
9409
9755
  async addEntityRelationship(name, rel) {
9410
- const filePath = path9.join(this.entitiesDir, `${name}.md`);
9411
- let entity;
9412
- try {
9413
- const content = await this.readStorageSecureFile(filePath);
9414
- entity = parseEntityFile(content, this.entitySchemas);
9415
- } catch (err) {
9416
- if (err instanceof SecureStoreLockedError) throw err;
9417
- if (!isErrnoCode(err, "ENOENT")) throw err;
9418
- log.debug(`addEntityRelationship: entity file ${name}.md not found`);
9419
- return;
9420
- }
9421
- const exists = entity.relationships.some((r) => r.target === rel.target && r.label === rel.label);
9422
- if (exists) return;
9423
- entity.relationships.push(rel);
9424
- entity.updated = (/* @__PURE__ */ new Date()).toISOString();
9425
- await this.writeStorageSecureFile(filePath, serializeEntityFile(entity, this.entitySchemas));
9426
- this.invalidateKnowledgeIndexCache();
9756
+ return this.entityStore.addEntityRelationship(name, rel);
9427
9757
  }
9428
- /**
9429
- * Add an activity entry to an entity file.
9430
- * Prepends to the beginning, prunes oldest entries beyond maxEntries.
9431
- */
9432
9758
  async addEntityActivity(name, entry, maxEntries) {
9433
- const filePath = path9.join(this.entitiesDir, `${name}.md`);
9434
- let entity;
9435
- try {
9436
- const content = await this.readStorageSecureFile(filePath);
9437
- entity = parseEntityFile(content, this.entitySchemas);
9438
- } catch (err) {
9439
- if (err instanceof SecureStoreLockedError) throw err;
9440
- if (!isErrnoCode(err, "ENOENT")) throw err;
9441
- log.debug(`addEntityActivity: entity file ${name}.md not found`);
9442
- return;
9443
- }
9444
- entity.activity.unshift(entry);
9445
- if (entity.activity.length > maxEntries) {
9446
- entity.activity = entity.activity.slice(0, maxEntries);
9447
- }
9448
- entity.updated = (/* @__PURE__ */ new Date()).toISOString();
9449
- await this.writeStorageSecureFile(filePath, serializeEntityFile(entity, this.entitySchemas));
9450
- this.invalidateKnowledgeIndexCache();
9759
+ return this.entityStore.addEntityActivity(name, entry, maxEntries);
9451
9760
  }
9452
9761
  async addEntityAlias(name, alias) {
9453
- const filePath = path9.join(this.entitiesDir, `${name}.md`);
9454
- let entity;
9455
- try {
9456
- const content = await this.readStorageSecureFile(filePath);
9457
- entity = parseEntityFile(content, this.entitySchemas);
9458
- } catch (err) {
9459
- if (err instanceof SecureStoreLockedError) throw err;
9460
- if (!isErrnoCode(err, "ENOENT")) throw err;
9461
- log.debug(`addEntityAlias: entity file ${name}.md not found`);
9462
- return;
9463
- }
9464
- if (entity.aliases.includes(alias)) return;
9465
- entity.aliases.push(alias);
9466
- entity.updated = (/* @__PURE__ */ new Date()).toISOString();
9467
- await this.writeStorageSecureFile(filePath, serializeEntityFile(entity, this.entitySchemas));
9468
- this.invalidateKnowledgeIndexCache();
9469
- this.bumpMemoryStatusVersion();
9762
+ return this.entityStore.addEntityAlias(name, alias);
9470
9763
  }
9471
9764
  async updateEntitySynthesis(name, synthesis, options = {}) {
9472
9765
  return this.entityStore.updateEntitySynthesis(name, synthesis, options);
9473
9766
  }
9474
- /**
9475
- * Backward-compatible alias for legacy callers/tests.
9476
- */
9767
+ /** Backward-compatible alias for legacy callers/tests. */
9477
9768
  async updateEntitySummary(name, summary) {
9478
9769
  const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
9479
- let synthesisTimelineCount;
9480
- try {
9481
- const filePath = path9.join(this.entitiesDir, `${name}.md`);
9482
- const content = await this.readStorageSecureFile(filePath);
9483
- synthesisTimelineCount = parseEntityFile(content, this.entitySchemas).timeline.length;
9484
- } catch (err) {
9485
- if (err instanceof SecureStoreLockedError) throw err;
9486
- if (!isErrnoCode(err, "ENOENT")) throw err;
9487
- synthesisTimelineCount = void 0;
9488
- }
9770
+ const raw = await this.readEntity(name);
9489
9771
  await this.updateEntitySynthesis(name, summary, {
9490
9772
  entityUpdatedAt: updatedAt,
9491
- synthesisTimelineCount,
9773
+ synthesisTimelineCount: raw ? parseEntityFile(raw, this.entitySchemas).timeline.length : void 0,
9492
9774
  updatedAt
9493
9775
  });
9494
9776
  }
@@ -9554,24 +9836,7 @@ ${reflection}
9554
9836
  );
9555
9837
  }
9556
9838
  async migrateEntityFilesToCompiledTruthTimeline() {
9557
- const entityNames = await this.listEntityNames();
9558
- let migrated = 0;
9559
- for (const entityName of entityNames) {
9560
- const raw = await this.readEntity(entityName);
9561
- if (!raw) continue;
9562
- const serialized = serializeEntityFile(parseEntityFile(raw, this.entitySchemas), this.entitySchemas);
9563
- if (raw.trimEnd() === serialized.trimEnd()) continue;
9564
- await this.writeStorageSecureFile(path9.join(this.entitiesDir, `${entityName}.md`), serialized);
9565
- migrated += 1;
9566
- }
9567
- if (migrated > 0) {
9568
- this.invalidateKnowledgeIndexCache();
9569
- this.bumpMemoryStatusVersion();
9570
- }
9571
- return {
9572
- total: entityNames.length,
9573
- migrated
9574
- };
9839
+ return this.entityStore.migrateEntityFilesToCompiledTruthTimeline();
9575
9840
  }
9576
9841
  // ---------------------------------------------------------------------------
9577
9842
  // Scoring + Knowledge Index (Knowledge Graph v7.0)
@@ -9666,17 +9931,27 @@ ${reflection}
9666
9931
  for (const entry of entries) {
9667
9932
  const memory = entry.memoryPath ? memoryPathMap.get(path9.resolve(entry.memoryPath)) : memoryMap.get(entry.memoryId);
9668
9933
  if (!memory) continue;
9669
- const newFm = {
9670
- ...memory.frontmatter,
9671
- accessCount: entry.newCount,
9672
- lastAccessed: entry.lastAccessed
9673
- };
9934
+ const rowIds = this.currentHistoricalIds();
9935
+ const newFm = canonicalizeEntityRefOption(
9936
+ { ...memory.frontmatter, accessCount: entry.newCount, lastAccessed: entry.lastAccessed },
9937
+ rowIds
9938
+ );
9674
9939
  try {
9675
9940
  const fileContent = `${serializeFrontmatter(newFm)}
9676
9941
 
9677
9942
  ${memory.content}
9678
9943
  `;
9679
9944
  await this.writeStorageSecureFile(memory.path, fileContent);
9945
+ if (typeof memory.frontmatter.entityRef === "string") {
9946
+ await this.entityRefRepair.repair(
9947
+ memory.path,
9948
+ newFm,
9949
+ memory.frontmatter.entityRef,
9950
+ rowIds,
9951
+ memory.content,
9952
+ { onFailRestore: memory }
9953
+ );
9954
+ }
9680
9955
  if (warm) {
9681
9956
  updateCacheOnWrite(this.baseDir, { ...memory, frontmatter: newFm }, keyId);
9682
9957
  }
@@ -9842,6 +10117,9 @@ ${memory.content}
9842
10117
  */
9843
10118
  async writeChunk(parentId, chunkIndex, chunkTotal, category, content, options = {}) {
9844
10119
  await this.ensureDirectories();
10120
+ const rawEntityRef = options.entityRef;
10121
+ let refIds = typeof options.entityRef === "string" ? this.currentHistoricalIds() : null;
10122
+ if (refIds) options = canonicalizeEntityRefOption(options, refIds);
9845
10123
  const now = /* @__PURE__ */ new Date();
9846
10124
  const today = now.toISOString().slice(0, 10);
9847
10125
  const id = `${parentId}-chunk-${chunkIndex}`;
@@ -9883,12 +10161,22 @@ ${memory.content}
9883
10161
  if (!sanitized.clean) {
9884
10162
  log.warn(`chunk content sanitized for ${id}; violations=${sanitized.violations.join(", ")}`);
9885
10163
  }
10164
+ const filePath = await this.resolveCategoryWritePath(category, id, today);
9886
10165
  const fileContent = `${serializeFrontmatter(fm)}
9887
10166
 
9888
10167
  ${sanitized.text}
9889
10168
  `;
9890
- const filePath = await this.resolveCategoryWritePath(category, id, today);
9891
- return await this.writeTombstoneBlockedChunk(
10169
+ let priorChunk = null;
10170
+ try {
10171
+ const priorRaw = await readMaybeEncryptedFile(filePath, this._secureStoreKey, this.baseDir);
10172
+ const parsedPrior = parseFrontmatter(priorRaw);
10173
+ if (parsedPrior) {
10174
+ priorChunk = { path: filePath, frontmatter: parsedPrior.frontmatter, content: parsedPrior.content };
10175
+ }
10176
+ } catch {
10177
+ priorChunk = null;
10178
+ }
10179
+ const written = await this.writeTombstoneBlockedChunk(
9892
10180
  filePath,
9893
10181
  fileContent,
9894
10182
  fm,
@@ -9899,6 +10187,13 @@ ${sanitized.text}
9899
10187
  log.debug(`wrote chunk ${id} (${chunkIndex + 1}/${chunkTotal}) to ${filePath}`);
9900
10188
  }
9901
10189
  );
10190
+ if (written === id && refIds && typeof rawEntityRef === "string") {
10191
+ await this.entityRefRepair.repair(filePath, fm, rawEntityRef, refIds, sanitized.text, {
10192
+ regateFact: true,
10193
+ ...priorChunk ? { onFailRestore: priorChunk } : { onFailRemove: filePath }
10194
+ });
10195
+ }
10196
+ return written;
9902
10197
  }
9903
10198
  /**
9904
10199
  * Get all chunks for a given parent memory ID.
@@ -9920,19 +10215,27 @@ ${sanitized.text}
9920
10215
  const oldMemory = memories.find((m) => m.frontmatter.id === oldMemoryId);
9921
10216
  if (!oldMemory) return false;
9922
10217
  const now = (/* @__PURE__ */ new Date()).toISOString();
9923
- const updatedFm = {
9924
- ...oldMemory.frontmatter,
9925
- status: "superseded",
9926
- supersededBy: newMemoryId,
9927
- supersededAt: now,
9928
- updated: now
9929
- };
10218
+ const refIdsAtWrite = this.currentHistoricalIds();
10219
+ const updatedFm = canonicalizeEntityRefOption(
10220
+ { ...oldMemory.frontmatter, status: "superseded", supersededBy: newMemoryId, supersededAt: now, updated: now },
10221
+ refIdsAtWrite
10222
+ );
9930
10223
  const fileContent = `${serializeFrontmatter(updatedFm)}
9931
10224
 
9932
10225
  ${oldMemory.content}
9933
10226
  `;
9934
10227
  try {
9935
10228
  await this.writeStorageSecureFile(oldMemory.path, fileContent);
10229
+ if (typeof oldMemory.frontmatter.entityRef === "string") {
10230
+ await this.entityRefRepair.repair(
10231
+ oldMemory.path,
10232
+ updatedFm,
10233
+ oldMemory.frontmatter.entityRef,
10234
+ refIdsAtWrite,
10235
+ oldMemory.content,
10236
+ { onFailRestore: oldMemory }
10237
+ );
10238
+ }
9936
10239
  this.bumpMemoryCorpusVersion();
9937
10240
  await this.appendGeneratedMemoryLifecycleEventFailOpen("storage.supersedeMemory", {
9938
10241
  memoryId: oldMemoryId,
@@ -9952,7 +10255,7 @@ ${oldMemory.content}
9952
10255
  id: oldMemoryId,
9953
10256
  content: stripCitationForTemplate(oldMemory.content, this.citationTemplate),
9954
10257
  contentHash: oldMemory.frontmatter.contentHash,
9955
- entityRef: oldMemory.frontmatter.entityRef,
10258
+ entityRef: updatedFm.entityRef,
9956
10259
  structuredAttributes: oldMemory.frontmatter.structuredAttributes
9957
10260
  },
9958
10261
  {
@@ -10028,18 +10331,27 @@ Reason: ${reason}`;
10028
10331
  const memory = memoryMap.get(id);
10029
10332
  if (!memory) continue;
10030
10333
  const now = (/* @__PURE__ */ new Date()).toISOString();
10031
- const updatedFm = {
10032
- ...memory.frontmatter,
10033
- status: "archived",
10034
- archivedAt: now,
10035
- updated: now
10036
- };
10334
+ const rowIds = this.currentHistoricalIds();
10335
+ const updatedFm = canonicalizeEntityRefOption(
10336
+ { ...memory.frontmatter, status: "archived", archivedAt: now, updated: now },
10337
+ rowIds
10338
+ );
10037
10339
  try {
10038
10340
  const fileContent = `${serializeFrontmatter(updatedFm)}
10039
10341
 
10040
10342
  ${memory.content}
10041
10343
  `;
10042
10344
  await this.writeStorageSecureFile(memory.path, fileContent);
10345
+ if (typeof memory.frontmatter.entityRef === "string") {
10346
+ await this.entityRefRepair.repair(
10347
+ memory.path,
10348
+ updatedFm,
10349
+ memory.frontmatter.entityRef,
10350
+ rowIds,
10351
+ memory.content,
10352
+ { onFailRestore: memory }
10353
+ );
10354
+ }
10043
10355
  this.bumpMemoryCorpusVersion();
10044
10356
  await this.appendGeneratedMemoryLifecycleEventFailOpen("storage.archiveMemories", {
10045
10357
  memoryId: id,
@@ -10200,4 +10512,4 @@ export {
10200
10512
  isValidBufferSurpriseEvent,
10201
10513
  StorageManager3 as StorageManager
10202
10514
  };
10203
- //# sourceMappingURL=chunk-4TGZCYHH.js.map
10515
+ //# sourceMappingURL=chunk-Q3CVY7LV.js.map