hippo-memory 1.68.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 (70) 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/context-select.d.ts +10 -0
  5. package/dist/api/context-select.js +16 -18
  6. package/dist/api/context.js +101 -60
  7. package/dist/api/forget.d.ts +2 -2
  8. package/dist/api/forget.js +11 -0
  9. package/dist/api/outcome.d.ts +4 -4
  10. package/dist/api/outcome.js +29 -6
  11. package/dist/api/promote.d.ts +3 -3
  12. package/dist/api/promote.js +45 -90
  13. package/dist/api/remember.d.ts +3 -2
  14. package/dist/api/remember.js +38 -11
  15. package/dist/capture/compact.js +21 -2
  16. package/dist/capture/copilot-transcript.d.ts +3 -0
  17. package/dist/capture/copilot-transcript.js +8 -1
  18. package/dist/capture/session-worker.d.ts +13 -0
  19. package/dist/capture/session-worker.js +194 -0
  20. package/dist/capture-contract.d.ts +3 -1
  21. package/dist/capture-contract.js +36 -0
  22. package/dist/cli/session-hooks.js +90 -22
  23. package/dist/cli/setup.js +70 -24
  24. package/dist/cli/usage.js +4 -1
  25. package/dist/cli.js +1 -1
  26. package/dist/connectors/github/ingest.js +2 -1
  27. package/dist/connectors/slack/deletion.js +2 -1
  28. package/dist/connectors/slack/ingest.js +2 -1
  29. package/dist/env.d.ts +2 -0
  30. package/dist/env.js +2 -0
  31. package/dist/hooks/copilot.d.ts +50 -8
  32. package/dist/hooks/copilot.js +116 -30
  33. package/dist/hooks/json-hooks.d.ts +2 -0
  34. package/dist/hooks/json-hooks.js +11 -2
  35. package/dist/hooks/shared.d.ts +7 -1
  36. package/dist/hooks/shared.js +18 -3
  37. package/dist/mcp/memory-tools.d.ts +2 -2
  38. package/dist/mcp/memory-tools.js +9 -8
  39. package/dist/mcp/request.js +3 -3
  40. package/dist/memory.d.ts +2 -0
  41. package/dist/memory.js +7 -0
  42. package/dist/prompt-recall.d.ts +5 -0
  43. package/dist/prompt-recall.js +13 -0
  44. package/dist/raw-archive.d.ts +2 -2
  45. package/dist/raw-archive.js +5 -3
  46. package/dist/recall-scope.d.ts +2 -5
  47. package/dist/recall-scope.js +5 -6
  48. package/dist/server/routes/memories.js +9 -6
  49. package/dist/server-detect.js +3 -2
  50. package/dist/server.d.ts +14 -1
  51. package/dist/server.js +16 -6
  52. package/dist/store/audit-event.d.ts +1 -1
  53. package/dist/store/audit-event.js +1 -1
  54. package/dist/store/entry-writes-group.d.ts +9 -0
  55. package/dist/store/entry-writes-group.js +157 -0
  56. package/dist/store/handoffs.d.ts +5 -2
  57. package/dist/store/handoffs.js +35 -5
  58. package/dist/store/open.d.ts +2 -0
  59. package/dist/store/open.js +10 -0
  60. package/dist/store/search-rows.js +3 -9
  61. package/dist/store/sessions.d.ts +5 -0
  62. package/dist/store/sessions.js +4 -1
  63. package/dist/store-port.d.ts +71 -0
  64. package/dist/store-port.js +23 -11
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  68. package/extensions/openclaw-plugin/package.json +1 -1
  69. package/openclaw.plugin.json +1 -1
  70. package/package.json +1 -1
package/README.md CHANGED
@@ -844,7 +844,7 @@ For Codex, it adds two hooks to `$CODEX_HOME/hooks.json` (else `~/.codex/hooks.j
844
844
 
845
845
  **Codex runs a new or changed hook only after you trust it, so open `/hooks` in Codex once and trust both;** `hippo doctor` reminds you. The per-prompt hook was checked against a real Codex request; the compaction hook follows Codex's documented `compact` start source and has not been watched end to end in Codex. Each hook also carries a `commandWindows` form (`hippo.cmd ...`), because Codex runs hooks through PowerShell on Windows, where the execution policy can block npm's `hippo.ps1`. hippo only ever appends these two entries and never rewrites one, since Codex treats a changed command as a new hook to trust. To remove: `hippo hook uninstall codex`, which takes out only hippo's exact commands and leaves every other hook, including one of yours that runs hippo.
846
846
 
847
- For GitHub Copilot, once `$COPILOT_HOME` (else `~/.copilot`) exists, `hippo setup` writes hippo's own `hooks/hippo.json`, adds a `hippo` server to `mcp-config.json` and puts a block in `copilot-instructions.md` that asks the agent to call `hippo_recall` and `hippo_remember`. The Copilot CLI, including its sessions inside VS Code, uses all four hooks, the server and the block. VS Code's Local agent runs only the session-start hook and reads neither of the other files, so it gets no pre-compact snapshot or end-of-session capture yet, and its recall tools need hippo added to VS Code's own `mcp.json`. The hooks, that `mcp.json` recipe and how to remove it all: [integrations/copilot.md](integrations/copilot.md).
847
+ For GitHub Copilot, when `$COPILOT_HOME` (else `~/.copilot`) or a VS Code User folder exists, `hippo setup` writes hippo's own `hooks/hippo.json`. When the Copilot folder was there before setup, it also adds a `hippo` server to `mcp-config.json` and a block to `copilot-instructions.md`. The block asks the agent to call `hippo_recall` and `hippo_remember`. The Copilot CLI, including its sessions inside VS Code, uses the session-start, tool-failure, pre-compact and session-end hooks, the server and the block. VS Code's agent mode (the Local agent, VS Code 1.109.3 or later) reads the same hooks file. It gets session start, the pre-compact snapshot and a capture after every reply. Setup also adds hippo to VS Code's own `mcp.json` and writes `prompts/hippo.instructions.md`, so its recall tools and instructions work too. The hooks, the VS Code files and how to remove them: [integrations/copilot.md](integrations/copilot.md).
848
848
 
849
849
  ### What the hook adds (Claude Code example)
850
850
 
@@ -1,3 +1,5 @@
1
- import type { Adapter } from './types.js';
1
+ import type { Adapter, AdapterContext } from './types.js';
2
+ /** VS Code's user-data resolution: portable install first, then the app-data override, then the platform default. */
3
+ export declare function vscodeDataFolders(ctx: Pick<AdapterContext, 'env' | 'home' | 'platform'>): string[];
2
4
  export declare const copilotAdapter: Adapter;
3
5
  //# sourceMappingURL=copilot.d.ts.map
@@ -7,7 +7,7 @@ import { gitLayout } from './git.js';
7
7
  const PRODUCTS = ['Code', 'Code - Insiders'];
8
8
  const MEMORY_TOOL = ['github.copilot-chat', 'memory-tool', 'memories'];
9
9
  /** VS Code's user-data resolution: portable install first, then the app-data override, then the platform default. */
10
- function dataFolders(ctx) {
10
+ export function vscodeDataFolders(ctx) {
11
11
  const { env, home, platform } = ctx;
12
12
  if (env.VSCODE_PORTABLE)
13
13
  return [path.join(env.VSCODE_PORTABLE, 'user-data')];
@@ -103,7 +103,7 @@ function projectContainers(data, ctx, matches, warnings) {
103
103
  export const copilotAdapter = {
104
104
  tool: 'copilot',
105
105
  list(ctx, scope) {
106
- const data = dataFolders(ctx);
106
+ const data = vscodeDataFolders(ctx);
107
107
  const warnings = [];
108
108
  const found = [];
109
109
  if (scope === 'user') {
@@ -1,12 +1,22 @@
1
1
  import type { AmbientLoadResult } from '../store/candidates.js';
2
2
  import { type MemoryEntry } from '../memory.js';
3
+ import type { ContextReads, HippoStore } from '../store-port.js';
3
4
  import { type HippoConfig } from '../config.js';
4
5
  import type { ProjectRef } from '../project-identity.js';
5
6
  import type { DeliveryObserver } from '../delivery-recorder.js';
6
7
  import type { ContextCost, ContextOpts, ContextResultEntry } from './context-types.js';
7
8
  import type { Context } from './types.js';
9
+ /** One store getContext reads rows from: its base search and its context reads. */
10
+ export interface ContextSource {
11
+ readonly store: HippoStore;
12
+ readonly reads: ContextReads;
13
+ }
8
14
  /** What getContext resolved from opts and config before it read a row. */
9
15
  export interface ContextPlan {
16
+ /** The served store, else hippo.db under the root. */
17
+ local: ContextSource;
18
+ /** The served store when it is not hippo.db; then every read and write goes through it and no hippo.db opens. */
19
+ other: HippoStore | undefined;
10
20
  pinnedOnly: boolean;
11
21
  limit: number;
12
22
  includeRecent: number;
@@ -241,7 +241,7 @@ export async function selectBySearch(ctx, plan, left, pools, admission) {
241
241
  const results = plan.hasGlobal && !plan.primaryIsGlobal
242
242
  ? await searchBothStores(ctx, plan, left, minResults, pools, admission.bothStoresAdmit)
243
243
  : await searchLocalRows(ctx, plan, left, minResults, pools.local.entries, admission.admit);
244
- auditContextRecall(ctx, plan, results.length);
244
+ await auditContextRecall(ctx, plan, results.length);
245
245
  return results;
246
246
  }
247
247
  // The pools were admitted at load, before ranking, dedupe and budget: a post-filter would let an excluded row fill the
@@ -273,6 +273,7 @@ async function searchLocalRows(ctx, plan, left, minResults, localEntries, admit)
273
273
  const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
274
274
  const localCost = cost && ((r) => price(r.entry, primaryIsGlobal));
275
275
  const vectorCandidates = contextVectorSpec(ctx, plan, admit);
276
+ const store = plan.local.store;
276
277
  const ctxResults = usePhysicsCtx
277
278
  ? await physicsSearch(query, localEntries, {
278
279
  budget: left,
@@ -282,6 +283,7 @@ async function searchLocalRows(ctx, plan, left, minResults, localEntries, admit)
282
283
  physicsConfig: ctxConfig.physics,
283
284
  scope: plan.activeScope,
284
285
  vectorCandidates,
286
+ store,
285
287
  })
286
288
  : await hybridSearch(query, localEntries, {
287
289
  budget: left,
@@ -290,6 +292,7 @@ async function searchLocalRows(ctx, plan, left, minResults, localEntries, admit)
290
292
  hippoRoot: ctx.hippoRoot,
291
293
  scope: plan.activeScope,
292
294
  vectorCandidates,
295
+ store,
293
296
  });
294
297
  return ctxResults.map((r) => ({
295
298
  entry: r.entry,
@@ -299,21 +302,21 @@ async function searchLocalRows(ctx, plan, left, minResults, localEntries, admit)
299
302
  }));
300
303
  }
301
304
  // Same 'recall' op api.recall emits; the pinned-only and no-query branches never search, so they never emit.
302
- function auditContextRecall(ctx, plan, resultCount) {
303
- const ctxRecallMetadata = {
304
- ...auditQueryFields(plan.query),
305
- results: resultCount,
306
- mode: 'context',
305
+ async function auditContextRecall(ctx, plan, resultCount) {
306
+ const row = {
307
+ tenantId: ctx.tenantId,
308
+ actor: ctx.actor.subject,
309
+ op: 'recall',
310
+ metadata: { ...auditQueryFields(plan.query), results: resultCount, mode: 'context' },
307
311
  };
312
+ if (plan.other) {
313
+ await plan.other.appendAuditEvents([row]);
314
+ return;
315
+ }
308
316
  if (plan.hasLocal) {
309
317
  const localDb = openHippoDb(ctx.hippoRoot);
310
318
  try {
311
- appendAuditEvent(localDb, {
312
- tenantId: ctx.tenantId,
313
- actor: ctx.actor.subject,
314
- op: 'recall',
315
- metadata: ctxRecallMetadata,
316
- });
319
+ appendAuditEvent(localDb, row);
317
320
  }
318
321
  finally {
319
322
  closeHippoDb(localDb);
@@ -322,12 +325,7 @@ function auditContextRecall(ctx, plan, resultCount) {
322
325
  if (plan.hasGlobal && !plan.primaryIsGlobal) {
323
326
  const globalDb = openHippoDb(plan.globalRoot);
324
327
  try {
325
- appendAuditEvent(globalDb, {
326
- tenantId: ctx.tenantId,
327
- actor: ctx.actor.subject,
328
- op: 'recall',
329
- metadata: ctxRecallMetadata,
330
- });
328
+ appendAuditEvent(globalDb, row);
331
329
  }
332
330
  finally {
333
331
  closeHippoDb(globalDb);
@@ -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