hippo-memory 1.61.0 → 1.63.0

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 (377) hide show
  1. package/README.md +37 -53
  2. package/dist/agent-memories/apply.d.ts +3 -1
  3. package/dist/agent-memories/apply.js +23 -1
  4. package/dist/agent-memories/claude-code.d.ts +4 -1
  5. package/dist/agent-memories/claude-code.js +53 -9
  6. package/dist/agent-memories/report.d.ts +1 -0
  7. package/dist/agent-memories/report.js +2 -2
  8. package/dist/agent-memories/sync.d.ts +3 -3
  9. package/dist/agent-memories/sync.js +46 -24
  10. package/dist/agent-memories/types.d.ts +0 -2
  11. package/dist/ambient-store.d.ts +4 -4
  12. package/dist/ambient-store.js +4 -3
  13. package/dist/api/assemble.d.ts +7 -10
  14. package/dist/api/assemble.js +62 -66
  15. package/dist/api/audit.d.ts +2 -2
  16. package/dist/api/audit.js +2 -2
  17. package/dist/api/auth.d.ts +6 -6
  18. package/dist/api/auth.js +5 -7
  19. package/dist/api/context-select.d.ts +50 -0
  20. package/dist/api/context-select.js +342 -0
  21. package/dist/api/context-types.d.ts +12 -17
  22. package/dist/api/context.d.ts +5 -4
  23. package/dist/api/context.js +208 -535
  24. package/dist/api/dormant.js +1 -1
  25. package/dist/api/drill-down.d.ts +4 -4
  26. package/dist/api/drill-down.js +56 -41
  27. package/dist/api/outcome.d.ts +8 -13
  28. package/dist/api/outcome.js +13 -17
  29. package/dist/api/promote.d.ts +5 -8
  30. package/dist/api/promote.js +55 -66
  31. package/dist/api/quarantine.js +3 -2
  32. package/dist/api/recall-types.d.ts +43 -55
  33. package/dist/api/recall.d.ts +3 -3
  34. package/dist/api/recall.js +310 -456
  35. package/dist/api/remember.d.ts +2 -2
  36. package/dist/api/sleep.d.ts +7 -35
  37. package/dist/api/sleep.js +206 -220
  38. package/dist/api/tokens.d.ts +2 -2
  39. package/dist/api/tokens.js +2 -2
  40. package/dist/api/types.d.ts +6 -10
  41. package/dist/api/types.js +2 -4
  42. package/dist/audit-prune.d.ts +4 -6
  43. package/dist/audit-prune.js +3 -5
  44. package/dist/audit.js +11 -33
  45. package/dist/auth.d.ts +8 -9
  46. package/dist/auth.js +5 -7
  47. package/dist/autolearn.d.ts +1 -1
  48. package/dist/autolearn.js +1 -1
  49. package/dist/availability.js +3 -5
  50. package/dist/capture/command.d.ts +5 -4
  51. package/dist/capture/command.js +8 -22
  52. package/dist/capture/compact.d.ts +2 -2
  53. package/dist/capture/compact.js +11 -15
  54. package/dist/capture/extract.js +37 -115
  55. package/dist/capture-error.d.ts +1 -1
  56. package/dist/capture-error.js +1 -1
  57. package/dist/churn-git.d.ts +1 -1
  58. package/dist/churn-git.js +1 -1
  59. package/dist/cli/audit.js +3 -4
  60. package/dist/cli/auth.js +3 -6
  61. package/dist/cli/briefs.js +324 -306
  62. package/dist/cli/context.js +44 -34
  63. package/dist/cli/continuity.js +283 -271
  64. package/dist/cli/curate.d.ts +1 -1
  65. package/dist/cli/curate.js +43 -58
  66. package/dist/cli/dag.js +5 -9
  67. package/dist/cli/decisions.js +334 -345
  68. package/dist/cli/explain.js +68 -61
  69. package/dist/cli/goals.js +1 -1
  70. package/dist/cli/init.js +1 -1
  71. package/dist/cli/maintenance.js +62 -51
  72. package/dist/cli/playbooks.js +391 -379
  73. package/dist/cli/projects.js +11 -6
  74. package/dist/cli/recall.js +30 -44
  75. package/dist/cli/remember.js +118 -87
  76. package/dist/cli/session-hooks.js +106 -115
  77. package/dist/cli/setup.d.ts +1 -1
  78. package/dist/cli/setup.js +267 -250
  79. package/dist/cli/shared.js +3 -3
  80. package/dist/cli/slack.js +1 -1
  81. package/dist/cli/sleep.js +27 -1
  82. package/dist/cli/status.d.ts +4 -4
  83. package/dist/cli/status.js +80 -76
  84. package/dist/cli/transfer.js +88 -107
  85. package/dist/cli/usage.js +9 -6
  86. package/dist/cli.d.ts +1 -1
  87. package/dist/cli.js +4 -9
  88. package/dist/compaction-record.d.ts +2 -2
  89. package/dist/compaction-record.js +89 -68
  90. package/dist/compare.d.ts +11 -16
  91. package/dist/compare.js +11 -16
  92. package/dist/config.d.ts +19 -18
  93. package/dist/config.js +90 -67
  94. package/dist/connectors/github/backfill.d.ts +2 -2
  95. package/dist/connectors/github/backfill.js +8 -15
  96. package/dist/connectors/github/cli-impl.js +3 -8
  97. package/dist/connectors/github/deletion.d.ts +5 -12
  98. package/dist/connectors/github/deletion.js +5 -12
  99. package/dist/connectors/github/dlq.d.ts +6 -9
  100. package/dist/connectors/github/dlq.js +2 -3
  101. package/dist/connectors/github/ingest.d.ts +5 -7
  102. package/dist/connectors/github/ingest.js +8 -12
  103. package/dist/connectors/github/octokit-client.d.ts +3 -5
  104. package/dist/connectors/github/octokit-client.js +5 -6
  105. package/dist/connectors/github/signature.d.ts +9 -39
  106. package/dist/connectors/github/signature.js +9 -39
  107. package/dist/connectors/github/tenant-routing.d.ts +1 -1
  108. package/dist/connectors/github/tenant-routing.js +1 -1
  109. package/dist/connectors/github/transform.js +2 -2
  110. package/dist/connectors/github/types.d.ts +2 -10
  111. package/dist/connectors/github/types.js +1 -3
  112. package/dist/connectors/slack/deletion.d.ts +3 -8
  113. package/dist/connectors/slack/deletion.js +3 -8
  114. package/dist/connectors/slack/dlq.d.ts +1 -1
  115. package/dist/connectors/slack/ingest.d.ts +1 -1
  116. package/dist/connectors/slack/ingest.js +7 -16
  117. package/dist/connectors/slack/signature.d.ts +1 -1
  118. package/dist/connectors/slack/tenant-routing.d.ts +3 -5
  119. package/dist/connectors/slack/tenant-routing.js +3 -5
  120. package/dist/connectors/slack/transform.d.ts +5 -6
  121. package/dist/connectors/slack/transform.js +5 -6
  122. package/dist/connectors/slack/types.d.ts +2 -6
  123. package/dist/connectors/slack/types.js +1 -3
  124. package/dist/connectors/slack/web-client.js +10 -3
  125. package/dist/connectors/slack/workspaces.d.ts +3 -5
  126. package/dist/connectors/slack/workspaces.js +3 -5
  127. package/dist/consolidate/conflicts.js +3 -14
  128. package/dist/consolidate/decay.js +9 -29
  129. package/dist/consolidate/llm-passes.js +4 -5
  130. package/dist/consolidate/merge.js +8 -23
  131. package/dist/consolidate/run.d.ts +1 -8
  132. package/dist/consolidate/run.js +3 -25
  133. package/dist/consolidate/sleep.js +5 -17
  134. package/dist/consolidate/traces.js +9 -21
  135. package/dist/customer-notes.d.ts +5 -7
  136. package/dist/customer-notes.js +82 -76
  137. package/dist/dag.d.ts +10 -21
  138. package/dist/dag.js +189 -203
  139. package/dist/db/continuity.js +2 -2
  140. package/dist/db/migrations/v14.js +1 -1
  141. package/dist/db/migrations/v15.js +1 -2
  142. package/dist/db/migrations/v16.js +3 -4
  143. package/dist/db/migrations/v17.js +2 -3
  144. package/dist/db/migrations/v19.js +1 -1
  145. package/dist/db/migrations/v20.js +1 -1
  146. package/dist/db/migrations/v21.js +2 -6
  147. package/dist/db/migrations/v22.js +2 -4
  148. package/dist/db/migrations/v23.js +1 -1
  149. package/dist/db/migrations/v24.js +4 -6
  150. package/dist/db/migrations/v25.js +2 -3
  151. package/dist/db/migrations/v26.js +3 -3
  152. package/dist/db/migrations/v27.js +2 -10
  153. package/dist/db/migrations/v28.js +5 -8
  154. package/dist/db/migrations/v29.js +3 -4
  155. package/dist/db/migrations/v30.js +2 -2
  156. package/dist/db/migrations/v31.js +1 -1
  157. package/dist/db/migrations/v32.js +1 -1
  158. package/dist/db/migrations/v33.js +3 -3
  159. package/dist/db/migrations/v34.js +1 -1
  160. package/dist/db/migrations/v35.js +3 -4
  161. package/dist/db/migrations/v36.js +3 -4
  162. package/dist/db/migrations/v37.js +5 -5
  163. package/dist/db/migrations/v38.js +7 -8
  164. package/dist/db/migrations/v39.js +1 -1
  165. package/dist/db/migrations/v40.js +4 -16
  166. package/dist/db/migrations/v41.js +3 -4
  167. package/dist/db/migrations/v42.js +3 -4
  168. package/dist/db/migrations/v45.js +1 -1
  169. package/dist/db/migrations/v46.js +1 -1
  170. package/dist/db/migrations/v47.js +1 -1
  171. package/dist/db/migrations/v48.js +1 -1
  172. package/dist/decisions.d.ts +2 -2
  173. package/dist/decisions.js +97 -80
  174. package/dist/dedupe.js +86 -61
  175. package/dist/delivery-recorder.js +154 -135
  176. package/dist/doctor.js +129 -110
  177. package/dist/dormant.js +1 -4
  178. package/dist/embedding-provider.d.ts +4 -8
  179. package/dist/embedding-provider.js +4 -8
  180. package/dist/embeddings.js +55 -47
  181. package/dist/env.d.ts +1 -1
  182. package/dist/env.js +12 -12
  183. package/dist/escape.d.ts +5 -0
  184. package/dist/escape.js +10 -0
  185. package/dist/eval-stats.d.ts +1 -2
  186. package/dist/eval-stats.js +1 -2
  187. package/dist/eval-suite.js +27 -21
  188. package/dist/extract.js +4 -9
  189. package/dist/failure-log.d.ts +3 -3
  190. package/dist/failure-log.js +1 -1
  191. package/dist/forward-claim-detector.d.ts +2 -4
  192. package/dist/forward-claim-detector.js +6 -11
  193. package/dist/goals.d.ts +3 -3
  194. package/dist/goals.js +103 -91
  195. package/dist/graph/read.d.ts +2 -2
  196. package/dist/graph/read.js +5 -6
  197. package/dist/graph/types.d.ts +8 -8
  198. package/dist/graph/write.d.ts +7 -14
  199. package/dist/graph/write.js +16 -23
  200. package/dist/graph-extract.d.ts +7 -8
  201. package/dist/graph-extract.js +62 -72
  202. package/dist/graph-recall.d.ts +2 -2
  203. package/dist/graph-recall.js +55 -49
  204. package/dist/graph-stream.d.ts +5 -6
  205. package/dist/graph-stream.js +66 -57
  206. package/dist/graph-view.d.ts +2 -2
  207. package/dist/graph-view.js +7 -7
  208. package/dist/half-life-migration.d.ts +1 -2
  209. package/dist/half-life-migration.js +2 -3
  210. package/dist/hooks/codex-session.js +1 -1
  211. package/dist/hooks/codex-wrapper.d.ts +1 -1
  212. package/dist/hooks/codex-wrapper.js +3 -2
  213. package/dist/hooks/json-hooks.d.ts +2 -2
  214. package/dist/hooks/json-hooks.js +5 -4
  215. package/dist/hooks/opencode.d.ts +1 -1
  216. package/dist/hooks/opencode.js +5 -4
  217. package/dist/hooks/shared.d.ts +3 -7
  218. package/dist/hooks/shared.js +1 -8
  219. package/dist/http-util.d.ts +2 -3
  220. package/dist/http-util.js +3 -0
  221. package/dist/importers/core.d.ts +2 -9
  222. package/dist/importers/core.js +15 -30
  223. package/dist/importers/sources.js +2 -1
  224. package/dist/importers/vault.js +2 -20
  225. package/dist/incidents.d.ts +1 -1
  226. package/dist/incidents.js +46 -39
  227. package/dist/instruction-detect.d.ts +1 -1
  228. package/dist/instruction-detect.js +1 -1
  229. package/dist/invalidation.d.ts +3 -0
  230. package/dist/invalidation.js +160 -114
  231. package/dist/json.d.ts +5 -0
  232. package/dist/json.js +4 -0
  233. package/dist/judgment.js +1 -2
  234. package/dist/local-embedding.js +1 -1
  235. package/dist/mcp/admin-tools.js +7 -17
  236. package/dist/mcp/format.js +1 -1
  237. package/dist/mcp/framing.js +3 -6
  238. package/dist/mcp/protocol.d.ts +2 -5
  239. package/dist/mcp/protocol.js +1 -3
  240. package/dist/mcp/recall-tools.js +12 -15
  241. package/dist/mcp/request.js +4 -3
  242. package/dist/mcp/session-state.js +2 -3
  243. package/dist/mcp/stdio.js +2 -1
  244. package/dist/mcp/tools.js +9 -6
  245. package/dist/memory-value-weights.d.ts +5 -8
  246. package/dist/memory-value-weights.js +5 -8
  247. package/dist/memory-value.d.ts +13 -13
  248. package/dist/memory-value.js +26 -37
  249. package/dist/memory.d.ts +20 -22
  250. package/dist/memory.js +24 -48
  251. package/dist/multihop.d.ts +1 -1
  252. package/dist/multihop.js +3 -2
  253. package/dist/owner-validation.d.ts +4 -5
  254. package/dist/owner-validation.js +4 -5
  255. package/dist/physics.d.ts +4 -4
  256. package/dist/physics.js +7 -9
  257. package/dist/policies.d.ts +9 -10
  258. package/dist/policies.js +96 -81
  259. package/dist/postinstall.js +3 -6
  260. package/dist/predictions/planning-fallacy.d.ts +9 -14
  261. package/dist/predictions/planning-fallacy.js +10 -16
  262. package/dist/predictions/store.d.ts +15 -23
  263. package/dist/predictions/store.js +36 -33
  264. package/dist/processes.d.ts +2 -7
  265. package/dist/processes.js +88 -72
  266. package/dist/project-briefs.d.ts +2 -3
  267. package/dist/project-briefs.js +141 -118
  268. package/dist/project-identity.d.ts +22 -9
  269. package/dist/project-identity.js +47 -12
  270. package/dist/project-merge.d.ts +28 -5
  271. package/dist/project-merge.js +213 -46
  272. package/dist/project-remote.d.ts +12 -0
  273. package/dist/project-remote.js +138 -0
  274. package/dist/prompt-recall.js +1 -2
  275. package/dist/rate-limit.d.ts +1 -1
  276. package/dist/rate-limit.js +1 -1
  277. package/dist/raw-archive.d.ts +9 -0
  278. package/dist/raw-archive.js +70 -53
  279. package/dist/recall-history.d.ts +19 -20
  280. package/dist/recall-history.js +24 -42
  281. package/dist/recall-pipeline.js +4 -28
  282. package/dist/recall-scope.d.ts +7 -8
  283. package/dist/recall-scope.js +7 -8
  284. package/dist/recall-trace.d.ts +5 -9
  285. package/dist/recall-trace.js +6 -10
  286. package/dist/refine-llm.d.ts +1 -1
  287. package/dist/refine-llm.js +2 -2
  288. package/dist/reject-flow.d.ts +3 -4
  289. package/dist/reject-flow.js +122 -117
  290. package/dist/rejection.d.ts +5 -6
  291. package/dist/rejection.js +7 -15
  292. package/dist/rerankers/clef.d.ts +1 -1
  293. package/dist/rerankers/jev.d.ts +1 -2
  294. package/dist/rerankers/jev.js +4 -5
  295. package/dist/rerankers/llm.d.ts +1 -2
  296. package/dist/rerankers/llm.js +1 -2
  297. package/dist/rerankers/types.d.ts +1 -2
  298. package/dist/rrf.d.ts +2 -2
  299. package/dist/rrf.js +2 -2
  300. package/dist/search/bm25-search.d.ts +1 -1
  301. package/dist/search/bm25-search.js +2 -1
  302. package/dist/search/boosts.js +2 -1
  303. package/dist/search/hybrid.d.ts +1 -1
  304. package/dist/search/hybrid.js +2 -1
  305. package/dist/search/physics-search.d.ts +1 -1
  306. package/dist/search/physics-search.js +2 -1
  307. package/dist/search/types.d.ts +2 -0
  308. package/dist/search/types.js +3 -1
  309. package/dist/secret-detect.d.ts +4 -5
  310. package/dist/secret-detect.js +6 -10
  311. package/dist/server/auth.js +5 -5
  312. package/dist/server/client-ip.js +1 -1
  313. package/dist/server/cursor.js +2 -1
  314. package/dist/server/mcp-http.js +4 -4
  315. package/dist/server/request.d.ts +3 -6
  316. package/dist/server/request.js +6 -7
  317. package/dist/server/routes/admin.js +5 -4
  318. package/dist/server/routes/customer-notes.js +6 -5
  319. package/dist/server/routes/decisions.js +4 -3
  320. package/dist/server/routes/incidents.js +7 -5
  321. package/dist/server/routes/memories.js +7 -7
  322. package/dist/server/routes/policies.js +3 -2
  323. package/dist/server/routes/predictions.js +12 -15
  324. package/dist/server/routes/processes.js +3 -2
  325. package/dist/server/routes/project-briefs.js +8 -7
  326. package/dist/server/routes/recall.js +95 -93
  327. package/dist/server/routes/skills.js +6 -5
  328. package/dist/server/types.d.ts +1 -1
  329. package/dist/server/validation.d.ts +1 -2
  330. package/dist/server/validation.js +7 -14
  331. package/dist/server-detect.js +72 -58
  332. package/dist/server.d.ts +2 -2
  333. package/dist/server.js +131 -117
  334. package/dist/shared.d.ts +26 -17
  335. package/dist/shared.js +102 -104
  336. package/dist/skills.d.ts +3 -3
  337. package/dist/skills.js +88 -72
  338. package/dist/store/audit-event.d.ts +2 -2
  339. package/dist/store/audit-event.js +1 -1
  340. package/dist/store/candidates.d.ts +2 -2
  341. package/dist/store/candidates.js +4 -3
  342. package/dist/store/conflicts.js +30 -22
  343. package/dist/store/delete-and-batch.d.ts +11 -14
  344. package/dist/store/delete-and-batch.js +40 -91
  345. package/dist/store/entry-reads.d.ts +17 -25
  346. package/dist/store/entry-reads.js +59 -40
  347. package/dist/store/entry-row.d.ts +6 -24
  348. package/dist/store/entry-row.js +6 -24
  349. package/dist/store/entry-writes.d.ts +6 -7
  350. package/dist/store/entry-writes.js +13 -11
  351. package/dist/store/handoffs.d.ts +1 -1
  352. package/dist/store/handoffs.js +7 -10
  353. package/dist/store/index-and-stats.d.ts +2 -6
  354. package/dist/store/index-and-stats.js +4 -10
  355. package/dist/store/mirrors.d.ts +6 -19
  356. package/dist/store/mirrors.js +14 -39
  357. package/dist/store/open.js +9 -31
  358. package/dist/store/rows.d.ts +5 -11
  359. package/dist/store/rows.js +6 -11
  360. package/dist/store/search-rows.d.ts +17 -34
  361. package/dist/store/search-rows.js +34 -56
  362. package/dist/store/sessions.d.ts +4 -5
  363. package/dist/store/sessions.js +5 -6
  364. package/dist/store/summaries.d.ts +13 -17
  365. package/dist/store/summaries.js +26 -70
  366. package/dist/support-bundle.js +4 -8
  367. package/dist/tenant.d.ts +1 -5
  368. package/dist/token-ledger.d.ts +1 -1
  369. package/dist/token-ledger.js +3 -5
  370. package/dist/trace.js +1 -3
  371. package/dist/version.d.ts +1 -1
  372. package/dist/version.js +1 -1
  373. package/dist/working-memory.d.ts +1 -1
  374. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  375. package/extensions/openclaw-plugin/package.json +1 -1
  376. package/openclaw.plugin.json +1 -1
  377. package/package.json +1 -1
@@ -1,16 +1,15 @@
1
1
  /**
2
- * E3.1 deterministic entity extraction (first slice)
3
- * (docs/plans/2026-06-01-e3-deterministic-extraction.md).
2
+ * Deterministic entity extraction.
4
3
  *
5
- * Populates the E3 graph from the already-structured consolidated E2-object tables -
4
+ * Populates the graph from the already-structured consolidated first-class object tables -
6
5
  * NO NLP, no precision gate for entities + supersedes. The graph is a pure derived
7
- * function of the current E2 state, so `extractGraph` is an idempotent REBUILD: clear
6
+ * function of the current object state, so `extractGraph` is an idempotent REBUILD: clear
8
7
  * the tenant's graph, then re-derive entities + `supersedes` relations from decisions /
9
- * policies / customer_notes / project_briefs (the four E2 types whose kind maps to the
8
+ * policies / customer_notes / project_briefs (the four object types whose kind maps to the
10
9
  * `entity_type` enum). All writes go through the src/graph.ts consolidated-source guard
11
10
  * (insertEntity / insertRelation / clearGraph); this module issues no raw SQL.
12
11
  *
13
- * Pass 3 (E3 cross-object, docs/plans/2026-06-02-e3-cross-object-references.md) adds the
12
+ * Pass 3 adds the
14
13
  * first CROSS-OBJECT relations: a deterministic NAME-MATCH heuristic that emits a
15
14
  * `references` edge when one consolidated object's text contains another entity's name.
16
15
  * It is conservative (word-boundary, length-bounded, ambiguity-guarded, per-source
@@ -27,6 +26,7 @@ import { loadPolicies } from './policies.js';
27
26
  import { loadCustomerNotes } from './customer-notes.js';
28
27
  import { loadProjectBriefs } from './project-briefs.js';
29
28
  import { assertTenantId } from './tenant.js';
29
+ import { escapeRegex } from './escape.js';
30
30
  /** Per-type load cap (the loaders default to 100). A type whose active or superseded
31
31
  * set exceeds this is truncated; `ExtractResult.truncated` records it so the
32
32
  * incompleteness is observable rather than silent. */
@@ -46,8 +46,8 @@ export const MAX_TARGET_NAMES = 5000;
46
46
  function keyOf(entityType, e2Id) {
47
47
  return `${entityType}:${e2Id}`;
48
48
  }
49
- /** The four E2-derived extraction entity types map 1:1 to source_object_type; the other
50
- * two EntityType members ('person', 'system') have no E2 table and stay unmapped. */
49
+ /** The four object-derived extraction entity types map 1:1 to source_object_type; the other
50
+ * two EntityType members ('person', 'system') have no object table and stay unmapped. */
51
51
  const ENTITY_TYPE_TO_SOURCE_OBJECT = {
52
52
  decision: 'decision',
53
53
  policy: 'policy',
@@ -56,18 +56,14 @@ const ENTITY_TYPE_TO_SOURCE_OBJECT = {
56
56
  person: undefined,
57
57
  system: undefined,
58
58
  };
59
- /** The E2 source-object ref for an extraction row (always set: every extracted row is an
60
- * E2 object). Throws on an unmappable entityType (a graph invariant violation). */
59
+ /** The source-object ref for an extraction row (always set: every extracted row is a
60
+ * first-class object). Throws on an unmappable entityType (a graph invariant violation). */
61
61
  function sourceObjectOf(entityType, e2Id) {
62
62
  const type = ENTITY_TYPE_TO_SOURCE_OBJECT[entityType];
63
63
  if (!type)
64
64
  throw new Error(`graph-extract: entityType '${entityType}' has no source_object_type mapping`);
65
65
  return { type, id: e2Id };
66
66
  }
67
- /** Escape a string for safe use as a literal inside a RegExp alternation. */
68
- function escapeRegex(s) {
69
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
70
- }
71
67
  /** Unordered entity-id pair key, so a relation between a,b is found in either direction. */
72
68
  function pairKey(a, b) {
73
69
  return a < b ? `${a}:${b}` : `${b}:${a}`;
@@ -95,7 +91,7 @@ textOf) {
95
91
  // loadProjectBriefs (wired at each extractGraph call site); every one of their row
96
92
  // types declares id: number, memoryId: string | null, supersededBy: number | null.
97
93
  // `any` here is the deliberate type-erasure boundary (see eslint-disable above)
98
- // that lets one loop handle all four E2 row shapes.
94
+ // that lets one loop handle all four object row shapes.
99
95
  rows.push({
100
96
  entityType,
101
97
  e2Id: r.id,
@@ -109,9 +105,9 @@ textOf) {
109
105
  return { rows, hitCap };
110
106
  }
111
107
  /**
112
- * Idempotent rebuild of the tenant's deterministic graph from its consolidated E2
108
+ * Idempotent rebuild of the tenant's deterministic graph from its consolidated first-class
113
109
  * objects. Returns the entity/relation counts (+ which types were truncated at the
114
- * per-type cap). Safe to re-run: output is a pure function of the current E2 state.
110
+ * per-type cap). Safe to re-run: output is a pure function of the current object state.
115
111
  */
116
112
  export function extractGraph(hippoRoot, tenantId) {
117
113
  assertTenantId('extractGraph', tenantId);
@@ -129,50 +125,27 @@ export function extractGraph(hippoRoot, tenantId) {
129
125
  const { rows, hitCap } = loadType(hippoRoot, tenantId, src.entityType, src.loadFn, src.nameOf, src.textOf);
130
126
  return { entityType: src.entityType, rows, hitCap };
131
127
  });
132
- // WRITE PHASE (codex P2): clear + every insert run in ONE transaction, so two
128
+ // WRITE PHASE: clear + every insert run in ONE transaction, so two
133
129
  // concurrent rebuilds serialize on the SQLite write lock (no duplicate rows)
134
130
  // and a throw mid-rebuild rolls back the clear (no bricked graph). No second
135
131
  // connection is opened inside.
136
132
  return runGraphRebuildTransaction(hippoRoot, tenantId, (txDb) => rebuildGraphRows(txDb, hippoRoot, tenantId, loaded));
137
133
  }
138
- /**
139
- * The deterministic rebuild WRITES, run inside `runGraphRebuildTransaction`'s
140
- * transaction (`txDb` is its connection). Clears the tenant's graph then
141
- * re-derives entities + `supersedes` + `references` from the preloaded rows. All
142
- * DB access here is on `txDb` (or in-memory) — no other connection is opened.
143
- */
144
- function rebuildGraphRows(txDb, hippoRoot, tenantId, loaded) {
145
- // Rebuild from scratch: the graph is derived, so clear then re-derive.
146
- clearGraph(hippoRoot, tenantId, txDb);
147
- const byType = {};
148
- const truncated = [];
149
- const allRows = [];
150
- const entityIdByKey = new Map();
151
- // The mirror memory per extracted key, null once forgotten/pruned (so a supersedes
152
- // edge anchors to the successor's object when the mirror is gone but still passes the
153
- // memory through when it lives).
154
- const memoryIdByKey = new Map();
155
- // Created entities ONLY (drives Pass 3 sources + targets), in stable insertion order.
156
- const created = [];
157
- // Pass 1: entities. Every ACTIVE/SUPERSEDED E2 row becomes an entity ANCHORED to its
158
- // authoritative E2 object (source_object_type/id) - it survives a forgotten mirror
159
- // (memory_id NULL). The mirror memory is passed through only when it still exists
160
- // (it remains a recall pointer until forgotten/pruned).
134
+ // Pass 1: entities. Every ACTIVE/SUPERSEDED object row becomes an entity ANCHORED to its
135
+ // authoritative object (source_object_type/id) - it survives a forgotten mirror
136
+ // (memory_id NULL). The mirror memory is passed through only when it still exists
137
+ // (it remains a recall pointer until forgotten/pruned).
138
+ function insertEntityRows(txDb, hippoRoot, tenantId, loaded) {
139
+ const pass = { byType: {}, truncated: [], allRows: [], entityIdByKey: new Map(), memoryIdByKey: new Map(), created: [] };
161
140
  for (const { entityType, rows, hitCap } of loaded) {
162
141
  if (hitCap)
163
- truncated.push(entityType);
164
- byType[entityType] = 0;
142
+ pass.truncated.push(entityType);
143
+ pass.byType[entityType] = 0;
165
144
  for (const row of rows) {
166
- allRows.push(row);
167
- // Normalise the label so a long/odd-but-valid E2 name can never throw in
168
- // insertEntity and (because clearGraph already ran) brick the rebuild
169
- // unrebuildably. E2 name fields (decisionText / policyName) are UNCAPPED at
170
- // source, and insertEntity REJECTS (not truncates) both an over-cap name AND an
171
- // empty one. So: TRIM FIRST (codex 2026-06-01: >512 leading-whitespace chars
172
- // would otherwise slice to a whitespace-only string -> trimmed to '' ->
173
- // 'name is required' throw), THEN cap to MAX_ENTITY_NAME_LEN; if the normalised
174
- // label is empty (the E2 save APIs forbid this, but be defensive) skip the row
175
- // rather than throw. This closes the entire name-brick class.
145
+ pass.allRows.push(row);
146
+ // Name fields are uncapped at source and insertEntity rejects an over-cap or empty name,
147
+ // which after clearGraph would brick the rebuild. Trim before capping so leading
148
+ // whitespace cannot slice to ''; skip a still-empty label rather than throw.
176
149
  const name = (row.name ?? '').trim().slice(0, MAX_ENTITY_NAME_LEN);
177
150
  if (name.length === 0)
178
151
  continue;
@@ -184,33 +157,35 @@ function rebuildGraphRows(txDb, hippoRoot, tenantId, loaded) {
184
157
  sourceObject,
185
158
  }, txDb);
186
159
  const k = keyOf(row.entityType, row.e2Id);
187
- entityIdByKey.set(k, entity.id);
188
- memoryIdByKey.set(k, row.memoryId);
189
- created.push({ entityId: entity.id, entityType: row.entityType, memoryId: row.memoryId, sourceObject, name, searchText: row.searchText ?? '', superseded: row.supersededBy !== null });
190
- byType[entityType] += 1;
160
+ pass.entityIdByKey.set(k, entity.id);
161
+ pass.memoryIdByKey.set(k, row.memoryId);
162
+ pass.created.push({ entityId: entity.id, entityType: row.entityType, memoryId: row.memoryId, sourceObject, name, searchText: row.searchText ?? '', superseded: row.supersededBy !== null });
163
+ pass.byType[entityType] += 1;
191
164
  }
192
165
  }
193
- // Pass 2: `supersedes` relations. For X superseded by Y (Y is the successor), emit
194
- // "Y supersedes X" - but only when BOTH X and Y were EXTRACTED (e.g. Y may be closed
195
- // and absent). The emit guard is ENTITY presence (entityIdByKey), not memory presence:
196
- // a forgotten successor mirror must still emit the edge. The relation is anchored to Y's
197
- // authoritative E2 object; Y's mirror memory is passed only when it still lives.
166
+ return pass;
167
+ }
168
+ // "Y supersedes X" - but only when BOTH X and Y were EXTRACTED (e.g. Y may be closed
169
+ // and absent). The emit guard is ENTITY presence (entityIdByKey), not memory presence:
170
+ // a forgotten successor mirror must still emit the edge. The relation is anchored to Y's
171
+ // authoritative object; Y's mirror memory is passed only when it still lives.
172
+ function insertSupersedesRelations(txDb, hippoRoot, tenantId, pass) {
198
173
  let relations = 0;
199
174
  // Entity-id pairs already related by supersedes (unordered). Pass 3 skips a references
200
175
  // edge for such a pair: a version-extends-its-predecessor's-name containment (e.g.
201
176
  // "Adopt X (managed)" contains "Adopt X") is a name artifact, not a cross-reference,
202
177
  // and supersedes already captures their relationship.
203
178
  const supersededPairs = new Set();
204
- for (const row of allRows) {
179
+ for (const row of pass.allRows) {
205
180
  if (row.supersededBy === null)
206
181
  continue;
207
182
  const xKey = keyOf(row.entityType, row.e2Id);
208
183
  const yKey = keyOf(row.entityType, row.supersededBy);
209
- const fromId = entityIdByKey.get(yKey); // successor Y
210
- const toId = entityIdByKey.get(xKey); // superseded X
184
+ const fromId = pass.entityIdByKey.get(yKey); // successor Y
185
+ const toId = pass.entityIdByKey.get(xKey); // superseded X
211
186
  if (fromId === undefined || toId === undefined)
212
187
  continue;
213
- const yMemoryId = memoryIdByKey.get(yKey) ?? null; // successor's mirror, null if gone
188
+ const yMemoryId = pass.memoryIdByKey.get(yKey) ?? null; // successor's mirror, null if gone
214
189
  insertRelation(hippoRoot, tenantId, {
215
190
  fromEntityId: fromId,
216
191
  toEntityId: toId,
@@ -221,11 +196,26 @@ function rebuildGraphRows(txDb, hippoRoot, tenantId, loaded) {
221
196
  supersededPairs.add(pairKey(fromId, toId));
222
197
  relations += 1;
223
198
  }
199
+ return { relations, supersededPairs };
200
+ }
201
+ /**
202
+ * The deterministic rebuild WRITES, run inside `runGraphRebuildTransaction`'s
203
+ * transaction (`txDb` is its connection). Clears the tenant's graph then
204
+ * re-derives entities + `supersedes` + `references` from the preloaded rows. All
205
+ * DB access here is on `txDb` (or in-memory) — no other connection is opened.
206
+ */
207
+ function rebuildGraphRows(txDb, hippoRoot, tenantId, loaded) {
208
+ // Rebuild from scratch: the graph is derived, so clear then re-derive.
209
+ clearGraph(hippoRoot, tenantId, txDb);
210
+ const pass = insertEntityRows(txDb, hippoRoot, tenantId, loaded);
211
+ const { byType, truncated, created } = pass;
212
+ const supersedes = insertSupersedesRelations(txDb, hippoRoot, tenantId, pass);
213
+ let relations = supersedes.relations;
224
214
  // Pass 3: cross-object `references` edges via conservative name matching. A source's
225
215
  // text containing a target entity's name -> "source references target". Sources +
226
- // targets are CREATED entities only, each anchored to its E2 source object (so the
216
+ // targets are CREATED entities only, each anchored to its source object (so the
227
217
  // edge survives a forgotten source mirror).
228
- const references = extractReferences(hippoRoot, tenantId, created, supersededPairs, truncated, txDb);
218
+ const references = extractReferences(hippoRoot, tenantId, created, supersedes.supersededPairs, truncated, txDb);
229
219
  relations += references;
230
220
  const entities = created.length;
231
221
  return { entities, relations, references, byType, truncated };
@@ -234,7 +224,7 @@ function rebuildGraphRows(txDb, hippoRoot, tenantId, loaded) {
234
224
  * Pass 3. Build a target-name index from the created entities (names within the length
235
225
  * bounds, ambiguous names dropped), scan each created entity's text once with one
236
226
  * combined word-boundary regex, and emit `references` edges (self-skipped, deduped,
237
- * per-source capped). Each edge is anchored to the source entity's E2 object (memoryId
227
+ * per-source capped). Each edge is anchored to the source entity's object (memoryId
238
228
  * passed through only when the mirror lives). Returns the number of references edges written.
239
229
  */
240
230
  function extractReferences(hippoRoot, tenantId, created, supersededPairs, truncated, txDb) {
@@ -244,7 +234,7 @@ function extractReferences(hippoRoot, tenantId, created, supersededPairs, trunca
244
234
  const ambiguous = new Set();
245
235
  for (const e of created) {
246
236
  // References are among ACTIVE entities only: a superseded (outdated) row is not a
247
- // current cross-reference target (codex).
237
+ // current cross-reference target.
248
238
  if (e.superseded)
249
239
  continue;
250
240
  // Decisions are SOURCE-only: their name is decision prose, referenced by supersedes,
@@ -275,7 +265,7 @@ function extractReferences(hippoRoot, tenantId, created, supersededPairs, trunca
275
265
  truncated.push('references-targets');
276
266
  // LONGEST name first, then alphabetical: JS regex alternation is leftmost-first, so
277
267
  // ordering longer names before their prefixes makes the match longest-at-position
278
- // (`postgres pro` wins over `postgres`; codex). Deterministic, so truncation is stable.
268
+ // (`postgres pro` wins over `postgres`). Deterministic, so truncation is stable.
279
269
  const targetNames = [...nameToId.keys()]
280
270
  .sort((a, b) => b.length - a.length || (a < b ? -1 : a > b ? 1 : 0))
281
271
  .slice(0, MAX_TARGET_NAMES);
@@ -284,7 +274,7 @@ function extractReferences(hippoRoot, tenantId, created, supersededPairs, trunca
284
274
  let references = 0;
285
275
  for (const src of created) {
286
276
  if (src.superseded)
287
- continue; // superseded sources hold only stale references (codex)
277
+ continue; // superseded sources hold only stale references
288
278
  if (!src.searchText)
289
279
  continue;
290
280
  const targets = new Set();
@@ -1,4 +1,4 @@
1
- import type { ResultCost, SearchResult } from './search/types.js';
1
+ import { type ResultCost, type SearchResult } from './search/types.js';
2
2
  /** Hard cap on `--hops` (a higher value just walks more of a finite graph; this bounds
3
3
  * worst-case work and keeps the flag honest). */
4
4
  export declare const MAX_HOPS = 3;
@@ -21,7 +21,7 @@ export interface GraphExpandOpts {
21
21
  * cmdRecall: a row is visible if valid_from <= asOf AND, when superseded, its successor
22
22
  * was not yet valid at asOf). */
23
23
  asOf?: string;
24
- /** Token budget for the augmented set (defaults to 4000, matching recall's default). */
24
+ /** Token budget for the augmented set (defaults to DEFAULT_RECALL_BUDGET, matching recall). */
25
25
  budget?: number;
26
26
  /** Budget cost per result; defaults to the memory text. */
27
27
  cost?: ResultCost;
@@ -1,19 +1,18 @@
1
1
  /**
2
- * E3.2 multi-hop graph recall (docs/plans/2026-06-02-e3.2-multihop-recall.md).
2
+ * Multi-hop graph recall.
3
3
  *
4
- * READ-ONLY consumer of the E3 graph substrate (entities/relations built by E3.1,
5
- * guarded by E3.3). Given the lexical recall seeds, walk the relations graph up to N
4
+ * READ-ONLY consumer of the graph substrate (entities/relations built by graph-extract,
5
+ * guarded by the graph-on-consolidated triggers). Given the lexical recall seeds, walk the relations graph up to N
6
6
  * hops and surface the memories of reached entities that the lexical search did not
7
7
  * already return.
8
8
  *
9
9
  * Relation-type-AGNOSTIC: it walks whatever edges exist. Today the graph holds only
10
10
  * `supersedes` edges (so a 1-hop walk surfaces a supersession-linked predecessor/
11
- * successor a lexical search may miss); the moment E3.1 emits cross-object edges
11
+ * successor a lexical search may miss); the moment extraction emits cross-object edges
12
12
  * (owns/depends-on/blocked-by/references) the SAME traversal lights up cross-entity
13
13
  * multi-hop with zero rework here.
14
14
  *
15
- * Design points (the first two were forced by the verify-stage benchmark, the rest by
16
- * codex review — all root-cause, not patches):
15
+ * Design points:
17
16
  * 1. Graph-reached memories are loaded DIRECTLY by id (tenant-scoped PK fetch), NOT
18
17
  * intersected with the recall handler's candidate set — that set is lexically
19
18
  * prefiltered (loadSearchRows filters by query tokens), so intersecting would exclude
@@ -29,14 +28,15 @@
29
28
  * nothing at realistic budgets.
30
29
  * 3. BOTH the local and global stores are expanded — a global seed's entities/relations
31
30
  * live under the global root, so graph recall must traverse each seed in the store its
32
- * graph lives in (codex review).
31
+ * graph lives in.
33
32
  * 4. By-id loads are chunked at 500 (loadEntriesByIds caps at 500/call), so a high-fanout
34
- * traversal (--hops 3 --max-neighbors 200 -> up to 600 ids) loses none (codex review).
33
+ * traversal (--hops 3 --max-neighbors 200 -> up to 600 ids) loses none.
35
34
  *
36
- * No graph writes (only SELECTs via graph.ts read helpers + store reads), so the E3.3
35
+ * No graph writes (only SELECTs via graph.ts read helpers + store reads), so the
37
36
  * check-graph-writes lint permits this module living outside graph.ts.
38
37
  */
39
38
  import { loadEntriesByIds } from './store/entry-reads.js';
39
+ import { DEFAULT_RECALL_BUDGET } from './search/types.js';
40
40
  import { estimateTokens } from './token-ledger.js';
41
41
  import { compareEntryIdentity } from './compare.js';
42
42
  import { loadEntitiesByMemoryId, loadEntitiesByIds, loadNeighborRelations } from './graph/read.js';
@@ -61,15 +61,9 @@ function loadByIdsChunked(root, tenantId, ids) {
61
61
  }
62
62
  return out;
63
63
  }
64
- /** Traverse one store's graph from its seeds into `hitsByOrigin`. Pure reads; mutates `seenMemoryIds`
65
- * and `seenContent` so a memory, or a share/promote copy of it, surfaces at most once across stores. */
66
- function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds, seenContent, hitsByOrigin, opts) {
67
- const { hops, maxNeighbors, tenantId, includeSuperseded, asOfDate, recallScope } = opts;
68
- // Seeds = graph entities (in THIS store) whose source memory is a base result.
69
- const seedEntities = loadEntitiesByMemoryId(root, tenantId, baseResults.map((r) => r.entry.id));
70
- if (seedEntities.length === 0)
71
- return;
72
- // BFS, both directions, up to `hops`. `visited` prevents re-expansion (cycle-safe).
64
+ /** BFS, both directions, up to `hops`: each reached entity's via, and the base memory it descends from. */
65
+ function walkRelations(root, tenantId, seedEntities, hops, maxNeighbors) {
66
+ // `visited` prevents re-expansion (cycle-safe).
73
67
  // `originMemByEntityId` propagates the base-result memory id each reached node descends
74
68
  // from (for adjacency placement + score inheritance).
75
69
  const visitedEntityIds = new Set(seedEntities.map((e) => e.id));
@@ -118,6 +112,43 @@ function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds,
118
112
  }
119
113
  frontier = nextFrontier;
120
114
  }
115
+ return { reached, originMemByEntityId };
116
+ }
117
+ /** The recall hard filters (as-of, superseded, scope) re-applied to a directly loaded graph-reached row. */
118
+ function passesRecallFilters(mem, via, successorValidFrom, opts) {
119
+ const { includeSuperseded, asOfDate, recallScope } = opts;
120
+ // A node reached as the `to` endpoint of a `supersedes` edge IS the superseded
121
+ // (older) version — the graph is the authoritative signal (the memory mirror's
122
+ // `superseded_by` is NOT set by `hippo decide`, only the decisions table is). By
123
+ // default recall shows current truth, so drop it unless --include-superseded; the
124
+ // `from` endpoint (the newer successor) is always kept.
125
+ const isSupersededEndpoint = via.relType === 'supersedes' && via.direction === 'to';
126
+ if (asOfDate) {
127
+ if (new Date(mem.valid_from) > asOfDate)
128
+ return false; // not yet valid at asOf
129
+ if (mem.superseded_by) {
130
+ const succVf = successorValidFrom.get(mem.superseded_by);
131
+ // Visible only while its successor was NOT yet valid at asOf (matches cmdRecall).
132
+ if (succVf && new Date(succVf) <= asOfDate)
133
+ return false;
134
+ }
135
+ }
136
+ else if (!includeSuperseded && (mem.superseded_by || isSupersededEndpoint)) {
137
+ return false; // default recall drops superseded
138
+ }
139
+ return recallScope.additive
140
+ ? passesCliRecallScopeFilter(mem.scope ?? null, recallScope.requested)
141
+ : passesScopeFilterForRecall(mem.scope ?? null, recallScope.requested);
142
+ }
143
+ /** Traverse one store's graph from its seeds into `hitsByOrigin`. Pure reads; mutates `seenMemoryIds`
144
+ * and `seenContent` so a memory, or a share/promote copy of it, surfaces at most once across stores. */
145
+ function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds, seenContent, hitsByOrigin, opts) {
146
+ const { hops, maxNeighbors, tenantId, asOfDate } = opts;
147
+ // Seeds = graph entities (in THIS store) whose source memory is a base result.
148
+ const seedEntities = loadEntitiesByMemoryId(root, tenantId, baseResults.map((r) => r.entry.id));
149
+ if (seedEntities.length === 0)
150
+ return;
151
+ const { reached, originMemByEntityId } = walkRelations(root, tenantId, seedEntities, hops, maxNeighbors);
121
152
  if (reached.size === 0)
122
153
  return;
123
154
  // Reached entities -> source memory ids -> load DIRECTLY by id (chunked), not lexical.
@@ -147,29 +178,7 @@ function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds,
147
178
  if (seenContent.has(mem.content))
148
179
  continue; // share/promote copy: same text, another id
149
180
  const via = reached.get(ent.id);
150
- // A node reached as the `to` endpoint of a `supersedes` edge IS the superseded
151
- // (older) version — the graph is the authoritative signal (the memory mirror's
152
- // `superseded_by` is NOT set by `hippo decide`, only the decisions table is). By
153
- // default recall shows current truth, so drop it unless --include-superseded; the
154
- // `from` endpoint (the newer successor) is always kept.
155
- const isSupersededEndpoint = via.relType === 'supersedes' && via.direction === 'to';
156
- if (asOfDate) {
157
- if (new Date(mem.valid_from) > asOfDate)
158
- continue; // not yet valid at asOf
159
- if (mem.superseded_by) {
160
- const succVf = successorValidFrom.get(mem.superseded_by);
161
- // Visible only while its successor was NOT yet valid at asOf (matches cmdRecall).
162
- if (succVf && new Date(succVf) <= asOfDate)
163
- continue;
164
- }
165
- }
166
- else if (!includeSuperseded && (mem.superseded_by || isSupersededEndpoint)) {
167
- continue; // default recall drops superseded
168
- }
169
- const scopeOk = recallScope.additive
170
- ? passesCliRecallScopeFilter(mem.scope ?? null, recallScope.requested)
171
- : passesScopeFilterForRecall(mem.scope ?? null, recallScope.requested);
172
- if (!scopeOk)
181
+ if (!passesRecallFilters(mem, via, successorValidFrom, opts))
173
182
  continue;
174
183
  const origin = originMemByEntityId.get(ent.id) ?? baseResults[0].entry.id;
175
184
  const originScore = baseScoreByMemId.get(origin) ?? baseResults[baseResults.length - 1].score;
@@ -202,7 +211,7 @@ export function graphExpandRecall(baseResults, opts) {
202
211
  if (hops <= 0 || baseResults.length === 0)
203
212
  return baseResults;
204
213
  const maxNeighbors = opts.maxNeighbors ?? DEFAULT_MAX_NEIGHBORS;
205
- const budget = opts.budget ?? 4000;
214
+ const budget = opts.budget ?? DEFAULT_RECALL_BUDGET;
206
215
  const includeSuperseded = opts.includeSuperseded ?? false;
207
216
  const asOfDate = opts.asOf ? new Date(opts.asOf) : null;
208
217
  const minResults = opts.minResults ?? 1;
@@ -222,8 +231,7 @@ export function graphExpandRecall(baseResults, opts) {
222
231
  return baseResults;
223
232
  // Closer hops first within each origin group, then by inherited score.
224
233
  // Hops asc and score desc are both true primary keys (unchanged);
225
- // compareEntryIdentity is only the TAIL for a same-hop, same-score tie
226
- // (T2, deterministic tie keys).
234
+ // compareEntryIdentity is only the TAIL for a same-hop, same-score tie.
227
235
  for (const hits of hitsByOrigin.values()) {
228
236
  hits.sort((a, b) => {
229
237
  const byHops = a.graphVia.hops - b.graphVia.hops;
@@ -243,16 +251,14 @@ export function graphExpandRecall(baseResults, opts) {
243
251
  // (NOTE: at a tight budget a new graph hit can displace a weakly-scored base result;
244
252
  // aggregate recall stays >= baseline, the displaced item is the lowest-value one.)
245
253
  // Protect the top --min-results base rows from eviction (graph expansion must not
246
- // violate the recall min-results floor; codex P2). They are kept regardless of budget;
254
+ // violate the recall min-results floor). They are kept regardless of budget;
247
255
  // baseResults is score-ordered, so slice(0, N) is the top N.
248
256
  const protectedCount = Math.min(Math.max(minResults, 1), baseResults.length);
249
257
  const keep = new Set(baseResults.slice(0, protectedCount));
250
258
  const price = opts.cost ?? ((r) => r.tokens);
251
259
  let usedTokens = [...keep].reduce((s, r) => s + price(r), 0);
252
- // T2 note: PLAIN stable score sort on purpose -- both input lists are
253
- // deterministically ordered by this point, stability inherits that, and a
254
- // base-vs-graph-hit tie keeps the BASE result first (the concat order),
255
- // preserving pre-T2 semantics.
260
+ // PLAIN stable score sort on purpose: both input lists are already deterministically
261
+ // ordered, and a base-vs-graph-hit tie keeps the BASE result first (the concat order).
256
262
  for (const r of [...baseResults.slice(protectedCount), ...allHits].sort((a, b) => b.score - a.score)) {
257
263
  const tokens = price(r);
258
264
  if (usedTokens + tokens > budget)
@@ -1,9 +1,8 @@
1
1
  /**
2
- * L1 — graph-retrieval ranked-list stream for RRF fusion
3
- * (docs/plans/2026-06-02-l1-graph-rrf-stream.md).
2
+ * Graph-retrieval ranked-list stream for RRF fusion.
4
3
  *
5
- * READ-ONLY consumer of the E3 graph substrate (entities/relations built by E3.1,
6
- * guarded by E3.3). Produces a ranked list of `entries[]` indices ordered by graph
4
+ * READ-ONLY consumer of the entity/relation graph. Produces a ranked list of
5
+ * `entries[]` indices ordered by graph
7
6
  * proximity to the strong lexical seeds, for use as a 3rd fusion input to `rrfFuse`
8
7
  * beside BM25 + dense (src/search.ts hybridSearch, scoring:'rrf').
9
8
  *
@@ -13,10 +12,10 @@
13
12
  * themselves are never scored (they already rank via BM25/dense; scoring them would
14
13
  * double-count and dilute the orthogonal graph signal).
15
14
  *
16
- * Reuses the E3.2 BFS traversal shape from graph-recall.ts (loadEntitiesByMemoryId
15
+ * Reuses the BFS traversal shape from graph-recall.ts (loadEntitiesByMemoryId
17
16
  * seeds -> loadNeighborRelations BFS both directions, per-hop fanout cap, visited set
18
17
  * -> loadEntitiesByIds to resolve reached -> memoryId). Expands across the local AND
19
- * global stores. Pure reads (SELECTs only via graph.ts helpers), so the E3.3
18
+ * global stores. Pure reads (SELECTs only via graph.ts helpers), so the
20
19
  * check-graph-writes lint permits this module living outside graph.ts.
21
20
  *
22
21
  * The graph stream's score scale (1/lexRank seed strength x decay^hops) only sets the
@@ -33,39 +33,40 @@ export function selectGraphSeeds(bm25Ranked, cosineRanked, seedCount) {
33
33
  .slice(0, seedCount)
34
34
  .map(([index, best]) => ({ index, strength: 1 / (best + 1) }));
35
35
  }
36
- /**
37
- * Accumulate per-entryIndex graph-proximity scores from ONE store's graph into
38
- * `graphScore`. Pure reads. `seeds` are the lexical seeds (index + strength); only the
39
- * seeds whose entities live in THIS store are expanded. The origin seed strength is
40
- * carried UNCHANGED along each BFS path; the per-hop decay is applied as decay^depth so
41
- * a neighbour's score = originSeedStrength x decay^(graph distance).
42
- */
43
- function accumulateForRoot(root, seeds, entries, memIdToIndex, graphScore, hops, decay, maxNeighbors, tenantId) {
44
- if (seeds.length === 0)
45
- return;
46
- // The strongest seed strength per source memory id (a memId could appear once, but
47
- // guard against dup indices mapping to the same memId).
48
- const strengthByMemId = new Map();
49
- for (const s of seeds) {
50
- const memId = entries[s.index].id;
51
- strengthByMemId.set(memId, Math.max(strengthByMemId.get(memId) ?? 0, s.strength));
52
- }
53
- const seedEntities = loadEntitiesByMemoryId(root, tenantId, [...strengthByMemId.keys()]);
54
- if (seedEntities.length === 0)
55
- return;
56
- // entityId -> origin seed strength (carried unchanged along the path). A seed entity is
57
- // loaded by memory id, so its memoryId is non-null here; the guard keeps the widened
58
- // (string | null) type honest (a null-memory entity is not a lexical seed).
59
- const originStrength = new Map();
60
- for (const e of seedEntities) {
61
- if (e.memoryId === null)
36
+ // Pass 1: accumulate the STRONGEST reaching-seed strength per new neighbour across ALL
37
+ // relations at this depth BEFORE committing any to `visited`. Marking a node
38
+ // visited mid-loop would lock it to whichever relation SQLite returned first, so a later
39
+ // edge from a STRONGER lexical seed would be dropped and the neighbour mis-scored. A node
40
+ // already in `visited` was committed at an earlier (shorter) depth and keeps that score.
41
+ function bestStrengthAtDepth(rels, frontierSet, visited, frontierStrength, originStrength) {
42
+ const bestStrengthThisDepth = new Map();
43
+ for (const rel of rels) {
44
+ const fromIn = frontierSet.has(rel.fromEntityId);
45
+ const toIn = frontierSet.has(rel.toEntityId);
46
+ let neighborId;
47
+ let reacherId;
48
+ if (fromIn && !toIn) {
49
+ neighborId = rel.toEntityId;
50
+ reacherId = rel.fromEntityId;
51
+ }
52
+ else if (toIn && !fromIn) {
53
+ neighborId = rel.fromEntityId;
54
+ reacherId = rel.toEntityId;
55
+ }
56
+ else
62
57
  continue;
63
- const st = strengthByMemId.get(e.memoryId) ?? 0;
64
- originStrength.set(e.id, Math.max(originStrength.get(e.id) ?? 0, st));
58
+ if (visited.has(neighborId))
59
+ continue;
60
+ const seedStrength = frontierStrength.get(reacherId) ?? originStrength.get(reacherId) ?? 0;
61
+ bestStrengthThisDepth.set(neighborId, Math.max(bestStrengthThisDepth.get(neighborId) ?? 0, seedStrength));
65
62
  }
66
- const visited = new Set(seedEntities.map((e) => e.id)); // seeds never re-reached
63
+ return bestStrengthThisDepth;
64
+ }
65
+ /** BFS from the seed entities: entityId -> best originSeedStrength x decay^depth over every reached entity. */
66
+ function reachedEntityScores(root, tenantId, seedIds, originStrength, hops, decay, maxNeighbors) {
67
+ const visited = new Set(seedIds); // seeds never re-reached
67
68
  const reachedScore = new Map(); // entityId -> best score
68
- let frontier = seedEntities.map((e) => e.id);
69
+ let frontier = seedIds;
69
70
  let frontierStrength = new Map(originStrength); // entityId -> seed strength
70
71
  for (let depth = 1; depth <= hops && frontier.length > 0; depth++) {
71
72
  const frontierSet = new Set(frontier);
@@ -73,32 +74,7 @@ function accumulateForRoot(root, seeds, entries, memIdToIndex, graphScore, hops,
73
74
  limit: Math.max(maxNeighbors, maxNeighbors * frontier.length),
74
75
  });
75
76
  const hopFactor = Math.pow(decay, depth);
76
- // Pass 1: accumulate the STRONGEST reaching-seed strength per new neighbour across ALL
77
- // relations at this depth BEFORE committing any to `visited` (codex P2). Marking a node
78
- // visited mid-loop would lock it to whichever relation SQLite returned first, so a later
79
- // edge from a STRONGER lexical seed would be dropped and the neighbour mis-scored. A node
80
- // already in `visited` was committed at an earlier (shorter) depth and keeps that score.
81
- const bestStrengthThisDepth = new Map();
82
- for (const rel of rels) {
83
- const fromIn = frontierSet.has(rel.fromEntityId);
84
- const toIn = frontierSet.has(rel.toEntityId);
85
- let neighborId;
86
- let reacherId;
87
- if (fromIn && !toIn) {
88
- neighborId = rel.toEntityId;
89
- reacherId = rel.fromEntityId;
90
- }
91
- else if (toIn && !fromIn) {
92
- neighborId = rel.fromEntityId;
93
- reacherId = rel.toEntityId;
94
- }
95
- else
96
- continue;
97
- if (visited.has(neighborId))
98
- continue;
99
- const seedStrength = frontierStrength.get(reacherId) ?? originStrength.get(reacherId) ?? 0;
100
- bestStrengthThisDepth.set(neighborId, Math.max(bestStrengthThisDepth.get(neighborId) ?? 0, seedStrength));
101
- }
77
+ const bestStrengthThisDepth = bestStrengthAtDepth(rels, frontierSet, visited, frontierStrength, originStrength);
102
78
  // Pass 2: commit strongest-first (then id asc — deterministic), so the per-hop fanout cap
103
79
  // keeps the highest-scoring neighbours rather than whichever SQLite happened to return.
104
80
  const nextFrontier = [];
@@ -119,6 +95,39 @@ function accumulateForRoot(root, seeds, entries, memIdToIndex, graphScore, hops,
119
95
  frontier = nextFrontier;
120
96
  frontierStrength = nextStrength;
121
97
  }
98
+ return reachedScore;
99
+ }
100
+ /**
101
+ * Accumulate per-entryIndex graph-proximity scores from ONE store's graph into
102
+ * `graphScore`. Pure reads. `seeds` are the lexical seeds (index + strength); only the
103
+ * seeds whose entities live in THIS store are expanded. The origin seed strength is
104
+ * carried UNCHANGED along each BFS path; the per-hop decay is applied as decay^depth so
105
+ * a neighbour's score = originSeedStrength x decay^(graph distance).
106
+ */
107
+ function accumulateForRoot(root, seeds, entries, memIdToIndex, graphScore, hops, decay, maxNeighbors, tenantId) {
108
+ if (seeds.length === 0)
109
+ return;
110
+ // The strongest seed strength per source memory id (a memId could appear once, but
111
+ // guard against dup indices mapping to the same memId).
112
+ const strengthByMemId = new Map();
113
+ for (const s of seeds) {
114
+ const memId = entries[s.index].id;
115
+ strengthByMemId.set(memId, Math.max(strengthByMemId.get(memId) ?? 0, s.strength));
116
+ }
117
+ const seedEntities = loadEntitiesByMemoryId(root, tenantId, [...strengthByMemId.keys()]);
118
+ if (seedEntities.length === 0)
119
+ return;
120
+ // entityId -> origin seed strength (carried unchanged along the path). A seed entity is
121
+ // loaded by memory id, so its memoryId is non-null here; the guard keeps the widened
122
+ // (string | null) type honest (a null-memory entity is not a lexical seed).
123
+ const originStrength = new Map();
124
+ for (const e of seedEntities) {
125
+ if (e.memoryId === null)
126
+ continue;
127
+ const st = strengthByMemId.get(e.memoryId) ?? 0;
128
+ originStrength.set(e.id, Math.max(originStrength.get(e.id) ?? 0, st));
129
+ }
130
+ const reachedScore = reachedEntityScores(root, tenantId, seedEntities.map((e) => e.id), originStrength, hops, decay, maxNeighbors);
122
131
  if (reachedScore.size === 0)
123
132
  return;
124
133
  // Reached entity ids -> source memory ids -> in-pool entry indices.
@@ -159,7 +168,7 @@ export function graphRankStream(entries, seeds, opts) {
159
168
  for (const root of roots) {
160
169
  accumulateForRoot(root, seeds, entries, memIdToIndex, graphScore, hops, decay, maxNeighbors, opts.tenantId);
161
170
  }
162
- // Seed-exclusion guard (plan-eng-critic MED): graphScore is keyed by entryIndex
171
+ // Seed-exclusion guard: graphScore is keyed by entryIndex
163
172
  // GLOBALLY across roots, but each root's BFS visited-set is per-root, so a memory that
164
173
  // is a seed in one store could be reached as a neighbour in the other store and pick up
165
174
  // a score via max(). Drop every seed index so the "seeds are never scored by the graph