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
@@ -3,7 +3,8 @@ import { rejectionDigest, insertRejectedValue, normalizeValueForRejection } from
3
3
  import { archiveRawMemory } from '../raw-archive.js';
4
4
  import { rowToMemoryConflict } from './rows.js';
5
5
  import { audit } from './audit-event.js';
6
- import { syncMirrorFiles, purgeMirrorBestEffort } from './mirrors.js';
6
+ import { syncChangedMirrors, purgeMirrorBestEffort } from './mirrors.js';
7
+ import { selectEntriesByIds } from './entry-reads.js';
7
8
  import { openStore } from './open.js';
8
9
  import { deleteEntryCore } from './delete-and-batch.js';
9
10
  function canonicalConflictPair(aId, bId) {
@@ -81,9 +82,9 @@ export function replaceDetectedConflicts(hippoRoot, detected, detectedAt = new D
81
82
  }));
82
83
  resolveStaleOpenConflicts(db, canonicalDetected, sameTenant, detectedAt);
83
84
  upsertDetectedConflicts(db, canonicalDetected, sameTenant, detectedAt);
84
- rebuildConflictsWithJson(db, sameTenant);
85
+ const changedIds = rebuildConflictsWithJson(db, sameTenant);
85
86
  db.exec('COMMIT');
86
- syncMirrorFiles(hippoRoot, db);
87
+ syncChangedMirrors(hippoRoot, db, [...selectEntriesByIds(db, changedIds).values()]);
87
88
  }
88
89
  catch (error) {
89
90
  try {
@@ -123,36 +124,38 @@ function resolveStaleOpenConflicts(db, canonicalDetected, sameTenant, detectedAt
123
124
  FROM memory_conflicts
124
125
  WHERE status = 'open'
125
126
  `).all();
127
+ const resolve = db.prepare(`UPDATE memory_conflicts SET status = 'resolved', updated_at = ? WHERE id = ?`);
126
128
  for (const row of openRows) {
127
129
  const key = `${row.memory_a_id}::${row.memory_b_id}`;
128
130
  const stale = !detectedKeys.has(key);
129
131
  // v1.11.0 residue: auto-resolve any open cross-tenant row. The insert
130
- // loop below (line 2089) and the refMap rebuild (line 2117) skip
132
+ // loop in upsertDetectedConflicts and the refMap rebuild skip
131
133
  // cross-tenant pairs, but the resolve-stale loop previously left
132
134
  // re-detected cross-tenant rows lingering status='open'. The
133
135
  // sameTenant() helper is already built one block up; no extra query.
134
136
  const crossTenant = !sameTenant(row.memory_a_id, row.memory_b_id);
135
- if (stale || crossTenant) {
136
- db.prepare(`UPDATE memory_conflicts SET status = 'resolved', updated_at = ? WHERE id = ?`).run(detectedAt, row.id);
137
- }
137
+ if (stale || crossTenant)
138
+ resolve.run(detectedAt, row.id);
138
139
  }
139
140
  }
140
141
  function upsertDetectedConflicts(db, canonicalDetected, sameTenant, detectedAt) {
142
+ const upsert = db.prepare(`
143
+ INSERT INTO memory_conflicts(memory_a_id, memory_b_id, reason, score, status, detected_at, updated_at)
144
+ VALUES (?, ?, ?, ?, 'open', ?, ?)
145
+ ON CONFLICT(memory_a_id, memory_b_id) DO UPDATE SET
146
+ reason = excluded.reason,
147
+ score = excluded.score,
148
+ status = 'open',
149
+ updated_at = excluded.updated_at
150
+ `);
141
151
  for (const conflict of canonicalDetected) {
142
152
  // Skip cross-tenant pairs — never persist a conflict spanning tenants.
143
153
  if (!sameTenant(conflict.memory_a_id, conflict.memory_b_id))
144
154
  continue;
145
- db.prepare(`
146
- INSERT INTO memory_conflicts(memory_a_id, memory_b_id, reason, score, status, detected_at, updated_at)
147
- VALUES (?, ?, ?, ?, 'open', ?, ?)
148
- ON CONFLICT(memory_a_id, memory_b_id) DO UPDATE SET
149
- reason = excluded.reason,
150
- score = excluded.score,
151
- status = 'open',
152
- updated_at = excluded.updated_at
153
- `).run(conflict.memory_a_id, conflict.memory_b_id, conflict.reason, conflict.score, detectedAt, detectedAt);
155
+ upsert.run(conflict.memory_a_id, conflict.memory_b_id, conflict.reason, conflict.score, detectedAt, detectedAt);
154
156
  }
155
157
  }
158
+ /** Rewrites only the rows whose conflicts_with_json changes, and returns their ids. */
156
159
  function rebuildConflictsWithJson(db, sameTenant) {
157
160
  // SAFETY: openConflicts' shape matches the two columns named above.
158
161
  const openConflicts = db.prepare(`
@@ -173,13 +176,18 @@ function rebuildConflictsWithJson(db, sameTenant) {
173
176
  refMap.get(row.memory_a_id).add(row.memory_b_id);
174
177
  refMap.get(row.memory_b_id).add(row.memory_a_id);
175
178
  }
176
- // SAFETY: memoryRows' shape matches the single `id` column selected
177
- // above.
178
- const memoryRows = db.prepare(`SELECT id FROM memories`).all();
179
+ // SAFETY: memoryRows' shape matches the two columns selected below.
180
+ const memoryRows = db.prepare(`SELECT id, conflicts_with_json FROM memories`).all();
181
+ const update = db.prepare(`UPDATE memories SET conflicts_with_json = ?, updated_at = datetime('now') WHERE id = ?`);
182
+ const changedIds = [];
179
183
  for (const memory of memoryRows) {
180
- const refs = Array.from(refMap.get(memory.id) ?? []).sort();
181
- db.prepare(`UPDATE memories SET conflicts_with_json = ?, updated_at = datetime('now') WHERE id = ?`).run(JSON.stringify(refs), memory.id);
184
+ const refsJson = JSON.stringify(Array.from(refMap.get(memory.id) ?? []).sort());
185
+ if (memory.conflicts_with_json === refsJson)
186
+ continue;
187
+ update.run(refsJson, memory.id);
188
+ changedIds.push(memory.id);
182
189
  }
190
+ return changedIds;
183
191
  }
184
192
  /**
185
193
  * Resolve a conflict by keeping one memory and weakening the other.
@@ -230,7 +238,7 @@ export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = fal
230
238
  stripConflictRefs(db, target, removal.loserRemoved);
231
239
  auditConflictResolve(db, target, removal, tenantId);
232
240
  db.exec('COMMIT');
233
- syncMirrorFiles(hippoRoot, db);
241
+ syncChangedMirrors(hippoRoot, db, [...selectEntriesByIds(db, [keepId, loserId]).values()]);
234
242
  if (removal.loserRemoved)
235
243
  purgeRemovedLoserMirrors(hippoRoot, db, loserId, removal);
236
244
  return { conflict: { ...conflict, status: 'resolved' }, loserId };
@@ -1,25 +1,16 @@
1
1
  import { type MemoryEntry } from '../memory.js';
2
- import { openHippoDb } from '../db.js';
2
+ import { openHippoDb, type DatabaseSyncLike } from '../db.js';
3
3
  import { type DormantMove } from '../dormant.js';
4
4
  /** Tables whose rows keep a first-class object's backing memory in `memory_id` (ON DELETE SET NULL); tests/dormant-memories.test.ts pins it to the schema. */
5
5
  export declare const MEMORY_BACKED_TABLES: readonly ["predictions", "decisions", "incidents", "processes", "policies", "skills", "project_briefs", "customer_notes"];
6
6
  /** Ids of memories that back a first-class object, for passes that plan deletes before making them. A table missing from an older schema is skipped. */
7
7
  export declare function memoriesBackingObjects(hippoRoot: string): Set<string>;
8
8
  /**
9
- * AT1 (plan §4, round-2 fix, designed from source): db-scoped delete core.
10
- * `deleteEntry` used to open+close its OWN connection, which meant it could
11
- * never compose inside a caller's transaction (unlike writeEntry/
12
- * writeEntryDbOnly, which already split this way). Split identically: row-
13
- * meta SELECT, `DELETE FROM memories`, FTS delete, `forget` audit, DAG
14
- * dirty-mark. NO filesystem I/O — the caller's own transaction may still be
15
- * rolled back, and mirror writes must only happen post-commit.
9
+ * db-scoped delete core, so a delete can compose inside a caller's transaction.
10
+ * NO filesystem I/O: the caller's transaction may still roll back, and mirrors are written post-commit.
16
11
  *
17
- * `opts.suppressForgetAudit` (default false, off): two AT1 callers set this
18
- * so a removed non-raw row does NOT ALSO emit a `forget` row, because each
19
- * already writes its own aggregate audit trail — `src/reject-flow.ts`'s
20
- * `rejectValue` (single `reject_value` row covering every same-digest row
21
- * removed) and `resolveConflict` (`conflict_resolve` row per resolution).
22
- * Default keeps `deleteEntry` byte-identical to its pre-split behavior.
12
+ * `opts.suppressForgetAudit` (default false): `rejectValue` and `resolveConflict` set it because each
13
+ * writes its own aggregate audit row, so a removed row must not ALSO emit a `forget` row.
23
14
  *
24
15
  * Returns `{tenantId, dagParentId}` for the removed row, or `null` if no row with `id`
25
16
  * existed or `automatic` refused it (pinned, raw, kept for good or backing an object at DELETE time, so a late pin wins).
@@ -49,6 +40,12 @@ export declare function deleteEntry(hippoRoot: string, id: string, opts?: {
49
40
  reason?: string;
50
41
  automatic?: boolean;
51
42
  }): boolean;
43
+ /** deleteEntry on the caller's open store, so a loop of deletes opens the store once; each delete still commits alone. */
44
+ export declare function deleteEntryOn(db: DatabaseSyncLike, hippoRoot: string, id: string, opts?: {
45
+ actor?: string;
46
+ reason?: string;
47
+ automatic?: boolean;
48
+ }): boolean;
52
49
  /** Consolidation's flush, one transaction. With `snapshot` (rows as the caller loaded them), a write keeps only
53
50
  * the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone.
54
51
  *
@@ -38,20 +38,11 @@ export function memoriesBackingObjects(hippoRoot) {
38
38
  return ids;
39
39
  }
40
40
  /**
41
- * AT1 (plan §4, round-2 fix, designed from source): db-scoped delete core.
42
- * `deleteEntry` used to open+close its OWN connection, which meant it could
43
- * never compose inside a caller's transaction (unlike writeEntry/
44
- * writeEntryDbOnly, which already split this way). Split identically: row-
45
- * meta SELECT, `DELETE FROM memories`, FTS delete, `forget` audit, DAG
46
- * dirty-mark. NO filesystem I/O — the caller's own transaction may still be
47
- * rolled back, and mirror writes must only happen post-commit.
41
+ * db-scoped delete core, so a delete can compose inside a caller's transaction.
42
+ * NO filesystem I/O: the caller's transaction may still roll back, and mirrors are written post-commit.
48
43
  *
49
- * `opts.suppressForgetAudit` (default false, off): two AT1 callers set this
50
- * so a removed non-raw row does NOT ALSO emit a `forget` row, because each
51
- * already writes its own aggregate audit trail — `src/reject-flow.ts`'s
52
- * `rejectValue` (single `reject_value` row covering every same-digest row
53
- * removed) and `resolveConflict` (`conflict_resolve` row per resolution).
54
- * Default keeps `deleteEntry` byte-identical to its pre-split behavior.
44
+ * `opts.suppressForgetAudit` (default false): `rejectValue` and `resolveConflict` set it because each
45
+ * writes its own aggregate audit row, so a removed row must not ALSO emit a `forget` row.
55
46
  *
56
47
  * Returns `{tenantId, dagParentId}` for the removed row, or `null` if no row with `id`
57
48
  * existed or `automatic` refused it (pinned, raw, kept for good or backing an object at DELETE time, so a late pin wins).
@@ -70,11 +61,8 @@ export function deleteEntryCore(db, id, opts) {
70
61
  if (!opts?.suppressForgetAudit) {
71
62
  audit(db, 'forget', id, opts?.reason ? { reason: opts.reason } : undefined, opts?.actor ?? 'cli', row.tenant_id);
72
63
  }
73
- // v0.30 / E2 — DAG live-coupling: forget of a child under a level-2
74
- // summary marks parent dirty. Non-atomic with the DELETE (no SAVEPOINT
75
- // wrapper here, same as pre-split deleteEntry); markSummaryDirtyInTx is
76
- // idempotent so any future child mutation re-marks parent if this fails.
77
- // Acceptable degradation, mirrors the pre-split audit best-effort posture.
64
+ // Forgetting a child of a summary marks the parent dirty. Not atomic with the DELETE, but
65
+ // markSummaryDirtyInTx is idempotent, so the next child mutation re-marks the parent if this fails.
78
66
  if (row.dag_parent_id) {
79
67
  markSummaryDirtyInTx(db, row.dag_parent_id, row.tenant_id ?? 'default', opts?.actor ?? 'cli');
80
68
  }
@@ -94,26 +82,30 @@ export function deleteEntryCore(db, id, opts) {
94
82
  export function deleteEntry(hippoRoot, id, opts) {
95
83
  const db = openStore(hippoRoot);
96
84
  try {
97
- db.exec('BEGIN IMMEDIATE');
98
- let result;
99
- try {
100
- result = deleteEntryCore(db, id, opts);
101
- db.exec('COMMIT');
102
- }
103
- catch (err) {
104
- if (db.isTransaction !== false)
105
- db.exec('ROLLBACK');
106
- throw err;
107
- }
108
- if (!result)
109
- return false;
110
- purgeMirrorBestEffort(hippoRoot, id, false, 'deleteEntry');
111
- return true;
85
+ return deleteEntryOn(db, hippoRoot, id, opts);
112
86
  }
113
87
  finally {
114
88
  closeHippoDb(db);
115
89
  }
116
90
  }
91
+ /** deleteEntry on the caller's open store, so a loop of deletes opens the store once; each delete still commits alone. */
92
+ export function deleteEntryOn(db, hippoRoot, id, opts) {
93
+ db.exec('BEGIN IMMEDIATE');
94
+ let result;
95
+ try {
96
+ result = deleteEntryCore(db, id, opts);
97
+ db.exec('COMMIT');
98
+ }
99
+ catch (err) {
100
+ if (db.isTransaction !== false)
101
+ db.exec('ROLLBACK');
102
+ throw err;
103
+ }
104
+ if (!result)
105
+ return false;
106
+ purgeMirrorBestEffort(hippoRoot, id, false, 'deleteEntry');
107
+ return true;
108
+ }
117
109
  // The child fields a level-2/3 summary is built from (loadChildrenOfSummary, generateDagSummary).
118
110
  const SUMMARY_INPUTS = ['content', 'created', 'dag_parent_id', 'kind'];
119
111
  function mergeOwnChanges(base, ours, live) {
@@ -138,26 +130,16 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
138
130
  return [];
139
131
  const db = openStore(hippoRoot);
140
132
  try {
141
- // BEGIN IMMEDIATE (codex delta-review P2): the AT1 tombstone probes below
142
- // READ before the first write. Under a deferred BEGIN, that read pins a
143
- // WAL snapshot; a concurrent writer (e.g. `hippo reject`) committing
144
- // between probe and first upsert would make the later write-lock upgrade
145
- // fail with SQLITE_BUSY and roll back the ENTIRE batch — the exact race
146
- // the probe exists to contain. Taking the write lock up front serializes
147
- // the probe and the writes on one consistent snapshot.
133
+ // IMMEDIATE: the tombstone probes below read before the first write, and under a deferred BEGIN a
134
+ // concurrent `hippo reject` would make the lock upgrade fail with SQLITE_BUSY and roll back the batch.
148
135
  db.exec('BEGIN IMMEDIATE');
149
- // v0.30 / E2 — DAG live-coupling: BEFORE deletes, snapshot dag_parent_id
150
- // for every doomed row so we can mark parents dirty post-COMMIT. Done
151
- // inside the same BEGIN so the SELECT sees pre-delete state.
152
- // independent-review-critic R1 HIGH: consolidate.ts/sleep flushes through
153
- // this path every cycle; without these hooks parents NEVER get marked
154
- // dirty for the dominant mutation source (decay, merge, garbage-collect).
136
+ // Snapshot every doomed row's dag_parent_id before the deletes: consolidation flushes through here
137
+ // every cycle, so without it parents would never be marked dirty for decay, merge or garbage-collect.
155
138
  const dirty = { parents: new Set(), tenantById: new Map() };
156
139
  const deletableIds = [];
157
140
  if (toDeleteIds.length > 0) {
158
141
  // A row pinned after the caller decided to delete it survives.
159
- const placeholders = toDeleteIds.map(() => '?').join(',');
160
- for (const row of selectAutoDeletableRows(db, placeholders, toDeleteIds, dirty))
142
+ for (const row of selectAutoDeletableRows(db, toDeleteIds, dirty))
161
143
  deletableIds.push(row.id);
162
144
  }
163
145
  // v39: batch writers bypass writeEntry, so stamp store-derived origins here too (a NULL origin hides new
@@ -202,8 +184,7 @@ function moveDormantAndDelete(db, dormantMoves, deletableIds, dirty) {
202
184
  const movable = [];
203
185
  if (dormantMoves.length > 0) {
204
186
  const byId = new Map(dormantMoves.map((m) => [m.entry.id, m]));
205
- const placeholders = dormantMoves.map(() => '?').join(',');
206
- for (const row of selectAutoDeletableRows(db, placeholders, [...byId.keys()], dirty))
187
+ for (const row of selectAutoDeletableRows(db, [...byId.keys()], dirty))
207
188
  movable.push(byId.get(row.id));
208
189
  }
209
190
  for (const move of movable) {
@@ -216,10 +197,10 @@ function moveDormantAndDelete(db, dormantMoves, deletableIds, dirty) {
216
197
  }
217
198
  return removedIds;
218
199
  }
219
- /** The still auto-deletable rows among `ids`, recording each one's DAG parent as dirty. */
220
- function selectAutoDeletableRows(db, placeholders, ids, dirty) {
200
+ /** The still auto-deletable rows among `ids`, recording each one's DAG parent as dirty. Placeholders come from `ids` itself. */
201
+ function selectAutoDeletableRows(db, ids, dirty) {
221
202
  // SAFETY: rows' shape matches the three columns named in the SELECT.
222
- const rows = db.prepare(`SELECT id, dag_parent_id, tenant_id FROM memories WHERE id IN (${placeholders}) AND ${AUTOMATIC_DELETE_SQL}`).all(...ids);
203
+ const rows = db.prepare(`SELECT id, dag_parent_id, tenant_id FROM memories WHERE id IN (${ids.map(() => '?').join(',')}) AND ${AUTOMATIC_DELETE_SQL}`).all(...ids);
223
204
  for (const row of rows) {
224
205
  if (row.dag_parent_id) {
225
206
  dirty.parents.add(row.dag_parent_id);
@@ -229,23 +210,8 @@ function selectAutoDeletableRows(db, placeholders, ids, dirty) {
229
210
  return rows;
230
211
  }
231
212
  function applyBatchWrites(db, stampedWrites, snapshot, dirty) {
232
- // AT1 P1 fix (codex, batch-transaction rejection race): the producer-side
233
- // check (e.g. consolidate.ts's merge pass) runs BEFORE this transaction,
234
- // on a different connection. A `hippo reject X` that commits in that
235
- // window is invisible to it — a queued same-id write of X already
236
- // sitting in `toWrite` (decay/replay re-persist, or a merge built before
237
- // the reject) would silently re-INSERT the just-rejected row via the
238
- // blind bypass. Fix: one indexed point probe per batch entry, on THIS
239
- // connection, INSIDE this transaction — closes the race regardless of
240
- // which write class hits it. N is small per sleep, so the extra query
241
- // per entry is cheap.
242
- //
243
- // Skip, don't throw: the batch must still complete for every OTHER
244
- // entry. Skipping is correct for every write class here — a merge
245
- // summary skip just means that rollup is absent this cycle (its source
246
- // facts stay merely demoted, recoverable next sleep); a skipped
247
- // demotion/replay re-persist of a rejected-removed row means it stays
248
- // gone, which is the entire point of the tombstone.
213
+ // Probe tombstones per entry on THIS connection inside the transaction: the producer's check ran earlier
214
+ // on another connection, so a reject committed in between would be re-inserted. Skip, never throw, so the rest lands.
249
215
  let batchRejectedSkips = 0;
250
216
  const written = [];
251
217
  const readLiveRow = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE id = ?`);
@@ -262,18 +228,8 @@ function applyBatchWrites(db, stampedWrites, snapshot, dirty) {
262
228
  if (base && !live)
263
229
  continue;
264
230
  written.push(row);
265
- // AT1 (plan §3, corrected): bypass the rejection guard here.
266
- // Consolidation merges are DETERMINISTIC CONCATENATION (mergeContents,
267
- // consolidate.ts:736-751) of already-guarded leaf facts, not an LLM
268
- // paraphrase — refusing mid-batch would abort the whole consolidation
269
- // transaction. The bypass is safe because consolidate.ts's merge pass
270
- // now checks the merged content's rejection digest against the
271
- // tenant's tombstones BEFORE ever pushing a merge into pendingWrites,
272
- // skipping that merge entirely on a hit, AND because the point-probe
273
- // immediately above closes the race window between that producer
274
- // check and this COMMIT. The guard itself still belongs on leaf
275
- // inserts, which write through writeEntry / writeEntryDbOnly and stay
276
- // guarded (bypassRejectionGuard defaults false).
231
+ // Bypass the guard: merges concatenate already-guarded facts, the merge pass checks merged content, and
232
+ // the probe above closes the race; a mid-batch refusal would abort the whole consolidation.
277
233
  upsertEntryRow(db, row, true);
278
234
  // Hook for writes: a new child, or a change to what its summary reads, marks the parent dirty; decay alone does not.
279
235
  if (row.dag_parent_id && (!live || SUMMARY_INPUTS.some((k) => row[k] !== live[k]))) {
@@ -286,15 +242,8 @@ function applyBatchWrites(db, stampedWrites, snapshot, dirty) {
286
242
  /** True, after auditing the refusal, when the write would introduce a rejected value. */
287
243
  function isRejectedBatchWrite(db, row) {
288
244
  const entryTenantId = row.tenantId ?? 'default';
289
- // Codex delta-review P2 fix: reuse checkRejectionGuard rather than a
290
- // bare tombstone probe — the guard's content-INTRODUCTION
291
- // classification must apply here too. A tombstone can legitimately
292
- // coexist with a live same-content row (resolveConflict deliberately
293
- // excludes keepId from its sweep; unreject-then-re-reject windows), and
294
- // an unconditional skip would starve that row of decay/replay metadata
295
- // updates forever. The guard throws only when the write is new-row or
296
- // changes content TO the rejected value; unchanged same-id re-persists
297
- // pass through, exactly as on the writeEntry path.
245
+ // checkRejectionGuard, not a bare tombstone probe: a tombstone can coexist with a live same-content row,
246
+ // and skipping every re-persist would starve it of decay/replay updates; only new or changed content is refused.
298
247
  try {
299
248
  checkRejectionGuard(db, entryTenantId, row.id, row.content);
300
249
  }
@@ -8,11 +8,18 @@ import { type DatabaseSyncLike } from '../db.js';
8
8
  * legacy single-tenant callers and the writeEntry/readEntry round-trip.
9
9
  */
10
10
  export declare function readEntry(hippoRoot: string, id: string, tenantId?: string): MemoryEntry | null;
11
+ /** Ids per `IN (...)` list: far under SQLite's bound-parameter limit, with room for the tenant filter. */
12
+ export declare const ID_CHUNK = 500;
13
+ /** `items` in consecutive slices of at most `size`. */
14
+ export declare function chunked<T>(items: readonly T[], size?: number): T[][];
15
+ /** Rows by id on the caller's handle, one query per chunk; an id missing or in another tenant is absent from the map. */
16
+ export declare function selectEntriesByIds(db: DatabaseSyncLike, ids: readonly string[], tenantId?: string): Map<string, MemoryEntry>;
17
+ /** Direct children of each parent, one query per chunk; each list is in `created ASC, id ASC` order. */
18
+ export declare function selectChildrenByParent(db: DatabaseSyncLike, parentIds: readonly string[], tenantId?: string): Map<string, MemoryEntry[]>;
11
19
  /**
12
20
  * Batched lookup. Caps at 500 ids per call to keep the IN(?,?,...) clause
13
21
  * within SQLite limits. Tenant filter is enforced when `tenantId` is passed.
14
- * Used by DAG-aware recall (docs/plans/2026-05-05-dag-recall.md Task 1.5)
15
- * to fetch parent summaries for a set of overflowed leaves.
22
+ * Used by DAG-aware recall to fetch parent summaries for a set of overflowed leaves.
16
23
  */
17
24
  export declare function loadEntriesByIds(hippoRoot: string, ids: readonly string[], tenantId?: string): MemoryEntry[];
18
25
  /**
@@ -20,10 +27,8 @@ export declare function loadEntriesByIds(hippoRoot: string, ids: readonly string
20
27
  * oldest-first. Used by `api.assemble` to walk a session's chronological
21
28
  * context. Excludes superseded rows.
22
29
  *
23
- * Cap semantics (v1.6.2 codex fix): when `cap` is provided, the NEWEST
24
- * `cap` rows are loaded — `ORDER BY created DESC LIMIT cap` server-side,
25
- * reversed to oldest-first client-side. Pre-v1.6.2 ordered ASC + LIMIT,
26
- * which silently dropped the newest rows and broke fresh-tail in assemble.
30
+ * Cap semantics: when `cap` is provided, the NEWEST `cap` rows are loaded (DESC LIMIT server-side,
31
+ * reversed client-side); ASC + LIMIT would drop the newest rows and break fresh-tail in assemble.
27
32
  *
28
33
  * Returns `[]` for an empty sessionId. Final order: `created ASC, id ASC`.
29
34
  */
@@ -33,11 +38,8 @@ export declare function loadSessionRawMemories(hippoRoot: string, sessionId: str
33
38
  * the full session size even when `rowCap` truncates the loaded window,
34
39
  * WITHOUT leaking rows the caller wouldn't have been allowed to load.
35
40
  *
36
- * v1.6.3 codex P1 / senior P0: an earlier draft of this helper ran an
37
- * unscoped COUNT, which let a no-scope caller infer the existence of
38
- * private rows by comparing `totalRaw` against `items.length`. This
39
- * version SQL-encodes the same default-deny rule `passesScopeFilterForRecall`
40
- * applies in TS:
41
+ * An unscoped COUNT would let a no-scope caller infer private rows by comparing `totalRaw`
42
+ * against `items.length`, so this SQL-encodes the default-deny rule `passesScopeFilterForRecall` applies in TS:
41
43
  * - explicit scope passed: exact-match
42
44
  * - no scope: rows where scope IS NULL, or scope is NOT a `<source>:private:*`
43
45
  * pattern AND not the `unknown:legacy` quarantine bucket.
@@ -51,29 +53,19 @@ export declare function countSessionRawMemories(hippoRoot: string, sessionId: st
51
53
  * `sessionId` is supplied, also constrains to a specific session — that
52
54
  * is the correct shape for "what did I just see in THIS session."
53
55
  *
54
- * v1.6.2 codex review fix: pre-v1.6.2 was tenant-wide only. With multiple
55
- * concurrent sessions in a tenant, fresh-tail recall surfaced unrelated
56
- * rows from other sessions and stamped them `isFreshTail=true`. Callers
57
- * that want session-scoped fresh-tail now pass `sessionId`. The
58
- * tenant-wide form (no sessionId) still exists for "anything new across
59
- * the whole tenant" — pass undefined to opt in.
56
+ * Without `sessionId`, concurrent sessions in a tenant surface each other's rows as fresh tail;
57
+ * pass undefined only for "anything new across the whole tenant".
60
58
  *
61
59
  * Bounded count cap at 200 — beyond that the caller should filter via
62
60
  * tags/scope rather than time-windowed recall.
63
61
  *
64
- * Deprecation note (v1.6.5) — the **tenant-wide call shape** (omitting
65
- * `sessionId`) is rarely the right shape for "what did I just see in this
66
- * conversation". `api.recall` enforces session scoping when
67
- * `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1` is set, throwing
68
- * `RecallContractError` instead. Tenant-wide remains the back-compat default
69
- * but is discouraged for new callers. Passing `sessionId` is fully supported
70
- * and recommended; this function is NOT deprecated as a whole.
62
+ * The tenant-wide shape is the back-compat default but discouraged; `api.recall` throws
63
+ * `RecallContractError` for it when `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1` is set.
71
64
  */
72
65
  export declare function loadFreshRawMemories(hippoRoot: string, count: number, tenantId?: string, sessionId?: string): MemoryEntry[];
73
66
  /**
74
67
  * Direct DAG children of a parent summary. Tenant scoped. Returns only rows
75
68
  * whose `dag_parent_id` matches `parentId`; does NOT walk recursively.
76
- * Used by `drillDown` (Task 3).
77
69
  */
78
70
  export declare function loadChildrenOf(hippoRoot: string, parentId: string, tenantId?: string): MemoryEntry[];
79
71
  /**
@@ -1,6 +1,7 @@
1
1
  import { closeHippoDb } from '../db.js';
2
2
  import { MEMORY_SELECT_COLUMNS, rowToEntry, parseJsonArray } from './rows.js';
3
3
  import { openStore } from './open.js';
4
+ import { escapeLike } from '../escape.js';
4
5
  /**
5
6
  * Read a memory entry by ID.
6
7
  *
@@ -22,11 +23,54 @@ export function readEntry(hippoRoot, id, tenantId) {
22
23
  closeHippoDb(db);
23
24
  }
24
25
  }
26
+ /** Ids per `IN (...)` list: far under SQLite's bound-parameter limit, with room for the tenant filter. */
27
+ export const ID_CHUNK = 500;
28
+ /** `items` in consecutive slices of at most `size`. */
29
+ export function chunked(items, size = ID_CHUNK) {
30
+ const out = [];
31
+ for (let i = 0; i < items.length; i += size)
32
+ out.push(items.slice(i, i + size));
33
+ return out;
34
+ }
35
+ /** Rows by id on the caller's handle, one query per chunk; an id missing or in another tenant is absent from the map. */
36
+ export function selectEntriesByIds(db, ids, tenantId) {
37
+ const byId = new Map();
38
+ const tenantClause = tenantId !== undefined ? ' AND tenant_id = ?' : '';
39
+ const tenantArgs = tenantId !== undefined ? [tenantId] : [];
40
+ for (const chunk of chunked([...new Set(ids)])) {
41
+ const placeholders = chunk.map(() => '?').join(',');
42
+ // SAFETY: selects exactly MEMORY_SELECT_COLUMNS, matching MemoryRow's field set.
43
+ const rows = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE id IN (${placeholders})${tenantClause}`).all(...chunk, ...tenantArgs);
44
+ for (const row of rows)
45
+ byId.set(row.id, rowToEntry(row));
46
+ }
47
+ return byId;
48
+ }
49
+ /** Direct children of each parent, one query per chunk; each list is in `created ASC, id ASC` order. */
50
+ export function selectChildrenByParent(db, parentIds, tenantId) {
51
+ const byParent = new Map();
52
+ const tenantClause = tenantId !== undefined ? ' AND tenant_id = ?' : '';
53
+ const tenantArgs = tenantId !== undefined ? [tenantId] : [];
54
+ for (const chunk of chunked([...new Set(parentIds)])) {
55
+ const placeholders = chunk.map(() => '?').join(',');
56
+ // SAFETY: selects exactly MEMORY_SELECT_COLUMNS, matching MemoryRow's field set.
57
+ const rows = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE dag_parent_id IN (${placeholders})${tenantClause} ORDER BY created ASC, id ASC`).all(...chunk, ...tenantArgs);
58
+ for (const row of rows) {
59
+ const entry = rowToEntry(row);
60
+ const parentId = entry.dag_parent_id ?? '';
61
+ const bucket = byParent.get(parentId);
62
+ if (bucket)
63
+ bucket.push(entry);
64
+ else
65
+ byParent.set(parentId, [entry]);
66
+ }
67
+ }
68
+ return byParent;
69
+ }
25
70
  /**
26
71
  * Batched lookup. Caps at 500 ids per call to keep the IN(?,?,...) clause
27
72
  * within SQLite limits. Tenant filter is enforced when `tenantId` is passed.
28
- * Used by DAG-aware recall (docs/plans/2026-05-05-dag-recall.md Task 1.5)
29
- * to fetch parent summaries for a set of overflowed leaves.
73
+ * Used by DAG-aware recall to fetch parent summaries for a set of overflowed leaves.
30
74
  */
31
75
  export function loadEntriesByIds(hippoRoot, ids, tenantId) {
32
76
  if (ids.length === 0)
@@ -35,9 +79,7 @@ export function loadEntriesByIds(hippoRoot, ids, tenantId) {
35
79
  const db = openStore(hippoRoot);
36
80
  try {
37
81
  const placeholders = capped.map(() => '?').join(',');
38
- // T2: no ORDER BY meant row order followed SQLite's IN(...) scan order
39
- // (undefined w.r.t. the caller's `ids` order). created ASC, id ASC
40
- // makes it deterministic.
82
+ // Without ORDER BY, rows follow SQLite's IN(...) scan order, which is undefined w.r.t. `ids`.
41
83
  // SAFETY: both branches select exactly MEMORY_SELECT_COLUMNS, matching
42
84
  // MemoryRow's field set.
43
85
  const rows = tenantId !== undefined
@@ -54,10 +96,8 @@ export function loadEntriesByIds(hippoRoot, ids, tenantId) {
54
96
  * oldest-first. Used by `api.assemble` to walk a session's chronological
55
97
  * context. Excludes superseded rows.
56
98
  *
57
- * Cap semantics (v1.6.2 codex fix): when `cap` is provided, the NEWEST
58
- * `cap` rows are loaded — `ORDER BY created DESC LIMIT cap` server-side,
59
- * reversed to oldest-first client-side. Pre-v1.6.2 ordered ASC + LIMIT,
60
- * which silently dropped the newest rows and broke fresh-tail in assemble.
99
+ * Cap semantics: when `cap` is provided, the NEWEST `cap` rows are loaded (DESC LIMIT server-side,
100
+ * reversed client-side); ASC + LIMIT would drop the newest rows and break fresh-tail in assemble.
61
101
  *
62
102
  * Returns `[]` for an empty sessionId. Final order: `created ASC, id ASC`.
63
103
  */
@@ -94,11 +134,8 @@ export function loadSessionRawMemories(hippoRoot, sessionId, tenantId, cap) {
94
134
  * the full session size even when `rowCap` truncates the loaded window,
95
135
  * WITHOUT leaking rows the caller wouldn't have been allowed to load.
96
136
  *
97
- * v1.6.3 codex P1 / senior P0: an earlier draft of this helper ran an
98
- * unscoped COUNT, which let a no-scope caller infer the existence of
99
- * private rows by comparing `totalRaw` against `items.length`. This
100
- * version SQL-encodes the same default-deny rule `passesScopeFilterForRecall`
101
- * applies in TS:
137
+ * An unscoped COUNT would let a no-scope caller infer private rows by comparing `totalRaw`
138
+ * against `items.length`, so this SQL-encodes the default-deny rule `passesScopeFilterForRecall` applies in TS:
102
139
  * - explicit scope passed: exact-match
103
140
  * - no scope: rows where scope IS NULL, or scope is NOT a `<source>:private:*`
104
141
  * pattern AND not the `unknown:legacy` quarantine bucket.
@@ -140,23 +177,14 @@ export function countSessionRawMemories(hippoRoot, sessionId, tenantId, scope) {
140
177
  * `sessionId` is supplied, also constrains to a specific session — that
141
178
  * is the correct shape for "what did I just see in THIS session."
142
179
  *
143
- * v1.6.2 codex review fix: pre-v1.6.2 was tenant-wide only. With multiple
144
- * concurrent sessions in a tenant, fresh-tail recall surfaced unrelated
145
- * rows from other sessions and stamped them `isFreshTail=true`. Callers
146
- * that want session-scoped fresh-tail now pass `sessionId`. The
147
- * tenant-wide form (no sessionId) still exists for "anything new across
148
- * the whole tenant" — pass undefined to opt in.
180
+ * Without `sessionId`, concurrent sessions in a tenant surface each other's rows as fresh tail;
181
+ * pass undefined only for "anything new across the whole tenant".
149
182
  *
150
183
  * Bounded count cap at 200 — beyond that the caller should filter via
151
184
  * tags/scope rather than time-windowed recall.
152
185
  *
153
- * Deprecation note (v1.6.5) — the **tenant-wide call shape** (omitting
154
- * `sessionId`) is rarely the right shape for "what did I just see in this
155
- * conversation". `api.recall` enforces session scoping when
156
- * `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1` is set, throwing
157
- * `RecallContractError` instead. Tenant-wide remains the back-compat default
158
- * but is discouraged for new callers. Passing `sessionId` is fully supported
159
- * and recommended; this function is NOT deprecated as a whole.
186
+ * The tenant-wide shape is the back-compat default but discouraged; `api.recall` throws
187
+ * `RecallContractError` for it when `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1` is set.
160
188
  */
161
189
  export function loadFreshRawMemories(hippoRoot, count, tenantId, sessionId) {
162
190
  if (count <= 0)
@@ -174,11 +202,8 @@ export function loadFreshRawMemories(hippoRoot, count, tenantId, sessionId) {
174
202
  sql += ' AND source_session_id = ?';
175
203
  params.push(sessionId);
176
204
  }
177
- // T2: tie tail keeps the LIMIT window keyed on `created` while making
178
- // same-`created` rows deterministic. `content` before `id` (codex
179
- // review): ids are random UUIDs, so an id-only tail would pick WHICH
180
- // same-created rows make the window per-instance; content is
181
- // cross-ingest-stable.
205
+ // Tie tail makes same-`created` rows deterministic; `content` before `id` because ids are random
206
+ // UUIDs, so an id-only tail would pick which same-created rows make the window per instance.
182
207
  sql += ' ORDER BY created DESC, content ASC, id ASC LIMIT ?';
183
208
  params.push(capped);
184
209
  // SAFETY: sql starts from MEMORY_SELECT_COLUMNS, matching MemoryRow.
@@ -192,17 +217,11 @@ export function loadFreshRawMemories(hippoRoot, count, tenantId, sessionId) {
192
217
  /**
193
218
  * Direct DAG children of a parent summary. Tenant scoped. Returns only rows
194
219
  * whose `dag_parent_id` matches `parentId`; does NOT walk recursively.
195
- * Used by `drillDown` (Task 3).
196
220
  */
197
221
  export function loadChildrenOf(hippoRoot, parentId, tenantId) {
198
222
  const db = openStore(hippoRoot);
199
223
  try {
200
- // SAFETY: both branches select exactly MEMORY_SELECT_COLUMNS, matching
201
- // MemoryRow's field set.
202
- const rows = tenantId !== undefined
203
- ? db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE dag_parent_id = ? AND tenant_id = ? ORDER BY created ASC, id ASC`).all(parentId, tenantId)
204
- : db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE dag_parent_id = ? ORDER BY created ASC, id ASC`).all(parentId);
205
- return rows.map(rowToEntry);
224
+ return selectChildrenByParent(db, [parentId], tenantId).get(parentId) ?? [];
206
225
  }
207
226
  finally {
208
227
  closeHippoDb(db);
@@ -236,7 +255,7 @@ export function selectAllEntries(db, tenantId) {
236
255
  /** Live rows whose source starts with `prefix`, on the caller's handle; LIKE folds case, so the prefix is checked again exactly. */
237
256
  export function selectLiveEntriesBySourcePrefix(db, tenantId, prefix) {
238
257
  // SAFETY: selects exactly MEMORY_SELECT_COLUMNS, matching MemoryRow's field set.
239
- const rows = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE tenant_id = ? AND superseded_by IS NULL AND source LIKE ? ESCAPE '\\'`).all(tenantId, `${prefix.replace(/[%_\\]/g, '\\$&')}%`);
258
+ const rows = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE tenant_id = ? AND superseded_by IS NULL AND source LIKE ? ESCAPE '\\'`).all(tenantId, `${escapeLike(prefix)}%`);
240
259
  return rows.map(rowToEntry).filter((entry) => entry.source.startsWith(prefix));
241
260
  }
242
261
  // Content of every tenant row tagged `tag`, without reading the rest of the store.