hippo-memory 1.63.1 → 1.64.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 (275) hide show
  1. package/README.md +13 -5
  2. package/dist/agent-memories/claude-code.js +3 -3
  3. package/dist/ambient-store.d.ts +2 -0
  4. package/dist/ambient-store.js +4 -3
  5. package/dist/api/assemble.d.ts +2 -0
  6. package/dist/api/assemble.js +11 -7
  7. package/dist/api/auth.d.ts +15 -11
  8. package/dist/api/auth.js +116 -22
  9. package/dist/api/context-select.d.ts +5 -3
  10. package/dist/api/context-select.js +6 -11
  11. package/dist/api/context-types.d.ts +2 -1
  12. package/dist/api/context.d.ts +1 -1
  13. package/dist/api/context.js +79 -55
  14. package/dist/api/dormant.d.ts +5 -5
  15. package/dist/api/dormant.js +13 -9
  16. package/dist/api/drill-down.d.ts +2 -0
  17. package/dist/api/drill-down.js +12 -7
  18. package/dist/api/forget.d.ts +1 -1
  19. package/dist/api/forget.js +8 -4
  20. package/dist/api/learn.js +5 -3
  21. package/dist/api/outcome.d.ts +1 -1
  22. package/dist/api/outcome.js +2 -1
  23. package/dist/api/promote.js +8 -5
  24. package/dist/api/recall-types.d.ts +4 -2
  25. package/dist/api/recall.js +51 -36
  26. package/dist/api/remember.d.ts +7 -0
  27. package/dist/api/remember.js +26 -2
  28. package/dist/api/sleep-run.d.ts +28 -0
  29. package/dist/api/sleep-run.js +267 -0
  30. package/dist/api/sleep.d.ts +5 -45
  31. package/dist/api/sleep.js +5 -252
  32. package/dist/api/types.d.ts +5 -0
  33. package/dist/api/types.js +4 -0
  34. package/dist/audit-prune.js +1 -1
  35. package/dist/audit.d.ts +1 -3
  36. package/dist/audit.js +7 -103
  37. package/dist/auth.d.ts +48 -4
  38. package/dist/auth.js +111 -42
  39. package/dist/autolearn.js +2 -2
  40. package/dist/capture/caller-session.d.ts +16 -0
  41. package/dist/capture/caller-session.js +58 -0
  42. package/dist/capture/command.d.ts +19 -1
  43. package/dist/capture/command.js +28 -21
  44. package/dist/capture/compact-resume-caller.d.ts +11 -0
  45. package/dist/capture/compact-resume-caller.js +35 -0
  46. package/dist/capture/compact.d.ts +0 -14
  47. package/dist/capture/compact.js +7 -160
  48. package/dist/capture/compaction-items-caller.d.ts +16 -0
  49. package/dist/capture/compaction-items-caller.js +32 -0
  50. package/dist/capture/extract.d.ts +2 -14
  51. package/dist/capture/extract.js +135 -346
  52. package/dist/capture/failure-caller.d.ts +21 -0
  53. package/dist/capture/failure-caller.js +74 -0
  54. package/dist/capture/failure-reading.d.ts +44 -0
  55. package/dist/capture/failure-reading.js +78 -0
  56. package/dist/capture/pre-compact-caller.d.ts +16 -0
  57. package/dist/capture/pre-compact-caller.js +39 -0
  58. package/dist/capture/session-end-caller.d.ts +21 -0
  59. package/dist/capture/session-end-caller.js +30 -0
  60. package/dist/capture/session-texts.d.ts +14 -0
  61. package/dist/capture/session-texts.js +42 -0
  62. package/dist/capture/transcript.d.ts +6 -1
  63. package/dist/capture/transcript.js +20 -16
  64. package/dist/capture/working-state.d.ts +21 -0
  65. package/dist/capture/working-state.js +154 -0
  66. package/dist/capture-error.d.ts +8 -22
  67. package/dist/capture-error.js +9 -61
  68. package/dist/cli/audit.js +25 -0
  69. package/dist/cli/auth.js +4 -3
  70. package/dist/cli/context.js +13 -158
  71. package/dist/cli/continuity.js +2 -1
  72. package/dist/cli/init.js +5 -3
  73. package/dist/cli/last-sleep.d.ts +6 -0
  74. package/dist/cli/last-sleep.js +68 -0
  75. package/dist/cli/quality-repair-once.d.ts +3 -0
  76. package/dist/cli/quality-repair-once.js +36 -0
  77. package/dist/cli/recall.js +2 -1
  78. package/dist/cli/remember.d.ts +2 -0
  79. package/dist/cli/remember.js +4 -1
  80. package/dist/cli/session-hooks.d.ts +0 -3
  81. package/dist/cli/session-hooks.js +12 -49
  82. package/dist/cli/setup.js +10 -2
  83. package/dist/cli/shared.d.ts +7 -14
  84. package/dist/cli/shared.js +19 -78
  85. package/dist/cli/sleep.js +6 -3
  86. package/dist/cli/usage.js +18 -9
  87. package/dist/cli.d.ts +1 -1
  88. package/dist/cli.js +3 -2
  89. package/dist/client.js +1 -1
  90. package/dist/compaction-record.d.ts +19 -1
  91. package/dist/compaction-record.js +61 -132
  92. package/dist/compaction-spool.d.ts +45 -0
  93. package/dist/compaction-spool.js +454 -0
  94. package/dist/config.d.ts +8 -0
  95. package/dist/config.js +49 -0
  96. package/dist/connectors/github/deletion.d.ts +1 -1
  97. package/dist/connectors/github/deletion.js +5 -17
  98. package/dist/consolidate/conflicts.js +4 -0
  99. package/dist/consolidate/flush-units.d.ts +9 -0
  100. package/dist/consolidate/flush-units.js +63 -0
  101. package/dist/consolidate/llm-passes.js +5 -4
  102. package/dist/consolidate/merge.js +5 -1
  103. package/dist/consolidate/run.d.ts +2 -1
  104. package/dist/consolidate/sleep.d.ts +2 -0
  105. package/dist/consolidate/sleep.js +82 -15
  106. package/dist/context-render.d.ts +14 -0
  107. package/dist/context-render.js +28 -0
  108. package/dist/dag.js +6 -0
  109. package/dist/dashboard-actions.js +3 -2
  110. package/dist/dashboard.js +4 -3
  111. package/dist/db/backup.d.ts +6 -0
  112. package/dist/db/backup.js +29 -0
  113. package/dist/db/busy.d.ts +9 -0
  114. package/dist/db/busy.js +35 -0
  115. package/dist/db/continuity.d.ts +1 -0
  116. package/dist/db/continuity.js +31 -4
  117. package/dist/db/meta.d.ts +2 -0
  118. package/dist/db/meta.js +7 -0
  119. package/dist/db/migrate.js +2 -3
  120. package/dist/db/migrations/index.d.ts +1 -1
  121. package/dist/db/migrations/index.js +5 -1
  122. package/dist/db/migrations/v39.js +2 -7
  123. package/dist/db/migrations/v53.d.ts +3 -0
  124. package/dist/db/migrations/v53.js +16 -0
  125. package/dist/db/migrations/v54.d.ts +3 -0
  126. package/dist/db/migrations/v54.js +25 -0
  127. package/dist/db/open.d.ts +11 -0
  128. package/dist/db/open.js +27 -0
  129. package/dist/db/tables.d.ts +2 -0
  130. package/dist/db/tables.js +6 -2
  131. package/dist/db.d.ts +2 -2
  132. package/dist/db.js +2 -2
  133. package/dist/doctor.d.ts +1 -1
  134. package/dist/doctor.js +31 -15
  135. package/dist/dormant.d.ts +16 -20
  136. package/dist/dormant.js +24 -30
  137. package/dist/entry/json-hooks.d.ts +3 -0
  138. package/dist/entry/json-hooks.js +4 -0
  139. package/dist/entry/project-identity.d.ts +2 -0
  140. package/dist/entry/project-identity.js +3 -0
  141. package/dist/entry/session-text.d.ts +7 -0
  142. package/dist/entry/session-text.js +8 -0
  143. package/dist/extract.js +6 -0
  144. package/dist/failure-log.d.ts +10 -2
  145. package/dist/failure-log.js +19 -2
  146. package/dist/gated-write.js +1 -1
  147. package/dist/graph/delta.d.ts +44 -0
  148. package/dist/graph/delta.js +109 -0
  149. package/dist/graph/types.d.ts +5 -0
  150. package/dist/graph/write.d.ts +20 -20
  151. package/dist/graph/write.js +103 -36
  152. package/dist/graph-extract.d.ts +43 -9
  153. package/dist/graph-extract.js +140 -157
  154. package/dist/graph-recall.d.ts +1 -0
  155. package/dist/graph-recall.js +2 -2
  156. package/dist/graph-view.d.ts +1 -0
  157. package/dist/graph-view.js +25 -2
  158. package/dist/half-life-migration.js +5 -1
  159. package/dist/handoff-evidence.d.ts +5 -0
  160. package/dist/handoff-evidence.js +30 -0
  161. package/dist/home-path.d.ts +5 -0
  162. package/dist/home-path.js +24 -0
  163. package/dist/hooks/json-hooks.d.ts +13 -2
  164. package/dist/hooks/json-hooks.js +246 -117
  165. package/dist/hooks/opencode.js +1 -1
  166. package/dist/hooks/shared.d.ts +2 -0
  167. package/dist/hooks/shared.js +6 -1
  168. package/dist/http-util.d.ts +16 -7
  169. package/dist/http-util.js +65 -20
  170. package/dist/importers/vault.js +2 -0
  171. package/dist/index.d.ts +2 -1
  172. package/dist/index.js +1 -0
  173. package/dist/json.d.ts +2 -0
  174. package/dist/json.js +7 -0
  175. package/dist/ledger-db.d.ts +10 -0
  176. package/dist/ledger-db.js +42 -0
  177. package/dist/mcp/admin-tools.d.ts +2 -2
  178. package/dist/mcp/admin-tools.js +47 -5
  179. package/dist/mcp/memory-tools.js +7 -2
  180. package/dist/mcp/protocol.d.ts +6 -0
  181. package/dist/mcp/protocol.js +4 -2
  182. package/dist/mcp/recall-tools.js +19 -6
  183. package/dist/mcp/request.js +4 -0
  184. package/dist/mcp/session-state.d.ts +4 -0
  185. package/dist/mcp/session-state.js +15 -6
  186. package/dist/mcp/shared-gate.d.ts +4 -0
  187. package/dist/mcp/shared-gate.js +22 -0
  188. package/dist/mcp/tools.js +1 -0
  189. package/dist/memory-quality.d.ts +35 -0
  190. package/dist/memory-quality.js +191 -0
  191. package/dist/memory.d.ts +4 -4
  192. package/dist/physics-state.js +1 -1
  193. package/dist/pilot-arm.d.ts +14 -3
  194. package/dist/pilot-arm.js +32 -10
  195. package/dist/postinstall.js +2 -2
  196. package/dist/project-briefs.js +4 -4
  197. package/dist/project-identity.d.ts +14 -10
  198. package/dist/project-identity.js +29 -11
  199. package/dist/project-merge.d.ts +0 -2
  200. package/dist/project-merge.js +9 -13
  201. package/dist/prompt-hook.d.ts +65 -0
  202. package/dist/prompt-hook.js +213 -0
  203. package/dist/prompt-recall.js +1 -1
  204. package/dist/quality-repair.d.ts +27 -0
  205. package/dist/quality-repair.js +220 -0
  206. package/dist/rate-limit.d.ts +1 -0
  207. package/dist/rate-limit.js +5 -3
  208. package/dist/raw-archive.d.ts +1 -1
  209. package/dist/raw-archive.js +6 -16
  210. package/dist/recall-scope.d.ts +28 -5
  211. package/dist/recall-scope.js +52 -7
  212. package/dist/recall-trace.js +1 -1
  213. package/dist/reject-flow.d.ts +2 -0
  214. package/dist/reject-flow.js +20 -6
  215. package/dist/secret-detect.js +7 -8
  216. package/dist/server/auth.d.ts +8 -6
  217. package/dist/server/auth.js +85 -38
  218. package/dist/server/client-ip.d.ts +2 -0
  219. package/dist/server/client-ip.js +16 -1
  220. package/dist/server/mcp-http.d.ts +9 -3
  221. package/dist/server/mcp-http.js +56 -13
  222. package/dist/server/request.d.ts +1 -1
  223. package/dist/server/request.js +5 -5
  224. package/dist/server/routes/admin.js +12 -6
  225. package/dist/server/routes/memories.js +9 -1
  226. package/dist/server/routes/recall.js +14 -2
  227. package/dist/server/types.d.ts +42 -5
  228. package/dist/server/validation.d.ts +5 -2
  229. package/dist/server/validation.js +19 -2
  230. package/dist/server.d.ts +21 -4
  231. package/dist/server.js +207 -49
  232. package/dist/session-digest.d.ts +0 -1
  233. package/dist/session-digest.js +3 -12
  234. package/dist/session-owners.d.ts +7 -0
  235. package/dist/session-owners.js +54 -0
  236. package/dist/share-scrub.d.ts +3 -0
  237. package/dist/share-scrub.js +8 -0
  238. package/dist/shared.d.ts +1 -0
  239. package/dist/shared.js +26 -18
  240. package/dist/store/candidates.d.ts +14 -3
  241. package/dist/store/candidates.js +48 -26
  242. package/dist/store/conflicts.d.ts +15 -0
  243. package/dist/store/conflicts.js +68 -44
  244. package/dist/store/delete-and-batch.d.ts +25 -1
  245. package/dist/store/delete-and-batch.js +121 -69
  246. package/dist/store/entry-reads.d.ts +5 -6
  247. package/dist/store/entry-reads.js +24 -12
  248. package/dist/store/entry-row.d.ts +7 -10
  249. package/dist/store/entry-row.js +40 -18
  250. package/dist/store/entry-writes.d.ts +3 -3
  251. package/dist/store/entry-writes.js +9 -23
  252. package/dist/store/handoffs.d.ts +9 -6
  253. package/dist/store/handoffs.js +72 -59
  254. package/dist/store/index-and-stats.js +2 -2
  255. package/dist/store/open.d.ts +3 -1
  256. package/dist/store/open.js +3 -3
  257. package/dist/store/search-rows.d.ts +8 -5
  258. package/dist/store/search-rows.js +15 -18
  259. package/dist/store/sessions.d.ts +25 -6
  260. package/dist/store/sessions.js +61 -19
  261. package/dist/store/summaries.d.ts +1 -1
  262. package/dist/store/summaries.js +3 -18
  263. package/dist/store/tenant-lookup.d.ts +7 -3
  264. package/dist/store/tenant-lookup.js +9 -9
  265. package/dist/store-port.d.ts +13 -0
  266. package/dist/store-port.js +20 -0
  267. package/dist/version.d.ts +5 -1
  268. package/dist/version.js +5 -1
  269. package/dist/working-memory.js +1 -1
  270. package/dist/write-budget.d.ts +15 -0
  271. package/dist/write-budget.js +15 -0
  272. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  273. package/extensions/openclaw-plugin/package.json +1 -1
  274. package/openclaw.plugin.json +1 -1
  275. package/package.json +8 -4
package/dist/shared.js CHANGED
@@ -15,18 +15,20 @@ import { loadAllEntries, readEntry } from './store/entry-reads.js';
15
15
  import { loadSearchEntries, loadRecallSearchEntries, recallScopeFilter } from './store/search-rows.js';
16
16
  import { tallySources } from './store/candidates.js';
17
17
  import { loadIndex } from './store/index-and-stats.js';
18
- import { passesScopeFilterForRecall, passesCliRecallScopeFilter } from './recall-scope.js';
18
+ import { isPersonalScope, passesScopeFilterForRecall, passesCliRecallScopeFilter } from './recall-scope.js';
19
19
  import { search } from './search/bm25-search.js';
20
20
  import { hybridSearch } from './search/hybrid.js';
21
21
  import { fitBudget } from './search/finalize.js';
22
22
  import { DEFAULT_LOCAL_BUMP, DEFAULT_RECALL_BUDGET } from './search/types.js';
23
23
  import { evalNow } from './ablation.js';
24
- import { deriveOriginProject, classifyOriginProject, resolveGlobalRootDir, resolveProjectIdentity } from './project-identity.js';
24
+ import { fallbackOrigin, classifyOriginProject, resolveGlobalRootDir, resolveProjectIdentity } from './project-identity.js';
25
+ import { isSharedStore } from './config.js';
25
26
  import { detectSecret } from './secret-detect.js';
26
27
  import { isQuarantineScope } from './quarantine.js';
27
28
  import { RejectedValueError } from './rejection.js';
28
29
  import { embedMemory, embedAll } from './embeddings.js';
29
30
  import { duplicateKey, storedTextKeys } from './same-text.js';
31
+ import { isReusable } from './memory-quality.js';
30
32
  import { log } from './log.js';
31
33
  // The rows are already copied; a failed background embed only delays vectors, so it warns instead of throwing.
32
34
  function logEmbedAllFailure(caller, err) {
@@ -67,6 +69,9 @@ export function promoteToGlobal(localRoot, id, opts) {
67
69
  if (isQuarantineScope(entry.scope)) {
68
70
  throw new BadRequestError(`Refusing to promote ${id}: it is quarantined pending review. Approve it first via 'hippo quarantine approve ${id}'.`);
69
71
  }
72
+ if (isPersonalScope(entry.scope ?? null)) {
73
+ throw new BadRequestError(`Refusing to promote ${id}: it is a personal memory and stays with its owner on this server.`);
74
+ }
70
75
  // Secret producer veto: promote is a producer path to the global store
71
76
  // exactly like shareMemory - same hard rule.
72
77
  const promoteSecret = detectSecret(entry);
@@ -76,14 +81,12 @@ export function promoteToGlobal(localRoot, id, opts) {
76
81
  }
77
82
  initGlobal();
78
83
  const globalRoot = getGlobalRoot();
79
- // Mint a new ID for the global store. origin_project rides along from the
80
- // local entry's write-time stamp via the spread; back-stop it for pre-v39
81
- // local rows so a promoted copy never lands NULL in the global store.
84
+ // A project store's NULL row gets its folder back; a shared store's folder is no caller's project, so NULL stays and the label names no path.
82
85
  const globalEntry = {
83
86
  ...entry,
84
87
  id: generateId('g'),
85
- source: `promoted:${localRoot}`,
86
- origin_project: entry.origin_project ?? deriveOriginProject(path.dirname(path.resolve(localRoot))),
88
+ source: isSharedStore(localRoot) ? `shared::${new Date().toISOString()}` : `promoted:${localRoot}`,
89
+ origin_project: entry.origin_project ?? fallbackOrigin(localRoot),
87
90
  };
88
91
  writeEntry(globalRoot, globalEntry, { actor: opts?.actor, afterWrite: opts?.afterWrite });
89
92
  // Fire-and-forget: embedMemory gates on availability and never rejects.
@@ -161,19 +164,19 @@ export async function searchBothHybrid(query, localRoot, globalRoot, options = {
161
164
  if (!fs.existsSync(root))
162
165
  return [];
163
166
  return recallScope
164
- ? loadRecallSearchEntries(root, query, searchWindow, tenantId, recallScope.requested, recallScope.additive ? 'additive' : 'exact', Boolean(includeSuperseded) || Boolean(asOf))
167
+ ? loadRecallSearchEntries(root, query, searchWindow, tenantId, recallScope.requested, recallScope.additive ? 'additive' : 'exact', Boolean(includeSuperseded) || Boolean(asOf), undefined, recallScope.ownScope)
165
168
  : loadSearchEntries(root, query, searchWindow, tenantId);
166
169
  };
167
170
  const passesScope = (e) => !recallScope || (recallScope.additive
168
- ? passesCliRecallScopeFilter(e.scope ?? null, recallScope.requested)
169
- : passesScopeFilterForRecall(e.scope ?? null, recallScope.requested));
171
+ ? passesCliRecallScopeFilter(e.scope ?? null, recallScope.requested) || passesScopeFilterForRecall(e.scope ?? null, undefined, recallScope.ownScope)
172
+ : passesScopeFilterForRecall(e.scope ?? null, recallScope.requested, recallScope.ownScope));
170
173
  const admit = (e) => passesScope(e) && (!entryFilter || entryFilter(e));
171
174
  const localEntries = loadEntries(localRoot).filter(admit);
172
175
  const globalEntries = loadEntries(globalRoot).filter(admit);
173
176
  // The vector arm loads under the same SQL rules as loadEntries, then the same JS admission.
174
177
  const vectorCandidates = {
175
178
  tenantId,
176
- scope: recallScope ? recallScopeFilter(recallScope.requested, recallScope.additive ? 'additive' : 'exact') : undefined,
179
+ scope: recallScope ? recallScopeFilter(recallScope.requested, recallScope.additive ? 'additive' : 'exact', recallScope.ownScope) : undefined,
177
180
  includeSuperseded: !recallScope || Boolean(includeSuperseded) || Boolean(asOf),
178
181
  admit,
179
182
  };
@@ -292,20 +295,22 @@ export function shareMemory(localRoot, id, options = {}) {
292
295
  if (isQuarantineScope(entry.scope)) {
293
296
  throw new BadRequestError(`Refusing to share ${id}: it is quarantined pending review. Approve it first via 'hippo quarantine approve ${id}'.`);
294
297
  }
298
+ if (isPersonalScope(entry.scope ?? null)) {
299
+ throw new BadRequestError(`Refusing to share ${id}: it is a personal memory and stays with its owner on this server.`);
300
+ }
295
301
  const score = transferScore(entry);
296
302
  if (score < 0.3 && !options.force)
297
303
  return null;
298
304
  initGlobal();
299
305
  const globalRoot = getGlobalRoot();
300
- // v39: canonical origin comes from the entry's own stamp (write-time,
301
- // store-location-derived); the localRoot parent basename is only a
302
- // fallback for pre-v39 rows and keeps the legacy source format intact.
306
+ // The label keeps the folder name for a user-global row; a NULL row gets `shared::`, which originFromSource reads as no project.
303
307
  const fallbackName = path.basename(path.resolve(localRoot, '..'));
304
- const originName = entry.origin_project ?? deriveOriginProject(path.dirname(path.resolve(localRoot)));
308
+ const originName = entry.origin_project ?? fallbackOrigin(localRoot);
309
+ const label = originName === '' ? fallbackName : (originName ?? '');
305
310
  const globalEntry = {
306
311
  ...entry,
307
312
  id: generateId('g'),
308
- source: `shared:${originName === '' ? fallbackName : originName}:${new Date().toISOString()}`,
313
+ source: `shared:${label}:${new Date().toISOString()}`,
309
314
  origin_project: originName,
310
315
  };
311
316
  writeEntry(globalRoot, globalEntry);
@@ -365,8 +370,8 @@ export function listPeers(globalRoot, tenantId) {
365
370
  .sort((a, b) => b.count - a.count);
366
371
  }
367
372
  function isAutoShareCandidate(entry, globalContentSet, minScore, stats) {
368
- // shareMemory refuses quarantined rows; filtering here keeps sleep from aborting on one.
369
- if (isQuarantineScope(entry.scope ?? null))
373
+ // shareMemory refuses quarantined and personal rows; filtering here keeps sleep from aborting on one.
374
+ if (isQuarantineScope(entry.scope ?? null) || isPersonalScope(entry.scope ?? null) || !isReusable(entry))
370
375
  return false;
371
376
  // Before the score: these rows describe one project only, and a git seed's 'error' tag clears the bar.
372
377
  if (entry.tags.some((t) => NEVER_AUTO_SHARE_TAGS.has(t)) || entry.source.startsWith(AGENT_MEMORY_SOURCE_PREFIX)) {
@@ -475,6 +480,9 @@ export function autoShare(localRoot, options = {}) {
475
480
  * Returns the count of newly copied entries.
476
481
  */
477
482
  export function syncGlobalToLocal(localRoot, globalRoot, opts = {}) {
483
+ if (isSharedStore(localRoot)) {
484
+ throw new BadRequestError(`Refusing to sync into ${localRoot}: a shared store takes no copies of a personal global store, whose rows would reach every member.`);
485
+ }
478
486
  if (!fs.existsSync(globalRoot))
479
487
  return 0;
480
488
  // Host-wide read. syncGlobalToLocal copies the global union into a
@@ -1,7 +1,9 @@
1
1
  import type { MemoryEntry, StrengthInputs } from '../memory.js';
2
+ import { type SqlFragment } from '../recall-scope.js';
2
3
  export interface AmbientRecallRequest {
3
4
  terms: string[];
4
5
  limit: number;
6
+ ownScope?: string;
5
7
  }
6
8
  export interface AmbientLoadResult {
7
9
  entries: MemoryEntry[];
@@ -11,11 +13,18 @@ export interface AmbientLoadResult {
11
13
  export declare const AMBIENT_PINNED_WHERE = "pinned = 1 AND superseded_by IS NULL AND tenant_id = ? ORDER BY created ASC, id ASC";
12
14
  /** Exported so the plan test runs the exact SQL; idx_memories_created_drift (db.ts v51) serves it. */
13
15
  export declare const AMBIENT_DRIFT_SQL = "SELECT 1 FROM memories WHERE superseded_by IS NULL AND tenant_id = ? AND (length(created) <> 24 OR created NOT LIKE '%Z') LIMIT 1";
14
- export declare function loadAmbientCandidates(hippoRoot: string, tenantId: string, recentNeeded: number, admit: (e: MemoryEntry) => boolean, recall?: AmbientRecallRequest): AmbientLoadResult;
16
+ /** The origins a caller's recent rows may carry: its project names, and user-global ('') rows when `userGlobal`. */
17
+ export interface RecentOrigins {
18
+ readonly names: readonly string[];
19
+ readonly userGlobal: boolean;
20
+ }
21
+ export declare function loadAmbientCandidates(hippoRoot: string, tenantId: string, recentNeeded: number, admit: (e: MemoryEntry) => boolean, recall?: AmbientRecallRequest, origins?: RecentOrigins): AmbientLoadResult;
15
22
  /** The rows a non-pinned context read may admit; each predicate is one getContext's admission applies again in JS. */
16
23
  export interface ContextCandidateFilter {
17
24
  /** Envelope scope asked for by name; absent applies recall's default deny. */
18
25
  exactScope?: string;
26
+ /** The caller's personal scope, which the default deny admits. */
27
+ ownScope?: string;
19
28
  /** Rows carrying one of these project names, and user-global rows, pass; absent admits every origin. */
20
29
  project?: readonly string[];
21
30
  /** Most rows returned; past it, the rows decay has worn least win. */
@@ -28,8 +37,10 @@ export declare function loadContextCandidates(hippoRoot: string, tenantId: strin
28
37
  export type StrengthRow = StrengthInputs & Pick<MemoryEntry, 'tags'>;
29
38
  /** Every tenant row as a StrengthRow, for whole-store health numbers; defaults match rowToEntry's. */
30
39
  export declare function loadStrengthRows(hippoRoot: string, tenantId: string): StrengthRow[];
31
- /** Text and source of tenant rows holding any of `words`; a row equal to a text apart from spacing holds its every word. */
32
- export declare function loadTextsHoldingWords(hippoRoot: string, tenantId: string, words: readonly string[]): Array<Pick<MemoryEntry, 'content' | 'source'>>;
40
+ export type HeldText = Pick<MemoryEntry, 'content' | 'source' | 'origin_project'>;
41
+ /** Text, source and origin of tenant rows holding any of `words` that `admit` passes, by default the team-visible ones; a row equal to a text apart from spacing holds its every word.
42
+ * With `project`, only rows carrying one of those names and user-global rows, as loadContextCandidates' filter. */
43
+ export declare function loadTextsHoldingWords(hippoRoot: string, tenantId: string, words: readonly string[], project?: readonly string[], admit?: SqlFragment): HeldText[];
33
44
  export interface SourceTally {
34
45
  source: string;
35
46
  count: number;
@@ -1,5 +1,5 @@
1
1
  import { closeHippoDb } from '../db.js';
2
- import { RECALL_DEFAULT_DENY_SCOPES } from '../recall-scope.js';
2
+ import { scopeAdmitSql } from '../recall-scope.js';
3
3
  import { MEMORY_SELECT_COLUMNS, rowToEntry, parseJsonArray } from './rows.js';
4
4
  import { openStore } from './open.js';
5
5
  import { originInSql } from '../project-identity.js';
@@ -9,9 +9,39 @@ const AMBIENT_SCOPED = 'superseded_by IS NULL AND tenant_id = ?';
9
9
  export const AMBIENT_PINNED_WHERE = `pinned = 1 AND ${AMBIENT_SCOPED} ORDER BY created ASC, id ASC`;
10
10
  /** Exported so the plan test runs the exact SQL; idx_memories_created_drift (db.ts v51) serves it. */
11
11
  export const AMBIENT_DRIFT_SQL = `SELECT 1 FROM memories WHERE ${AMBIENT_SCOPED} AND (length(created) <> 24 OR created NOT LIKE '%Z') LIMIT 1`;
12
- // The pins plus the `recentNeeded` newest rows that pass `admit`, for ambient
13
- // injection. One connection; `recall` piggybacks the prompt-recall FTS query on it too.
14
- export function loadAmbientCandidates(hippoRoot, tenantId, recentNeeded, admit, recall) {
12
+ function inOrigins(e, origins) {
13
+ const origin = e.origin_project ?? null;
14
+ return origin === '' ? origins.userGlobal : origin !== null && origins.names.includes(origin);
15
+ }
16
+ // The newest rows `admit` keeps, newest first. The first window stays unfiltered so admit still sees, and the
17
+ // delivery ledger still counts, the other-project rows it refuses; past it, the reads narrow to the caller's origins.
18
+ function loadRecentRows(run, drifted, tenantId, needed, admit, origins) {
19
+ const keep = origins ? (e) => admit(e) && inOrigins(e, origins) : admit;
20
+ // `id DESC` mirrors getContext's comparator, not loadFreshRawMemories'
21
+ // cross-ingest-stable order: that would change what the hook injects.
22
+ const newest = 'ORDER BY created DESC, id DESC';
23
+ const window = Math.max(needed * 4, 32);
24
+ const originSql = origins && (origins.userGlobal ? `(origin_project = '' OR ${originInSql(origins.names)})` : originInSql(origins.names));
25
+ const ownWhere = originSql ? `${AMBIENT_SCOPED} AND ${originSql}` : AMBIENT_SCOPED;
26
+ const ownParams = [tenantId, ...(origins?.names ?? [])];
27
+ if (!drifted) {
28
+ const windowed = run(`${AMBIENT_SCOPED} ${newest} LIMIT ?`, [tenantId, window]);
29
+ const kept = windowed.filter(keep);
30
+ if (kept.length >= needed || windowed.length < window)
31
+ return kept;
32
+ if (origins) {
33
+ const ownWindow = run(`${ownWhere} ${newest} LIMIT ?`, [...ownParams, window]);
34
+ const ownKept = ownWindow.filter(keep);
35
+ if (ownKept.length >= needed || ownWindow.length < window)
36
+ return ownKept;
37
+ }
38
+ }
39
+ // Text order is chronological only for standard UTC ISO (memory.ts), and a window of junk can hide older rows.
40
+ return run(`${ownWhere} ${newest}`, ownParams).filter(keep);
41
+ }
42
+ // The pins plus the `recentNeeded` newest rows that pass `admit`, for ambient injection. One connection;
43
+ // `recall` piggybacks the prompt-recall FTS query on it too. `origins` narrows the recent rows past the first window.
44
+ export function loadAmbientCandidates(hippoRoot, tenantId, recentNeeded, admit, recall, origins) {
15
45
  // A SQL LIMIT takes an integer; the Array.slice this replaced truncated one,
16
46
  // and include_recent is any non-negative finite number at the HTTP edge.
17
47
  const needed = Math.trunc(recentNeeded);
@@ -20,25 +50,13 @@ export function loadAmbientCandidates(hippoRoot, tenantId, recentNeeded, admit,
20
50
  // SAFETY: every `where` below starts from MEMORY_SELECT_COLUMNS' table.
21
51
  const run = (where, params) => db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE ${where}`).all(...params).map(rowToEntry);
22
52
  const byId = new Map();
23
- const scoped = AMBIENT_SCOPED;
24
53
  for (const e of run(AMBIENT_PINNED_WHERE, [tenantId])) {
25
54
  if (admit(e))
26
55
  byId.set(e.id, e);
27
56
  }
28
57
  if (needed > 0) {
29
- // Text order is chronological only for canonical UTC ISO (memory.ts).
30
58
  const drifted = db.prepare(AMBIENT_DRIFT_SQL).get(tenantId) !== undefined;
31
- // `id DESC` mirrors getContext's comparator, not loadFreshRawMemories'
32
- // cross-ingest-stable order: that would change what the hook injects.
33
- const window = Math.max(needed * 4, 32);
34
- const windowed = drifted
35
- ? []
36
- : run(`${scoped} ORDER BY created DESC, id DESC LIMIT ?`, [tenantId, window]);
37
- let kept = windowed.filter(admit);
38
- if (drifted || (kept.length < needed && windowed.length === window)) {
39
- kept = run(`${scoped} ORDER BY created DESC, id DESC`, [tenantId]).filter(admit);
40
- }
41
- for (const e of kept)
59
+ for (const e of loadRecentRows(run, drifted, tenantId, needed, admit, origins))
42
60
  byId.set(e.id, e);
43
61
  }
44
62
  // loadAllEntries' order: rankedPinned's comparator can tie and Array.sort
@@ -51,7 +69,7 @@ export function loadAmbientCandidates(hippoRoot, tenantId, recentNeeded, admit,
51
69
  return { entries };
52
70
  const ftsQuery = pickRarestFtsQuery(db, recall.terms);
53
71
  const recallEntries = ftsQuery
54
- ? loadRecallSearchEntriesFromDb(db, ftsQuery, recall.limit, tenantId, undefined, 'exact', false)
72
+ ? loadRecallSearchEntriesFromDb(db, ftsQuery, recall.limit, tenantId, undefined, 'exact', false, undefined, recall.ownScope)
55
73
  : [];
56
74
  return { entries, recall: recallEntries };
57
75
  }
@@ -74,9 +92,9 @@ export function loadContextCandidates(hippoRoot, tenantId, filter) {
74
92
  params.push(filter.exactScope);
75
93
  }
76
94
  else {
77
- // isRestrictedScope's rule; NOT LIKE folds ASCII case as its /:private:/i does.
78
- where.push(`(scope IS NULL OR (scope NOT IN (${RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ')}) AND scope NOT LIKE '%:private:%'))`);
79
- params.push(...RECALL_DEFAULT_DENY_SCOPES);
95
+ const admit = scopeAdmitSql('', filter.ownScope);
96
+ where.push(admit.sql);
97
+ params.push(...admit.params);
80
98
  }
81
99
  if (filter.project !== undefined) {
82
100
  where.push(`(origin_project = '' OR ${originInSql(filter.project)})`);
@@ -117,19 +135,23 @@ export function loadStrengthRows(hippoRoot, tenantId) {
117
135
  closeHippoDb(db);
118
136
  }
119
137
  }
120
- /** Text and source of tenant rows holding any of `words`; a row equal to a text apart from spacing holds its every word. */
121
- export function loadTextsHoldingWords(hippoRoot, tenantId, words) {
138
+ // No owner: for session capture, a personal or connector-private row must never stop a team copy being written.
139
+ const TEAM_VISIBLE = scopeAdmitSql('');
140
+ /** Text, source and origin of tenant rows holding any of `words` that `admit` passes, by default the team-visible ones; a row equal to a text apart from spacing holds its every word.
141
+ * With `project`, only rows carrying one of those names and user-global rows, as loadContextCandidates' filter. */
142
+ export function loadTextsHoldingWords(hippoRoot, tenantId, words, project, admit = TEAM_VISIBLE) {
122
143
  const unique = [...new Set(words)];
123
144
  const out = [];
145
+ const originWhere = project === undefined ? '' : ` AND (origin_project = '' OR ${originInSql(project)})`;
124
146
  const db = openStore(hippoRoot);
125
147
  try {
126
148
  // Chunked so one statement stays far under SQLite's bound-parameter limit.
127
149
  for (let i = 0; i < unique.length; i += 200) {
128
150
  const chunk = unique.slice(i, i + 200);
129
- // SAFETY: rows' shape matches the two columns named in the SELECT below.
130
- const rows = db.prepare(`SELECT content, source FROM memories WHERE tenant_id = ? AND (${chunk.map(() => 'instr(content, ?) > 0').join(' OR ')})`).all(tenantId, ...chunk);
151
+ // SAFETY: rows' shape matches the three columns named in the SELECT below.
152
+ const rows = db.prepare(`SELECT content, source, origin_project FROM memories WHERE tenant_id = ?${originWhere} AND ${admit.sql} AND (${chunk.map(() => 'instr(content, ?) > 0').join(' OR ')})`).all(tenantId, ...(project ?? []), ...admit.params, ...chunk);
131
153
  for (const row of rows)
132
- out.push({ content: row.content, source: row.source ?? 'cli' });
154
+ out.push({ content: row.content, source: row.source ?? 'cli', origin_project: row.origin_project });
133
155
  }
134
156
  return out;
135
157
  }
@@ -1,12 +1,27 @@
1
+ import { type DatabaseSyncLike } from '../db.js';
1
2
  import { type MemoryConflict } from './rows.js';
2
3
  export declare function listMemoryConflicts(hippoRoot: string, status?: string, tenantId?: string): MemoryConflict[];
4
+ /** Conflicts whose two rows `actor` may both touch; someone else's personal row hides its whole pair. */
5
+ export declare function listTouchableConflicts(hippoRoot: string, status: string, tenantId: string, actor: {
6
+ owner?: string;
7
+ }): MemoryConflict[];
3
8
  type DetectedConflict = {
4
9
  memory_a_id: string;
5
10
  memory_b_id: string;
6
11
  reason: string;
7
12
  score: number;
8
13
  };
14
+ type SameTenant = (a: string, b: string) => boolean;
9
15
  export declare function replaceDetectedConflicts(hippoRoot: string, detected: Array<DetectedConflict>, detectedAt?: string): void;
16
+ /** Every memory's tenant, and each stored conflicts_with_json other than '[]', read before the refresh takes the write lock. */
17
+ export interface ConflictRefreshReads {
18
+ sameTenant: SameTenant;
19
+ storedRefs: ReadonlyMap<string, string | null>;
20
+ }
21
+ /** The refresh's read of the whole memories table, kept out of the write lock because it grows with the store. */
22
+ export declare function readConflictRefresh(db: DatabaseSyncLike): ConflictRefreshReads;
23
+ /** Under the write lock: the memory_conflicts rows, then each memory whose refs change; returns the ids it rewrote. */
24
+ export declare function writeConflictRefresh(db: DatabaseSyncLike, reads: ConflictRefreshReads, detected: readonly DetectedConflict[], detectedAt: string): string[];
10
25
  /**
11
26
  * AT1 (plan §5): additive-optional opts for resolveConflict.
12
27
  * `rejectLoserValue` implies removal of the loser regardless of
@@ -1,4 +1,4 @@
1
- import { closeHippoDb } from '../db.js';
1
+ import { closeHippoDb, openHippoDb } from '../db.js';
2
2
  import { rejectionDigest, insertRejectedValue, normalizeValueForRejection } from '../rejection.js';
3
3
  import { archiveRawMemory } from '../raw-archive.js';
4
4
  import { rowToMemoryConflict } from './rows.js';
@@ -7,6 +7,9 @@ import { syncChangedMirrors, purgeMirrorBestEffort } from './mirrors.js';
7
7
  import { selectEntriesByIds } from './entry-reads.js';
8
8
  import { openStore } from './open.js';
9
9
  import { deleteEntryCore } from './delete-and-batch.js';
10
+ import { BadRequestError } from '../api-errors.js';
11
+ import { canTouchScope, isPersonalScope } from '../recall-scope.js';
12
+ import { selectMemoryReach } from './tenant-lookup.js';
10
13
  function canonicalConflictPair(aId, bId) {
11
14
  return aId < bId
12
15
  ? { memory_a_id: aId, memory_b_id: bId }
@@ -70,50 +73,66 @@ export function listMemoryConflicts(hippoRoot, status = 'open', tenantId) {
70
73
  closeHippoDb(db);
71
74
  }
72
75
  }
76
+ /** Conflicts whose two rows `actor` may both touch; someone else's personal row hides its whole pair. */
77
+ export function listTouchableConflicts(hippoRoot, status, tenantId, actor) {
78
+ const conflicts = listMemoryConflicts(hippoRoot, status, tenantId);
79
+ const db = openHippoDb(hippoRoot);
80
+ try {
81
+ return conflicts.filter((c) => [c.memory_a_id, c.memory_b_id].every((id) => canTouchScope(actor, selectMemoryReach(db, id)?.scope ?? null)));
82
+ }
83
+ finally {
84
+ closeHippoDb(db);
85
+ }
86
+ }
73
87
  export function replaceDetectedConflicts(hippoRoot, detected, detectedAt = new Date().toISOString()) {
74
88
  const db = openStore(hippoRoot);
75
89
  try {
76
- db.exec('BEGIN');
77
- const sameTenant = loadSameTenantCheck(db);
78
- const canonicalDetected = detected.map((conflict) => ({
79
- ...canonicalConflictPair(conflict.memory_a_id, conflict.memory_b_id),
80
- reason: conflict.reason,
81
- score: conflict.score,
82
- }));
83
- resolveStaleOpenConflicts(db, canonicalDetected, sameTenant, detectedAt);
84
- upsertDetectedConflicts(db, canonicalDetected, sameTenant, detectedAt);
85
- const changedIds = rebuildConflictsWithJson(db, sameTenant);
86
- db.exec('COMMIT');
90
+ const changedIds = writeConflictRefresh(db, readConflictRefresh(db), detected, detectedAt);
87
91
  syncChangedMirrors(hippoRoot, db, [...selectEntriesByIds(db, changedIds).values()]);
88
92
  }
89
- catch (error) {
90
- try {
91
- db.exec('ROLLBACK');
92
- }
93
- catch {
94
- // Ignore nested rollback failures.
95
- }
96
- throw error;
97
- }
98
93
  finally {
99
94
  closeHippoDb(db);
100
95
  }
101
96
  }
102
- function loadSameTenantCheck(db) {
103
- // Tenant guard (E2): a conflict is meaningful only within one tenant.
104
- // Build id -> tenant_id once and skip cross-tenant pairs both when
105
- // inserting rows and when rebuilding conflicts_with_json, so a stale
106
- // cross-tenant row can neither persist nor leak a foreign id.
97
+ /** The refresh's read of the whole memories table, kept out of the write lock because it grows with the store. */
98
+ export function readConflictRefresh(db) {
99
+ // Tenant guard (E2): a conflict is meaningful only within one tenant, so cross-tenant pairs are
100
+ // skipped on insert and on rebuild, and a stale cross-tenant row can neither persist nor leak a foreign id.
107
101
  const tenantById = new Map();
108
- // SAFETY: rows' shape matches the two columns named in the SELECT below.
109
- for (const r of db.prepare(`SELECT id, tenant_id FROM memories`).all()) {
102
+ const storedRefs = new Map();
103
+ // SAFETY: rows' shape matches the three columns named in the SELECT.
104
+ for (const r of db.prepare(`SELECT id, tenant_id, conflicts_with_json FROM memories`).all()) {
110
105
  tenantById.set(r.id, r.tenant_id);
106
+ if (r.conflicts_with_json !== '[]')
107
+ storedRefs.set(r.id, r.conflicts_with_json);
111
108
  }
112
- return (a, b) => {
109
+ const sameTenant = (a, b) => {
113
110
  const ta = tenantById.get(a);
114
111
  const tb = tenantById.get(b);
115
112
  return ta !== undefined && tb !== undefined && ta === tb;
116
113
  };
114
+ return { sameTenant, storedRefs };
115
+ }
116
+ /** Under the write lock: the memory_conflicts rows, then each memory whose refs change; returns the ids it rewrote. */
117
+ export function writeConflictRefresh(db, reads, detected, detectedAt) {
118
+ const canonicalDetected = detected.map((conflict) => ({
119
+ ...canonicalConflictPair(conflict.memory_a_id, conflict.memory_b_id),
120
+ reason: conflict.reason,
121
+ score: conflict.score,
122
+ }));
123
+ db.exec('BEGIN IMMEDIATE');
124
+ try {
125
+ resolveStaleOpenConflicts(db, canonicalDetected, reads.sameTenant, detectedAt);
126
+ upsertDetectedConflicts(db, canonicalDetected, reads.sameTenant, detectedAt);
127
+ const changedIds = rebuildConflictsWithJson(db, reads);
128
+ db.exec('COMMIT');
129
+ return changedIds;
130
+ }
131
+ catch (error) {
132
+ if (db.isTransaction !== false)
133
+ db.exec('ROLLBACK');
134
+ throw error;
135
+ }
117
136
  }
118
137
  function resolveStaleOpenConflicts(db, canonicalDetected, sameTenant, detectedAt) {
119
138
  const detectedKeys = new Set(canonicalDetected.map((conflict) => `${conflict.memory_a_id}::${conflict.memory_b_id}`));
@@ -156,7 +175,7 @@ function upsertDetectedConflicts(db, canonicalDetected, sameTenant, detectedAt)
156
175
  }
157
176
  }
158
177
  /** Rewrites only the rows whose conflicts_with_json changes, and returns their ids. */
159
- function rebuildConflictsWithJson(db, sameTenant) {
178
+ function rebuildConflictsWithJson(db, { sameTenant, storedRefs }) {
160
179
  // SAFETY: openConflicts' shape matches the two columns named above.
161
180
  const openConflicts = db.prepare(`
162
181
  SELECT memory_a_id, memory_b_id
@@ -176,16 +195,16 @@ function rebuildConflictsWithJson(db, sameTenant) {
176
195
  refMap.get(row.memory_a_id).add(row.memory_b_id);
177
196
  refMap.get(row.memory_b_id).add(row.memory_a_id);
178
197
  }
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 = ?`);
198
+ // Only a row holding refs, or due some, can change. Compare-and-set, so a writer since the read keeps its value and the next sleep redoes the row.
199
+ const update = db.prepare(`UPDATE memories SET conflicts_with_json = ?, updated_at = datetime('now') WHERE id = ? AND conflicts_with_json IS ?`);
182
200
  const changedIds = [];
183
- for (const memory of memoryRows) {
184
- const refsJson = JSON.stringify(Array.from(refMap.get(memory.id) ?? []).sort());
185
- if (memory.conflicts_with_json === refsJson)
201
+ for (const id of new Set([...storedRefs.keys(), ...refMap.keys()])) {
202
+ const stored = storedRefs.has(id) ? storedRefs.get(id) ?? null : '[]';
203
+ const refsJson = JSON.stringify(Array.from(refMap.get(id) ?? []).sort());
204
+ if (stored === refsJson)
186
205
  continue;
187
- update.run(refsJson, memory.id);
188
- changedIds.push(memory.id);
206
+ if (Number(update.run(refsJson, id, stored).changes ?? 0) > 0)
207
+ changedIds.push(id);
189
208
  }
190
209
  return changedIds;
191
210
  }
@@ -228,7 +247,7 @@ export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = fal
228
247
  : null;
229
248
  if (!loserId)
230
249
  return null;
231
- db.exec('BEGIN');
250
+ db.exec('BEGIN IMMEDIATE');
232
251
  // Mark conflict as resolved
233
252
  db.prepare(`UPDATE memory_conflicts SET status = 'resolved', updated_at = datetime('now') WHERE id = ?`)
234
253
  .run(conflictId);
@@ -297,10 +316,10 @@ function removeConflictLoser(db, t) {
297
316
  extraRemovedRawIds: [],
298
317
  };
299
318
  const { loserId, opts } = t;
300
- // SAFETY: loserRow's shape matches the three columns named in the
319
+ // SAFETY: loserRow's shape matches the four columns named in the
301
320
  // SELECT above.
302
321
  const loserRow = db
303
- .prepare(`SELECT kind, content, tenant_id FROM memories WHERE id = ?${t.scope.memScope}`)
322
+ .prepare(`SELECT kind, content, tenant_id, scope FROM memories WHERE id = ?${t.scope.memScope}`)
304
323
  .get(loserId, ...t.scope.memArgs);
305
324
  if (loserRow) {
306
325
  const actor = opts?.rejectedBy ?? 'cli';
@@ -323,6 +342,9 @@ function removeConflictLoser(db, t) {
323
342
  }
324
343
  /** Tombstones the loser's digest and removes its same-tenant duplicates into `removal`; returns the digest. */
325
344
  function tombstoneLoserValue(db, t, loserRow, who, removal) {
345
+ if (isPersonalScope(loserRow.scope)) {
346
+ throw new BadRequestError(`cannot reject the value of personal memory ${t.loserId}: a rejection reaches the whole tenant, so its reason would show to everyone; resolve with forget instead`);
347
+ }
326
348
  const { actor, reason } = who;
327
349
  const rejectedDigest = rejectionDigest(loserRow.content);
328
350
  insertRejectedValue(db, {
@@ -347,14 +369,16 @@ function tombstoneLoserValue(db, t, loserRow, who, removal) {
347
369
  // to keep it in this same resolution, and this branch must not
348
370
  // undo that choice in the same transaction.
349
371
  const loserTenantId = loserRow.tenant_id ?? 'default';
350
- // SAFETY: dupRows' shape matches the three columns named in the
351
- // SELECT above.
372
+ // SAFETY: dupRows' shape matches the four columns named in the SELECT above.
352
373
  const dupRows = db
353
- .prepare(`SELECT id, kind, content FROM memories WHERE tenant_id = ? AND id != ? AND id != ?`)
374
+ .prepare(`SELECT id, kind, content, scope FROM memories WHERE tenant_id = ? AND id != ? AND id != ?`)
354
375
  .all(loserTenantId, t.loserId, t.keepId);
355
376
  for (const dup of dupRows) {
356
377
  if (rejectionDigest(dup.content) !== rejectedDigest)
357
378
  continue;
379
+ // Another person's personal row is outside this resolver's reach, as it is outside their recall.
380
+ if (isPersonalScope(dup.scope) && dup.scope !== loserRow.scope)
381
+ continue;
358
382
  if (dup.kind === 'raw') {
359
383
  archiveRawMemory(db, dup.id, { reason, who: actor });
360
384
  removal.extraRemovedRawIds.push(dup.id);
@@ -5,6 +5,8 @@ import { type DormantMove } from '../dormant.js';
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
+ /** memoriesBackingObjects on the caller's handle. */
9
+ export declare function memoriesBackingObjectsOn(db: DatabaseSyncLike): Set<string>;
8
10
  /**
9
11
  * db-scoped delete core, so a delete can compose inside a caller's transaction.
10
12
  * NO filesystem I/O: the caller's transaction may still roll back, and mirrors are written post-commit.
@@ -46,7 +48,7 @@ export declare function deleteEntryOn(db: DatabaseSyncLike, hippoRoot: string, i
46
48
  reason?: string;
47
49
  automatic?: boolean;
48
50
  }): boolean;
49
- /** Consolidation's flush, one transaction. With `snapshot` (rows as the caller loaded them), a write keeps only
51
+ /** Writes, deletes and dormant moves in one transaction. With `snapshot` (rows as the caller loaded them), a write keeps only
50
52
  * the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone.
51
53
  *
52
54
  * `dormant` (src/dormant.ts): each move's snapshot is inserted into `dormant_memories` and its `memories` row
@@ -57,4 +59,26 @@ export declare function batchWriteAndDelete(hippoRoot: string, toWrite: MemoryEn
57
59
  snapshot?: ReadonlyMap<string, MemoryEntry>;
58
60
  dormant?: DormantMove[];
59
61
  }): string[];
62
+ /** Ops that must commit in one transaction, so a flush split across several never lands part of one. */
63
+ export interface FlushComponent {
64
+ writes: readonly MemoryEntry[];
65
+ deletes: readonly string[];
66
+ dormant: readonly DormantMove[];
67
+ }
68
+ /** Tags a flush error with the ids of the component it stopped at, for the partial sleep audit row; the first tag wins. */
69
+ export declare function noteFailedUnit(err: Error, component: FlushComponent | undefined): void;
70
+ /** The ids noteFailedUnit tagged `err` with, if a flush threw it. */
71
+ export declare function failedUnitOf(err: Error | null): string[] | undefined;
72
+ /** batchWriteAndDelete on the caller's open store from component `from`, each component whole, closing the transaction at the
73
+ * first component boundary after `holdMs`. Returns the next component's index and the ids that left `memories`. */
74
+ export declare function batchWriteAndDeleteOn(db: DatabaseSyncLike, hippoRoot: string, components: readonly FlushComponent[], from: number, opts: {
75
+ snapshot?: ReadonlyMap<string, MemoryEntry>;
76
+ holdMs: number;
77
+ clock?: () => number;
78
+ }): FlushChunk;
79
+ /** Where the next transaction starts, and the ids this one removed from `memories`. */
80
+ export interface FlushChunk {
81
+ next: number;
82
+ removedIds: string[];
83
+ }
60
84
  //# sourceMappingURL=delete-and-batch.d.ts.map