hippo-memory 1.63.2 → 1.64.1

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 (280) hide show
  1. package/README.md +11 -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/maintenance.d.ts +2 -1
  76. package/dist/cli/maintenance.js +17 -11
  77. package/dist/cli/quality-repair-once.d.ts +3 -0
  78. package/dist/cli/quality-repair-once.js +36 -0
  79. package/dist/cli/recall.js +2 -1
  80. package/dist/cli/remember.d.ts +2 -0
  81. package/dist/cli/remember.js +4 -1
  82. package/dist/cli/session-hooks.d.ts +0 -3
  83. package/dist/cli/session-hooks.js +12 -49
  84. package/dist/cli/setup.js +10 -2
  85. package/dist/cli/shared.d.ts +7 -14
  86. package/dist/cli/shared.js +19 -78
  87. package/dist/cli/sleep.js +6 -3
  88. package/dist/cli/usage.js +18 -9
  89. package/dist/cli.d.ts +1 -1
  90. package/dist/cli.js +3 -2
  91. package/dist/client.js +1 -1
  92. package/dist/compaction-record.d.ts +19 -1
  93. package/dist/compaction-record.js +61 -132
  94. package/dist/compaction-spool.d.ts +45 -0
  95. package/dist/compaction-spool.js +454 -0
  96. package/dist/config.d.ts +8 -0
  97. package/dist/config.js +49 -0
  98. package/dist/connectors/github/deletion.d.ts +1 -1
  99. package/dist/connectors/github/deletion.js +5 -17
  100. package/dist/consolidate/conflicts.js +4 -0
  101. package/dist/consolidate/flush-units.d.ts +9 -0
  102. package/dist/consolidate/flush-units.js +63 -0
  103. package/dist/consolidate/llm-passes.js +5 -4
  104. package/dist/consolidate/merge.js +5 -1
  105. package/dist/consolidate/run.d.ts +2 -1
  106. package/dist/consolidate/sleep.d.ts +2 -0
  107. package/dist/consolidate/sleep.js +82 -15
  108. package/dist/context-render.d.ts +14 -0
  109. package/dist/context-render.js +28 -0
  110. package/dist/dag.js +6 -0
  111. package/dist/dashboard-actions.js +3 -2
  112. package/dist/dashboard.js +4 -3
  113. package/dist/db/backup.d.ts +6 -0
  114. package/dist/db/backup.js +29 -0
  115. package/dist/db/busy.d.ts +9 -0
  116. package/dist/db/busy.js +35 -0
  117. package/dist/db/continuity.d.ts +1 -0
  118. package/dist/db/continuity.js +31 -4
  119. package/dist/db/meta.d.ts +2 -0
  120. package/dist/db/meta.js +7 -0
  121. package/dist/db/migrate.js +2 -3
  122. package/dist/db/migrations/index.d.ts +1 -1
  123. package/dist/db/migrations/index.js +5 -1
  124. package/dist/db/migrations/v39.js +2 -7
  125. package/dist/db/migrations/v53.d.ts +3 -0
  126. package/dist/db/migrations/v53.js +16 -0
  127. package/dist/db/migrations/v54.d.ts +3 -0
  128. package/dist/db/migrations/v54.js +25 -0
  129. package/dist/db/open.d.ts +11 -0
  130. package/dist/db/open.js +27 -0
  131. package/dist/db/tables.d.ts +2 -0
  132. package/dist/db/tables.js +6 -2
  133. package/dist/db.d.ts +2 -2
  134. package/dist/db.js +2 -2
  135. package/dist/doctor.d.ts +1 -1
  136. package/dist/doctor.js +31 -15
  137. package/dist/dormant.d.ts +16 -20
  138. package/dist/dormant.js +24 -30
  139. package/dist/embedding-provider.js +4 -3
  140. package/dist/entry/json-hooks.d.ts +3 -0
  141. package/dist/entry/json-hooks.js +4 -0
  142. package/dist/entry/project-identity.d.ts +2 -0
  143. package/dist/entry/project-identity.js +3 -0
  144. package/dist/entry/session-text.d.ts +7 -0
  145. package/dist/entry/session-text.js +8 -0
  146. package/dist/extract.js +6 -0
  147. package/dist/failure-log.d.ts +10 -2
  148. package/dist/failure-log.js +19 -2
  149. package/dist/gated-write.js +1 -1
  150. package/dist/graph/delta.d.ts +44 -0
  151. package/dist/graph/delta.js +109 -0
  152. package/dist/graph/types.d.ts +5 -0
  153. package/dist/graph/write.d.ts +20 -20
  154. package/dist/graph/write.js +103 -36
  155. package/dist/graph-extract.d.ts +43 -9
  156. package/dist/graph-extract.js +140 -157
  157. package/dist/graph-recall.d.ts +1 -0
  158. package/dist/graph-recall.js +2 -2
  159. package/dist/graph-view.d.ts +1 -0
  160. package/dist/graph-view.js +25 -2
  161. package/dist/half-life-migration.js +5 -1
  162. package/dist/handoff-evidence.d.ts +5 -0
  163. package/dist/handoff-evidence.js +30 -0
  164. package/dist/home-path.d.ts +5 -0
  165. package/dist/home-path.js +24 -0
  166. package/dist/hooks/json-hooks.d.ts +13 -2
  167. package/dist/hooks/json-hooks.js +246 -117
  168. package/dist/hooks/opencode.js +1 -1
  169. package/dist/hooks/shared.d.ts +2 -0
  170. package/dist/hooks/shared.js +6 -1
  171. package/dist/http-util.d.ts +16 -7
  172. package/dist/http-util.js +65 -20
  173. package/dist/importers/vault.js +2 -0
  174. package/dist/index.d.ts +2 -1
  175. package/dist/index.js +1 -0
  176. package/dist/json.d.ts +2 -0
  177. package/dist/json.js +7 -0
  178. package/dist/ledger-db.d.ts +10 -0
  179. package/dist/ledger-db.js +42 -0
  180. package/dist/local-embedding.d.ts +2 -0
  181. package/dist/local-embedding.js +20 -4
  182. package/dist/mcp/admin-tools.d.ts +2 -2
  183. package/dist/mcp/admin-tools.js +47 -5
  184. package/dist/mcp/memory-tools.js +7 -2
  185. package/dist/mcp/protocol.d.ts +6 -0
  186. package/dist/mcp/protocol.js +4 -2
  187. package/dist/mcp/recall-tools.js +19 -6
  188. package/dist/mcp/request.js +4 -0
  189. package/dist/mcp/session-state.d.ts +4 -0
  190. package/dist/mcp/session-state.js +15 -6
  191. package/dist/mcp/shared-gate.d.ts +4 -0
  192. package/dist/mcp/shared-gate.js +22 -0
  193. package/dist/mcp/tools.js +1 -0
  194. package/dist/memory-quality.d.ts +35 -0
  195. package/dist/memory-quality.js +191 -0
  196. package/dist/memory.d.ts +4 -4
  197. package/dist/physics-state.js +1 -1
  198. package/dist/pilot-arm.d.ts +14 -3
  199. package/dist/pilot-arm.js +32 -10
  200. package/dist/postinstall.js +2 -2
  201. package/dist/project-briefs.js +4 -4
  202. package/dist/project-identity.d.ts +14 -10
  203. package/dist/project-identity.js +29 -11
  204. package/dist/project-merge.d.ts +0 -2
  205. package/dist/project-merge.js +9 -13
  206. package/dist/prompt-hook.d.ts +65 -0
  207. package/dist/prompt-hook.js +213 -0
  208. package/dist/prompt-recall.js +1 -1
  209. package/dist/quality-repair.d.ts +27 -0
  210. package/dist/quality-repair.js +220 -0
  211. package/dist/rate-limit.d.ts +1 -0
  212. package/dist/rate-limit.js +5 -3
  213. package/dist/raw-archive.d.ts +1 -1
  214. package/dist/raw-archive.js +6 -16
  215. package/dist/recall-scope.d.ts +28 -5
  216. package/dist/recall-scope.js +52 -7
  217. package/dist/recall-trace.js +1 -1
  218. package/dist/reject-flow.d.ts +2 -0
  219. package/dist/reject-flow.js +20 -6
  220. package/dist/secret-detect.js +7 -8
  221. package/dist/server/auth.d.ts +8 -6
  222. package/dist/server/auth.js +85 -38
  223. package/dist/server/client-ip.d.ts +2 -0
  224. package/dist/server/client-ip.js +16 -1
  225. package/dist/server/mcp-http.d.ts +9 -3
  226. package/dist/server/mcp-http.js +56 -13
  227. package/dist/server/request.d.ts +1 -1
  228. package/dist/server/request.js +5 -5
  229. package/dist/server/routes/admin.js +12 -6
  230. package/dist/server/routes/memories.js +9 -1
  231. package/dist/server/routes/recall.js +14 -2
  232. package/dist/server/types.d.ts +42 -5
  233. package/dist/server/validation.d.ts +5 -2
  234. package/dist/server/validation.js +19 -2
  235. package/dist/server.d.ts +21 -4
  236. package/dist/server.js +207 -49
  237. package/dist/session-digest.d.ts +0 -1
  238. package/dist/session-digest.js +3 -12
  239. package/dist/session-owners.d.ts +7 -0
  240. package/dist/session-owners.js +54 -0
  241. package/dist/share-scrub.d.ts +3 -0
  242. package/dist/share-scrub.js +8 -0
  243. package/dist/shared.d.ts +1 -0
  244. package/dist/shared.js +26 -18
  245. package/dist/store/candidates.d.ts +14 -3
  246. package/dist/store/candidates.js +48 -26
  247. package/dist/store/conflicts.d.ts +15 -0
  248. package/dist/store/conflicts.js +68 -44
  249. package/dist/store/delete-and-batch.d.ts +25 -1
  250. package/dist/store/delete-and-batch.js +121 -69
  251. package/dist/store/entry-reads.d.ts +5 -6
  252. package/dist/store/entry-reads.js +24 -12
  253. package/dist/store/entry-row.d.ts +7 -10
  254. package/dist/store/entry-row.js +40 -18
  255. package/dist/store/entry-writes.d.ts +3 -3
  256. package/dist/store/entry-writes.js +9 -23
  257. package/dist/store/handoffs.d.ts +9 -6
  258. package/dist/store/handoffs.js +72 -59
  259. package/dist/store/index-and-stats.js +2 -2
  260. package/dist/store/open.d.ts +3 -1
  261. package/dist/store/open.js +3 -3
  262. package/dist/store/search-rows.d.ts +8 -5
  263. package/dist/store/search-rows.js +15 -18
  264. package/dist/store/sessions.d.ts +25 -6
  265. package/dist/store/sessions.js +61 -19
  266. package/dist/store/summaries.d.ts +1 -1
  267. package/dist/store/summaries.js +3 -18
  268. package/dist/store/tenant-lookup.d.ts +7 -3
  269. package/dist/store/tenant-lookup.js +9 -9
  270. package/dist/store-port.d.ts +13 -0
  271. package/dist/store-port.js +20 -0
  272. package/dist/version.d.ts +5 -1
  273. package/dist/version.js +5 -1
  274. package/dist/working-memory.js +1 -1
  275. package/dist/write-budget.d.ts +15 -0
  276. package/dist/write-budget.js +15 -0
  277. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  278. package/extensions/openclaw-plugin/package.json +1 -1
  279. package/openclaw.plugin.json +1 -1
  280. package/package.json +8 -4
@@ -9,20 +9,23 @@ import { loadFreshActiveTaskSnapshot, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AG
9
9
  import { loadLatestHandoff } from '../store/handoffs.js';
10
10
  import { estimateTokens } from '../token-ledger.js';
11
11
  import { markRetrieved, COMPACTION_MEMORY_TAG } from '../memory.js';
12
- import { isContentWorthStoring } from '../audit.js';
12
+ import { isWorthSurfacing } from '../memory-quality.js';
13
13
  import { getGlobalRoot } from '../shared.js';
14
14
  import { writeRecallTraceAtRoot } from '../recall-trace.js';
15
15
  import { evalNow, isRecallBoostAblated } from '../ablation.js';
16
16
  import { dropHeldCopies } from '../same-text.js';
17
- import { loadConfig } from '../config.js';
17
+ import { BadRequestError } from '../api-errors.js';
18
+ import { isSharedStore, loadConfig } from '../config.js';
19
+ import { log } from '../log.js';
18
20
  import { resolveProjectIdentity, classifyOriginProject, isGlobalStoreRoot, projectId, projectNames } from '../project-identity.js';
19
21
  import { promptTokens } from '../prompt-recall.js';
20
22
  import { detectSecret } from '../secret-detect.js';
21
23
  import { isSessionDigestRow } from '../session-digest.js';
22
24
  import { addAmbientTallies, ambientStateFromTallies } from '../ambient.js';
23
25
  import { loadAmbientTallies } from '../ambient-store.js';
24
- import { passesScopeFilterForRecall, assertScopeRequestAllowed } from '../recall-scope.js';
26
+ import { passesScopeFilterForRecall, assertScopeRequestAllowed, personalScopeOf } from '../recall-scope.js';
25
27
  import { finiteOr, selectBySearch, selectPinned, selectStrongest, } from './context-select.js';
28
+ import { ownerOrSubject } from './types.js';
26
29
  export { oneCopyPerMemory } from './context-select.js';
27
30
  /**
28
31
  * v39: the single ambient-injection admission policy, shared by getContext
@@ -37,10 +40,10 @@ export { oneCopyPerMemory } from './context-select.js';
37
40
  * - Origin partition: other-project rows are excluded unless
38
41
  * `includeCrossProject`.
39
42
  */
40
- function ambientAdmitEntry(e, currentProject, includeCrossProject, exactScope) {
43
+ function ambientAdmitEntry(e, currentProject, includeCrossProject, exactScope, own) {
41
44
  if (!ambientSecretAdmit(e, currentProject))
42
45
  return false;
43
- if (!passesScopeFilterForRecall(e.scope ?? null, exactScope))
46
+ if (!passesScopeFilterForRecall(e.scope ?? null, exactScope, own))
44
47
  return false;
45
48
  if (includeCrossProject)
46
49
  return true;
@@ -61,11 +64,11 @@ export function ambientSecretAdmit(e, currentProject) {
61
64
  }
62
65
  /** Most rows per store a no-query context reads; past it, ranking and ambientState see the strongest by decay. */
63
66
  export const CONTEXT_CANDIDATE_CAP = 2000;
64
- // The pinned-only branch needs pins and recent-N candidates, not the corpus; `recall` applies there only.
65
- function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit, window, recall, onQualityDrop) {
67
+ // The pinned-only branch needs pins and recent-N candidates, not the corpus; `recent` and `recall` apply there only.
68
+ function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, recent, admit, window, recall, onQualityDrop) {
66
69
  if (!pinnedOnly) {
67
70
  const rows = 'query' in window
68
- ? loadRecallSearchEntries(hippoRoot, window.query, DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, window.exactScope, 'exact', false, window.project)
71
+ ? loadRecallSearchEntries(hippoRoot, window.query, DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, window.exactScope, 'exact', false, window.project, window.ownScope)
69
72
  : loadContextCandidates(hippoRoot, tenantId, window);
70
73
  return { entries: rows.filter(admit) };
71
74
  }
@@ -74,12 +77,12 @@ function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admi
74
77
  const admitAmbient = (e) => {
75
78
  if (!admit(e))
76
79
  return false;
77
- if (e.pinned || isContentWorthStoring(e.content))
80
+ if (e.pinned || isWorthSurfacing(e))
78
81
  return true;
79
82
  onQualityDrop?.(e);
80
83
  return false;
81
84
  };
82
- return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient, recall);
85
+ return loadAmbientCandidates(hippoRoot, tenantId, recent.needed, admitAmbient, recall, recent.origins);
83
86
  }
84
87
  /**
85
88
  * Assemble a context bundle: recalled memories (pinned-only / strength-sorted
@@ -141,20 +144,36 @@ export async function getContext(ctx, opts = {}) {
141
144
  ambientState: recorded.ambientState,
142
145
  };
143
146
  }
147
+ const isolationWarned = new Set();
148
+ /** A laptop config copied onto a team store must not open every member's rows to every prompt, so the flag loses there. */
149
+ function warnIsolationIgnored(hippoRoot) {
150
+ if (isolationWarned.has(hippoRoot))
151
+ return;
152
+ isolationWarned.add(hippoRoot);
153
+ log.warn(`${hippoRoot}: "contextProjectIsolation": false is ignored on a shared store; a read opts in with cross_project.`);
154
+ }
144
155
  function planContext(ctx, opts) {
145
156
  const pinnedOnly = opts.pinnedOnly === true;
146
157
  // Global memories do not establish a project boundary for task state.
147
158
  const hasLocal = isInitialized(ctx.hippoRoot);
148
159
  const query = (opts.q ?? '').trim() || '*';
149
160
  const globalRoot = getGlobalRoot();
150
- const hasGlobal = isInitialized(globalRoot);
161
+ // The store's own flag counts too, so no surface can read a shared store as its owner.
162
+ const sharedStore = opts.sharedStore === true || isSharedStore(ctx.hippoRoot);
163
+ // A store serving many people is not its operator's, so the operator's own global store stays out.
164
+ const hasGlobal = !sharedStore && isInitialized(globalRoot);
151
165
  const primaryIsGlobal = isGlobalStoreRoot(ctx.hippoRoot);
152
166
  // opts.scope is only the tag boost, opts.exactScope the envelope request; other-project memories are
153
167
  // excluded unless the caller asks for them (crossProject) or isolation is disabled.
154
168
  const config = loadConfig(ctx.hippoRoot);
155
169
  const isolationEnabled = config.contextProjectIsolation !== false;
156
170
  const currentProject = opts.currentProject ?? resolveProjectIdentity(process.cwd());
157
- const includeCrossProject = opts.crossProject === true || !isolationEnabled;
171
+ // A caller with no project reads every row as its own; on a shared store those rows are other people's.
172
+ if (sharedStore && projectId(currentProject).trim() === '')
173
+ throw new BadRequestError('a shared store needs the caller\'s project');
174
+ if (sharedStore && !isolationEnabled)
175
+ warnIsolationIgnored(ctx.hippoRoot);
176
+ const includeCrossProject = opts.crossProject === true || (!isolationEnabled && !sharedStore);
158
177
  // Decided before the ambient loads so the pinned-only FTS candidate query can share their connection.
159
178
  const promptRecallPending = pinnedOnly && Boolean(opts.prompt?.trim()) && config.pinnedInject.promptRecall === true;
160
179
  const cost = opts.cost;
@@ -167,12 +186,14 @@ function planContext(ctx, opts) {
167
186
  includeRecent: opts.includeRecent ?? 0,
168
187
  activeScope: opts.scope ?? '',
169
188
  exactScope: opts.exactScope || undefined,
189
+ ownScope: personalScopeOf(ctx.actor) ?? undefined,
170
190
  query,
171
191
  hasLocal,
172
192
  hasGlobal,
173
193
  globalRoot,
174
194
  primaryIsGlobal,
175
195
  hasLocalTaskState: hasLocal && !primaryIsGlobal,
196
+ sharedStore,
176
197
  config,
177
198
  currentProject,
178
199
  includeCrossProject,
@@ -190,7 +211,7 @@ function promptRecallRequest(opts, plan) {
190
211
  ? Array.from(promptTokens(opts.prompt ?? ''))
191
212
  : [];
192
213
  return promptRecallTerms.length > 0
193
- ? { terms: promptRecallTerms, limit: Math.floor(finiteOr(pinnedInject.promptRecallCandidates, 100, 1)) }
214
+ ? { terms: promptRecallTerms, limit: Math.floor(finiteOr(pinnedInject.promptRecallCandidates, 100, 1)), ownScope: plan.ownScope }
194
215
  : undefined;
195
216
  }
196
217
  /** Reports the block's facts to the observer and returns the budget left after the fixed headers. */
@@ -204,9 +225,33 @@ function openBlockBudget(plan, opts, budget) {
204
225
  ? Math.max(0, blockBudget - cost.fixed(blockBudget, { cross: plan.includeCrossProject, promptRecall: promptRecallPending, ambient: !pinnedOnly && config.ambient.enabled }))
205
226
  : blockBudget;
206
227
  }
228
+ const NO_TASK_STATE = { snapshot: null, handoff: null, events: [] };
229
+ // Keyed on the RAW snapshot: a scope-hidden active session must not fall through to another session's ambient handoff.
230
+ function loadRawTaskState(ctx, opts, plan) {
231
+ if (!plan.hasLocalTaskState)
232
+ return NO_TASK_STATE;
233
+ // On a shared store task state is one person's, keyed by owner and project so it follows them into their next session.
234
+ const key = plan.sharedStore
235
+ ? { owner: ownerOrSubject(ctx.actor), project: projectNames(plan.currentProject) }
236
+ : undefined;
237
+ // Bounded read: an orphaned snapshot ages out of this ambient surface; the owner session's read stays unbounded.
238
+ const snapshot = loadFreshActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId, { sessionId: opts.currentSessionId }, key);
239
+ const sessionId = snapshot?.session_id;
240
+ const handoff = sessionId
241
+ ? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, sessionId, {}, key)
242
+ : loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, undefined, {
243
+ unfinishedOnly: true,
244
+ maxAgeMs: SNAPSHOT_AMBIENT_MAX_AGE_MS,
245
+ // Scope is admitted in SQL so a newer denied row can't hide an older eligible one before LIMIT 1.
246
+ scopeFilter: 'default-deny',
247
+ }, key);
248
+ // Raw session id here too: each event is admitted on its own scope, same as recall and the CLI.
249
+ const events = sessionId ? listSessionEvents(ctx.hippoRoot, ctx.tenantId, { session_id: sessionId, limit: 5 }) : [];
250
+ return { snapshot, handoff, events };
251
+ }
207
252
  // Sections print ahead of the memories, so they are paid first; one that does not fit is dropped, as an oversize entry is.
208
253
  function loadTaskSections(ctx, opts, plan, startLeft) {
209
- const { exactScope, hasLocalTaskState, cost } = plan;
254
+ const { exactScope, ownScope, cost } = plan;
210
255
  let left = startLeft;
211
256
  const pays = (tokens) => {
212
257
  if (tokens > left)
@@ -214,38 +259,12 @@ function loadTaskSections(ctx, opts, plan, startLeft) {
214
259
  left -= tokens;
215
260
  return true;
216
261
  };
217
- // Bounded read: an orphaned snapshot ages out of this ambient surface; the owner session's read stays unbounded.
218
262
  const rowScope = (r) => r?.scope ?? null;
219
- const rawActiveSnapshot = hasLocalTaskState
220
- ? loadFreshActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId, {
221
- sessionId: opts.currentSessionId,
222
- })
223
- : null;
263
+ const raw = loadRawTaskState(ctx, opts, plan);
224
264
  // The same envelope rule ambientAdmitEntry applies to memory rows.
225
- const activeSnapshot = rawActiveSnapshot && passesScopeFilterForRecall(rowScope(rawActiveSnapshot), exactScope)
226
- ? rawActiveSnapshot
227
- : null;
228
- // Key on the RAW snapshot: a scope-hidden active session must not fall through to another session's ambient handoff.
229
- const rawSessionHandoff = !hasLocalTaskState
230
- ? null
231
- : rawActiveSnapshot?.session_id
232
- ? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, rawActiveSnapshot.session_id)
233
- : loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, undefined, {
234
- unfinishedOnly: true,
235
- maxAgeMs: SNAPSHOT_AMBIENT_MAX_AGE_MS,
236
- // Scope is admitted in SQL so a newer denied row can't hide an older eligible one before LIMIT 1.
237
- scopeFilter: 'default-deny',
238
- });
239
- const sessionHandoff = rawSessionHandoff && passesScopeFilterForRecall(rowScope(rawSessionHandoff), exactScope)
240
- ? rawSessionHandoff
241
- : null;
242
- // Raw session id here too: each event is admitted on its own scope, same as recall and the CLI.
243
- const recentSessionEvents = hasLocalTaskState && rawActiveSnapshot?.session_id
244
- ? listSessionEvents(ctx.hippoRoot, ctx.tenantId, {
245
- session_id: rawActiveSnapshot.session_id,
246
- limit: 5,
247
- }).filter((e) => passesScopeFilterForRecall(rowScope(e), exactScope))
248
- : [];
265
+ const activeSnapshot = raw.snapshot && passesScopeFilterForRecall(rowScope(raw.snapshot), exactScope, ownScope) ? raw.snapshot : null;
266
+ const sessionHandoff = raw.handoff && passesScopeFilterForRecall(rowScope(raw.handoff), exactScope, ownScope) ? raw.handoff : null;
267
+ const recentSessionEvents = raw.events.filter((e) => passesScopeFilterForRecall(rowScope(e), exactScope, ownScope));
249
268
  const shownSnapshot = activeSnapshot && (!cost || pays(cost.snapshot(activeSnapshot))) ? activeSnapshot : null;
250
269
  const shownHandoff = sessionHandoff && (!cost || pays(cost.handoff(sessionHandoff))) ? sessionHandoff : null;
251
270
  const shownEvents = recentSessionEvents.length > 0 && (!cost || pays(cost.trail(recentSessionEvents))) ? recentSessionEvents : [];
@@ -254,14 +273,11 @@ function loadTaskSections(ctx, opts, plan, startLeft) {
254
273
  }
255
274
  function ambientAdmission(opts, plan, shownHandoff) {
256
275
  const transcriptHandoffSession = shownHandoff?.evidence?.derivedFrom === 'transcript' ? shownHandoff.sessionId : null;
257
- let digestHiddenForHandoff = false;
258
276
  const ambientAdmit = (e) => {
259
277
  // A printed handoff already carries the session's closing message, which its digest would print a second time.
260
- if (transcriptHandoffSession !== null && e.source_session_id === transcriptHandoffSession && isSessionDigestRow(e)) {
261
- digestHiddenForHandoff = true;
278
+ if (transcriptHandoffSession !== null && e.source_session_id === transcriptHandoffSession && isSessionDigestRow(e))
262
279
  return false;
263
- }
264
- return ambientAdmitEntry(e, plan.currentProject, plan.includeCrossProject, plan.exactScope);
280
+ return ambientAdmitEntry(e, plan.currentProject, plan.includeCrossProject, plan.exactScope, plan.ownScope);
265
281
  };
266
282
  const ownSessionId = opts.currentSessionId || '';
267
283
  // Inside admit, not after the load, so the loader's window widens past a session's own items.
@@ -272,10 +288,17 @@ function ambientAdmission(opts, plan, shownHandoff) {
272
288
  const admit = (e) => !e.superseded_by && !isOwnCompactionItem(e) && ambientAdmit(e);
273
289
  // The two-store search has always ranked a session's own compaction items; only the local search drops them.
274
290
  const bothStoresAdmit = (e) => !e.superseded_by && ambientAdmit(e);
275
- return { ambientAdmit, admit, bothStoresAdmit, digestHidden: () => digestHiddenForHandoff };
291
+ return { ambientAdmit, admit, bothStoresAdmit };
292
+ }
293
+ /** Origins the recent backfill may read past its first window; on a shared store, only the caller's own project rows. */
294
+ function recentOrigins(plan) {
295
+ if (plan.sharedStore)
296
+ return { names: projectNames(plan.currentProject).filter((n) => n !== ''), userGlobal: false };
297
+ return plan.originProject && { names: plan.originProject, userGlobal: true };
276
298
  }
277
299
  function loadPools(ctx, plan, admission, recallRequest) {
278
- const { obs, pinnedOnly, primaryIsGlobal, hasGlobal, exactScope } = plan;
300
+ const { obs, pinnedOnly, primaryIsGlobal, hasGlobal, exactScope, ownScope } = plan;
301
+ const recent = { needed: plan.includeRecent, origins: recentOrigins(plan) };
279
302
  const searches = plan.query !== '*' && !pinnedOnly;
280
303
  const searchesBoth = searches && hasGlobal && !primaryIsGlobal;
281
304
  const poolAdmit = searchesBoth ? admission.bothStoresAdmit : admission.admit;
@@ -283,19 +306,20 @@ function loadPools(ctx, plan, admission, recallRequest) {
283
306
  const qualityDrop = (isGlobal) => obs && !plan.promptRecallPending ? (e) => obs.qualityDropped(e, isGlobal) : undefined;
284
307
  // The window's predicates are ones admit applies anyway, so below the cap the admitted rows are unchanged.
285
308
  const window = searches
286
- ? { query: plan.query, exactScope, project: plan.originProject }
309
+ ? { query: plan.query, exactScope, ownScope, project: plan.originProject }
287
310
  : {
288
311
  exactScope,
312
+ ownScope,
289
313
  project: plan.originProject,
290
314
  cap: CONTEXT_CANDIDATE_CAP,
291
315
  now: evalNow(),
292
316
  };
293
317
  // Tenant-scoped loads: never resolveTenantId({}) here.
294
318
  const local = plan.hasLocal
295
- ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, plan.includeRecent, loadAdmit, window, recallRequest, qualityDrop(primaryIsGlobal))
319
+ ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, recent, loadAdmit, window, recallRequest, qualityDrop(primaryIsGlobal))
296
320
  : { entries: [] };
297
321
  const global = hasGlobal && !primaryIsGlobal
298
- ? loadAmbientEntries(plan.globalRoot, ctx.tenantId, pinnedOnly, plan.includeRecent, loadAdmit, window, recallRequest, qualityDrop(true))
322
+ ? loadAmbientEntries(plan.globalRoot, ctx.tenantId, pinnedOnly, recent, loadAdmit, window, recallRequest, qualityDrop(true))
299
323
  : { entries: [] };
300
324
  return { local, global };
301
325
  }
@@ -369,7 +393,7 @@ function recordRetrieval(ctx, opts, plan, selected, activeSnapshot) {
369
393
  return { items, ambientState: plan.config.ambient.enabled ? readAmbientState(ctx, plan) : undefined };
370
394
  }
371
395
  function readAmbientState(ctx, plan) {
372
- const filter = { exactScope: plan.exactScope, project: plan.originProject, currentProject: projectNames(plan.currentProject), now: evalNow() };
396
+ const filter = { exactScope: plan.exactScope, ownScope: plan.ownScope, project: plan.originProject, currentProject: projectNames(plan.currentProject), now: evalNow() };
373
397
  const roots = [...(plan.hasLocal ? [ctx.hippoRoot] : []), ...(plan.hasGlobal && !plan.primaryIsGlobal ? [plan.globalRoot] : [])];
374
398
  const tallies = roots.map((root) => loadAmbientTallies(root, ctx.tenantId, filter));
375
399
  const total = tallies.length > 0 ? tallies.reduce(addAmbientTallies) : undefined;
@@ -9,12 +9,12 @@ import type { Context } from './types.js';
9
9
  export declare function listDormant(ctx: Context, opts?: ListDormantOpts): DormantMemory[];
10
10
  /**
11
11
  * Bring a dormant memory back into active memory. It returns as if just
12
- * recalled: `last_retrieved` is now, so it gets a full half-life before it
13
- * can fade again. Every other field is the snapshot taken when it went
14
- * dormant.
12
+ * recalled, with a full half-life. A row audit repair set aside returns
13
+ * `verified`, so no quality check judges it again. Every other field is the
14
+ * snapshot taken when it went dormant.
15
15
  *
16
16
  * Throws when the tenant has no dormant memory with that id (another
17
- * tenant's id reads the same way), when a live memory already holds the id,
17
+ * tenant's id, or another person's personal row, reads the same way), when a live memory already holds the id,
18
18
  * and RejectedValueError when the value has been rejected since. On any
19
19
  * throw the dormant copy stays where it is.
20
20
  */
@@ -25,6 +25,6 @@ export declare function restoreDormant(ctx: Context, id: string): MemoryEntry;
25
25
  * dormant memory with that id.
26
26
  */
27
27
  export declare function forgetDormant(ctx: Context, id: string): void;
28
- /** Whether the tenant holds a dormant memory with this id (for "not found" hints). */
28
+ /** Whether the tenant holds a dormant memory with this id that the caller may touch (for "not found" hints). */
29
29
  export declare function isDormant(ctx: Context, id: string): boolean;
30
30
  //# sourceMappingURL=dormant.d.ts.map
@@ -10,6 +10,8 @@ import { listDormantRows, readDormantSnapshot, deleteDormantRow, hasDormantRow,
10
10
  import { createMemory, calculateStrength } from '../memory.js';
11
11
  import { appendAuditEvent, reportAuditWriteFailure } from '../audit.js';
12
12
  import { loadConfig } from '../config.js';
13
+ import { canTouchScope, personalScopeOf, touchableScopeSql } from '../recall-scope.js';
14
+ const touchable = (ctx) => touchableScopeSql('', personalScopeOf(ctx.actor));
13
15
  /**
14
16
  * A tenant's dormant memories (src/dormant.ts): what sleep moved out of
15
17
  * active memory instead of deleting, when `dormant.enabled` is on. Newest
@@ -18,7 +20,7 @@ import { loadConfig } from '../config.js';
18
20
  export function listDormant(ctx, opts = {}) {
19
21
  const db = openHippoDb(ctx.hippoRoot);
20
22
  try {
21
- return listDormantRows(db, ctx.tenantId, opts);
23
+ return listDormantRows(db, ctx.tenantId, opts, touchable(ctx));
22
24
  }
23
25
  finally {
24
26
  closeHippoDb(db);
@@ -26,12 +28,12 @@ export function listDormant(ctx, opts = {}) {
26
28
  }
27
29
  /**
28
30
  * Bring a dormant memory back into active memory. It returns as if just
29
- * recalled: `last_retrieved` is now, so it gets a full half-life before it
30
- * can fade again. Every other field is the snapshot taken when it went
31
- * dormant.
31
+ * recalled, with a full half-life. A row audit repair set aside returns
32
+ * `verified`, so no quality check judges it again. Every other field is the
33
+ * snapshot taken when it went dormant.
32
34
  *
33
35
  * Throws when the tenant has no dormant memory with that id (another
34
- * tenant's id reads the same way), when a live memory already holds the id,
36
+ * tenant's id, or another person's personal row, reads the same way), when a live memory already holds the id,
35
37
  * and RejectedValueError when the value has been rejected since. On any
36
38
  * throw the dormant copy stays where it is.
37
39
  */
@@ -42,7 +44,7 @@ export function restoreDormant(ctx, id) {
42
44
  db.exec('BEGIN IMMEDIATE');
43
45
  try {
44
46
  const dormant = readDormantSnapshot(db, ctx.tenantId, id);
45
- if (!dormant) {
47
+ if (!dormant || !canTouchScope(ctx.actor, dormant.entry.scope ?? null)) {
46
48
  throw new NotFoundError(`dormant memory not found: ${id}`);
47
49
  }
48
50
  if (db.prepare(`SELECT 1 FROM memories WHERE id = ?`).get(id) !== undefined) {
@@ -59,6 +61,8 @@ export function restoreDormant(ctx, id) {
59
61
  ...dormant.entry,
60
62
  last_retrieved: now.toISOString(),
61
63
  };
64
+ if (dormant.reason === 'quality-repair')
65
+ revived.confidence = 'verified';
62
66
  restored = stampOriginProject(ctx.hippoRoot, { ...revived, strength: calculateStrength(revived, now) });
63
67
  writeEntryDbOnly(db, restored, { actor: ctx.actor.subject });
64
68
  deleteDormantRow(db, ctx.tenantId, id);
@@ -104,7 +108,7 @@ export function restoreDormant(ctx, id) {
104
108
  export function forgetDormant(ctx, id) {
105
109
  const db = openHippoDb(ctx.hippoRoot);
106
110
  try {
107
- if (!deleteDormantRow(db, ctx.tenantId, id)) {
111
+ if (!hasDormantRow(db, ctx.tenantId, id, touchable(ctx)) || !deleteDormantRow(db, ctx.tenantId, id)) {
108
112
  throw new NotFoundError(`dormant memory not found: ${id}`);
109
113
  }
110
114
  try {
@@ -127,11 +131,11 @@ export function forgetDormant(ctx, id) {
127
131
  // Counted like every other permanent removal (forget, archiveRaw).
128
132
  updateStatsUnlessBusy(ctx.hippoRoot, { forgotten: 1 }, `removed ${id}`);
129
133
  }
130
- /** Whether the tenant holds a dormant memory with this id (for "not found" hints). */
134
+ /** Whether the tenant holds a dormant memory with this id that the caller may touch (for "not found" hints). */
131
135
  export function isDormant(ctx, id) {
132
136
  const db = openHippoDb(ctx.hippoRoot);
133
137
  try {
134
- return hasDormantRow(db, ctx.tenantId, id);
138
+ return hasDormantRow(db, ctx.tenantId, id, touchable(ctx));
135
139
  }
136
140
  finally {
137
141
  closeHippoDb(db);
@@ -1,3 +1,4 @@
1
+ import type { CallerProject } from '../prompt-hook.js';
1
2
  import type { Context } from './types.js';
2
3
  export interface DrillDownOpts {
3
4
  /** Cap on number of children returned. Default 50. */
@@ -19,6 +20,7 @@ export interface DrillDownOpts {
19
20
  */
20
21
  depth?: number;
21
22
  cost?: DrillDownCost;
23
+ project?: CallerProject;
22
24
  }
23
25
  export interface DrillDownSummary {
24
26
  id: string;
@@ -3,7 +3,8 @@ import { closeHippoDb } from '../db.js';
3
3
  import { openStore } from '../store/open.js';
4
4
  import { selectEntriesByIds, selectChildrenByParent } from '../store/entry-reads.js';
5
5
  import { estimateTokens } from '../token-ledger.js';
6
- import { passesScopeFilterForRecall } from '../recall-scope.js';
6
+ import { passesScopeFilterForRecall, personalScopeOf } from '../recall-scope.js';
7
+ import { classifyOriginProject } from '../project-identity.js';
7
8
  /**
8
9
  * Walk one step down the DAG from a level-2 (or higher) summary to its direct
9
10
  * children. Companion to `recall(... summarizeOverflow: true)` — when recall
@@ -44,16 +45,20 @@ function drillDownOn(db, ctx, summaryId, depth, opts, limit) {
44
45
  // unauthorised tenants. The two cases collapse into not_found.
45
46
  if (!summary)
46
47
  return { failure: 'not_found' };
47
- if ((summary.dag_level ?? 0) < 2)
48
- return { failure: 'not_drillable' };
49
- if (!passesScopeFilterForRecall(summary.scope ?? null, undefined)) {
48
+ const own = personalScopeOf(ctx.actor) ?? undefined;
49
+ if (!passesScopeFilterForRecall(summary.scope ?? null, undefined, own)) {
50
50
  // codex round 3 P1: collapse to not_found. A distinguishable
51
51
  // "scope_blocked" tells a no-scope caller "this row exists, just
52
52
  // not for you" — same existence-leak the HTTP 404 collapse was
53
53
  // already preventing. Match the HTTP behaviour at the API level.
54
54
  return { failure: 'not_found' };
55
55
  }
56
- const { collected, level0DirectCount } = collectDescendants(db, ctx.tenantId, summaryId, depth);
56
+ const shown = (row) => !opts.project || classifyOriginProject(row.origin_project, opts.project) !== 'cross-project';
57
+ if (!shown(summary))
58
+ return { failure: 'not_found' };
59
+ if ((summary.dag_level ?? 0) < 2)
60
+ return { failure: 'not_drillable' };
61
+ const { collected, level0DirectCount } = collectDescendants(db, ctx.tenantId, summaryId, depth, own, shown);
57
62
  const summaryOut = {
58
63
  id: summary.id,
59
64
  content: summary.content,
@@ -83,7 +88,7 @@ function drillDownOn(db, ctx, summaryId, depth, opts, limit) {
83
88
  }
84
89
  // BFS with a visited set: dag_parent_id is not unique, so a misconfigured tree could emit a child twice past depth 1.
85
90
  // The level-0 count is kept apart so a legacy summary's descendantCount fallback counts direct children only.
86
- function collectDescendants(db, tenantId, summaryId, depth) {
91
+ function collectDescendants(db, tenantId, summaryId, depth, own, shown) {
87
92
  const collected = [];
88
93
  const visited = new Set([summaryId]);
89
94
  let frontier = [summaryId];
@@ -93,7 +98,7 @@ function collectDescendants(db, tenantId, summaryId, depth) {
93
98
  const kidsByParent = selectChildrenByParent(db, frontier, tenantId);
94
99
  for (const parentId of frontier) {
95
100
  const kids = kidsByParent.get(parentId) ?? [];
96
- const eligibleKids = kids.filter((c) => passesScopeFilterForRecall(c.scope ?? null, undefined));
101
+ const eligibleKids = kids.filter((c) => passesScopeFilterForRecall(c.scope ?? null, undefined, own) && shown(c));
97
102
  for (const k of eligibleKids) {
98
103
  if (visited.has(k.id))
99
104
  continue;
@@ -33,7 +33,7 @@ export interface RejectResult {
33
33
  * forms — pass exactly one:
34
34
  * - `memoryId`: reject the CURRENT content of an existing memory. Removes
35
35
  * that row and every other live row in the tenant whose normalized
36
- * digest matches (not just the id passed).
36
+ * digest matches (not just the id passed), except another person's personal rows.
37
37
  * - `value`: pre-emptive form — tombstone content that may not currently
38
38
  * be stored (or is already gone). Zero removals.
39
39
  *
@@ -4,11 +4,13 @@ import { BadRequestError, NotFoundError } from '../api-errors.js';
4
4
  import { deleteEntry } from '../store/delete-and-batch.js';
5
5
  import { updateStatsUnlessBusy } from '../store/index-and-stats.js';
6
6
  import { rejectValue, unrejectValue, listRejectionsForTenant } from '../reject-flow.js';
7
- import { selectMemoryTenant } from '../store/tenant-lookup.js';
7
+ import { selectMemoryReach } from '../store/tenant-lookup.js';
8
+ import { canTouchScope, personalScopeOf } from '../recall-scope.js';
8
9
  export function forget(ctx, id) {
9
10
  const db = openHippoDb(ctx.hippoRoot);
10
11
  try {
11
- if (selectMemoryTenant(db, id) !== ctx.tenantId) {
12
+ const reach = selectMemoryReach(db, id);
13
+ if (reach?.tenantId !== ctx.tenantId || !canTouchScope(ctx.actor, reach.scope)) {
12
14
  throw new NotFoundError(`memory not found: ${id}`);
13
15
  }
14
16
  }
@@ -31,7 +33,7 @@ export function forget(ctx, id) {
31
33
  * forms — pass exactly one:
32
34
  * - `memoryId`: reject the CURRENT content of an existing memory. Removes
33
35
  * that row and every other live row in the tenant whose normalized
34
- * digest matches (not just the id passed).
36
+ * digest matches (not just the id passed), except another person's personal rows.
35
37
  * - `value`: pre-emptive form — tombstone content that may not currently
36
38
  * be stored (or is already gone). Zero removals.
37
39
  *
@@ -46,7 +48,8 @@ export function reject(ctx, opts) {
46
48
  // keeps the error message consistent with the rest of this module.
47
49
  const db = openHippoDb(ctx.hippoRoot);
48
50
  try {
49
- if (selectMemoryTenant(db, opts.memoryId) !== ctx.tenantId) {
51
+ const reach = selectMemoryReach(db, opts.memoryId);
52
+ if (reach?.tenantId !== ctx.tenantId || !canTouchScope(ctx.actor, reach.scope)) {
50
53
  throw new NotFoundError(`memory not found: ${opts.memoryId}`);
51
54
  }
52
55
  }
@@ -61,6 +64,7 @@ export function reject(ctx, opts) {
61
64
  reason: opts.reason,
62
65
  memoryId: opts.memoryId,
63
66
  value: opts.value,
67
+ ownScope: personalScopeOf(ctx.actor) ?? undefined,
64
68
  });
65
69
  return { digest: result.digest, removedIds: result.removedIds };
66
70
  }
package/dist/api/learn.js CHANGED
@@ -6,6 +6,7 @@ import { embedMemory } from '../embeddings.js';
6
6
  import { extractInvalidationTarget, invalidateMatching } from '../invalidation.js';
7
7
  import { computeSchemaFit, createMemory, Layer } from '../memory.js';
8
8
  import { extractPathTags } from '../path-context.js';
9
+ import { canReadScope, personalScopeOf, touchableScopeSql } from '../recall-scope.js';
9
10
  import { RejectedValueError } from '../rejection.js';
10
11
  import { duplicateKey, longestWord, storedTextKeys } from '../same-text.js';
11
12
  import { loadTextsHoldingWords } from '../store/candidates.js';
@@ -37,9 +38,10 @@ export function learn(ctx, opts) {
37
38
  function writeLessons(ctx, lessons, opts, config) {
38
39
  const { hippoRoot, tenantId } = ctx;
39
40
  const { profile } = opts;
40
- // Schema fit needs every row; without it, only rows holding a lesson's longest word can be its copy.
41
- const existing = profile.full ? loadAllEntries(hippoRoot, tenantId) : undefined;
42
- const keys = storedTextKeys(existing ?? loadTextsHoldingWords(hippoRoot, tenantId, lessons.map(longestWord)));
41
+ // Schema fit needs every row; without it, only rows holding a lesson's longest word can be its copy. Learn is host-admin only, so both read what an admin can.
42
+ const existing = profile.full ? loadAllEntries(hippoRoot, tenantId).filter((e) => e.scope == null || canReadScope(ctx.actor, e.scope)) : undefined;
43
+ const readable = touchableScopeSql('', personalScopeOf(ctx.actor));
44
+ const keys = storedTextKeys(existing ?? loadTextsHoldingWords(hippoRoot, tenantId, lessons.map(longestWord), undefined, readable));
43
45
  const pathTags = profile.full ? extractPathTags(opts.repoPath) : [];
44
46
  const counts = { added: 0, skipped: 0, rejected: 0, invalidations: [] };
45
47
  for (const lesson of lessons) {
@@ -2,7 +2,7 @@ import type { Context } from './types.js';
2
2
  /**
3
3
  * Apply a positive/negative outcome to a list of recently-recalled memory ids.
4
4
  * Used by the MCP `hippo_outcome` tool and the HTTP `POST /v1/outcome` route.
5
- * Tenant-scoped: ids that don't belong to ctx.tenantId are silently skipped
5
+ * Tenant-scoped: ids outside ctx.tenantId, or in someone else's personal scope, are silently skipped
6
6
  * (matches the prior MCP semantics — a stale id from another tenant doesn't
7
7
  * crash the call). Each successful outcome emits one audit_log row with
8
8
  * op='outcome' tagged with ctx.actor.subject.
@@ -7,6 +7,7 @@ import { loadIndex } from '../store/index-and-stats.js';
7
7
  import { applyOutcome, CHURN_STALE_TAG } from '../memory.js';
8
8
  import { appendAuditEvent } from '../audit.js';
9
9
  import { recordTraceOutcome } from '../recall-trace.js';
10
+ import { canTouchScope } from '../recall-scope.js';
10
11
  export function outcome(ctx, ids, good, opts) {
11
12
  const appliedIds = [];
12
13
  const db = openStore(ctx.hippoRoot);
@@ -14,7 +15,7 @@ export function outcome(ctx, ids, good, opts) {
14
15
  const live = selectEntriesByIds(db, ids, ctx.tenantId);
15
16
  for (const id of ids) {
16
17
  const entry = live.get(id);
17
- if (!entry)
18
+ if (!entry || !canTouchScope(ctx.actor, entry.scope ?? null))
18
19
  continue;
19
20
  let updated = applyOutcome(entry, good);
20
21
  if (good && updated.tags.includes(CHURN_STALE_TAG)) { // a good outcome reconfirms the entry
@@ -15,7 +15,8 @@ import { appendAuditEvent } from '../audit.js';
15
15
  import { promoteToGlobal } from '../shared.js';
16
16
  import { archiveRawMemory } from '../raw-archive.js';
17
17
  import { loadConfig } from '../config.js';
18
- import { selectMemoryTenant } from '../store/tenant-lookup.js';
18
+ import { selectMemoryReach } from '../store/tenant-lookup.js';
19
+ import { canTouchScope } from '../recall-scope.js';
19
20
  export function promote(ctx, id) {
20
21
  // Tenant scope: promoteToGlobal reads the entry from the local root via
21
22
  // readEntry without a tenant filter, so a Bearer for tenant A could
@@ -25,7 +26,8 @@ export function promote(ctx, id) {
25
26
  // another tenant).
26
27
  const ownerDb = openHippoDb(ctx.hippoRoot);
27
28
  try {
28
- if (selectMemoryTenant(ownerDb, id) !== ctx.tenantId) {
29
+ const reach = selectMemoryReach(ownerDb, id);
30
+ if (reach?.tenantId !== ctx.tenantId || !canTouchScope(ctx.actor, reach.scope)) {
29
31
  throw new NotFoundError(`memory not found: ${id}`);
30
32
  }
31
33
  }
@@ -78,10 +80,10 @@ export function supersede(ctx, oldId, newContent) {
78
80
  }
79
81
  return { ok: true, oldId, newId: newEntry.id };
80
82
  }
81
- /** The tenant-scoped row to supersede; readEntry's tenant filter makes another tenant's id read as not found. */
83
+ /** The tenant-scoped row to supersede; another tenant's id, or someone else's personal row, reads as not found. */
82
84
  function readSupersedable(ctx, oldId) {
83
85
  const old = readEntry(ctx.hippoRoot, oldId, ctx.tenantId);
84
- if (!old) {
86
+ if (!old || !canTouchScope(ctx.actor, old.scope ?? null)) {
85
87
  throw new NotFoundError(`Memory not found: ${oldId}`);
86
88
  }
87
89
  // The CAS UPDATE closes the race; this check only gives a clearer error in the common single-writer case.
@@ -140,7 +142,8 @@ export function archiveRaw(ctx, id, reason, opts = {}) {
140
142
  // pre-check. Deny cross-tenant access with the same not-found message
141
143
  // archiveRawMemory itself would throw on a missing row, so we don't
142
144
  // leak whether the id exists in another tenant.
143
- if (selectMemoryTenant(db, id) !== ctx.tenantId) {
145
+ const reach = selectMemoryReach(db, id);
146
+ if (reach?.tenantId !== ctx.tenantId || !canTouchScope(ctx.actor, reach.scope)) {
144
147
  throw new NotFoundError(`memory not found: ${id}`);
145
148
  }
146
149
  archiveRawMemory(db, id, {
@@ -5,6 +5,7 @@ import type { RerankStep, SearchResult } from '../search/types.js';
5
5
  import type { PlanningFallacyHint, PlanningFallacyWatching } from '../predictions/planning-fallacy.js';
6
6
  import type { AnchoringHint, RecallHistorySnapshot } from '../recall-history.js';
7
7
  import type { AvailabilityHint } from '../availability.js';
8
+ import type { CallerProject } from '../prompt-hook.js';
8
9
  export interface RecallOpts {
9
10
  query: string;
10
11
  limit?: number;
@@ -76,8 +77,8 @@ export interface RecallOpts {
76
77
  * session handoff, recent session events) on the result. Default false to keep
77
78
  * the hot path cheap; agent boot paths should set this to true.
78
79
  *
79
- * All three lookups are tenant-scoped to ctx.tenantId via the store
80
- * helpers. No risk of cross-tenant leak.
80
+ * All three lookups are tenant-scoped to ctx.tenantId. On a shared store they are
81
+ * the caller's own, keyed by owner and `project`, and empty without a project.
81
82
  *
82
83
  * Note: when no active snapshot exists, sessionHandoff is null and
83
84
  * recentSessionEvents is []. We deliberately do NOT fall back to the latest
@@ -86,6 +87,7 @@ export interface RecallOpts {
86
87
  * `hippo session resume`.
87
88
  */
88
89
  includeContinuity?: boolean;
90
+ project?: CallerProject;
89
91
  /**
90
92
  * When set AND `(ctx.tenantId, sessionId)` has active goals AND
91
93
  * `goalTag` is unset, `api.recall` applies the dlPFC goal-stack boost lifted