@remnic/core 10.0.94 → 10.0.96

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 (235) hide show
  1. package/dist/abstraction-nodes.d.ts.map +1 -1
  2. package/dist/abstraction-nodes.js +7 -1
  3. package/dist/access-admin-ops-surface.js +91 -90
  4. package/dist/access-audit.js +2 -2
  5. package/dist/access-cli.js +91 -90
  6. package/dist/access-cli.js.map +1 -1
  7. package/dist/access-extraction-force-flush.js +91 -90
  8. package/dist/access-http-briefing.js +91 -90
  9. package/dist/access-http-query.js +91 -90
  10. package/dist/access-http.js +91 -90
  11. package/dist/access-identity-continuity-surface.js +91 -90
  12. package/dist/access-lcm-surface.js +91 -90
  13. package/dist/access-mcp.js +91 -90
  14. package/dist/access-observe-write-surface.js +91 -90
  15. package/dist/access-operations-batch.js +91 -90
  16. package/dist/access-operations.js +91 -90
  17. package/dist/access-recall-concurrency.js +91 -90
  18. package/dist/access-recall-response.js +91 -90
  19. package/dist/access-recall-surface.js +91 -90
  20. package/dist/access-schema.d.ts +5 -5
  21. package/dist/access-service.js +91 -90
  22. package/dist/active-recall.js +16 -16
  23. package/dist/briefing.js +15 -15
  24. package/dist/buffer.js +7 -7
  25. package/dist/calibration.js +2 -2
  26. package/dist/{capsule-merge-6IDAYTKG.js → capsule-merge-ILR35DIB.js} +10 -10
  27. package/dist/causal-consolidation.js +16 -16
  28. package/dist/{chunk-QHARXWTC.js → chunk-2LCX6TAD.js} +2 -2
  29. package/dist/{chunk-UN4RVUG2.js → chunk-2R3ACFYN.js} +3 -3
  30. package/dist/{chunk-RQFXZYTF.js → chunk-33C4BK7I.js} +4 -4
  31. package/dist/{chunk-UBEKET2V.js → chunk-3R63SXC5.js} +4 -4
  32. package/dist/{chunk-LNGELD2M.js → chunk-3VWU3KF3.js} +2 -2
  33. package/dist/{chunk-FWRXW7CZ.js → chunk-4CXK3GT3.js} +29 -29
  34. package/dist/{chunk-DNY63QWJ.js → chunk-4HK7DYOL.js} +2 -2
  35. package/dist/{chunk-LLPMMMWG.js → chunk-4IJFJQPD.js} +2 -2
  36. package/dist/{chunk-GBJUTX44.js → chunk-4WKR4P7H.js} +2 -2
  37. package/dist/{chunk-ILY5SSOM.js → chunk-53ZVHXVZ.js} +6 -6
  38. package/dist/{chunk-FQAQ63QA.js → chunk-5AJIGWY4.js} +2 -2
  39. package/dist/{chunk-CIWMTNHW.js → chunk-5NFF2TSD.js} +2 -2
  40. package/dist/{chunk-D5XG2KPS.js → chunk-7E7J7QXB.js} +2 -2
  41. package/dist/{chunk-2WCLTWV2.js → chunk-7NVK6AGC.js} +2 -2
  42. package/dist/{chunk-PC5DRBGD.js → chunk-AP4BFWSW.js} +2 -2
  43. package/dist/{chunk-CL4JU3DF.js → chunk-B7L4ZYFY.js} +3 -3
  44. package/dist/{chunk-LQ5SS4OP.js → chunk-D6IGZYTN.js} +3 -3
  45. package/dist/{chunk-XJ2S5UBF.js → chunk-DWWX3FU3.js} +3 -3
  46. package/dist/{chunk-IL3RHBWM.js → chunk-EJ23D4JG.js} +3 -3
  47. package/dist/{chunk-F4QFU5IZ.js → chunk-EZ7XJJ5L.js} +2 -2
  48. package/dist/{chunk-FZZMLGWQ.js → chunk-F4BUSX3D.js} +2 -2
  49. package/dist/{chunk-LXATZLSC.js → chunk-FL3O34XF.js} +12 -5
  50. package/dist/chunk-FL3O34XF.js.map +1 -0
  51. package/dist/{chunk-DPPLNZ3A.js → chunk-FM4P7HSW.js} +4 -4
  52. package/dist/{chunk-QDESY76F.js → chunk-FQACH7MC.js} +2 -2
  53. package/dist/{chunk-XPXHQI6N.js → chunk-FVMEZ57K.js} +2 -2
  54. package/dist/{chunk-F2XNSIPP.js → chunk-G2CGIFRP.js} +10 -3
  55. package/dist/chunk-G2CGIFRP.js.map +1 -0
  56. package/dist/{chunk-ZDV5YWID.js → chunk-GLDIMGOS.js} +6 -6
  57. package/dist/{chunk-YIXJSW5M.js → chunk-HA5DASDG.js} +3 -3
  58. package/dist/{chunk-S3S72HRM.js → chunk-HCCRWSP7.js} +2 -2
  59. package/dist/{chunk-NMGHGTVR.js → chunk-HRMOKGQM.js} +6 -6
  60. package/dist/{chunk-OUVYYKYU.js → chunk-IBKGQ6EY.js} +2 -2
  61. package/dist/{chunk-RNG7S3FY.js → chunk-ICHLN5L6.js} +6 -6
  62. package/dist/chunk-ITAUMNQU.js +40 -0
  63. package/dist/chunk-ITAUMNQU.js.map +1 -0
  64. package/dist/{chunk-R23JWJD3.js → chunk-J426U7XK.js} +9 -9
  65. package/dist/{chunk-QMFAMOT2.js → chunk-JH5AV542.js} +2 -2
  66. package/dist/{chunk-WGGRZYSQ.js → chunk-KE3JWIZK.js} +8 -8
  67. package/dist/{chunk-SQ3GEIEL.js → chunk-KUQLUYIR.js} +5 -5
  68. package/dist/{chunk-RKABLU5M.js → chunk-KVHGCISP.js} +9 -9
  69. package/dist/{chunk-RUTN5MMC.js → chunk-LRLQ2PRP.js} +5 -5
  70. package/dist/{chunk-X5HGU2EM.js → chunk-NT772J7I.js} +2 -2
  71. package/dist/{chunk-BPK32FRM.js → chunk-NTDHSV7N.js} +61 -6
  72. package/dist/chunk-NTDHSV7N.js.map +1 -0
  73. package/dist/{chunk-4EZWSDFL.js → chunk-NX4XDDFQ.js} +3 -3
  74. package/dist/{chunk-IAYUTGF2.js → chunk-O3GZPLVY.js} +4 -4
  75. package/dist/{chunk-IGOW4TJE.js → chunk-O3JG4PD3.js} +2 -2
  76. package/dist/{chunk-UHMHJX5G.js → chunk-OIG4ZFMB.js} +4 -4
  77. package/dist/{chunk-IQ55QGY5.js → chunk-R4JYSBZE.js} +2 -2
  78. package/dist/{chunk-Z3FTSHT5.js → chunk-RUQE5C32.js} +2 -2
  79. package/dist/{chunk-ESLOLCD4.js → chunk-RUWKIPWJ.js} +2 -2
  80. package/dist/{chunk-W54Z2MOP.js → chunk-RXSGBO7Y.js} +2 -2
  81. package/dist/{chunk-D5S6RL32.js → chunk-TFMLRVMW.js} +166 -145
  82. package/dist/{chunk-D5S6RL32.js.map → chunk-TFMLRVMW.js.map} +1 -1
  83. package/dist/{chunk-BOK6BN5M.js → chunk-THV6PZ7V.js} +2 -2
  84. package/dist/{chunk-J7BIFOYP.js → chunk-U6TC6Z5B.js} +2 -2
  85. package/dist/{chunk-X2BVB74H.js → chunk-UZQXJDOE.js} +2 -2
  86. package/dist/{chunk-SUTEBYGI.js → chunk-V7T45MI6.js} +2 -2
  87. package/dist/{chunk-A4F7OIC5.js → chunk-WVIWDMFB.js} +2 -2
  88. package/dist/{chunk-BN2EBCLB.js → chunk-XPCUKWLE.js} +2 -2
  89. package/dist/{chunk-OJXK5C6Z.js → chunk-ZG4GNPRE.js} +7 -7
  90. package/dist/{chunk-TYRWSE4B.js → chunk-ZUMHQEW4.js} +2 -2
  91. package/dist/{chunk-DAOMWGO2.js → chunk-ZX5N5D6M.js} +4 -4
  92. package/dist/cli.js +91 -90
  93. package/dist/coding/export-okf-codegraph.js +14 -14
  94. package/dist/compounding/engine.js +16 -16
  95. package/dist/config.js +16 -16
  96. package/dist/connectors/codex-materialize-runner.js +15 -15
  97. package/dist/connectors/index.js +15 -15
  98. package/dist/consolidation-provenance-check.js +8 -8
  99. package/dist/consolidation-undo.js +6 -6
  100. package/dist/contradiction/index.js +6 -6
  101. package/dist/convergence-refresh.js +17 -17
  102. package/dist/corpus-watermark.js +17 -17
  103. package/dist/cue-anchors.d.ts.map +1 -1
  104. package/dist/cue-anchors.js +8 -2
  105. package/dist/{entity-canonical-id-migration-JJSTZF73.js → entity-canonical-id-migration-IG77BOGD.js} +5 -5
  106. package/dist/entity-mention-index-cache.js +2 -2
  107. package/dist/entity-retrieval.js +16 -16
  108. package/dist/explicit-capture.js +91 -90
  109. package/dist/extraction.js +21 -21
  110. package/dist/harmonic-construction.js +9 -3
  111. package/dist/harmonic-graph-version.d.ts +14 -0
  112. package/dist/harmonic-graph-version.d.ts.map +1 -0
  113. package/dist/harmonic-graph-version.js +17 -0
  114. package/dist/harmonic-retrieval.d.ts +19 -14
  115. package/dist/harmonic-retrieval.d.ts.map +1 -1
  116. package/dist/harmonic-retrieval.js +22 -17
  117. package/dist/index.js +105 -104
  118. package/dist/lcm/engine.js +3 -3
  119. package/dist/lcm/index.js +8 -8
  120. package/dist/local-llm.js +2 -2
  121. package/dist/location/index.js +16 -16
  122. package/dist/maintenance/memory-governance.js +15 -15
  123. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +15 -15
  124. package/dist/maintenance/rebuild-memory-projection-runner.js +17 -17
  125. package/dist/maintenance/rebuild-memory-projection.js +16 -16
  126. package/dist/memory-browse.js +7 -7
  127. package/dist/namespaces/migrate.js +16 -16
  128. package/dist/namespaces/search.js +6 -6
  129. package/dist/namespaces/storage.js +15 -15
  130. package/dist/operator-doctor-corpus.js +18 -18
  131. package/dist/operator-toolkit.js +24 -24
  132. package/dist/orchestration/backlog-scheduler.js +92 -91
  133. package/dist/orchestration/backlog-scheduler.js.map +1 -1
  134. package/dist/orchestration/buffer-drain-runner.js +92 -91
  135. package/dist/orchestration/extraction-persist-failure.d.ts +25 -1
  136. package/dist/orchestration/extraction-persist-failure.d.ts.map +1 -1
  137. package/dist/orchestration/extraction-persist.d.ts +3 -6
  138. package/dist/orchestration/extraction-persist.d.ts.map +1 -1
  139. package/dist/orchestration/maintenance.js +25 -25
  140. package/dist/orchestrator.js +93 -92
  141. package/dist/page-versioning.js +6 -6
  142. package/dist/qmd.js +4 -4
  143. package/dist/recall-navigation.js +91 -90
  144. package/dist/recall-state-view-admission.js +7 -7
  145. package/dist/recall-why-service.js +7 -7
  146. package/dist/{resolution-OVVIF7ZY.js → resolution-G4SLHSEU.js} +7 -7
  147. package/dist/resolution-G4SLHSEU.js.map +1 -0
  148. package/dist/resume-bundles.js +17 -17
  149. package/dist/schemas.d.ts +9 -9
  150. package/dist/search/factory.js +5 -5
  151. package/dist/search/index.js +5 -5
  152. package/dist/semantic-consolidation.js +16 -16
  153. package/dist/semantic-rule-promotion.js +15 -15
  154. package/dist/semantic-rule-verifier.js +15 -15
  155. package/dist/source-agent-qualifier.js +18 -18
  156. package/dist/standup/build.js +15 -15
  157. package/dist/storage.js +14 -14
  158. package/dist/summarizer.js +3 -3
  159. package/dist/support-passport/index.js +3 -3
  160. package/dist/transfer/capsule-import.js +8 -8
  161. package/dist/transfer/export-okf.js +15 -15
  162. package/dist/verified-recall.js +15 -15
  163. package/dist/who-knows-cli.js +16 -16
  164. package/dist/who-knows.js +15 -15
  165. package/package.json +2 -2
  166. package/src/abstraction-nodes.ts +7 -2
  167. package/src/cue-anchors.ts +8 -3
  168. package/src/harmonic-graph-cache.test.ts +244 -0
  169. package/src/harmonic-graph-version.ts +46 -0
  170. package/src/harmonic-retrieval.ts +92 -2
  171. package/src/orchestration/extraction-persist-failure.test.ts +90 -0
  172. package/src/orchestration/extraction-persist-failure.ts +39 -1
  173. package/src/orchestration/extraction-persist.ts +29 -21
  174. package/src/orchestration/recall-internal.ts +1 -1
  175. package/src/orchestrator-persist-branch-failure.test.ts +271 -0
  176. package/dist/chunk-BPK32FRM.js.map +0 -1
  177. package/dist/chunk-F2XNSIPP.js.map +0 -1
  178. package/dist/chunk-LXATZLSC.js.map +0 -1
  179. /package/dist/{capsule-merge-6IDAYTKG.js.map → capsule-merge-ILR35DIB.js.map} +0 -0
  180. /package/dist/{chunk-QHARXWTC.js.map → chunk-2LCX6TAD.js.map} +0 -0
  181. /package/dist/{chunk-UN4RVUG2.js.map → chunk-2R3ACFYN.js.map} +0 -0
  182. /package/dist/{chunk-RQFXZYTF.js.map → chunk-33C4BK7I.js.map} +0 -0
  183. /package/dist/{chunk-UBEKET2V.js.map → chunk-3R63SXC5.js.map} +0 -0
  184. /package/dist/{chunk-LNGELD2M.js.map → chunk-3VWU3KF3.js.map} +0 -0
  185. /package/dist/{chunk-FWRXW7CZ.js.map → chunk-4CXK3GT3.js.map} +0 -0
  186. /package/dist/{chunk-DNY63QWJ.js.map → chunk-4HK7DYOL.js.map} +0 -0
  187. /package/dist/{chunk-LLPMMMWG.js.map → chunk-4IJFJQPD.js.map} +0 -0
  188. /package/dist/{chunk-GBJUTX44.js.map → chunk-4WKR4P7H.js.map} +0 -0
  189. /package/dist/{chunk-ILY5SSOM.js.map → chunk-53ZVHXVZ.js.map} +0 -0
  190. /package/dist/{chunk-FQAQ63QA.js.map → chunk-5AJIGWY4.js.map} +0 -0
  191. /package/dist/{chunk-CIWMTNHW.js.map → chunk-5NFF2TSD.js.map} +0 -0
  192. /package/dist/{chunk-D5XG2KPS.js.map → chunk-7E7J7QXB.js.map} +0 -0
  193. /package/dist/{chunk-2WCLTWV2.js.map → chunk-7NVK6AGC.js.map} +0 -0
  194. /package/dist/{chunk-PC5DRBGD.js.map → chunk-AP4BFWSW.js.map} +0 -0
  195. /package/dist/{chunk-CL4JU3DF.js.map → chunk-B7L4ZYFY.js.map} +0 -0
  196. /package/dist/{chunk-LQ5SS4OP.js.map → chunk-D6IGZYTN.js.map} +0 -0
  197. /package/dist/{chunk-XJ2S5UBF.js.map → chunk-DWWX3FU3.js.map} +0 -0
  198. /package/dist/{chunk-IL3RHBWM.js.map → chunk-EJ23D4JG.js.map} +0 -0
  199. /package/dist/{chunk-F4QFU5IZ.js.map → chunk-EZ7XJJ5L.js.map} +0 -0
  200. /package/dist/{chunk-FZZMLGWQ.js.map → chunk-F4BUSX3D.js.map} +0 -0
  201. /package/dist/{chunk-DPPLNZ3A.js.map → chunk-FM4P7HSW.js.map} +0 -0
  202. /package/dist/{chunk-QDESY76F.js.map → chunk-FQACH7MC.js.map} +0 -0
  203. /package/dist/{chunk-XPXHQI6N.js.map → chunk-FVMEZ57K.js.map} +0 -0
  204. /package/dist/{chunk-ZDV5YWID.js.map → chunk-GLDIMGOS.js.map} +0 -0
  205. /package/dist/{chunk-YIXJSW5M.js.map → chunk-HA5DASDG.js.map} +0 -0
  206. /package/dist/{chunk-S3S72HRM.js.map → chunk-HCCRWSP7.js.map} +0 -0
  207. /package/dist/{chunk-NMGHGTVR.js.map → chunk-HRMOKGQM.js.map} +0 -0
  208. /package/dist/{chunk-OUVYYKYU.js.map → chunk-IBKGQ6EY.js.map} +0 -0
  209. /package/dist/{chunk-RNG7S3FY.js.map → chunk-ICHLN5L6.js.map} +0 -0
  210. /package/dist/{chunk-R23JWJD3.js.map → chunk-J426U7XK.js.map} +0 -0
  211. /package/dist/{chunk-QMFAMOT2.js.map → chunk-JH5AV542.js.map} +0 -0
  212. /package/dist/{chunk-WGGRZYSQ.js.map → chunk-KE3JWIZK.js.map} +0 -0
  213. /package/dist/{chunk-SQ3GEIEL.js.map → chunk-KUQLUYIR.js.map} +0 -0
  214. /package/dist/{chunk-RKABLU5M.js.map → chunk-KVHGCISP.js.map} +0 -0
  215. /package/dist/{chunk-RUTN5MMC.js.map → chunk-LRLQ2PRP.js.map} +0 -0
  216. /package/dist/{chunk-X5HGU2EM.js.map → chunk-NT772J7I.js.map} +0 -0
  217. /package/dist/{chunk-4EZWSDFL.js.map → chunk-NX4XDDFQ.js.map} +0 -0
  218. /package/dist/{chunk-IAYUTGF2.js.map → chunk-O3GZPLVY.js.map} +0 -0
  219. /package/dist/{chunk-IGOW4TJE.js.map → chunk-O3JG4PD3.js.map} +0 -0
  220. /package/dist/{chunk-UHMHJX5G.js.map → chunk-OIG4ZFMB.js.map} +0 -0
  221. /package/dist/{chunk-IQ55QGY5.js.map → chunk-R4JYSBZE.js.map} +0 -0
  222. /package/dist/{chunk-Z3FTSHT5.js.map → chunk-RUQE5C32.js.map} +0 -0
  223. /package/dist/{chunk-ESLOLCD4.js.map → chunk-RUWKIPWJ.js.map} +0 -0
  224. /package/dist/{chunk-W54Z2MOP.js.map → chunk-RXSGBO7Y.js.map} +0 -0
  225. /package/dist/{chunk-BOK6BN5M.js.map → chunk-THV6PZ7V.js.map} +0 -0
  226. /package/dist/{chunk-J7BIFOYP.js.map → chunk-U6TC6Z5B.js.map} +0 -0
  227. /package/dist/{chunk-X2BVB74H.js.map → chunk-UZQXJDOE.js.map} +0 -0
  228. /package/dist/{chunk-SUTEBYGI.js.map → chunk-V7T45MI6.js.map} +0 -0
  229. /package/dist/{chunk-A4F7OIC5.js.map → chunk-WVIWDMFB.js.map} +0 -0
  230. /package/dist/{chunk-BN2EBCLB.js.map → chunk-XPCUKWLE.js.map} +0 -0
  231. /package/dist/{chunk-OJXK5C6Z.js.map → chunk-ZG4GNPRE.js.map} +0 -0
  232. /package/dist/{chunk-TYRWSE4B.js.map → chunk-ZUMHQEW4.js.map} +0 -0
  233. /package/dist/{chunk-DAOMWGO2.js.map → chunk-ZX5N5D6M.js.map} +0 -0
  234. /package/dist/{entity-canonical-id-migration-JJSTZF73.js.map → entity-canonical-id-migration-IG77BOGD.js.map} +0 -0
  235. /package/dist/{resolution-OVVIF7ZY.js.map → harmonic-graph-version.js.map} +0 -0
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { realpath } from "node:fs/promises";
3
3
  import { listJsonFilesStrict, readJsonFile } from "./json-store.js";
4
4
  import { throwIfAborted } from "./abort-error.js";
5
+ import { readHarmonicGraphStamp } from "./harmonic-graph-version.js";
5
6
  import { inferMemoryStatus, toMemoryPathRel } from "./memory-lifecycle-ledger-utils.js";
6
7
  import {
7
8
  HARMONIC_SOURCE_MEMORY_INSERTED_AT_KEY,
@@ -403,7 +404,7 @@ export interface ProjectedHarmonicGraph {
403
404
  * one of that node's surviving active sources, and is dropped once no
404
405
  * reference survives.
405
406
  */
406
- export async function readProjectedHarmonicGraph(options: {
407
+ async function buildProjectedHarmonicGraph(options: {
407
408
  memoryDir: string;
408
409
  abstractionNodeStoreDir?: string;
409
410
  anchorsEnabled: boolean;
@@ -460,6 +461,90 @@ export async function readProjectedHarmonicGraph(options: {
460
461
  return { nodes: projectedNodes, anchors };
461
462
  }
462
463
 
464
+ const GRAPH_CACHE_TTL_MS = 30_000;
465
+ const GRAPH_CACHE_MAX_ENTRIES = 8;
466
+
467
+ interface GraphCacheEntry {
468
+ stamp: string;
469
+ startedAtMs: number;
470
+ graph: ProjectedHarmonicGraph;
471
+ }
472
+
473
+ const graphCache = new Map<string, GraphCacheEntry>();
474
+ /** Counters for tests and diagnostics. */
475
+ export const harmonicGraphCacheStats = { hits: 0, misses: 0 };
476
+
477
+ /** Freeze records and their nested arrays/objects so a caller cannot corrupt the shared cache. */
478
+ function deepFreeze<T extends object>(value: T): T {
479
+ // isFrozen is true for primitives and null, and a frozen value is not revisited, so this ends on cycles too.
480
+ if (Object.isFrozen(value)) return value;
481
+ Object.freeze(value);
482
+ for (const child of Object.values(value)) deepFreeze(child);
483
+ return value;
484
+ }
485
+
486
+ export function clearHarmonicGraphCache(): void {
487
+ graphCache.clear();
488
+ }
489
+
490
+ /**
491
+ * `readProjectedHarmonicGraph` with an opt-in in-memory cache. Every recall used
492
+ * to re-read all nodes, anchors and source memories from disk (about 4.7 s on a
493
+ * 2107 node, 9199 anchor, 4105 source graph). With `useCache` a completed graph is
494
+ * reused while the stamp below is unchanged: the memory corpus, memory status and
495
+ * cold-write version sentinels (bumped by every memory write, in any process) and
496
+ * the graph version sentinel (bumped by node and anchor writes). A stamp read is
497
+ * four stats, not a scan of the memories. The TTL bounds the one input a sentinel
498
+ * cannot see, which is wall-clock validity expiry. An explicit `nowMs` bypasses the
499
+ * cache so tests stay deterministic. The cached arrays are frozen: callers must not
500
+ * mutate them.
501
+ */
502
+ export async function readProjectedHarmonicGraph(options: {
503
+ memoryDir: string;
504
+ abstractionNodeStoreDir?: string;
505
+ anchorsEnabled: boolean;
506
+ abortSignal?: AbortSignal;
507
+ temporalExpiredInInjection?: boolean;
508
+ nowMs?: number;
509
+ useCache?: boolean;
510
+ }): Promise<ProjectedHarmonicGraph> {
511
+ if (options.useCache !== true || options.nowMs !== undefined) return buildProjectedHarmonicGraph(options);
512
+ // Same cancellation contract as the builder: a cache hit must not mask an aborted signal.
513
+ throwIfAborted(options.abortSignal, "harmonic retrieval aborted");
514
+ const nodeStoreDir = resolveAbstractionNodeStoreDir(options.memoryDir, options.abstractionNodeStoreDir);
515
+ const key = JSON.stringify([
516
+ options.memoryDir,
517
+ nodeStoreDir,
518
+ options.anchorsEnabled === true,
519
+ options.temporalExpiredInInjection === true,
520
+ ]);
521
+ // The stamp is read BEFORE the build, so a write that lands mid-build leaves the
522
+ // entry stale and the next call rebuilds.
523
+ const stamp = readHarmonicGraphStamp(options.memoryDir, nodeStoreDir);
524
+ const startedAtMs = Date.now();
525
+ const hit = graphCache.get(key);
526
+ if (hit && hit.stamp === stamp && startedAtMs - hit.startedAtMs < GRAPH_CACHE_TTL_MS) {
527
+ harmonicGraphCacheStats.hits++;
528
+ graphCache.delete(key);
529
+ graphCache.set(key, hit);
530
+ return hit.graph;
531
+ }
532
+ harmonicGraphCacheStats.misses++;
533
+ const built = await buildProjectedHarmonicGraph(options);
534
+ const graph = {
535
+ nodes: deepFreeze(built.nodes),
536
+ anchors: deepFreeze(built.anchors),
537
+ };
538
+ graphCache.delete(key);
539
+ graphCache.set(key, { stamp, startedAtMs, graph });
540
+ while (graphCache.size > GRAPH_CACHE_MAX_ENTRIES) {
541
+ const oldest = graphCache.keys().next().value;
542
+ if (oldest === undefined) break;
543
+ graphCache.delete(oldest);
544
+ }
545
+ return graph;
546
+ }
547
+
463
548
  export async function searchHarmonicRetrieval(options: {
464
549
  memoryDir: string;
465
550
  abstractionNodeStoreDir?: string;
@@ -469,6 +554,8 @@ export async function searchHarmonicRetrieval(options: {
469
554
  anchorsEnabled: boolean;
470
555
  abortSignal?: AbortSignal;
471
556
  temporalExpiredInInjection?: boolean;
557
+ /** Share one projected graph between recalls until a write bumps a version sentinel (see readProjectedHarmonicGraph). */
558
+ useGraphCache?: boolean;
472
559
  }): Promise<HarmonicRetrievalResult[]> {
473
560
  throwIfAborted(options.abortSignal, "harmonic retrieval aborted");
474
561
  const queryTokens = new Set(normalizeRecallTokens(options.query, ["what", "which"]));
@@ -476,7 +563,10 @@ export async function searchHarmonicRetrieval(options: {
476
563
 
477
564
  // One projection for every graph reader: nodes and anchors both arrive
478
565
  // already narrowed to the namespace's current active memories.
479
- const { nodes: eligibleNodes, anchors } = await readProjectedHarmonicGraph(options);
566
+ const { nodes: eligibleNodes, anchors } = await readProjectedHarmonicGraph({
567
+ ...options,
568
+ useCache: options.useGraphCache === true,
569
+ });
480
570
  const candidates = new Map<string, HarmonicCandidate>();
481
571
 
482
572
  for (const node of eligibleNodes) {
@@ -0,0 +1,90 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { CommittedWriteFinalization } from "./extraction-persist-failure.js";
5
+
6
+ function recordingFinalizer() {
7
+ const calls: boolean[] = [];
8
+ return {
9
+ calls,
10
+ finalize: async (scheduleQmd: boolean) => {
11
+ calls.push(scheduleQmd);
12
+ },
13
+ };
14
+ }
15
+
16
+ test("#3288: the bound finalizer runs once across the success tail and catch sites", async () => {
17
+ const finalization = new CommittedWriteFinalization();
18
+ const { calls, finalize } = recordingFinalizer();
19
+ const once = finalization.bind(finalize);
20
+
21
+ const value = await finalization.run(async () => {
22
+ await once(true);
23
+ await once(false);
24
+ return "persisted";
25
+ });
26
+
27
+ assert.equal(value, "persisted");
28
+ assert.deepEqual(calls, [true], "a second caller reuses the first run instead of finalizing again");
29
+ });
30
+
31
+ test("#3288: the pass boundary finalizes with QMD scheduling, then rethrows the original error", async () => {
32
+ const finalization = new CommittedWriteFinalization();
33
+ const { calls, finalize } = recordingFinalizer();
34
+ const cause = new Error("branch without its own catch site");
35
+
36
+ await assert.rejects(
37
+ finalization.run(async () => {
38
+ finalization.bind(finalize);
39
+ throw cause;
40
+ }),
41
+ (error: Error) => error === cause,
42
+ );
43
+ assert.deepEqual(calls, [true]);
44
+ });
45
+
46
+ test("#3288: the pass boundary does not finalize again after a catch site already did", async () => {
47
+ const finalization = new CommittedWriteFinalization();
48
+ const { calls, finalize } = recordingFinalizer();
49
+ const cause = new Error("catch site rethrow");
50
+
51
+ await assert.rejects(
52
+ finalization.run(async () => {
53
+ const once = finalization.bind(finalize);
54
+ await once(true);
55
+ throw cause;
56
+ }),
57
+ (error: Error) => error === cause,
58
+ );
59
+ assert.deepEqual(calls, [true]);
60
+ });
61
+
62
+ test("#3288: a throw before the finalizer is bound propagates without finalizing", async () => {
63
+ const finalization = new CommittedWriteFinalization();
64
+ const cause = new Error("failed before any write");
65
+
66
+ await assert.rejects(
67
+ finalization.run(async () => {
68
+ throw cause;
69
+ }),
70
+ (error: Error) => error === cause,
71
+ );
72
+ });
73
+
74
+ test("#3288: a failing finalizer does not mask the original error", async () => {
75
+ const finalization = new CommittedWriteFinalization();
76
+ const cause = new Error("branch failure");
77
+ let attempts = 0;
78
+
79
+ await assert.rejects(
80
+ finalization.run(async () => {
81
+ finalization.bind(async () => {
82
+ attempts += 1;
83
+ throw new Error("finalization failure");
84
+ });
85
+ throw cause;
86
+ }),
87
+ (error: Error) => error === cause,
88
+ );
89
+ assert.equal(attempts, 1);
90
+ });
@@ -1,5 +1,7 @@
1
1
  import { log } from "../logger.js";
2
2
 
3
+ type FinalizeCommittedWrites = (scheduleQmd: boolean) => Promise<void>;
4
+
3
5
  /**
4
6
  * Fail a persistence pass after a durable write rejected (#3227, same
5
7
  * contract as the entity write path from #3228). Earlier writes in the batch
@@ -8,7 +10,7 @@ import { log } from "../logger.js";
8
10
  * instead of recording the fingerprint and clearing the source turns.
9
11
  */
10
12
  export async function rethrowAfterCommittedWriteFinalization(
11
- finalizeCommittedWrites: (scheduleQmd: boolean) => Promise<void>,
13
+ finalizeCommittedWrites: FinalizeCommittedWrites,
12
14
  cause: unknown,
13
15
  outputKind: string,
14
16
  ): Promise<never> {
@@ -17,3 +19,39 @@ export async function rethrowAfterCommittedWriteFinalization(
17
19
  );
18
20
  throw cause;
19
21
  }
22
+
23
+ /**
24
+ * Batch-end finalization for one persistExtraction pass (#3288).
25
+ *
26
+ * `bind` wraps the pass's finalizer so it runs at most once. The success
27
+ * tail, the per-branch catch sites and the pass boundary can all reach it,
28
+ * and a second run would append the same behavior signals again.
29
+ *
30
+ * `run` is the pass boundary. A throw from a branch with no catch site of its
31
+ * own (a chunked-parent post-write step, the semantic-merge post effects, the
32
+ * profile or question write) still finalizes the writes already committed
33
+ * before the original error propagates. When a catch site or the success tail
34
+ * already ran the finalizer, the boundary only rethrows. Nothing is durable
35
+ * before the finalizer is bound, so an earlier throw propagates as is.
36
+ */
37
+ export class CommittedWriteFinalization {
38
+ private finalizer: FinalizeCommittedWrites | undefined;
39
+ private started: Promise<void> | undefined;
40
+
41
+ bind(finalize: FinalizeCommittedWrites): FinalizeCommittedWrites {
42
+ this.finalizer = (scheduleQmd) => (this.started ??= finalize(scheduleQmd));
43
+ return this.finalizer;
44
+ }
45
+
46
+ async run<T>(pass: () => Promise<T>): Promise<T> {
47
+ try {
48
+ return await pass();
49
+ } catch (err) {
50
+ if (!this.finalizer || this.started) throw err;
51
+ return rethrowAfterCommittedWriteFinalization(this.finalizer, err, "persist pass");
52
+ }
53
+ }
54
+ }
55
+
56
+ /** A pass method's parameter list without its leading CommittedWriteFinalization. */
57
+ export type ArgsAfterFinalization<Params> = Params extends [CommittedWriteFinalization, ...infer Rest] ? Rest : never;
@@ -1,7 +1,7 @@
1
1
  import { classifyExtractionOrigin, type ExtractionSourceContext } from "./extraction-origin-context.js";
2
2
  import { confirmActiveFactDuplicate, persistUnderFactDedupWindow } from "./fact-dedup-window.js";
3
3
  import { writePromotionUnderFactDedupWindow } from "./promotion-dedup-window.js";
4
- import { rethrowAfterCommittedWriteFinalization } from "./extraction-persist-failure.js";
4
+ import { type ArgsAfterFinalization, CommittedWriteFinalization, rethrowAfterCommittedWriteFinalization } from "./extraction-persist-failure.js";
5
5
  import { evaluateInjectionScreen, screenEntityForIndex, screenPersistStrings } from "./extraction-injection-gate.js";
6
6
  /**
7
7
  * Extraction persistence coordinator (issue #1526, seam 16).
@@ -152,7 +152,13 @@ export class ExtractionPersistCoordinator {
152
152
  return this.deps.getDependencyPropagationDelivery();
153
153
  }
154
154
 
155
- async persistExtraction(
155
+ persistExtraction(...args: ArgsAfterFinalization<Parameters<ExtractionPersistCoordinator["persistExtractionPass"]>>): Promise<ExtractionPersistResult> {
156
+ const finalization = new CommittedWriteFinalization(); // #3288: one batch-end finalization boundary per pass
157
+ return finalization.run(() => this.persistExtractionPass(finalization, ...args));
158
+ }
159
+
160
+ private async persistExtractionPass(
161
+ finalization: CommittedWriteFinalization,
156
162
  result: ExtractionResult,
157
163
  storage: StorageManager,
158
164
  threadIdForExtraction?: string | null,
@@ -1125,7 +1131,7 @@ export class ExtractionPersistCoordinator {
1125
1131
  0,
1126
1132
  this.deps.config.extractionMaxProfileUpdatesPerRun,
1127
1133
  );
1128
- const finalizeCommittedWrites = async (scheduleQmd: boolean): Promise<void> => {
1134
+ const finalizeCommittedWrites = finalization.bind(async (scheduleQmd: boolean): Promise<void> => {
1129
1135
  await finalizeExtractionCommittedWrites({
1130
1136
  saveContentHashIndexes: () => this.deps.saveContentHashIndexes(),
1131
1137
  behaviorSignalsByStorage: behaviorSignalsByStorage.values(),
@@ -1150,7 +1156,7 @@ export class ExtractionPersistCoordinator {
1150
1156
  requestQmdMaintenance: () => this.deps.requestQmdMaintenance(),
1151
1157
  scheduleQmd,
1152
1158
  });
1153
- };
1159
+ });
1154
1160
 
1155
1161
  if (
1156
1162
  facts.length < result.facts.length ||
@@ -2211,6 +2217,24 @@ export class ExtractionPersistCoordinator {
2211
2217
  const tombstoneBlocked = parentWrite.tombstoneBlocked;
2212
2218
  const postWriteGuard =
2213
2219
  faithfulnessEnforceStatus === "pending_review" || tombstoneBlocked;
2220
+ // #3288: the parent is durable now. Track it before any post-write step
2221
+ // that can throw (non-chunked rule, #3231); a retry exact-dedupes it.
2222
+ trackBehaviorSignals(
2223
+ targetStorage,
2224
+ buildBehaviorSignalsForMemory({
2225
+ memoryId: parentId,
2226
+ category: writeCategory,
2227
+ content: fact.content,
2228
+ namespace: this.deps.storageDirNamespace(targetStorage.dir),
2229
+ confidence: fact.confidence,
2230
+ source: "extraction",
2231
+ }),
2232
+ );
2233
+ trackPersistedId(targetStorage, parentId, {
2234
+ pendingReview: postWriteGuard,
2235
+ category: writeCategory,
2236
+ harmonicFact,
2237
+ });
2214
2238
  // #1645: defer contradiction auto-resolve until tombstone status is
2215
2239
  // known (see applyDeferredContradictionResolve).
2216
2240
  const contradictionOutcome = await this.deps.applyDeferredContradictionResolve(
@@ -2306,11 +2330,6 @@ export class ExtractionPersistCoordinator {
2306
2330
  log.debug(
2307
2331
  `chunked memory ${parentId} into ${chunkResult.chunks.length} chunks`,
2308
2332
  );
2309
- trackPersistedId(targetStorage, parentId, {
2310
- pendingReview: postWriteGuard,
2311
- category: writeCategory,
2312
- harmonicFact,
2313
- });
2314
2333
  await anchorSnapshots.replace(targetStorage, parentId, writeCategory, memoryPathById);
2315
2334
  // #1576 (cursor Medium): keep pending_review ids out of threadEpisodeIdsForGraph — else later active facts build thread-predecessor edges to an unfaithful memory.
2316
2335
  if (
@@ -2490,17 +2509,6 @@ export class ExtractionPersistCoordinator {
2490
2509
  } finally {
2491
2510
  // #1522: the catalog write touch lives in the storage chokepoint.
2492
2511
  }
2493
- trackBehaviorSignals(
2494
- targetStorage,
2495
- buildBehaviorSignalsForMemory({
2496
- memoryId: parentId,
2497
- category: writeCategory,
2498
- content: fact.content,
2499
- namespace: this.deps.storageDirNamespace(targetStorage.dir),
2500
- confidence: fact.confidence,
2501
- source: "extraction",
2502
- }),
2503
- );
2504
2512
  continue; // Skip the normal write below
2505
2513
  }
2506
2514
  }
@@ -2967,7 +2975,7 @@ export class ExtractionPersistCoordinator {
2967
2975
  }
2968
2976
 
2969
2977
  // Shared batch-end finalization (hash, behavior, harmonic, temporal).
2970
- // Failure paths call the same helper with scheduleQmd=true (#3231).
2978
+ // Failure paths call it with scheduleQmd=true (#3231); it runs once per pass (#3288).
2971
2979
  await finalizeCommittedWrites(false);
2972
2980
 
2973
2981
  const dedupSuffix = `${dedupedCount > 0 ? ` (${dedupedCount} deduped)` : ""}${semanticMergedCount > 0 ? ` (${semanticMergedCount} merged)` : ""}`;
@@ -1537,7 +1537,7 @@ export class RecallInternalCoordinator {
1537
1537
  sessionKey,
1538
1538
  anchorsEnabled: resolveConsolidationCapabilities(this.deps.config).abstractionAnchors,
1539
1539
  temporalExpiredInInjection: this.deps.config.temporalExpiredInInjection,
1540
- abortSignal: harmonicRetrievalAbort.signal,
1540
+ abortSignal: harmonicRetrievalAbort.signal, useGraphCache: true,
1541
1541
  }),
1542
1542
  ),
1543
1543
  );
@@ -0,0 +1,271 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import test from "node:test";
5
+
6
+ import { resetExtractionLlmHealthForTests } from "./extraction-llm-health.js";
7
+ import { Orchestrator } from "./orchestrator.js";
8
+ import type { StorageManager } from "./storage.js";
9
+ import {
10
+ cleanupDir,
11
+ harmonicNodeStoreStatus,
12
+ makeHarmonicLifecycleConfig,
13
+ mkTempMemoryDir,
14
+ } from "./testing/orchestrator-lite.js";
15
+ import type { ExtractionResult } from "./types.js";
16
+
17
+ /**
18
+ * #3288: a branch that throws after an earlier fact in the same batch is
19
+ * durable must still land that fact's batch-end effects (behavior signal,
20
+ * harmonic node, temporal/tag index entry, QMD request) before the error
21
+ * reaches the caller. A retry exact-dedupes the earlier fact, so this failure
22
+ * path is the only chance those effects get.
23
+ */
24
+ const EARLIER = "Prefers terse queue names for background jobs in Project Gamma.";
25
+
26
+ type BranchCase = {
27
+ label: string;
28
+ config?: Parameters<typeof makeHarmonicLifecycleConfig>[1];
29
+ /** Outputs persisted after the EARLIER fact; one of them hits the injected failure. */
30
+ later: Partial<ExtractionResult>;
31
+ /** Installs the failure and returns the error the pass must rethrow. */
32
+ inject: (orchestrator: Orchestrator, storage: StorageManager, memoryDir: string) => Promise<Error>;
33
+ /**
34
+ * Proves the failing branch had already committed its own write when it
35
+ * threw and returns that write's id. It is durable too, so batch-end
36
+ * finalization must cover it as well as the earlier fact.
37
+ */
38
+ committedBeforeFailure?: (storage: StorageManager) => Promise<string>;
39
+ };
40
+
41
+ async function assertEarlierFactFinalized(branch: BranchCase): Promise<void> {
42
+ const memoryDir = await mkTempMemoryDir(`persist-branch-${branch.label}`);
43
+ const config = makeHarmonicLifecycleConfig(memoryDir, {
44
+ chunkingEnabled: false,
45
+ multiGraphMemoryEnabled: false,
46
+ factDeduplicationEnabled: true,
47
+ extractionMinImportanceLevel: "trivial",
48
+ entityActivityLogEnabled: false,
49
+ queryAwareIndexingEnabled: true,
50
+ ...branch.config,
51
+ });
52
+ const orchestrator = new Orchestrator(config);
53
+ const storage = await orchestrator.getStorageForNamespace();
54
+ const temporalIdBatches: string[][] = [];
55
+ // Finalization starts the temporal/tag update without awaiting it; cleanup
56
+ // waits for it so the temp dir is not removed under its lock.
57
+ const temporalUpdates: Promise<void>[] = [];
58
+ // Bracket access is TypeScript's typed escape hatch for the private method; the spy only wraps it.
59
+ const originalTemporal = orchestrator["updateTemporalTagIndexes"].bind(orchestrator);
60
+ orchestrator["updateTemporalTagIndexes"] = (target, persistedIds) => {
61
+ temporalIdBatches.push([...persistedIds]);
62
+ const update = originalTemporal(target, persistedIds);
63
+ temporalUpdates.push(update);
64
+ return update;
65
+ };
66
+ let qmdMaintenanceCalls = 0;
67
+ const originalQmd = orchestrator.requestQmdMaintenance.bind(orchestrator);
68
+ orchestrator.requestQmdMaintenance = () => {
69
+ qmdMaintenanceCalls += 1;
70
+ originalQmd();
71
+ };
72
+ try {
73
+ await storage.ensureDirectories();
74
+ const failure = await branch.inject(orchestrator, storage, memoryDir);
75
+ const result: ExtractionResult = {
76
+ entities: [],
77
+ questions: [],
78
+ profileUpdates: [],
79
+ ...branch.later,
80
+ facts: [
81
+ { content: EARLIER, category: "preference", confidence: 0.95, tags: ["queue-naming"] },
82
+ ...(branch.later.facts ?? []),
83
+ ],
84
+ };
85
+
86
+ await assert.rejects(
87
+ orchestrator.persistExtraction(result, storage, null, { sessionKey: `session:${branch.label}` }),
88
+ (error: Error) => error === failure,
89
+ "the branch failure must reach the caller unchanged",
90
+ );
91
+ const branchWriteId = await branch.committedBeforeFailure?.(storage);
92
+
93
+ const earlier = (await storage.readAllMemories()).filter((m) => m.frontmatter.category === "preference");
94
+ assert.equal(earlier.length, 1, "the earlier fact must stay durable");
95
+ const factId = earlier[0]!.frontmatter.id;
96
+ assert.ok(
97
+ (await storage.readBehaviorSignals(50)).some(
98
+ (s) => s.memoryId === factId && s.signalType === "preference_affinity",
99
+ ),
100
+ "the earlier fact's behavior signal must land before the rethrow",
101
+ );
102
+ const harmonicSources = (await harmonicNodeStoreStatus(memoryDir)).latestNode?.sourceMemoryIds ?? [];
103
+ for (const id of branchWriteId ? [factId, branchWriteId] : [factId]) {
104
+ assert.ok(harmonicSources.includes(id), `the harmonic node must cover durable write ${id} before the rethrow`);
105
+ assert.ok(
106
+ temporalIdBatches.some((ids) => ids.includes(id)),
107
+ `the temporal/tag index update must cover durable write ${id} before the rethrow`,
108
+ );
109
+ }
110
+ assert.ok(qmdMaintenanceCalls > 0, "the partial batch must still schedule QMD maintenance");
111
+ } finally {
112
+ await Promise.allSettled(temporalUpdates);
113
+ orchestrator.requestQmdMaintenance = originalQmd;
114
+ orchestrator["updateTemporalTagIndexes"] = originalTemporal;
115
+ try {
116
+ await orchestrator.destroy();
117
+ } finally {
118
+ resetExtractionLlmHealthForTests();
119
+ await cleanupDir(memoryDir);
120
+ }
121
+ }
122
+ }
123
+
124
+ test("#3288: a chunk write failure after the chunked parent is durable finalizes the earlier fact", async () => {
125
+ await assertEarlierFactFinalized({
126
+ label: "chunk-write",
127
+ config: {
128
+ chunkingEnabled: true,
129
+ chunkingTargetTokens: 12,
130
+ chunkingMinTokens: 10,
131
+ chunkingOverlapSentences: 0,
132
+ },
133
+ later: {
134
+ facts: [
135
+ {
136
+ content:
137
+ "Project Gamma runs its nightly export on a dedicated worker pool. " +
138
+ "The export writes compressed archives to the cold storage bucket. " +
139
+ "Each archive is verified against a checksum manifest before upload. " +
140
+ "Failed uploads are retried three times with exponential backoff.",
141
+ category: "fact",
142
+ confidence: 0.9,
143
+ tags: ["export"],
144
+ },
145
+ ],
146
+ },
147
+ inject: async (_orchestrator, storage) => {
148
+ const failure = new Error("synthetic chunk write failure");
149
+ storage.writeChunk = async () => {
150
+ throw failure;
151
+ };
152
+ return failure;
153
+ },
154
+ committedBeforeFailure: async (storage) => {
155
+ const parents = (await storage.readAllMemories()).filter((m) => m.frontmatter.tags?.includes("chunked"));
156
+ assert.equal(parents.length, 1, "the chunked parent must be durable before the chunk write throws");
157
+ return parents[0]!.frontmatter.id;
158
+ },
159
+ });
160
+ });
161
+
162
+ const MERGE_TARGET_ID = "merge-target-gamma";
163
+ const MERGE_SEED = "The Project Gamma billing service deploys on a Tuesday cadence.";
164
+ const MERGE_INCOMING = "The Project Gamma billing service deploys at 09:00 UTC sharp.";
165
+
166
+ /** Seed an active merge target and a deterministic merge judge for MERGE_INCOMING only. */
167
+ async function armSemanticMerge(orchestrator: Orchestrator, memoryDir: string): Promise<void> {
168
+ const created = new Date(Date.now() - 3600_000).toISOString();
169
+ const seededDir = path.join(memoryDir, "facts", created.slice(0, 10));
170
+ await mkdir(seededDir, { recursive: true });
171
+ await writeFile(
172
+ path.join(seededDir, `${MERGE_TARGET_ID}.md`),
173
+ [
174
+ "---",
175
+ `id: ${MERGE_TARGET_ID}`,
176
+ "category: fact",
177
+ `created: ${created}`,
178
+ `updated: ${created}`,
179
+ "source: extraction",
180
+ "confidence: 0.9",
181
+ "confidenceTier: explicit",
182
+ "status: active",
183
+ "importanceScore: 0.9",
184
+ "importanceLevel: high",
185
+ "---",
186
+ "",
187
+ MERGE_SEED,
188
+ "",
189
+ ].join("\n"),
190
+ "utf8",
191
+ );
192
+ // Band neighbor for the incoming fact only ([minSimilarity, threshold)).
193
+ const embeddings = orchestrator.embeddingFallback;
194
+ embeddings.isAvailable = async () => true;
195
+ embeddings.search = async (query) => (query === MERGE_INCOMING ? [{ id: MERGE_TARGET_ID, score: 0.85, path: "" }] : []);
196
+ embeddings.indexFile = async () => {};
197
+ embeddings.removeFromIndex = async () => {};
198
+ // Deterministic merge judge; every other local-LLM prompt gets no answer.
199
+ orchestrator.localLlm.chatCompletion = async (messages) =>
200
+ messages[0]?.role === "system" && messages[0].content.startsWith("You maintain a long-term memory store")
201
+ ? {
202
+ content: JSON.stringify({
203
+ decision: "merge",
204
+ targetId: MERGE_TARGET_ID,
205
+ mergedContent: `${MERGE_SEED} ${MERGE_INCOMING}`,
206
+ reason: "deterministic judge for the merge-branch failure test",
207
+ }),
208
+ }
209
+ : null;
210
+ }
211
+
212
+ test("#3288: a semantic-merge post-effect failure after the merge commits finalizes the earlier fact", async () => {
213
+ await assertEarlierFactFinalized({
214
+ label: "semantic-merge",
215
+ config: {
216
+ embeddingFallbackEnabled: true,
217
+ versioningEnabled: true,
218
+ semanticMerge: { enabled: true },
219
+ multiGraphMemoryEnabled: true,
220
+ },
221
+ later: {
222
+ facts: [{ content: MERGE_INCOMING, category: "fact", confidence: 0.9, tags: [] }],
223
+ },
224
+ inject: async (orchestrator, _storage, memoryDir) => {
225
+ await armSemanticMerge(orchestrator, memoryDir);
226
+ // The merged-target graph rebuild fails open, but the edge-cache
227
+ // invalidation that follows it is not wrapped: an unavailable graph
228
+ // index escapes runMergedTargetPostEffects after the merge committed.
229
+ const failure = new Error("synthetic graph index failure");
230
+ orchestrator["graphIndexFor"] = () => {
231
+ throw failure;
232
+ };
233
+ return failure;
234
+ },
235
+ committedBeforeFailure: async (storage) => {
236
+ const target = await storage.getMemoryByIdIncludingArchived(MERGE_TARGET_ID);
237
+ assert.ok(target?.content.includes(MERGE_INCOMING), "the merge must be committed before the post effect throws");
238
+ return MERGE_TARGET_ID;
239
+ },
240
+ });
241
+ });
242
+
243
+ test("#3288: a profile append failure after an earlier fact is durable finalizes that fact", async () => {
244
+ await assertEarlierFactFinalized({
245
+ label: "profile-append",
246
+ later: { profileUpdates: ["Works mostly on the Project Gamma export pipeline."] },
247
+ inject: async (_orchestrator, storage) => {
248
+ const failure = new Error("synthetic profile append failure");
249
+ storage.appendToProfile = async () => {
250
+ throw failure;
251
+ };
252
+ return failure;
253
+ },
254
+ });
255
+ });
256
+
257
+ test("#3288: a question write failure after an earlier fact is durable finalizes that fact", async () => {
258
+ await assertEarlierFactFinalized({
259
+ label: "question-write",
260
+ later: {
261
+ questions: [{ question: "Which bucket holds the Project Gamma archives?", context: "export pipeline", priority: 0.5 }],
262
+ },
263
+ inject: async (_orchestrator, storage) => {
264
+ const failure = new Error("synthetic question write failure");
265
+ storage.writeQuestion = async () => {
266
+ throw failure;
267
+ };
268
+ return failure;
269
+ },
270
+ });
271
+ });