hippo-memory 1.61.0 → 1.62.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 (57) hide show
  1. package/README.md +28 -53
  2. package/dist/agent-memories/apply.d.ts +1 -1
  3. package/dist/agent-memories/claude-code.d.ts +3 -1
  4. package/dist/agent-memories/claude-code.js +53 -9
  5. package/dist/agent-memories/sync.d.ts +3 -3
  6. package/dist/agent-memories/sync.js +14 -6
  7. package/dist/agent-memories/types.d.ts +0 -2
  8. package/dist/api/assemble.js +55 -54
  9. package/dist/api/context-select.d.ts +49 -0
  10. package/dist/api/context-select.js +344 -0
  11. package/dist/api/context.d.ts +2 -2
  12. package/dist/api/context.js +195 -522
  13. package/dist/api/drill-down.js +36 -33
  14. package/dist/api/promote.js +55 -66
  15. package/dist/api/recall.js +303 -438
  16. package/dist/api/sleep.js +203 -218
  17. package/dist/capture/compact.d.ts +1 -1
  18. package/dist/capture/compact.js +2 -2
  19. package/dist/cli/briefs.js +324 -306
  20. package/dist/cli/context.js +44 -34
  21. package/dist/cli/continuity.js +283 -271
  22. package/dist/cli/curate.js +35 -34
  23. package/dist/cli/decisions.js +333 -333
  24. package/dist/cli/explain.js +66 -60
  25. package/dist/cli/maintenance.js +62 -51
  26. package/dist/cli/playbooks.js +387 -370
  27. package/dist/cli/projects.js +8 -5
  28. package/dist/cli/recall.js +28 -43
  29. package/dist/cli/remember.js +113 -70
  30. package/dist/cli/session-hooks.js +100 -90
  31. package/dist/cli/setup.js +267 -246
  32. package/dist/cli/status.js +73 -64
  33. package/dist/cli/transfer.js +85 -99
  34. package/dist/compaction-record.d.ts +0 -2
  35. package/dist/compaction-record.js +1 -1
  36. package/dist/customer-notes.js +77 -68
  37. package/dist/dag.js +222 -186
  38. package/dist/decisions.js +93 -76
  39. package/dist/doctor.js +11 -7
  40. package/dist/goals.js +99 -86
  41. package/dist/incidents.js +45 -38
  42. package/dist/policies.js +85 -68
  43. package/dist/processes.js +87 -71
  44. package/dist/project-briefs.js +135 -108
  45. package/dist/project-merge.d.ts +12 -4
  46. package/dist/project-merge.js +130 -45
  47. package/dist/shared.d.ts +9 -0
  48. package/dist/shared.js +10 -8
  49. package/dist/skills.js +81 -65
  50. package/dist/store/search-rows.d.ts +2 -2
  51. package/dist/store/search-rows.js +15 -9
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  55. package/extensions/openclaw-plugin/package.json +1 -1
  56. package/openclaw.plugin.json +1 -1
  57. package/package.json +1 -1
@@ -1,31 +1,29 @@
1
1
  // Ambient context injection: the admission policy and getContext.
2
- import { openHippoDb, closeHippoDb } from '../db.js';
3
2
  import { isInitialized } from '../store/open.js';
4
3
  import { strengthenRetrieved } from '../store/entry-writes.js';
5
- import { loadRecallSearchEntries, recallScopeFilter } from '../store/search-rows.js';
4
+ import { loadRecallSearchEntries } from '../store/search-rows.js';
5
+ import { DEFAULT_SEARCH_CANDIDATE_LIMIT } from '../store/rows.js';
6
6
  import { loadAmbientCandidates, loadContextCandidates, } from '../store/candidates.js';
7
7
  import { loadIndex, saveIndex, updateStats } from '../store/index-and-stats.js';
8
8
  import { loadFreshActiveTaskSnapshot, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS } from '../store/sessions.js';
9
9
  import { loadLatestHandoff } from '../store/handoffs.js';
10
10
  import { estimateTokens } from '../token-ledger.js';
11
- import { calculateStrength, markRetrieved, COMPACTION_MEMORY_TAG } from '../memory.js';
12
- import { appendAuditEvent, auditQueryFields, isContentWorthStoring } from '../audit.js';
13
- import { getGlobalRoot, searchBothHybrid } from '../shared.js';
11
+ import { markRetrieved, COMPACTION_MEMORY_TAG } from '../memory.js';
12
+ import { isContentWorthStoring } from '../audit.js';
13
+ import { getGlobalRoot } from '../shared.js';
14
14
  import { writeRecallTraceAtRoot } from '../recall-trace.js';
15
15
  import { evalNow, isRecallBoostAblated } from '../ablation.js';
16
- import { hybridSearch } from '../search/hybrid.js';
17
- import { physicsSearch } from '../search/physics-search.js';
18
- import { compareScoredResults } from '../compare.js';
19
16
  import { dropHeldCopies } from '../same-text.js';
20
- import { scopeMatch } from '../scope.js';
21
17
  import { loadConfig } from '../config.js';
22
18
  import { resolveProjectIdentity, classifyOriginProject, isGlobalStoreRoot } from '../project-identity.js';
23
- import { promptTokens, contentTokens, gatePromptRecall, } from '../prompt-recall.js';
19
+ import { promptTokens } from '../prompt-recall.js';
24
20
  import { detectSecret } from '../secret-detect.js';
25
21
  import { isSessionDigestRow } from '../session-digest.js';
26
22
  import { addAmbientTallies, ambientStateFromTallies } from '../ambient.js';
27
23
  import { loadAmbientTallies } from '../ambient-store.js';
28
24
  import { passesScopeFilterForRecall, assertScopeRequestAllowed } from '../recall-scope.js';
25
+ import { finiteOr, selectBySearch, selectPinned, selectStrongest, } from './context-select.js';
26
+ export { oneCopyPerMemory } from './context-select.js';
29
27
  /**
30
28
  * v39: the single ambient-injection admission policy, shared by getContext
31
29
  * and the CLI-side ambient-state summary so the two cannot drift.
@@ -67,7 +65,7 @@ export const CONTEXT_CANDIDATE_CAP = 2000;
67
65
  function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit, window, recall, onQualityDrop) {
68
66
  if (!pinnedOnly) {
69
67
  const rows = 'query' in window
70
- ? loadRecallSearchEntries(hippoRoot, window.query, CONTEXT_CANDIDATE_CAP, tenantId, window.exactScope, 'exact', false)
68
+ ? loadRecallSearchEntries(hippoRoot, window.query, DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, window.exactScope, 'exact', false, window.project)
71
69
  : loadContextCandidates(hippoRoot, tenantId, window);
72
70
  return { entries: rows.filter(admit) };
73
71
  }
@@ -83,27 +81,6 @@ function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admi
83
81
  };
84
82
  return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient, recall);
85
83
  }
86
- // Share and promote copy a memory to the global store under a new id, so equal content is the only link.
87
- // A pinned copy wins, then the stronger one after the ranking's own global discount; a tie keeps the local copy.
88
- export function oneCopyPerMemory(local, global, now) {
89
- const score = (e, isGlobal) => calculateStrength(e, now) * (isGlobal ? 1 / 1.2 : 1);
90
- const best = new Map();
91
- const offer = (entry, isGlobal) => {
92
- const held = best.get(entry.content);
93
- const wins = !held || (held.entry.pinned !== entry.pinned
94
- ? entry.pinned
95
- : score(entry, isGlobal) > score(held.entry, held.isGlobal));
96
- if (wins)
97
- best.set(entry.content, { entry, isGlobal });
98
- };
99
- for (const e of local)
100
- offer(e, false);
101
- for (const e of global)
102
- offer(e, true);
103
- const kept = new Set([...best.values()].map((b) => b.entry));
104
- return [local.filter((e) => kept.has(e)), global.filter((e) => kept.has(e))];
105
- }
106
- const finiteOr = (v, dflt, min) => Number.isFinite(v) && v >= min ? v : dflt;
107
84
  /**
108
85
  * Assemble a context bundle: recalled memories (pinned-only / strength-sorted
109
86
  * fallback / hybrid search) + active task snapshot + session handoff + recent
@@ -117,77 +94,134 @@ const finiteOr = (v, dflt, min) => Number.isFinite(v) && v >= min ? v : dflt;
117
94
  * Tenant scope: all `loadAllEntries` / snapshot / handoff / events reads use
118
95
  * `ctx.tenantId`. Cross-tenant rows are filtered out.
119
96
  *
120
- * Returns an empty result (`entries: []`, snapshot/handoff/events undefined)
97
+ * @returns An empty result (`entries: []`, snapshot/handoff/events undefined)
121
98
  * when there's nothing to surface (no memories AND no snapshot AND no handoff
122
99
  * AND no recent events).
123
100
  */
124
101
  export async function getContext(ctx, opts = {}) {
125
- const pinnedOnly = opts.pinnedOnly === true;
126
102
  const budget = opts.budget ?? 1500;
127
- const limit = opts.limit ?? Number.POSITIVE_INFINITY;
128
- const includeRecent = opts.includeRecent ?? 0;
129
- const activeScope = opts.scope ?? '';
130
103
  assertScopeRequestAllowed(ctx.actor, opts.exactScope);
131
- const exactScope = opts.exactScope || undefined;
132
104
  if (budget <= 0) {
133
105
  return { entries: [], tokens: 0 };
134
106
  }
107
+ const plan = planContext(ctx, opts);
108
+ const sections = loadTaskSections(ctx, opts, plan, openBlockBudget(plan, opts, budget));
109
+ const admission = ambientAdmission(opts, plan, sections.shownHandoff);
110
+ const pools = loadPools(ctx, plan, admission, promptRecallRequest(opts, plan));
111
+ const noSections = !sections.shownSnapshot && !sections.shownHandoff && sections.shownEvents.length === 0;
112
+ if (!plan.promptRecallPending && pools.local.entries.length === 0 && pools.global.entries.length === 0 && noSections) {
113
+ return { entries: [], tokens: 0 };
114
+ }
115
+ const picked = plan.pinnedOnly
116
+ ? selectPinned(ctx, opts, plan, sections.left, pools, admission)
117
+ : plan.query === '*'
118
+ ? selectStrongest(plan, sections.left, pools)
119
+ : await selectBySearch(ctx, plan, sections.left, pools, admission);
120
+ if (picked === null) {
121
+ return { entries: [], tokens: 0 };
122
+ }
123
+ const { items, tokens } = finalizeSelection(picked, plan);
124
+ if (items.length === 0 && noSections) {
125
+ // A query that found nothing is the coverage-gap signal, so it still gets a trace; pinned-only stays read-only.
126
+ if (!plan.pinnedOnly)
127
+ traceEmptyContext(ctx, opts, plan.query);
128
+ return { entries: [], tokens: 0 };
129
+ }
130
+ // pinnedOnly is the UserPromptSubmit hot path — read-only so pinned
131
+ // memories don't inflate retrieval_count or extend half_life every turn.
132
+ const recorded = plan.pinnedOnly
133
+ ? { items, ambientState: undefined }
134
+ : recordRetrieval(ctx, opts, plan, items, sections.activeSnapshot);
135
+ return {
136
+ entries: recorded.items,
137
+ tokens,
138
+ activeSnapshot: sections.shownSnapshot ?? undefined,
139
+ sessionHandoff: sections.shownHandoff ?? undefined,
140
+ recentEvents: sections.shownEvents.length > 0 ? sections.shownEvents : undefined,
141
+ ambientState: recorded.ambientState,
142
+ };
143
+ }
144
+ function planContext(ctx, opts) {
145
+ const pinnedOnly = opts.pinnedOnly === true;
135
146
  // Global memories do not establish a project boundary for task state.
136
147
  const hasLocal = isInitialized(ctx.hippoRoot);
137
148
  const query = (opts.q ?? '').trim() || '*';
138
149
  const globalRoot = getGlobalRoot();
139
150
  const hasGlobal = isInitialized(globalRoot);
140
151
  const primaryIsGlobal = isGlobalStoreRoot(ctx.hippoRoot);
141
- const hasLocalTaskState = hasLocal && !primaryIsGlobal;
142
- // v39 memory scope isolation (docs/plans/2026-07-01-memory-scope-isolation.md).
143
- // S2: envelope-filter parity with api.recall; opts.scope is only the tag boost, opts.exactScope the envelope request.
144
- // S3: origin partition - other-project memories are excluded unless the
145
- // caller explicitly asks for them (crossProject) or isolation is disabled.
152
+ // opts.scope is only the tag boost, opts.exactScope the envelope request; other-project memories are
153
+ // excluded unless the caller asks for them (crossProject) or isolation is disabled.
146
154
  const config = loadConfig(ctx.hippoRoot);
147
155
  const isolationEnabled = config.contextProjectIsolation !== false;
148
156
  const currentProjectName = opts.currentProject ?? resolveProjectIdentity(process.cwd()).name;
149
157
  const includeCrossProject = opts.crossProject === true || !isolationEnabled;
150
- // Z1: decided before the ambient loads so the FTS candidate query below (pinned-only
151
- // branch) can piggyback on that connection instead of opening its own.
158
+ // Decided before the ambient loads so the pinned-only FTS candidate query can share their connection.
152
159
  const promptRecallPending = pinnedOnly && Boolean(opts.prompt?.trim()) && config.pinnedInject.promptRecall === true;
153
- const promptRecallTerms = promptRecallPending && config.pinnedInject.enabled
154
- ? Array.from(promptTokens(opts.prompt ?? ''))
155
- : [];
156
- const recallRequest = promptRecallTerms.length > 0
157
- ? { terms: promptRecallTerms, limit: Math.floor(finiteOr(config.pinnedInject.promptRecallCandidates, 100, 1)) }
158
- : undefined;
159
160
  const cost = opts.cost;
160
161
  const price = (entry, isGlobal, promptRecall) => cost
161
162
  ? cost.entry({ entry, isGlobal, promptRecall, origin: entry.origin_project ?? null, category: classifyOriginProject(entry.origin_project, currentProjectName) })
162
163
  : estimateTokens(entry.content);
164
+ return {
165
+ pinnedOnly,
166
+ limit: opts.limit ?? Number.POSITIVE_INFINITY,
167
+ includeRecent: opts.includeRecent ?? 0,
168
+ activeScope: opts.scope ?? '',
169
+ exactScope: opts.exactScope || undefined,
170
+ query,
171
+ hasLocal,
172
+ hasGlobal,
173
+ globalRoot,
174
+ primaryIsGlobal,
175
+ hasLocalTaskState: hasLocal && !primaryIsGlobal,
176
+ config,
177
+ currentProjectName,
178
+ includeCrossProject,
179
+ originProject: includeCrossProject || currentProjectName === '' ? undefined : currentProjectName,
180
+ promptRecallPending,
181
+ cost,
182
+ price,
183
+ obs: opts.deliveryObserver,
184
+ };
185
+ }
186
+ /** The prompt-recall candidate request the ambient loads carry, when the prompt has any terms. */
187
+ function promptRecallRequest(opts, plan) {
188
+ const { pinnedInject } = plan.config;
189
+ const promptRecallTerms = plan.promptRecallPending && pinnedInject.enabled
190
+ ? Array.from(promptTokens(opts.prompt ?? ''))
191
+ : [];
192
+ return promptRecallTerms.length > 0
193
+ ? { terms: promptRecallTerms, limit: Math.floor(finiteOr(pinnedInject.promptRecallCandidates, 100, 1)) }
194
+ : undefined;
195
+ }
196
+ /** Reports the block's facts to the observer and returns the budget left after the fixed headers. */
197
+ function openBlockBudget(plan, opts, budget) {
198
+ const { config, cost, obs, pinnedOnly, promptRecallPending } = plan;
163
199
  const blockBudget = pinnedOnly && opts.budget === undefined ? config.pinnedInject.budget : budget;
164
- const obs = opts.deliveryObserver;
165
- obs?.facts({ projectName: currentProjectName, budgetTokens: blockBudget, promptRecall: promptRecallPending });
200
+ obs?.facts({ projectName: plan.currentProjectName, budgetTokens: blockBudget, promptRecall: promptRecallPending });
166
201
  if (pinnedOnly && !config.pinnedInject.enabled)
167
202
  obs?.disabled();
168
- let left = cost
169
- ? Math.max(0, blockBudget - cost.fixed(blockBudget, { cross: includeCrossProject, promptRecall: promptRecallPending, ambient: !pinnedOnly && config.ambient.enabled }))
203
+ return cost
204
+ ? Math.max(0, blockBudget - cost.fixed(blockBudget, { cross: plan.includeCrossProject, promptRecall: promptRecallPending, ambient: !pinnedOnly && config.ambient.enabled }))
170
205
  : blockBudget;
171
- // Sections print ahead of the memories, so they are paid first; one that does not fit is dropped, as an oversize entry is.
206
+ }
207
+ // Sections print ahead of the memories, so they are paid first; one that does not fit is dropped, as an oversize entry is.
208
+ function loadTaskSections(ctx, opts, plan, startLeft) {
209
+ const { exactScope, hasLocalTaskState, cost } = plan;
210
+ let left = startLeft;
172
211
  const pays = (tokens) => {
173
212
  if (tokens > left)
174
213
  return false;
175
214
  left -= tokens;
176
215
  return true;
177
216
  };
178
- // DF1 T2: bounded read — an orphaned snapshot (no later pre-compact
179
- // superseded it, no session-end closed it) must age out of this ambient
180
- // surface instead of injecting into every future prompt forever. Owner
181
- // reads (opts.currentSessionId matches the snapshot's session_id) stay
182
- // unbounded; see loadFreshActiveTaskSnapshot's own doc comment for the
183
- // exact null/empty-id matching rules.
217
+ // Bounded read: an orphaned snapshot ages out of this ambient surface; the owner session's read stays unbounded.
184
218
  const rowScope = (r) => r?.scope ?? null;
185
219
  const rawActiveSnapshot = hasLocalTaskState
186
220
  ? loadFreshActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId, {
187
221
  sessionId: opts.currentSessionId,
188
222
  })
189
223
  : null;
190
- // W1: the same envelope rule ambientAdmitEntry applies to memory rows.
224
+ // The same envelope rule ambientAdmitEntry applies to memory rows.
191
225
  const activeSnapshot = rawActiveSnapshot && passesScopeFilterForRecall(rowScope(rawActiveSnapshot), exactScope)
192
226
  ? rawActiveSnapshot
193
227
  : null;
@@ -199,7 +233,7 @@ export async function getContext(ctx, opts = {}) {
199
233
  : loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, undefined, {
200
234
  unfinishedOnly: true,
201
235
  maxAgeMs: SNAPSHOT_AMBIENT_MAX_AGE_MS,
202
- // codex P2: admit scope in SQL so a newer denied row can't hide an older eligible one before LIMIT 1.
236
+ // Scope is admitted in SQL so a newer denied row can't hide an older eligible one before LIMIT 1.
203
237
  scopeFilter: 'default-deny',
204
238
  });
205
239
  const sessionHandoff = rawSessionHandoff && passesScopeFilterForRecall(rowScope(rawSessionHandoff), exactScope)
@@ -215,7 +249,10 @@ export async function getContext(ctx, opts = {}) {
215
249
  const shownSnapshot = activeSnapshot && (!cost || pays(cost.snapshot(activeSnapshot))) ? activeSnapshot : null;
216
250
  const shownHandoff = sessionHandoff && (!cost || pays(cost.handoff(sessionHandoff))) ? sessionHandoff : null;
217
251
  const shownEvents = recentSessionEvents.length > 0 && (!cost || pays(cost.trail(recentSessionEvents))) ? recentSessionEvents : [];
218
- obs?.sections(Number(shownSnapshot !== null) + Number(shownHandoff !== null) + Number(shownEvents.length > 0), Number(activeSnapshot !== shownSnapshot) + Number(sessionHandoff !== shownHandoff) + Number(recentSessionEvents.length !== shownEvents.length));
252
+ plan.obs?.sections(Number(shownSnapshot !== null) + Number(shownHandoff !== null) + Number(shownEvents.length > 0), Number(activeSnapshot !== shownSnapshot) + Number(sessionHandoff !== shownHandoff) + Number(recentSessionEvents.length !== shownEvents.length));
253
+ return { activeSnapshot, shownSnapshot, shownHandoff, shownEvents, left };
254
+ }
255
+ function ambientAdmission(opts, plan, shownHandoff) {
219
256
  const transcriptHandoffSession = shownHandoff?.evidence?.derivedFrom === 'transcript' ? shownHandoff.sessionId : null;
220
257
  let digestHiddenForHandoff = false;
221
258
  const ambientAdmit = (e) => {
@@ -224,7 +261,7 @@ export async function getContext(ctx, opts = {}) {
224
261
  digestHiddenForHandoff = true;
225
262
  return false;
226
263
  }
227
- return ambientAdmitEntry(e, currentProjectName, includeCrossProject, exactScope);
264
+ return ambientAdmitEntry(e, plan.currentProjectName, plan.includeCrossProject, plan.exactScope);
228
265
  };
229
266
  const ownSessionId = opts.currentSessionId || '';
230
267
  // Inside admit, not after the load, so the loader's window widens past a session's own items.
@@ -233,473 +270,109 @@ export async function getContext(ctx, opts = {}) {
233
270
  e.tags.includes(COMPACTION_MEMORY_TAG);
234
271
  // Superseded rows never inject; which rows reach ambientAdmitEntry matters because it regex-scans content for secrets.
235
272
  const admit = (e) => !e.superseded_by && !isOwnCompactionItem(e) && ambientAdmit(e);
236
- const loadAdmit = obs ? obs.watchAdmit(admit) : admit;
237
- const qualityDrop = (isGlobal) => obs && !promptRecallPending ? (e) => obs.qualityDropped(e, isGlobal) : undefined;
273
+ // The two-store search has always ranked a session's own compaction items; only the local search drops them.
274
+ const bothStoresAdmit = (e) => !e.superseded_by && ambientAdmit(e);
275
+ return { ambientAdmit, admit, bothStoresAdmit, digestHidden: () => digestHiddenForHandoff };
276
+ }
277
+ function loadPools(ctx, plan, admission, recallRequest) {
278
+ const { obs, pinnedOnly, primaryIsGlobal, hasGlobal, exactScope } = plan;
279
+ const searches = plan.query !== '*' && !pinnedOnly;
280
+ const searchesBoth = searches && hasGlobal && !primaryIsGlobal;
281
+ const poolAdmit = searchesBoth ? admission.bothStoresAdmit : admission.admit;
282
+ const loadAdmit = obs ? obs.watchAdmit(poolAdmit) : poolAdmit;
283
+ const qualityDrop = (isGlobal) => obs && !plan.promptRecallPending ? (e) => obs.qualityDropped(e, isGlobal) : undefined;
238
284
  // The window's predicates are ones admit applies anyway, so below the cap the admitted rows are unchanged.
239
- const searchesLocalRows = query !== '*' && !(hasGlobal && !primaryIsGlobal);
240
- const originProject = includeCrossProject || currentProjectName === '' ? undefined : currentProjectName;
241
- const window = searchesLocalRows && !pinnedOnly
242
- ? { query, exactScope }
285
+ const window = searches
286
+ ? { query: plan.query, exactScope, project: plan.originProject }
243
287
  : {
244
288
  exactScope,
245
- project: originProject,
289
+ project: plan.originProject,
246
290
  cap: CONTEXT_CANDIDATE_CAP,
247
291
  now: evalNow(),
248
292
  };
249
- // Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
250
- const localLoad = hasLocal
251
- ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, window, recallRequest, qualityDrop(primaryIsGlobal))
293
+ // Tenant-scoped loads: never resolveTenantId({}) here.
294
+ const local = plan.hasLocal
295
+ ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, plan.includeRecent, loadAdmit, window, recallRequest, qualityDrop(primaryIsGlobal))
252
296
  : { entries: [] };
253
- const globalLoad = hasGlobal && !primaryIsGlobal
254
- ? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, window, recallRequest, qualityDrop(true))
297
+ const global = hasGlobal && !primaryIsGlobal
298
+ ? loadAmbientEntries(plan.globalRoot, ctx.tenantId, pinnedOnly, plan.includeRecent, loadAdmit, window, recallRequest, qualityDrop(true))
255
299
  : { entries: [] };
256
- let localEntries = localLoad.entries;
257
- let globalEntries = globalLoad.entries;
258
- // Computed after markRetrieved runs, so avgStrength reflects post-retrieval strengths.
259
- let ambientState;
260
- if (!promptRecallPending &&
261
- localEntries.length === 0 &&
262
- globalEntries.length === 0 &&
263
- !shownSnapshot &&
264
- !shownHandoff &&
265
- shownEvents.length === 0) {
266
- return { entries: [], tokens: 0 };
267
- }
268
- let selectedItems = [];
269
- let totalTokens = 0;
270
- if (pinnedOnly) {
271
- // loadConfig is safe even when local isn't initialised — returns defaults.
272
- const pinnedCfg = loadConfig(ctx.hippoRoot);
273
- if (!pinnedCfg.pinnedInject.enabled) {
274
- return { entries: [], tokens: 0 };
275
- }
276
- // Effective budget: explicit opts.budget wins over config, less what the sections took.
277
- const effBudget = left;
278
- const nowP = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
279
- obs?.offer(localEntries, primaryIsGlobal);
280
- obs?.offer(globalEntries, true);
281
- const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, nowP);
282
- obs?.dropMissing([...localEntries, ...globalEntries], [...localPool, ...globalPool], 'load', 'duplicate');
283
- const selectedIds = new Set();
284
- let usedP = 0;
285
- // Pinned entries are explicit user intent, the recent-N list an automatic
286
- // backfill. Both loops share ONE budget and the recent loop runs first, so
287
- // pins are ranked here and reserve their share before it can spend.
288
- const pinnedLocal = localPool.filter((e) => e.pinned);
289
- const pinnedGlobal = globalPool.filter((e) => e.pinned);
290
- const rankedPinned = [
291
- ...pinnedLocal.map((e) => ({ entry: e, isGlobal: primaryIsGlobal })),
292
- ...pinnedGlobal.map((e) => ({ entry: e, isGlobal: true })),
293
- ]
294
- .map(({ entry, isGlobal }) => {
295
- const scopeSig = scopeMatch(entry.tags, activeScope);
296
- const sBst = scopeSig === 1 ? 1.5 : scopeSig === -1 ? 0.5 : 1.0;
297
- return {
298
- entry,
299
- score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1) * sBst,
300
- tokens: price(entry, isGlobal),
301
- isGlobal,
302
- };
303
- })
304
- .sort(compareScoredResults);
305
- // Mirror the pinned admission loop's own `continue`-not-`break`
306
- // semantics (further down) so the reserve equals what that loop will
307
- // actually admit -- a big pin near the front should not block smaller
308
- // pins behind it from reserving their share too.
309
- // Dedupe by id: `syncGlobalToLocal` copies global rows into the local
310
- // store preserving `entry.id`, so a synced pin appears in BOTH
311
- // `pinnedLocal` and `pinnedGlobal` and would otherwise reserve its cost
312
- // twice. The admission loop already dedupes via `selectedIds`; the
313
- // reserve has to mirror that or it silently starves recents of budget a
314
- // single returned pin never needed.
315
- let pinnedReserve = 0;
316
- const reservedIds = new Set();
317
- for (const r of rankedPinned) {
318
- if (reservedIds.has(r.entry.id))
319
- continue;
320
- if (pinnedReserve + r.tokens <= effBudget) {
321
- pinnedReserve += r.tokens;
322
- reservedIds.add(r.entry.id);
323
- }
324
- }
325
- // Known, accepted tradeoff: a pin that also lands in the recent-N slice
326
- // is counted once in `pinnedReserve` (here) AND admitted again by the
327
- // recent loop below, so a little budget goes unused (`recentBudget` is
328
- // more conservative than it needs to be in that case). That only
329
- // under-fills recents slightly -- it never displaces a pin -- so it is
330
- // the safe direction and is not worth extra bookkeeping to recover.
331
- const recentBudget = Math.max(0, effBudget - pinnedReserve);
332
- // Z1: gate the backfill on the prompt instead of recency (docs/plans/2026-09-26-z1-prompt-recall.md).
333
- const promptRecallOn = promptRecallPending;
334
- if (promptRecallOn) {
335
- const rawMetric = pinnedCfg.pinnedInject.promptRecallMetric;
336
- const metric = rawMetric === 'cosine' ? 'cosine' : 'jaccard';
337
- const gate = {
338
- metric,
339
- threshold: finiteOr(pinnedCfg.pinnedInject.promptRecallThreshold, 0.04, 0),
340
- minShared: finiteOr(pinnedCfg.pinnedInject.promptRecallMinShared, 2, 0),
341
- maxItems: finiteOr(pinnedCfg.pinnedInject.promptRecallMaxItems, 5, 1),
342
- };
343
- const p = promptTokens(opts.prompt ?? '');
344
- if (p.size > 0) {
345
- // Candidates came off the ambient load's own connection (recallRequest above), not a fresh open.
346
- // A candidate carrying a pin's text would inject that memory a second time.
347
- const pinnedText = new Set(rankedPinned.map((r) => r.entry.content));
348
- const ineligibleReason = (e) => !admit(e) ? 'scope'
349
- : e.pinned ? 'pinned'
350
- : !isContentWorthStoring(e.content) ? 'quality'
351
- : pinnedText.has(e.content) ? 'duplicate'
352
- : null;
353
- const eligible = (e) => {
354
- const why = ineligibleReason(e);
355
- if (why !== null && why !== 'pinned')
356
- obs?.reject(e, 'eligible', why);
357
- return why === null;
358
- };
359
- obs?.offer(localLoad.recall ?? [], primaryIsGlobal, 'prompt-recall');
360
- obs?.offer(globalLoad.recall ?? [], true, 'prompt-recall');
361
- const localEligible = (localLoad.recall ?? []).filter(eligible);
362
- const globalEligible = (globalLoad.recall ?? []).filter(eligible);
363
- const [localCandidates, globalCandidates] = oneCopyPerMemory(localEligible, globalEligible, nowP);
364
- obs?.dropMissing([...localEligible, ...globalEligible], [...localCandidates, ...globalCandidates], 'eligible', 'duplicate');
365
- const seenCandidateIds = new Set();
366
- const candidateItems = [];
367
- // Local wins the id collision (a global row synced into the local store).
368
- for (const e of localCandidates) {
369
- if (seenCandidateIds.has(e.id))
370
- continue;
371
- seenCandidateIds.add(e.id);
372
- candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: primaryIsGlobal });
373
- }
374
- for (const e of globalCandidates) {
375
- if (seenCandidateIds.has(e.id))
376
- continue;
377
- seenCandidateIds.add(e.id);
378
- candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: true });
379
- }
380
- const gated = gatePromptRecall(p, candidateItems, gate);
381
- obs?.gated(p, candidateItems, gate, gated);
382
- for (const g of gated) {
383
- if (selectedIds.has(g.item.id))
384
- continue;
385
- const tokens = price(g.item.entry, g.item.isGlobal, true);
386
- if (usedP + tokens > recentBudget) {
387
- obs?.reject(g.item.entry, 'budget', 'budget', g.score, tokens);
388
- continue;
389
- }
390
- selectedItems.push({ entry: g.item.entry, score: g.score, tokens, isGlobal: g.item.isGlobal, promptRecall: true });
391
- selectedIds.add(g.item.id);
392
- usedP += tokens;
393
- }
394
- }
395
- }
396
- else if (includeRecent > 0) {
397
- const recent = [
398
- ...localPool.map((entry) => ({ entry, isGlobal: primaryIsGlobal })),
399
- ...globalPool.map((entry) => ({ entry, isGlobal: true })),
400
- ]
401
- // T2 (src/compare.ts) note: this already carries an explicit
402
- // per-instance tiebreak (created desc -> id localeCompare) and is
403
- // deliberately left as-is rather than routed through
404
- // compareEntryIdentity. `created` reflects ingest order, so it is
405
- // cross-ingest stable at ms granularity; the residual is honest,
406
- // not silently ignored — rows created in the same millisecond fall
407
- // to `id.localeCompare`, which is per-instance random (id is
408
- // crypto.randomUUID()), so this listing is per-instance-
409
- // deterministic but NOT cross-ingest-stable under same-ms
410
- // collisions.
411
- .sort((a, b) => {
412
- const byCreated = Date.parse(b.entry.created) - Date.parse(a.entry.created);
413
- return byCreated !== 0 ? byCreated : b.entry.id.localeCompare(a.entry.id);
414
- })
415
- // DF3 (docs/plans/2026-08-23-df3-include-recent-quality-floor.md):
416
- // filter before slice, not after — the caller asked for N recent
417
- // *useful* entries, so a junk row must be skipped and backfilled
418
- // past, not counted against the N. Skip-only: no mutation, no audit
419
- // row, nothing becomes unrecoverable.
420
- //
421
- // `entry.pinned ||` bypass IS needed here (codex review finding,
422
- // corrects the earlier claim in this comment that it wasn't): under
423
- // budget pressure, a pinned entry that fails the heuristic gets
424
- // dropped from this recent slice, and an unpinned entry backfills
425
- // into its slot and consumes `usedP` in the loop below. By the time
426
- // the pinned block runs (further down), the budget it needed is
427
- // already spent, so it hits `continue` and the pinned entry is
428
- // omitted entirely — the pinned block is NOT a safety net once the
429
- // recent loop has already spent the shared budget.
430
- .filter(({ entry }) => entry.pinned || isContentWorthStoring(entry.content))
431
- .slice(0, includeRecent)
432
- .map(({ entry, isGlobal }) => ({
433
- entry,
434
- score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1),
435
- tokens: price(entry, isGlobal),
436
- isGlobal,
437
- }));
438
- for (const r of recent) {
439
- if (selectedIds.has(r.entry.id))
440
- continue;
441
- if (usedP + r.tokens > recentBudget) {
442
- obs?.reject(r.entry, 'budget', 'budget', r.score, r.tokens);
443
- continue;
444
- }
445
- selectedItems.push(r);
446
- selectedIds.add(r.entry.id);
447
- usedP += r.tokens;
448
- }
449
- }
450
- if (pinnedLocal.length === 0 &&
451
- pinnedGlobal.length === 0 &&
452
- selectedItems.length === 0 &&
453
- !digestHiddenForHandoff) {
454
- return { entries: [], tokens: 0 };
455
- }
456
- for (const r of rankedPinned) {
457
- if (selectedIds.has(r.entry.id))
458
- continue;
459
- if (usedP + r.tokens > effBudget) {
460
- obs?.reject(r.entry, 'budget', 'budget', r.score, r.tokens);
461
- continue;
462
- }
463
- selectedItems.push(r);
464
- selectedIds.add(r.entry.id);
465
- usedP += r.tokens;
466
- }
467
- totalTokens = usedP;
468
- }
469
- else if (query === '*') {
470
- // No query: return strongest memories by strength, up to budget.
471
- const now = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
472
- const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, now);
473
- const localRanked = localPool
474
- .map((e) => ({
475
- entry: e,
476
- score: calculateStrength(e, now),
477
- tokens: price(e, primaryIsGlobal),
478
- isGlobal: primaryIsGlobal,
479
- }))
480
- .sort(compareScoredResults);
481
- const globalRanked = globalPool
482
- .map((e) => ({
483
- entry: e,
484
- score: calculateStrength(e, now) * (1 / 1.2),
485
- tokens: price(e, true),
486
- isGlobal: true,
487
- }))
488
- .sort(compareScoredResults);
489
- const combined = [...localRanked, ...globalRanked].sort(compareScoredResults);
490
- let used = 0;
491
- for (const r of combined) {
492
- if (used + r.tokens > left)
493
- continue;
494
- selectedItems.push(r);
495
- used += r.tokens;
496
- }
497
- totalTokens = used;
498
- }
499
- else {
500
- // Real query: hybrid search (global + local) or physics+hybrid (local only).
501
- let results;
502
- const minResults = cost ? 0 : undefined; // a priced block skips an oversize top hit too, so the budget bounds it
503
- if (hasGlobal && !primaryIsGlobal) {
504
- // searchBothHybrid loads from the store roots itself, so the ambient
505
- // filter above never saw its candidates. Admission runs INSIDE the
506
- // search via the opt-in entryFilter, BEFORE ranking, cross-store
507
- // content-dedupe, and budgeting - a post-filter instead would let an
508
- // excluded row saturate the budget (codex rounds 1+3) or shadow its
509
- // admitted duplicate in the dedupe pass (codex round 4). Recall paths
510
- // never set entryFilter, so their behavior is unchanged.
511
- const localIndex = loadIndex(ctx.hippoRoot);
512
- const isGlobalHit = (e) => !localIndex.entries[e.id];
513
- const merged = await searchBothHybrid(query, ctx.hippoRoot, globalRoot, {
514
- budget: left,
515
- minResults,
516
- cost: cost && ((r) => price(r.entry, isGlobalHit(r.entry))),
517
- scope: activeScope,
518
- tenantId: ctx.tenantId,
519
- entryFilter: ambientAdmit,
520
- });
521
- results = merged.map((r) => ({
522
- entry: r.entry,
523
- score: r.score,
524
- tokens: price(r.entry, isGlobalHit(r.entry)),
525
- isGlobal: isGlobalHit(r.entry),
526
- }));
527
- }
528
- else {
529
- const ctxConfig = loadConfig(ctx.hippoRoot);
530
- const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
531
- const localCost = cost && ((r) => price(r.entry, primaryIsGlobal));
532
- const vectorCandidates = {
533
- tenantId: ctx.tenantId, scope: recallScopeFilter(exactScope, 'exact'), includeSuperseded: false, admit,
534
- };
535
- const ctxResults = usePhysicsCtx
536
- ? await physicsSearch(query, localEntries, {
537
- budget: left,
538
- minResults,
539
- cost: localCost,
540
- hippoRoot: ctx.hippoRoot,
541
- physicsConfig: ctxConfig.physics,
542
- scope: activeScope,
543
- vectorCandidates,
544
- })
545
- : await hybridSearch(query, localEntries, {
546
- budget: left,
547
- minResults,
548
- cost: localCost,
549
- hippoRoot: ctx.hippoRoot,
550
- scope: activeScope,
551
- vectorCandidates,
552
- });
553
- results = ctxResults.map((r) => ({
554
- entry: r.entry,
555
- score: r.score,
556
- tokens: price(r.entry, primaryIsGlobal),
557
- isGlobal: primaryIsGlobal,
558
- }));
559
- }
560
- selectedItems = results;
561
- totalTokens = results.reduce((sum, r) => sum + r.tokens, 0);
562
- // A5 H4: emit recall audit row for context-mode searches (matches the
563
- // 'recall' op emitted by api.recall for parity). pinnedOnly + '*' fallback
564
- // never hit the search engines, so they don't emit (matches cmdContext).
565
- const ctxRecallMetadata = {
566
- ...auditQueryFields(query),
567
- results: selectedItems.length,
568
- mode: 'context',
569
- };
570
- if (hasLocal) {
571
- const localDb = openHippoDb(ctx.hippoRoot);
572
- try {
573
- appendAuditEvent(localDb, {
574
- tenantId: ctx.tenantId,
575
- actor: ctx.actor.subject,
576
- op: 'recall',
577
- metadata: ctxRecallMetadata,
578
- });
579
- }
580
- finally {
581
- closeHippoDb(localDb);
582
- }
583
- }
584
- if (hasGlobal && !primaryIsGlobal) {
585
- const globalDb = openHippoDb(globalRoot);
586
- try {
587
- appendAuditEvent(globalDb, {
588
- tenantId: ctx.tenantId,
589
- actor: ctx.actor.subject,
590
- op: 'recall',
591
- metadata: ctxRecallMetadata,
592
- });
593
- }
594
- finally {
595
- closeHippoDb(globalDb);
596
- }
597
- }
598
- }
599
- if (limit < selectedItems.length) {
600
- const cut = selectedItems.slice(0, limit);
601
- obs?.dropMissing(selectedItems.map((r) => r.entry), cut.map((r) => r.entry), 'limit', 'limit');
602
- selectedItems = cut;
300
+ return { local, global };
301
+ }
302
+ /** The `limit` cut, then the held-copy drop, then each entry's origin annotation. */
303
+ function finalizeSelection(picked, plan) {
304
+ const { obs } = plan;
305
+ let selected = picked;
306
+ if (plan.limit < selected.length) {
307
+ const cut = selected.slice(0, plan.limit);
308
+ obs?.dropMissing(selected.map((r) => r.entry), cut.map((r) => r.entry), 'limit', 'limit');
309
+ selected = cut;
603
310
  }
604
- const heldDropped = dropHeldCopies(selectedItems, (r) => r.entry); // after the last cut, so a merged row that was cut hides nothing
605
- obs?.dropMissing(selectedItems.map((r) => r.entry), heldDropped.map((r) => r.entry), 'limit', 'duplicate');
606
- selectedItems = heldDropped;
607
- totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
608
- // v39: annotate every returned entry with its origin and how it relates to
609
- // the active project, so renderers can demarcate cross-project inclusions.
610
- selectedItems = selectedItems.map((r) => ({
311
+ const heldDropped = dropHeldCopies(selected, (r) => r.entry); // after the last cut, so a merged row that was cut hides nothing
312
+ obs?.dropMissing(selected.map((r) => r.entry), heldDropped.map((r) => r.entry), 'limit', 'duplicate');
313
+ selected = heldDropped;
314
+ const tokens = selected.reduce((sum, r) => sum + r.tokens, 0);
315
+ // Every returned entry carries its origin and how it relates to the active project, so renderers can mark cross-project rows.
316
+ selected = selected.map((r) => ({
611
317
  ...r,
612
318
  origin: r.entry.origin_project ?? null,
613
- category: classifyOriginProject(r.entry.origin_project, currentProjectName),
319
+ category: classifyOriginProject(r.entry.origin_project, plan.currentProjectName),
614
320
  }));
615
- obs?.selected(selectedItems);
616
- if (selectedItems.length === 0 &&
617
- !shownSnapshot &&
618
- !shownHandoff &&
619
- shownEvents.length === 0) {
620
- // LC1 F5 fix: this bare early-return used to skip tracing entirely — a
621
- // query that found nothing is exactly the coverage-gap signal Track LC
622
- // needs. Write an empty trace (result_count 0, no result rows) so it
623
- // lands in the training corpus. Never touches localIndex/
624
- // last_retrieval_ids/last_trace_id — by construction it can't desync
625
- // (mirrors the CLI zero-result path). Skipped under pinnedOnly (hot
626
- // path stays read-only, same reason it skips markRetrieved). Fail-soft
627
- // internally; never throws.
628
- if (!pinnedOnly) {
629
- // No snapshot in this branch, so the caller's own id is the only session to stamp.
630
- writeRecallTraceAtRoot(ctx.hippoRoot, {
631
- tenantId: ctx.tenantId,
632
- sessionId: opts.currentSessionId || null,
633
- pipeline: 'context',
634
- query,
635
- explainMode: false,
636
- results: [],
637
- });
638
- }
639
- return { entries: [], tokens: 0 };
640
- }
641
- // pinnedOnly is the UserPromptSubmit hot path — read-only so pinned
642
- // memories don't inflate retrieval_count or extend half_life by 2 days per
643
- // turn over a long session.
644
- if (!pinnedOnly) {
645
- const toUpdate = selectedItems.map((s) => s.entry);
646
- const updatedEntries = markRetrieved(toUpdate);
647
- const localIndex = loadIndex(ctx.hippoRoot);
648
- const retrievedIds = updatedEntries.map((u) => u.id);
649
- const strengthenedHere = strengthenRetrieved(ctx.hippoRoot, retrievedIds, { recallBoostAblated: isRecallBoostAblated() });
650
- if (hasGlobal)
651
- strengthenRetrieved(globalRoot, retrievedIds.filter((id) => !strengthenedHere.has(id)), { recallBoostAblated: isRecallBoostAblated() });
652
- localIndex.last_retrieval_ids = retrievedIds;
653
- // LC1 F1 structural fix (docs/plans/2026-08-02-lc1-recall-trace-persistence.md):
654
- // write the trace FIRST — post-limit, post-annotation `selectedItems`
655
- // actually returned, on a fresh short-lived connection (the audit
656
- // handles above ~2410 are already closed by this point, matching this
657
- // block's own per-call-handle convention: writeEntry, saveIndex) — then
658
- // fold the resulting id into `localIndex` so the SAME `saveIndex` call
659
- // below persists last_retrieval_ids + last_trace_id atomically.
660
- // LOCKSTEP INVARIANT: last_trace_id must only ever advance together
661
- // with last_retrieval_ids; a two-connection stamp-then-clear design
662
- // could desync them on a crash between writes. A failed trace write
663
- // (traceId null) sets last_trace_id to null rather than leaving the
664
- // OLD id pointing at ids that are about to be overwritten. Fail-soft
665
- // internally; never throws.
666
- const traceId = writeRecallTraceAtRoot(ctx.hippoRoot, {
667
- tenantId: ctx.tenantId,
668
- sessionId: opts.currentSessionId || activeSnapshot?.session_id || null,
669
- pipeline: 'context',
670
- query,
671
- explainMode: false,
672
- results: selectedItems.map((s) => ({
673
- memoryId: s.entry.id,
674
- score: s.score,
675
- })),
676
- });
677
- localIndex.last_trace_id = traceId !== null ? String(traceId) : null;
678
- saveIndex(ctx.hippoRoot, localIndex);
679
- updateStats(ctx.hippoRoot, { recalled: selectedItems.length });
680
- // Replace selectedItems entries with markRetrieved-updated copies so
681
- // the returned ContextResult reflects post-recall state.
682
- selectedItems = selectedItems.map((s) => ({
683
- ...s,
684
- entry: updatedEntries.find((u) => u.id === s.entry.id) ?? s.entry,
685
- }));
686
- // Read after strengthenRetrieved commits, so the rows just retrieved count at their new strength.
687
- if (config.ambient.enabled) {
688
- const filter = { exactScope, project: originProject, currentProject: currentProjectName, now: evalNow() };
689
- const roots = [...(hasLocal ? [ctx.hippoRoot] : []), ...(hasGlobal && !primaryIsGlobal ? [globalRoot] : [])];
690
- const tallies = roots.map((root) => loadAmbientTallies(root, ctx.tenantId, filter));
691
- const total = tallies.length > 0 ? tallies.reduce(addAmbientTallies) : undefined;
692
- if (total && total.total > 0)
693
- ambientState = ambientStateFromTallies(total);
694
- }
695
- }
696
- return {
697
- entries: selectedItems,
698
- tokens: totalTokens,
699
- activeSnapshot: shownSnapshot ?? undefined,
700
- sessionHandoff: shownHandoff ?? undefined,
701
- recentEvents: shownEvents.length > 0 ? shownEvents : undefined,
702
- ambientState,
703
- };
321
+ obs?.selected(selected);
322
+ return { items: selected, tokens };
323
+ }
324
+ // Never touches the index, so it cannot desync last_retrieval_ids from last_trace_id; fail-soft, never throws.
325
+ function traceEmptyContext(ctx, opts, query) {
326
+ // No snapshot in this branch, so the caller's own id is the only session to stamp.
327
+ writeRecallTraceAtRoot(ctx.hippoRoot, {
328
+ tenantId: ctx.tenantId,
329
+ sessionId: opts.currentSessionId || null,
330
+ pipeline: 'context',
331
+ query,
332
+ explainMode: false,
333
+ results: [],
334
+ });
335
+ }
336
+ /** Strengthens and marks the returned rows, then writes the trace and index together. */
337
+ function recordRetrieval(ctx, opts, plan, selected, activeSnapshot) {
338
+ const toUpdate = selected.map((s) => s.entry);
339
+ const updatedEntries = markRetrieved(toUpdate);
340
+ const localIndex = loadIndex(ctx.hippoRoot);
341
+ const retrievedIds = updatedEntries.map((u) => u.id);
342
+ const strengthenedHere = strengthenRetrieved(ctx.hippoRoot, retrievedIds, { recallBoostAblated: isRecallBoostAblated() });
343
+ if (plan.hasGlobal)
344
+ strengthenRetrieved(plan.globalRoot, retrievedIds.filter((id) => !strengthenedHere.has(id)), { recallBoostAblated: isRecallBoostAblated() });
345
+ localIndex.last_retrieval_ids = retrievedIds;
346
+ // Trace first on its own short-lived connection, then fold its id into localIndex so one saveIndex moves
347
+ // last_retrieval_ids and last_trace_id in lockstep; a failed trace write stores null, never a stale id.
348
+ const traceId = writeRecallTraceAtRoot(ctx.hippoRoot, {
349
+ tenantId: ctx.tenantId,
350
+ sessionId: opts.currentSessionId || activeSnapshot?.session_id || null,
351
+ pipeline: 'context',
352
+ query: plan.query,
353
+ explainMode: false,
354
+ results: selected.map((s) => ({
355
+ memoryId: s.entry.id,
356
+ score: s.score,
357
+ })),
358
+ });
359
+ localIndex.last_trace_id = traceId !== null ? String(traceId) : null;
360
+ saveIndex(ctx.hippoRoot, localIndex);
361
+ updateStats(ctx.hippoRoot, { recalled: selected.length });
362
+ // Replace selectedItems entries with markRetrieved-updated copies so
363
+ // the returned ContextResult reflects post-recall state.
364
+ const items = selected.map((s) => ({
365
+ ...s,
366
+ entry: updatedEntries.find((u) => u.id === s.entry.id) ?? s.entry,
367
+ }));
368
+ // Read after strengthenRetrieved commits, so the rows just retrieved count at their new strength.
369
+ return { items, ambientState: plan.config.ambient.enabled ? readAmbientState(ctx, plan) : undefined };
370
+ }
371
+ function readAmbientState(ctx, plan) {
372
+ const filter = { exactScope: plan.exactScope, project: plan.originProject, currentProject: plan.currentProjectName, now: evalNow() };
373
+ const roots = [...(plan.hasLocal ? [ctx.hippoRoot] : []), ...(plan.hasGlobal && !plan.primaryIsGlobal ? [plan.globalRoot] : [])];
374
+ const tallies = roots.map((root) => loadAmbientTallies(root, ctx.tenantId, filter));
375
+ const total = tallies.length > 0 ? tallies.reduce(addAmbientTallies) : undefined;
376
+ return total && total.total > 0 ? ambientStateFromTallies(total) : undefined;
704
377
  }
705
378
  //# sourceMappingURL=context.js.map