hippo-memory 1.67.0 → 1.69.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 (82) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-memories/copilot.d.ts +3 -1
  3. package/dist/agent-memories/copilot.js +2 -2
  4. package/dist/api/auth.d.ts +14 -18
  5. package/dist/api/auth.js +93 -60
  6. package/dist/api/context-select.d.ts +10 -0
  7. package/dist/api/context-select.js +16 -18
  8. package/dist/api/context.js +101 -60
  9. package/dist/api/forget.d.ts +2 -2
  10. package/dist/api/forget.js +11 -0
  11. package/dist/api/outcome.d.ts +4 -4
  12. package/dist/api/outcome.js +29 -6
  13. package/dist/api/promote.d.ts +3 -3
  14. package/dist/api/promote.js +45 -90
  15. package/dist/api/remember.d.ts +3 -2
  16. package/dist/api/remember.js +38 -11
  17. package/dist/api/types.d.ts +9 -0
  18. package/dist/auth.d.ts +19 -0
  19. package/dist/auth.js +14 -6
  20. package/dist/capture/compact.js +21 -2
  21. package/dist/capture/copilot-transcript.d.ts +3 -0
  22. package/dist/capture/copilot-transcript.js +8 -1
  23. package/dist/capture/session-worker.d.ts +13 -0
  24. package/dist/capture/session-worker.js +194 -0
  25. package/dist/capture-contract.d.ts +3 -1
  26. package/dist/capture-contract.js +36 -0
  27. package/dist/cli/session-hooks.js +90 -22
  28. package/dist/cli/setup.js +70 -24
  29. package/dist/cli/usage.js +4 -1
  30. package/dist/cli.js +1 -1
  31. package/dist/connectors/github/ingest.js +2 -1
  32. package/dist/connectors/slack/deletion.js +2 -1
  33. package/dist/connectors/slack/ingest.js +2 -1
  34. package/dist/embeddings.d.ts +10 -10
  35. package/dist/embeddings.js +103 -18
  36. package/dist/env.d.ts +2 -0
  37. package/dist/env.js +2 -0
  38. package/dist/hooks/copilot.d.ts +50 -8
  39. package/dist/hooks/copilot.js +116 -30
  40. package/dist/hooks/json-hooks.d.ts +2 -0
  41. package/dist/hooks/json-hooks.js +11 -2
  42. package/dist/hooks/shared.d.ts +7 -1
  43. package/dist/hooks/shared.js +18 -3
  44. package/dist/mcp/memory-tools.d.ts +2 -2
  45. package/dist/mcp/memory-tools.js +9 -8
  46. package/dist/mcp/request.js +3 -3
  47. package/dist/memory.d.ts +2 -0
  48. package/dist/memory.js +7 -0
  49. package/dist/prompt-recall.d.ts +5 -0
  50. package/dist/prompt-recall.js +13 -0
  51. package/dist/raw-archive.d.ts +2 -2
  52. package/dist/raw-archive.js +5 -3
  53. package/dist/recall-scope.d.ts +2 -5
  54. package/dist/recall-scope.js +5 -6
  55. package/dist/server/routes/admin.js +2 -2
  56. package/dist/server/routes/memories.js +9 -6
  57. package/dist/server-detect.js +3 -2
  58. package/dist/server.d.ts +20 -4
  59. package/dist/server.js +23 -11
  60. package/dist/store/audit-event.d.ts +1 -1
  61. package/dist/store/audit-event.js +1 -1
  62. package/dist/store/entry-writes-group.d.ts +9 -0
  63. package/dist/store/entry-writes-group.js +157 -0
  64. package/dist/store/handoffs.d.ts +5 -2
  65. package/dist/store/handoffs.js +35 -5
  66. package/dist/store/key-writes.d.ts +9 -0
  67. package/dist/store/key-writes.js +30 -0
  68. package/dist/store/open.d.ts +2 -0
  69. package/dist/store/open.js +10 -0
  70. package/dist/store/search-rows.js +3 -9
  71. package/dist/store/sessions.d.ts +5 -0
  72. package/dist/store/sessions.js +4 -1
  73. package/dist/store/vector-writes.d.ts +6 -0
  74. package/dist/store/vector-writes.js +59 -0
  75. package/dist/store-port.d.ts +143 -1
  76. package/dist/store-port.js +48 -12
  77. package/dist/version.d.ts +1 -1
  78. package/dist/version.js +1 -1
  79. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  80. package/extensions/openclaw-plugin/package.json +1 -1
  81. package/openclaw.plugin.json +1 -1
  82. package/package.json +1 -1
@@ -1,12 +1,9 @@
1
1
  // Ambient context injection: the admission policy and getContext.
2
2
  import { isInitialized } from '../store/open.js';
3
3
  import { strengthenRetrieved } from '../store/entry-writes.js';
4
- import { loadRecallSearchEntries } from '../store/search-rows.js';
5
4
  import { DEFAULT_SEARCH_CANDIDATE_LIMIT } from '../store/rows.js';
6
- import { loadAmbientCandidates, loadContextCandidates, } from '../store/candidates.js';
7
5
  import { loadIndex, saveIndex, updateStats } from '../store/index-and-stats.js';
8
- import { loadFreshActiveTaskSnapshot, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS } from '../store/sessions.js';
9
- import { loadLatestHandoff } from '../store/handoffs.js';
6
+ import { freshActiveSnapshot, SNAPSHOT_AMBIENT_MAX_AGE_MS } from '../store/sessions.js';
10
7
  import { estimateTokens } from '../token-ledger.js';
11
8
  import { markRetrieved, COMPACTION_MEMORY_TAG } from '../memory.js';
12
9
  import { isWorthSurfacing } from '../memory-quality.js';
@@ -22,7 +19,7 @@ import { promptTokens } from '../prompt-recall.js';
22
19
  import { detectSecret } from '../secret-detect.js';
23
20
  import { isSessionDigestRow } from '../session-digest.js';
24
21
  import { addAmbientTallies, ambientStateFromTallies } from '../ambient.js';
25
- import { loadAmbientTallies } from '../ambient-store.js';
22
+ import { requireGroup, sqliteStore, storeFor } from '../store-port.js';
26
23
  import { passesScopeFilterForRecall, assertScopeRequestAllowed, personalScopeOf } from '../recall-scope.js';
27
24
  import { finiteOr, selectBySearch, selectPinned, selectStrongest, } from './context-select.js';
28
25
  import { ownerOrSubject } from './types.js';
@@ -64,13 +61,27 @@ export function ambientSecretAdmit(e, currentProject) {
64
61
  }
65
62
  /** Most rows per store a no-query context reads; past it, ranking and ambientState see the strongest by decay. */
66
63
  export const CONTEXT_CANDIDATE_CAP = 2000;
64
+ /** A store's base search and context reads; a store without the group throws StoreNotPortedError. */
65
+ function sourceOf(store) {
66
+ return { store, reads: requireGroup(store, 'contextReads') };
67
+ }
67
68
  // 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) {
69
+ // `recheck` is `admit` without the delivery observer, so a row checked twice is not refused twice in the ledger.
70
+ async function loadAmbientEntries(source, tenantId, pinnedOnly, recent, admit, recheck, window, recall, onQualityDrop) {
71
+ const ownTenant = (e) => e.tenantId === tenantId;
69
72
  if (!pinnedOnly) {
70
73
  const rows = 'query' in window
71
- ? loadRecallSearchEntries(hippoRoot, window.query, DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, window.exactScope, 'exact', false, window.project, window.ownScope)
72
- : loadContextCandidates(hippoRoot, tenantId, window);
73
- return { entries: rows.filter(admit) };
74
+ ? await source.store.searchRecallEntries(window.query, {
75
+ limit: DEFAULT_SEARCH_CANDIDATE_LIMIT,
76
+ tenantId,
77
+ requestedScope: window.exactScope,
78
+ explicitScopeMode: 'exact',
79
+ includeSuperseded: false,
80
+ originProjects: window.project,
81
+ ownScope: window.ownScope,
82
+ })
83
+ : await source.reads.contextCandidates(tenantId, window);
84
+ return { entries: rows.filter((e) => ownTenant(e) && admit(e)) };
74
85
  }
75
86
  // The quality floor runs on the recent-N slice AFTER this load, so the load
76
87
  // counts by it too, or it stops short of a store whose newest rows are junk.
@@ -82,7 +93,10 @@ function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, recent, admit, wind
82
93
  onQualityDrop?.(e);
83
94
  return false;
84
95
  };
85
- return loadAmbientCandidates(hippoRoot, tenantId, recent.needed, admitAmbient, recall, recent.origins);
96
+ const loaded = await source.reads.ambientCandidates(tenantId, { recentNeeded: recent.needed, admit: admitAmbient, recall, origins: recent.origins });
97
+ // Pins and recent rows reach core only through the store's own call to admit, so core applies it again with the tenant.
98
+ const entries = loaded.entries.filter((e) => ownTenant(e) && recheck(e));
99
+ return loaded.recall ? { entries, recall: loaded.recall.filter(ownTenant) } : { entries };
86
100
  }
87
101
  /**
88
102
  * Assemble a context bundle: recalled memories (pinned-only / strength-sorted
@@ -107,10 +121,10 @@ export async function getContext(ctx, opts = {}) {
107
121
  if (budget <= 0) {
108
122
  return { entries: [], tokens: 0 };
109
123
  }
110
- const plan = planContext(ctx, opts);
111
- const sections = loadTaskSections(ctx, opts, plan, openBlockBudget(plan, opts, budget));
124
+ const plan = planContext(ctx, opts, sourceOf(storeFor(ctx)));
125
+ const sections = await loadTaskSections(ctx, opts, plan, openBlockBudget(plan, opts, budget));
112
126
  const admission = ambientAdmission(opts, plan, sections.shownHandoff);
113
- const pools = loadPools(ctx, plan, admission, promptRecallRequest(opts, plan));
127
+ const pools = await loadPools(ctx, plan, admission, promptRecallRequest(opts, plan));
114
128
  const noSections = !sections.shownSnapshot && !sections.shownHandoff && sections.shownEvents.length === 0;
115
129
  if (!plan.promptRecallPending && pools.local.entries.length === 0 && pools.global.entries.length === 0 && noSections) {
116
130
  return { entries: [], tokens: 0 };
@@ -127,14 +141,14 @@ export async function getContext(ctx, opts = {}) {
127
141
  if (items.length === 0 && noSections) {
128
142
  // A query that found nothing is the coverage-gap signal, so it still gets a trace; pinned-only stays read-only.
129
143
  if (!plan.pinnedOnly)
130
- traceEmptyContext(ctx, opts, plan.query);
144
+ await traceEmptyContext(ctx, opts, plan);
131
145
  return { entries: [], tokens: 0 };
132
146
  }
133
147
  // pinnedOnly is the UserPromptSubmit hot path — read-only so pinned
134
148
  // memories don't inflate retrieval_count or extend half_life every turn.
135
149
  const recorded = plan.pinnedOnly
136
150
  ? { items, ambientState: undefined }
137
- : recordRetrieval(ctx, opts, plan, items, sections.activeSnapshot);
151
+ : await recordRetrieval(ctx, opts, plan, items, sections.activeSnapshot);
138
152
  return {
139
153
  entries: recorded.items,
140
154
  tokens,
@@ -152,16 +166,17 @@ function warnIsolationIgnored(hippoRoot) {
152
166
  isolationWarned.add(hippoRoot);
153
167
  log.warn(`${hippoRoot}: "contextProjectIsolation": false is ignored on a shared store; a read opts in with cross_project.`);
154
168
  }
155
- function planContext(ctx, opts) {
169
+ function planContext(ctx, opts, local) {
156
170
  const pinnedOnly = opts.pinnedOnly === true;
171
+ const other = local.store.kind !== 'sqlite' ? local.store : undefined;
157
172
  // Global memories do not establish a project boundary for task state.
158
- const hasLocal = isInitialized(ctx.hippoRoot);
173
+ const hasLocal = other !== undefined || isInitialized(ctx.hippoRoot);
159
174
  const query = (opts.q ?? '').trim() || '*';
160
175
  const globalRoot = getGlobalRoot();
161
176
  // The store's own flag counts too, so no surface can read a shared store as its owner.
162
177
  const sharedStore = opts.sharedStore === true || isSharedStore(ctx.hippoRoot);
163
178
  // 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);
179
+ const hasGlobal = !sharedStore && other === undefined && isInitialized(globalRoot);
165
180
  const primaryIsGlobal = isGlobalStoreRoot(ctx.hippoRoot);
166
181
  // opts.scope is only the tag boost, opts.exactScope the envelope request; other-project memories are
167
182
  // excluded unless the caller asks for them (crossProject) or isolation is disabled.
@@ -181,6 +196,8 @@ function planContext(ctx, opts) {
181
196
  ? cost.entry({ entry, isGlobal, promptRecall, origin: entry.origin_project ?? null, category: classifyOriginProject(entry.origin_project, currentProject) })
182
197
  : estimateTokens(entry.content);
183
198
  return {
199
+ local,
200
+ other,
184
201
  pinnedOnly,
185
202
  limit: opts.limit ?? Number.POSITIVE_INFINITY,
186
203
  includeRecent: opts.includeRecent ?? 0,
@@ -227,30 +244,25 @@ function openBlockBudget(plan, opts, budget) {
227
244
  }
228
245
  const NO_TASK_STATE = { snapshot: null, handoff: null, events: [] };
229
246
  // 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) {
247
+ async function loadRawTaskState(ctx, opts, plan) {
231
248
  if (!plan.hasLocalTaskState)
232
249
  return NO_TASK_STATE;
250
+ const { store, reads } = plan.local;
233
251
  // On a shared store task state is one person's, keyed by owner and project so it follows them into their next session.
234
252
  const key = plan.sharedStore
235
253
  ? { owner: ownerOrSubject(ctx.actor), project: projectNames(plan.currentProject) }
236
- : undefined;
254
+ : null;
255
+ const block = await store.continuity(ctx.tenantId, 5, key);
237
256
  // 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 };
257
+ const snapshot = freshActiveSnapshot(block.activeSnapshot, { sessionId: opts.currentSessionId });
258
+ // The block's handoff and events are the raw snapshot's session's, so they stand only while that snapshot is fresh.
259
+ if (snapshot?.session_id)
260
+ return { snapshot, handoff: block.sessionHandoff, events: block.recentSessionEvents };
261
+ // An unfinished handoff's scope is admitted in the read, so a newer denied row can't hide an older eligible one.
262
+ return { snapshot, handoff: await reads.unfinishedHandoff(ctx.tenantId, SNAPSHOT_AMBIENT_MAX_AGE_MS, key), events: [] };
251
263
  }
252
264
  // Sections print ahead of the memories, so they are paid first; one that does not fit is dropped, as an oversize entry is.
253
- function loadTaskSections(ctx, opts, plan, startLeft) {
265
+ async function loadTaskSections(ctx, opts, plan, startLeft) {
254
266
  const { exactScope, ownScope, cost } = plan;
255
267
  let left = startLeft;
256
268
  const pays = (tokens) => {
@@ -260,7 +272,7 @@ function loadTaskSections(ctx, opts, plan, startLeft) {
260
272
  return true;
261
273
  };
262
274
  const rowScope = (r) => r?.scope ?? null;
263
- const raw = loadRawTaskState(ctx, opts, plan);
275
+ const raw = await loadRawTaskState(ctx, opts, plan);
264
276
  // The same envelope rule ambientAdmitEntry applies to memory rows.
265
277
  const activeSnapshot = raw.snapshot && passesScopeFilterForRecall(rowScope(raw.snapshot), exactScope, ownScope) ? raw.snapshot : null;
266
278
  const sessionHandoff = raw.handoff && passesScopeFilterForRecall(rowScope(raw.handoff), exactScope, ownScope) ? raw.handoff : null;
@@ -296,7 +308,7 @@ function recentOrigins(plan) {
296
308
  return { names: projectNames(plan.currentProject).filter((n) => n !== ''), userGlobal: false };
297
309
  return plan.originProject && { names: plan.originProject, userGlobal: true };
298
310
  }
299
- function loadPools(ctx, plan, admission, recallRequest) {
311
+ async function loadPools(ctx, plan, admission, recallRequest) {
300
312
  const { obs, pinnedOnly, primaryIsGlobal, hasGlobal, exactScope, ownScope } = plan;
301
313
  const recent = { needed: plan.includeRecent, origins: recentOrigins(plan) };
302
314
  const searches = plan.query !== '*' && !pinnedOnly;
@@ -316,10 +328,10 @@ function loadPools(ctx, plan, admission, recallRequest) {
316
328
  };
317
329
  // Tenant-scoped loads: never resolveTenantId({}) here.
318
330
  const local = plan.hasLocal
319
- ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, recent, loadAdmit, window, recallRequest, qualityDrop(primaryIsGlobal))
331
+ ? await loadAmbientEntries(plan.local, ctx.tenantId, pinnedOnly, recent, loadAdmit, poolAdmit, window, recallRequest, qualityDrop(primaryIsGlobal))
320
332
  : { entries: [] };
321
333
  const global = hasGlobal && !primaryIsGlobal
322
- ? loadAmbientEntries(plan.globalRoot, ctx.tenantId, pinnedOnly, recent, loadAdmit, window, recallRequest, qualityDrop(true))
334
+ ? await loadAmbientEntries(sourceOf(sqliteStore(plan.globalRoot)), ctx.tenantId, pinnedOnly, recent, loadAdmit, poolAdmit, window, recallRequest, qualityDrop(true))
323
335
  : { entries: [] };
324
336
  return { local, global };
325
337
  }
@@ -345,31 +357,35 @@ function finalizeSelection(picked, plan) {
345
357
  obs?.selected(selected);
346
358
  return { items: selected, tokens };
347
359
  }
348
- // Never touches the index, so it cannot desync last_retrieval_ids from last_trace_id; fail-soft, never throws.
349
- function traceEmptyContext(ctx, opts, query) {
360
+ // Never touches the index, so it cannot desync last_retrieval_ids from last_trace_id.
361
+ async function traceEmptyContext(ctx, opts, plan) {
350
362
  // No snapshot in this branch, so the caller's own id is the only session to stamp.
351
- writeRecallTraceAtRoot(ctx.hippoRoot, {
363
+ const trace = {
352
364
  tenantId: ctx.tenantId,
353
365
  sessionId: opts.currentSessionId || null,
354
366
  pipeline: 'context',
355
- query,
367
+ query: plan.query,
356
368
  explainMode: false,
357
369
  results: [],
358
- });
370
+ };
371
+ if (!plan.other) {
372
+ writeRecallTraceAtRoot(ctx.hippoRoot, trace);
373
+ return;
374
+ }
375
+ // The empty reply is already decided, so a lost trace logs as the hippo.db write does rather than failing the call.
376
+ try {
377
+ await plan.other.finishRecall({ goalLog: [], audit: [], trace });
378
+ }
379
+ catch (error) {
380
+ log.error(`recall trace write failed: ${error instanceof Error ? error.message : String(error)}`);
381
+ }
359
382
  }
360
383
  /** Strengthens and marks the returned rows, then writes the trace and index together. */
361
- function recordRetrieval(ctx, opts, plan, selected, activeSnapshot) {
384
+ async function recordRetrieval(ctx, opts, plan, selected, activeSnapshot) {
362
385
  const toUpdate = selected.map((s) => s.entry);
363
386
  const updatedEntries = markRetrieved(toUpdate);
364
- const localIndex = loadIndex(ctx.hippoRoot);
365
387
  const retrievedIds = updatedEntries.map((u) => u.id);
366
- const strengthenedHere = strengthenRetrieved(ctx.hippoRoot, retrievedIds, { recallBoostAblated: isRecallBoostAblated() });
367
- if (plan.hasGlobal)
368
- strengthenRetrieved(plan.globalRoot, retrievedIds.filter((id) => !strengthenedHere.has(id)), { recallBoostAblated: isRecallBoostAblated() });
369
- localIndex.last_retrieval_ids = retrievedIds;
370
- // Trace first on its own short-lived connection, then fold its id into localIndex so one saveIndex moves
371
- // last_retrieval_ids and last_trace_id in lockstep; a failed trace write stores null, never a stale id.
372
- const traceId = writeRecallTraceAtRoot(ctx.hippoRoot, {
388
+ const trace = {
373
389
  tenantId: ctx.tenantId,
374
390
  sessionId: opts.currentSessionId || activeSnapshot?.session_id || null,
375
391
  pipeline: 'context',
@@ -379,10 +395,11 @@ function recordRetrieval(ctx, opts, plan, selected, activeSnapshot) {
379
395
  memoryId: s.entry.id,
380
396
  score: s.score,
381
397
  })),
382
- });
383
- localIndex.last_trace_id = traceId !== null ? String(traceId) : null;
384
- saveIndex(ctx.hippoRoot, localIndex);
385
- updateStats(ctx.hippoRoot, { recalled: selected.length });
398
+ };
399
+ if (plan.other)
400
+ await recordOnStore(ctx, plan.other, retrievedIds, trace, selected.length);
401
+ else
402
+ recordOnHippoDb(ctx, plan, retrievedIds, trace, selected.length);
386
403
  // Replace selectedItems entries with markRetrieved-updated copies so
387
404
  // the returned ContextResult reflects post-recall state.
388
405
  const items = selected.map((s) => ({
@@ -390,12 +407,36 @@ function recordRetrieval(ctx, opts, plan, selected, activeSnapshot) {
390
407
  entry: updatedEntries.find((u) => u.id === s.entry.id) ?? s.entry,
391
408
  }));
392
409
  // Read after strengthenRetrieved commits, so the rows just retrieved count at their new strength.
393
- return { items, ambientState: plan.config.ambient.enabled ? readAmbientState(ctx, plan) : undefined };
410
+ return { items, ambientState: plan.config.ambient.enabled ? await readAmbientState(ctx, plan) : undefined };
411
+ }
412
+ function recordOnHippoDb(ctx, plan, retrievedIds, trace, recalled) {
413
+ const localIndex = loadIndex(ctx.hippoRoot);
414
+ const strengthenedHere = strengthenRetrieved(ctx.hippoRoot, retrievedIds, { recallBoostAblated: isRecallBoostAblated() });
415
+ if (plan.hasGlobal)
416
+ strengthenRetrieved(plan.globalRoot, retrievedIds.filter((id) => !strengthenedHere.has(id)), { recallBoostAblated: isRecallBoostAblated() });
417
+ localIndex.last_retrieval_ids = retrievedIds;
418
+ // Trace first on its own short-lived connection, then fold its id into localIndex so one saveIndex moves
419
+ // last_retrieval_ids and last_trace_id in lockstep; a failed trace write stores null, never a stale id.
420
+ const traceId = writeRecallTraceAtRoot(ctx.hippoRoot, trace);
421
+ localIndex.last_trace_id = traceId !== null ? String(traceId) : null;
422
+ saveIndex(ctx.hippoRoot, localIndex);
423
+ updateStats(ctx.hippoRoot, { recalled });
424
+ }
425
+ // The index's last-recall ids feed only outcomeForLastRecall, which no other store serves, so they are not written here.
426
+ async function recordOnStore(ctx, store, retrievedIds, trace, recalled) {
427
+ const strengthen = { ids: retrievedIds, opts: { tenantId: ctx.tenantId, recallBoostAblated: isRecallBoostAblated() } };
428
+ await store.finishRecall({ goalLog: [], audit: [], trace, strengthen });
429
+ await store.bumpRecallStats(recalled);
394
430
  }
395
- function readAmbientState(ctx, plan) {
431
+ async function readAmbientState(ctx, plan) {
396
432
  const filter = { exactScope: plan.exactScope, ownScope: plan.ownScope, project: plan.originProject, currentProject: projectNames(plan.currentProject), now: evalNow() };
397
- const roots = [...(plan.hasLocal ? [ctx.hippoRoot] : []), ...(plan.hasGlobal && !plan.primaryIsGlobal ? [plan.globalRoot] : [])];
398
- const tallies = roots.map((root) => loadAmbientTallies(root, ctx.tenantId, filter));
433
+ const sources = [
434
+ ...(plan.hasLocal ? [plan.local.reads] : []),
435
+ ...(plan.hasGlobal && !plan.primaryIsGlobal ? [sourceOf(sqliteStore(plan.globalRoot)).reads] : []),
436
+ ];
437
+ const tallies = [];
438
+ for (const reads of sources)
439
+ tallies.push(await reads.ambientTallies(ctx.tenantId, filter));
399
440
  const total = tallies.length > 0 ? tallies.reduce(addAmbientTallies) : undefined;
400
441
  return total && total.total > 0 ? ambientStateFromTallies(total) : undefined;
401
442
  }
@@ -1,5 +1,5 @@
1
1
  import type { RejectedValueRow } from '../rejection.js';
2
- import type { Context } from './types.js';
2
+ import type { Context, StoreReply } from './types.js';
3
3
  /**
4
4
  * Delete a memory by id. `deleteEntry` threads ctx.actor.subject into its internal
5
5
  * audit hook, so exactly one 'forget' event lands with the supplied actor.
@@ -14,7 +14,7 @@ export interface ForgetResult {
14
14
  ok: true;
15
15
  id: string;
16
16
  }
17
- export declare function forget(ctx: Context, id: string): ForgetResult;
17
+ export declare function forget<C extends Context>(ctx: C, id: string): StoreReply<C, ForgetResult>;
18
18
  export interface RejectOpts {
19
19
  /** By-id form: reject the CURRENT content of an existing memory. */
20
20
  memoryId?: string;
@@ -4,9 +4,20 @@ 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 { requireGroup } from '../store-port.js';
7
8
  import { selectMemoryReach } from '../store/tenant-lookup.js';
8
9
  import { canTouchScope, personalScopeOf } from '../recall-scope.js';
9
10
  export function forget(ctx, id) {
11
+ const reply = ctx.store ? forgetThroughStore(ctx, ctx.store, id) : forgetOnHippoDb(ctx, id);
12
+ // SAFETY: a C typed with a store gets the promise its path returns; a wide C is typed as the union, which a caller has to await anyway.
13
+ return reply;
14
+ }
15
+ async function forgetThroughStore(ctx, store, id) {
16
+ const entryWrites = requireGroup(store, 'entryWrites');
17
+ await entryWrites.forget({ tenantId: ctx.tenantId, actor: ctx.actor.subject, ownScope: personalScopeOf(ctx.actor), id });
18
+ return { ok: true, id };
19
+ }
20
+ function forgetOnHippoDb(ctx, id) {
10
21
  const db = openHippoDb(ctx.hippoRoot);
11
22
  try {
12
23
  const reach = selectMemoryReach(db, id);
@@ -1,4 +1,4 @@
1
- import type { Context } from './types.js';
1
+ import type { Context, StoreReply } 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.
@@ -27,9 +27,9 @@ export interface OutcomeResult {
27
27
  applied: number;
28
28
  appliedIds: string[];
29
29
  }
30
- export declare function outcome(ctx: Context, ids: ReadonlyArray<string>, good: boolean, opts?: {
30
+ export declare function outcome<C extends Context>(ctx: C, ids: ReadonlyArray<string>, good: boolean, opts?: {
31
31
  traceId?: number;
32
- }): OutcomeResult;
32
+ }): StoreReply<C, OutcomeResult>;
33
33
  /**
34
34
  * Apply an outcome to the ids most recently returned by `recall()`.
35
35
  *
@@ -52,5 +52,5 @@ export interface OutcomeForLastRecallResult {
52
52
  applied: number;
53
53
  ids: string[];
54
54
  }
55
- export declare function outcomeForLastRecall(ctx: Context, good: boolean): OutcomeForLastRecallResult;
55
+ export declare function outcomeForLastRecall<C extends Context>(ctx: C, good: boolean): StoreReply<C, OutcomeForLastRecallResult>;
56
56
  //# sourceMappingURL=outcome.d.ts.map
@@ -4,11 +4,26 @@ import { openStore } from '../store/open.js';
4
4
  import { writeEntryOn } from '../store/entry-writes.js';
5
5
  import { selectEntriesByIds } from '../store/entry-reads.js';
6
6
  import { loadIndex } from '../store/index-and-stats.js';
7
- import { applyOutcome, CHURN_STALE_TAG } from '../memory.js';
7
+ import { entryAfterOutcome } 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
+ import { canTouchScope, personalScopeOf } from '../recall-scope.js';
11
+ import { SqliteBlockedError } from '../db/sqlite-blocked.js';
12
+ import { requireGroup } from '../store-port.js';
11
13
  export function outcome(ctx, ids, good, opts) {
14
+ const reply = ctx.store ? outcomeThroughStore(ctx, ctx.store, ids, good, opts) : outcomeOnHippoDb(ctx, ids, good, opts);
15
+ // SAFETY: a C typed with a store gets the promise its path returns; a wide C is typed as the union, which a caller has to await anyway.
16
+ return reply;
17
+ }
18
+ /** The rows and their audit rows commit together here; the trace link has no store method, since only the last-recall path sends one. */
19
+ async function outcomeThroughStore(ctx, store, ids, good, opts) {
20
+ const entryWrites = requireGroup(store, 'entryWrites');
21
+ if (opts?.traceId !== undefined)
22
+ throw new Error('an outcome links its recall trace on hippo.db only, never through a store');
23
+ const appliedIds = await entryWrites.applyOutcome({ tenantId: ctx.tenantId, actor: ctx.actor.subject, ownScope: personalScopeOf(ctx.actor), ids, good });
24
+ return { applied: appliedIds.length, appliedIds };
25
+ }
26
+ function outcomeOnHippoDb(ctx, ids, good, opts) {
12
27
  const appliedIds = [];
13
28
  const db = openStore(ctx.hippoRoot);
14
29
  try {
@@ -17,10 +32,7 @@ export function outcome(ctx, ids, good, opts) {
17
32
  const entry = live.get(id);
18
33
  if (!entry || !canTouchScope(ctx.actor, entry.scope ?? null))
19
34
  continue;
20
- let updated = applyOutcome(entry, good);
21
- if (good && updated.tags.includes(CHURN_STALE_TAG)) { // a good outcome reconfirms the entry
22
- updated = { ...updated, tags: updated.tags.filter((t) => t !== CHURN_STALE_TAG) };
23
- }
35
+ const updated = entryAfterOutcome(entry, good);
24
36
  writeEntryOn(db, ctx.hippoRoot, updated, { actor: ctx.actor.subject });
25
37
  live.set(id, updated); // a repeated id builds on its first outcome, as a fresh read would
26
38
  appendAuditEvent(db, {
@@ -50,6 +62,17 @@ export function outcome(ctx, ids, good, opts) {
50
62
  return { applied: appliedIds.length, appliedIds };
51
63
  }
52
64
  export function outcomeForLastRecall(ctx, good) {
65
+ const reply = ctx.store ? lastRecallUnderStore(ctx, ctx.store, good) : outcomeForLastRecallOnHippoDb({ ...ctx, store: undefined }, good);
66
+ // SAFETY: a C typed with a store gets the promise its path returns; a wide C is typed as the union, which a caller has to await anyway.
67
+ return reply;
68
+ }
69
+ /** The last recall's ids and trace sit in hippo.db's meta table, which only the CLI and context write, so no other store holds them. */
70
+ async function lastRecallUnderStore(ctx, store, good) {
71
+ if (store.kind !== 'sqlite')
72
+ throw new SqliteBlockedError(store.kind);
73
+ return outcomeForLastRecallOnHippoDb({ ...ctx, store: undefined }, good);
74
+ }
75
+ function outcomeForLastRecallOnHippoDb(ctx, good) {
53
76
  const idx = loadIndex(ctx.hippoRoot);
54
77
  const ids = idx.last_retrieval_ids;
55
78
  if (ids.length === 0)
@@ -1,5 +1,5 @@
1
1
  import { type DatabaseSyncLike } from '../db.js';
2
- import type { Context } from './types.js';
2
+ import type { Context, StoreReply } from './types.js';
3
3
  /**
4
4
  * Copy a local memory into the global store. Mirrors `cmdPromote` in cli.ts:
5
5
  * the `writeEntry` inside `promoteToGlobal` emits a 'remember' on the global
@@ -26,7 +26,7 @@ export interface SupersedeResult {
26
26
  oldId: string;
27
27
  newId: string;
28
28
  }
29
- export declare function supersede(ctx: Context, oldId: string, newContent: string): SupersedeResult;
29
+ export declare function supersede<C extends Context>(ctx: C, oldId: string, newContent: string): StoreReply<C, SupersedeResult>;
30
30
  /**
31
31
  * Archive a kind='raw' memory: snapshot into raw_archive, mark archived, delete.
32
32
  *
@@ -47,5 +47,5 @@ export interface ArchiveRawResult {
47
47
  ok: true;
48
48
  archivedAt: string;
49
49
  }
50
- export declare function archiveRaw(ctx: Context, id: string, reason: string, opts?: ArchiveRawOpts): ArchiveRawResult;
50
+ export declare function archiveRaw<C extends Context>(ctx: C, id: string, reason: string, opts?: ArchiveRawOpts): StoreReply<C, ArchiveRawResult>;
51
51
  //# sourceMappingURL=promote.d.ts.map