hippo-memory 1.64.1 → 1.65.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 (58) hide show
  1. package/dist/agent-memories/sync.js +3 -2
  2. package/dist/api/recall-record.d.ts +46 -0
  3. package/dist/api/recall-record.js +98 -0
  4. package/dist/api/recall-request.d.ts +32 -0
  5. package/dist/api/recall-request.js +121 -0
  6. package/dist/api/recall-types.d.ts +7 -1
  7. package/dist/api/recall.d.ts +3 -2
  8. package/dist/api/recall.js +174 -181
  9. package/dist/api/tokens.d.ts +1 -1
  10. package/dist/api/tokens.js +13 -17
  11. package/dist/cli/recall.d.ts +1 -1
  12. package/dist/cli/recall.js +18 -49
  13. package/dist/cli/shared.js +2 -2
  14. package/dist/cli.d.ts +16 -0
  15. package/dist/cli.js +18 -1
  16. package/dist/config.d.ts +2 -0
  17. package/dist/config.js +4 -0
  18. package/dist/db/connect.d.ts +5 -0
  19. package/dist/db/connect.js +52 -0
  20. package/dist/db/open.d.ts +9 -11
  21. package/dist/db/open.js +23 -116
  22. package/dist/db/request-stores.d.ts +30 -0
  23. package/dist/db/request-stores.js +100 -0
  24. package/dist/db/sqlite-blocked.d.ts +6 -0
  25. package/dist/db/sqlite-blocked.js +11 -0
  26. package/dist/db.d.ts +3 -1
  27. package/dist/db.js +3 -1
  28. package/dist/embeddings.js +2 -1
  29. package/dist/goals.d.ts +18 -11
  30. package/dist/goals.js +38 -36
  31. package/dist/http-util.d.ts +2 -0
  32. package/dist/http-util.js +5 -0
  33. package/dist/mcp/memory-tools.js +4 -1
  34. package/dist/mcp/recall-tools.js +28 -133
  35. package/dist/mcp/request.d.ts +7 -0
  36. package/dist/mcp/request.js +63 -41
  37. package/dist/mcp/session-state.d.ts +1 -3
  38. package/dist/mcp/session-state.js +3 -5
  39. package/dist/predictions/planning-fallacy.d.ts +52 -0
  40. package/dist/predictions/planning-fallacy.js +70 -55
  41. package/dist/recall-trace.d.ts +1 -1
  42. package/dist/recall-trace.js +5 -4
  43. package/dist/search/physics-search.js +4 -1
  44. package/dist/search/vector.js +2 -0
  45. package/dist/server/routes/recall.d.ts +1 -1
  46. package/dist/server/routes/recall.js +30 -218
  47. package/dist/server.d.ts +12 -1
  48. package/dist/server.js +33 -22
  49. package/dist/store/entry-writes.d.ts +2 -0
  50. package/dist/store/entry-writes.js +24 -16
  51. package/dist/store-port.d.ts +70 -1
  52. package/dist/store-port.js +87 -9
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  56. package/extensions/openclaw-plugin/package.json +1 -1
  57. package/openclaw.plugin.json +1 -1
  58. package/package.json +1 -1
@@ -1,18 +1,13 @@
1
1
  // Read path: recall (sync) and retrieve (async, adds the vector arm).
2
2
  import { envRequireSessionScopedFreshTail } from '../env.js';
3
- import { openHippoDb, closeHippoDb } from '../db.js';
4
3
  import { DEFAULT_SEARCH_CANDIDATE_LIMIT } from '../store/rows.js';
5
- import { strengthenRetrieved } from '../store/entry-writes.js';
6
4
  import { isRecallBoostAblated } from '../ablation.js';
7
5
  import { loadEntriesByIds, loadFreshRawMemories } from '../store/entry-reads.js';
8
6
  import { loadRecallSearchEntries, recallScopeFilter } from '../store/search-rows.js';
9
- import { loadActiveTaskSnapshot, listSessionEvents } from '../store/sessions.js';
10
- import { loadLatestHandoff } from '../store/handoffs.js';
11
7
  import { estimateTokens } from '../token-ledger.js';
12
8
  import { formatHandoffEvidenceLine } from '../handoff.js';
13
- import { appendAuditEvent, auditQueryFields } from '../audit.js';
14
- import { writeRecallTrace, writeRecallTraceAtRoot } from '../recall-trace.js';
15
- import { applyGoalStackBoost } from '../goals.js';
9
+ import { activeGoalsWithPolicies, boostByGoals } from '../goals.js';
10
+ import { continuityAt, finishRecallAt, storeFor } from '../store-port.js';
16
11
  import { hybridSearch } from '../search/hybrid.js';
17
12
  import { physicsSearch } from '../search/physics-search.js';
18
13
  import { churnStaleFactor } from '../search/boosts.js';
@@ -20,11 +15,12 @@ import { compareEntryIdentity } from '../compare.js';
20
15
  import { dropHeldCopies, duplicateKey, storedTextKeys } from '../same-text.js';
21
16
  import { isSharedStore, loadConfig } from '../config.js';
22
17
  import { classifyOriginProject, projectNames } from '../project-identity.js';
23
- import { computePlanningFallacyOutput } from '../predictions/planning-fallacy.js';
18
+ import { decidePlanningFallacy, detectPlanningClaim, planningFallacyEvidenceAt } from '../predictions/planning-fallacy.js';
24
19
  import { detectAnchoring, hashQueryText, biasHintEnabled } from '../recall-history.js';
25
20
  import { detectAvailabilityBias } from '../availability.js';
26
21
  import { passesScopeFilterForRecall, assertScopeRequestAllowed, personalScopeOf } from '../recall-scope.js';
27
22
  import { ownerOrSubject, RecallContractError } from './types.js';
23
+ import { anchoringRows, availabilityRows, recallAuditMetadata, recallAuditRow } from './recall-record.js';
28
24
  /**
29
25
  * Shared construction helper for `RecallSuppressionSummary`. Used by
30
26
  * `api.recall`, `cmdRecall`, and the MCP `hippo_recall` handler so all three
@@ -43,7 +39,8 @@ export function buildSuppressionSummary(counts) {
43
39
  };
44
40
  }
45
41
  /**
46
- * Domain-level recall. Loads BM25-ranked candidates from SQLite scoped to
42
+ * Domain-level recall on hippo.db only, never `ctx.store`, since it is synchronous: under another store its first open throws
43
+ * `SqliteBlockedError`. Loads BM25-ranked candidates from SQLite scoped to
47
44
  * `ctx.tenantId` and keeps that order whatever `mode` says; `retrieve` is the
48
45
  * mode-aware, strengthening variant the HTTP route uses.
49
46
  *
@@ -62,7 +59,11 @@ export function recall(ctx, opts) {
62
59
  assertScopeRequestAllowed(ctx.actor, opts.scope);
63
60
  const windowSize = recallWindowSize(opts);
64
61
  const own = personalScopeOf(ctx.actor) ?? undefined;
65
- return recallFrom(ctx, opts, windowSize, loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false, recallOrigin(opts), own), own);
62
+ const all = loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false, recallOrigin(opts), own);
63
+ const plan = planRecall(ctx, opts, all, own);
64
+ const { result, writes } = composeRecall(ctx, opts, windowSize, all, plan, readRecallSync(ctx, opts, plan));
65
+ finishRecallAt(ctx.hippoRoot, writes);
66
+ return result;
66
67
  }
67
68
  function recallOrigin(opts) {
68
69
  return opts.project ? projectNames(opts.project) : undefined;
@@ -70,7 +71,7 @@ function recallOrigin(opts) {
70
71
  function inCallerProject(entry, opts) {
71
72
  return !opts.project || classifyOriginProject(entry.origin_project, opts.project) !== 'cross-project';
72
73
  }
73
- /** Mode-aware recall that strengthens each returned row; never writes last_retrieval_ids (contract lock). */
74
+ /** Mode-aware recall through the served store that strengthens each returned row; never writes last_retrieval_ids (contract lock). */
74
75
  export async function retrieve(ctx, opts) {
75
76
  assertScopeRequestAllowed(ctx.actor, opts.scope);
76
77
  const windowSize = recallWindowSize(opts);
@@ -78,7 +79,8 @@ export async function retrieve(ctx, opts) {
78
79
  const own = personalScopeOf(ctx.actor) ?? undefined;
79
80
  if (opts.showRanked)
80
81
  return retrieveFromStore(ctx, opts, windowSize, opts.showRanked, own);
81
- let candidates = loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false, recallOrigin(opts), own);
82
+ const store = storeFor(ctx);
83
+ let candidates = await store.searchRecallEntries(opts.query, recallSearchArgs(ctx, opts, windowSize, own));
82
84
  if (opts.mode === 'hybrid' || opts.mode === 'physics') {
83
85
  const searchOpts = { budget: Infinity, hippoRoot: ctx.hippoRoot, scope: opts.scope ?? null, vectorCandidates: recallVectorSpec(ctx, opts, own) };
84
86
  const ranked = opts.mode === 'physics'
@@ -87,10 +89,26 @@ export async function retrieve(ctx, opts) {
87
89
  const rankedIds = new Set(ranked.map((r) => r.entry.id));
88
90
  candidates = [...ranked.map((r) => r.entry), ...candidates.filter((e) => !rankedIds.has(e.id))];
89
91
  }
90
- const result = recallFrom(ctx, opts, windowSize, candidates, own);
91
- strengthenRetrieved(ctx.hippoRoot, result.results.map((r) => r.id), { tenantId: ctx.tenantId, recallBoostAblated: isRecallBoostAblated() });
92
+ const plan = planRecall(ctx, opts, candidates, own);
93
+ const { result, writes } = composeRecall(ctx, opts, windowSize, candidates, plan, await readRecall(store, ctx, opts, plan));
94
+ await store.finishRecall({ ...writes, strengthen: strengthenOf(ctx, result.results.map((r) => r.id)) });
92
95
  return result;
93
96
  }
97
+ /** The recall load's arguments: exact scope, current rows only, the caller's project and personal scope. */
98
+ function recallSearchArgs(ctx, opts, limit, own) {
99
+ return {
100
+ limit,
101
+ tenantId: ctx.tenantId,
102
+ requestedScope: opts.scope,
103
+ explicitScopeMode: 'exact',
104
+ includeSuperseded: false,
105
+ originProjects: recallOrigin(opts),
106
+ ownScope: own,
107
+ };
108
+ }
109
+ function strengthenOf(ctx, ids) {
110
+ return { ids, opts: { tenantId: ctx.tenantId, recallBoostAblated: isRecallBoostAblated() } };
111
+ }
94
112
  /** api.retrieve's vector arm: the recall load's exact-scope rule, current rows only. */
95
113
  function recallVectorSpec(ctx, opts, own) {
96
114
  return {
@@ -105,39 +123,44 @@ function recallVectorSpec(ctx, opts, own) {
105
123
  const SHOW_RANKED_LEXICAL_WINDOW = 1000;
106
124
  /** `retrieve` under `showRanked`: physics when `mode` says so, hybrid otherwise, over a wide lexical window plus the nearest vectors. */
107
125
  async function retrieveFromStore(ctx, opts, windowSize, show, own) {
108
- const loaded = loadRecallSearchEntries(ctx.hippoRoot, opts.query, Math.max(windowSize, SHOW_RANKED_LEXICAL_WINDOW), ctx.tenantId, opts.scope, 'exact', false, recallOrigin(opts), own);
126
+ const store = storeFor(ctx);
127
+ const loaded = await store.searchRecallEntries(opts.query, recallSearchArgs(ctx, opts, Math.max(windowSize, SHOW_RANKED_LEXICAL_WINDOW), own));
109
128
  const pool = loaded.filter((e) => passesScopeFilterForRecall(e.scope ?? null, opts.scope, own));
110
129
  // No scope option: the scope boost follows HIPPO_SCOPE and the skill env, as MCP recall always ranked.
111
130
  const searchOpts = { budget: Infinity, hippoRoot: ctx.hippoRoot, vectorCandidates: recallVectorSpec(ctx, opts, own) };
112
131
  let ranked = opts.mode === 'physics'
113
132
  ? await physicsSearch(opts.query, pool, { ...searchOpts, physicsConfig: loadConfig(ctx.hippoRoot).physics })
114
133
  : await hybridSearch(opts.query, pool, searchOpts);
115
- if (opts.sessionId && !opts.goalTag) {
116
- const db = openHippoDb(ctx.hippoRoot);
117
- try {
118
- ranked = applyGoalStackBoost(db, ranked, { sessionId: opts.sessionId, tenantId: ctx.tenantId, limit: ranked.length });
119
- }
120
- finally {
121
- closeHippoDb(db);
122
- }
134
+ // One goal read serves both boosts; the ranked list's log rows come first, so its scores win the log's first-write.
135
+ const goals = opts.sessionId && !opts.goalTag ? await store.activeGoals({ sessionId: opts.sessionId, tenantId: ctx.tenantId }) : null;
136
+ let rankedLog = [];
137
+ if (goals && opts.sessionId) {
138
+ const boost = boostByGoals(ranked, goals, { sessionId: opts.sessionId, tenantId: ctx.tenantId, limit: ranked.length });
139
+ ranked = boost.results;
140
+ rankedLog = boost.log;
123
141
  }
124
142
  const window = ranked.slice(0, windowSize).map((r) => r.entry);
125
- const result = recallFrom(ctx, { ...opts, suppressRecallTrace: true }, windowSize, window, own);
143
+ const bandOpts = { ...opts, suppressRecallTrace: true };
144
+ const plan = planRecall(ctx, bandOpts, window, own);
145
+ const { result, writes } = composeRecall(ctx, bandOpts, windowSize, window, plan, await readRecall(store, ctx, bandOpts, plan, goals), false);
126
146
  // Rows the vector arm added count as candidates too.
127
147
  const inPool = new Set(pool.map((e) => e.id));
128
148
  const candidates = [...pool, ...ranked.map((r) => r.entry).filter((e) => !inPool.has(e.id))];
129
149
  const shown = show({ ranked, pool: candidates, droppedByScope: loaded.length - pool.length }, result);
130
- strengthenRetrieved(ctx.hippoRoot, shown, { tenantId: ctx.tenantId, recallBoostAblated: isRecallBoostAblated() });
131
- if (!opts.suppressRecallTrace) {
132
- const scores = new Map(ranked.map((r) => [r.entry.id, r.score]));
133
- writeRecallTraceAtRoot(ctx.hippoRoot, {
150
+ const scores = new Map(ranked.map((r) => [r.entry.id, r.score]));
151
+ const shownRow = recallAuditRow(callerOf(ctx), 'recall', undefined, recallAuditMetadata(opts.query, shown.ids.length));
152
+ await store.finishRecall({
153
+ goalLog: [...rankedLog, ...writes.goalLog],
154
+ audit: [...writes.audit, shownRow, ...shown.audit],
155
+ trace: opts.suppressRecallTrace ? undefined : {
134
156
  tenantId: ctx.tenantId,
135
157
  sessionId: opts.sessionId ?? null,
136
158
  pipeline: 'mcp',
137
159
  query: opts.query,
138
- results: shown.map((id) => ({ memoryId: id, score: scores.get(id) ?? 0 })),
139
- });
140
- }
160
+ results: shown.ids.map((id) => ({ memoryId: id, score: scores.get(id) ?? 0 })),
161
+ },
162
+ strengthen: strengthenOf(ctx, shown.ids),
163
+ });
141
164
  return result;
142
165
  }
143
166
  /** Contract preflight: throws before any store-touching work. */
@@ -162,36 +185,66 @@ function recallWindowSize(opts) {
162
185
  }
163
186
  return opts.scorerWindow ?? DEFAULT_SEARCH_CANDIDATE_LIMIT;
164
187
  }
165
- function recallFrom(ctx, opts, windowSize, all, own) {
188
+ const CONTINUITY_EVENT_LIMIT = 5;
189
+ function planRecall(ctx, opts, all, own) {
166
190
  const limit = opts.limit ?? 10;
167
191
  const window = admitCandidates(opts, all, limit, own);
168
- // One db handle spans the goal-stack boost and the audit and trace rows; it closes before the continuity block.
169
- const db = openHippoDb(ctx.hippoRoot);
170
- let bands;
171
- try {
172
- bands = rankBands(db, ctx, opts, window, limit, own);
173
- auditAndTraceRecall(db, ctx, opts, bands.rankedOut);
174
- }
175
- finally {
176
- closeHippoDb(db);
177
- }
192
+ return {
193
+ limit,
194
+ window,
195
+ own,
196
+ goalBoost: opts.sessionId && !opts.goalTag ? { sessionId: opts.sessionId, tenantId: ctx.tenantId, limit } : null,
197
+ overflow: (opts.summarizeOverflow ?? true) && window.entries.length > limit ? overflowGroups(window, limit) : new Map(),
198
+ claim: detectPlanningClaim(opts.query),
199
+ };
200
+ }
201
+ /** The plan's reads on hippo.db directly, for the synchronous recall that cannot await the port. */
202
+ function readRecallSync(ctx, opts, plan) {
203
+ const { hippoRoot, tenantId } = ctx;
204
+ const freshCount = opts.freshTailCount ?? 0;
205
+ return {
206
+ goals: plan.goalBoost ? activeGoalsWithPolicies(hippoRoot, { sessionId: plan.goalBoost.sessionId, tenantId }) : null,
207
+ parents: plan.overflow.size > 0 ? loadEntriesByIds(hippoRoot, [...plan.overflow.keys()], tenantId) : [],
208
+ freshRaws: freshCount > 0 ? loadFreshRawMemories(hippoRoot, freshCount, tenantId, opts.freshTailSessionId, recallOrigin(opts)) : [],
209
+ continuity: opts.includeContinuity ? continuityAt(hippoRoot, tenantId, CONTINUITY_EVENT_LIMIT, continuityKey(ctx, opts)) : undefined,
210
+ planning: plan.claim ? planningFallacyEvidenceAt(hippoRoot, tenantId, plan.claim.classQueryTokens) : null,
211
+ };
212
+ }
213
+ /** The plan's reads through the store; `goals` already read by the caller skips that read. */
214
+ async function readRecall(store, ctx, opts, plan, goals) {
215
+ const { tenantId } = ctx;
216
+ const freshCount = opts.freshTailCount ?? 0;
217
+ return {
218
+ goals: goals !== undefined ? goals : plan.goalBoost ? await store.activeGoals({ sessionId: plan.goalBoost.sessionId, tenantId }) : null,
219
+ parents: plan.overflow.size > 0 ? await store.entriesByIds([...plan.overflow.keys()], tenantId) : [],
220
+ freshRaws: freshCount > 0 ? await store.freshRawEntries(freshCount, tenantId, opts.freshTailSessionId, recallOrigin(opts) ?? null) : [],
221
+ continuity: opts.includeContinuity ? await store.continuity(tenantId, CONTINUITY_EVENT_LIMIT, continuityKey(ctx, opts)) : undefined,
222
+ planning: plan.claim ? await store.planningFallacyEvidence(tenantId, plan.claim.classQueryTokens) : null,
223
+ };
224
+ }
225
+ // On a shared store the tenant's newest row is another developer's; no project keys to nothing, so the block is empty.
226
+ function continuityKey(ctx, opts) {
227
+ if (!isSharedStore(ctx.hippoRoot))
228
+ return null;
229
+ return { owner: ownerOrSubject(ctx.actor), project: opts.project ? projectNames(opts.project) : [] };
230
+ }
231
+ /** The reply and the rows it writes, from the candidates and the plan's reads; touches no store.
232
+ * `auditBand` false: the caller shows a cut of the band and audits the rows it shows. */
233
+ function composeRecall(ctx, opts, windowSize, all, plan, reads, auditBand = true) {
234
+ const { window } = plan;
235
+ const bands = rankBands(opts, plan, reads);
178
236
  const rankedOut = bands.rankedOut;
179
- const { continuity, continuityTokens } = opts.includeContinuity
180
- ? loadContinuity(ctx, opts, own)
181
- : { continuity: undefined, continuityTokens: undefined };
237
+ const continuity = reads.continuity ? scopeContinuity(reads.continuity, opts, plan.own) : undefined;
182
238
  // Query-derived, so MCP and CLI read this one hint instead of recomputing; HIPPO_AUTODEBIAS=off disables it.
183
- // The hint and the no-class-match / tiebreak watching variant are mutually exclusive; both go out as optional fields.
184
- const planningFallacyOutput = computePlanningFallacyOutput(ctx.hippoRoot, ctx.tenantId, opts.query, { actor: ctx.actor.subject });
185
- const planningFallacyHint = planningFallacyOutput.hint ?? null;
186
- const planningFallacyWatching = planningFallacyOutput.watching ?? null;
187
- const { anchoringHint, suppressedByInterference } = detectRecallAnchoring(ctx, opts, rankedOut[0]?.id ?? null);
188
- const availabilityHint = detectRecallAvailability(ctx, opts, bands.baseSlice, window.entries);
239
+ const planning = plan.claim && reads.planning ? decidePlanningFallacy(plan.claim, reads.planning, ctx.tenantId, ctx.actor.subject) : null;
240
+ const { anchoringHint, suppressedByInterference } = detectRecallAnchoring(opts, rankedOut[0]?.id ?? null);
241
+ const availabilityHint = detectRecallAvailability(opts, bands.baseSlice, window.entries);
189
242
  const result = {
190
243
  results: rankedOut,
191
244
  total: window.entries.length,
192
245
  tokens: rankedOut.reduce((acc, r) => acc + estimateTokens(r.content), 0),
193
246
  continuity,
194
- continuityTokens,
247
+ continuityTokens: continuity ? continuityTokensOf(continuity) : undefined,
195
248
  windowSize,
196
249
  suppressionSummary: buildSuppressionSummary({
197
250
  totalCandidates: all.length,
@@ -202,15 +255,21 @@ function recallFrom(ctx, opts, windowSize, all, own) {
202
255
  suppressedByInterference,
203
256
  }),
204
257
  };
205
- if (planningFallacyHint)
206
- result.planningFallacyHint = planningFallacyHint;
207
- if (planningFallacyWatching)
208
- result.planningFallacyWatching = planningFallacyWatching;
258
+ // The hint and the no-class-match / tiebreak watching variant are mutually exclusive; both go out as optional fields.
259
+ if (planning?.output.hint)
260
+ result.planningFallacyHint = planning.output.hint;
261
+ if (planning?.output.watching)
262
+ result.planningFallacyWatching = planning.output.watching;
209
263
  if (anchoringHint)
210
264
  result.anchoringHint = anchoringHint;
211
265
  if (availabilityHint)
212
266
  result.availabilityHint = availabilityHint;
213
- return result;
267
+ const who = callerOf(ctx);
268
+ const audit = auditBand ? [recallAuditRow(who, 'recall', undefined, recallAuditMetadata(opts.query, rankedOut.length))] : [];
269
+ if (planning?.audit)
270
+ audit.push(planning.audit);
271
+ audit.push(...anchoringRows(who, anchoringHint), ...availabilityRows(who, availabilityHint));
272
+ return { result, writes: { goalLog: bands.goalLog, audit, trace: recallTrace(ctx, opts, rankedOut) } };
214
273
  }
215
274
  // The SQL load already applied tenant and scope; any recall-mode loader must go through loadRecallSearchEntries.
216
275
  function admitCandidates(opts, all, limit, own) {
@@ -234,24 +293,21 @@ function admitCandidates(opts, all, limit, own) {
234
293
  return { entries, baseSlice, droppedPreRank, droppedByBudget: entries.length - baseSlice.length };
235
294
  }
236
295
  // The goal-stack boost touches the primary band only; the fresh-tail and summary bands keep their fixed placement.
237
- function rankBands(db, ctx, opts, window, limit, own) {
296
+ function rankBands(opts, plan, reads) {
297
+ const { window, limit, own } = plan;
238
298
  let baseScored = window.baseSlice.map((entry, idx) => ({
239
299
  entry,
240
300
  score: Math.max(0, 1 - idx / Math.max(1, limit)),
241
301
  }));
242
302
  // Allocated only under explain, so the boost's default-path math stays byte-identical.
243
303
  const explainTrace = opts.explain ? new Map() : undefined;
244
- if (opts.sessionId && !opts.goalTag) {
245
- baseScored = applyGoalStackBoost(db, baseScored, {
246
- sessionId: opts.sessionId,
247
- tenantId: ctx.tenantId,
248
- limit,
249
- trace: explainTrace,
250
- });
304
+ let goalLog = [];
305
+ if (plan.goalBoost && reads.goals) {
306
+ const boost = boostByGoals(baseScored, reads.goals, { ...plan.goalBoost, trace: explainTrace });
307
+ baseScored = boost.results;
308
+ goalLog = boost.log;
251
309
  }
252
- let substituted = (opts.summarizeOverflow ?? true) && window.entries.length > limit
253
- ? substituteOverflow(ctx, opts, window.entries, baseScored.map((r) => r.entry), limit, own)
254
- : [];
310
+ let substituted = substituteOverflow(opts, plan.overflow, reads.parents, limit, own);
255
311
  let heldDropped = 0;
256
312
  if (!opts.keepHeldCopies) {
257
313
  const shownIds = new Set(dropHeldCopies([...baseScored.map((r) => r.entry), ...substituted.map((s) => s.entry)], (e) => e).map((e) => e.id));
@@ -263,7 +319,7 @@ function rankBands(db, ctx, opts, window, limit, own) {
263
319
  const baseRanked = baseScored.map((r) => baseItem(r, opts, explainTrace));
264
320
  const summaryRanked = substituted.map((s) => summaryItem(s, opts));
265
321
  const freshRanked = (opts.freshTailCount ?? 0) > 0
266
- ? freshTailBand(ctx, opts, baseRanked, summaryRanked, opts.keepHeldCopies ? [] : [...baseSlice, ...substituted.map((s) => s.entry)], own)
322
+ ? freshTailBand(opts, reads.freshRaws, baseRanked, summaryRanked, opts.keepHeldCopies ? [] : [...baseSlice, ...substituted.map((s) => s.entry)], own)
267
323
  : [];
268
324
  return {
269
325
  rankedOut: [...freshRanked, ...baseRanked, ...summaryRanked],
@@ -271,28 +327,12 @@ function rankBands(db, ctx, opts, window, limit, own) {
271
327
  heldDropped,
272
328
  summarySubstitutions: substituted.length,
273
329
  freshTailAdded: freshRanked.length,
330
+ goalLog,
274
331
  };
275
332
  }
276
333
  // When the overflow holds 2+ children of one level-2 summary, that summary stands in for them, capped at 30% of
277
334
  // `limit`. Each one is tenant-scoped and re-checked against the scope filter; drillDown recovers the children.
278
- function substituteOverflow(ctx, opts, entries, baseSlice, limit, own) {
279
- const overflow = entries.slice(limit);
280
- const baseIds = new Set(baseSlice.map((e) => e.id));
281
- const overflowByParent = new Map();
282
- for (const e of overflow) {
283
- const parentId = e.dag_parent_id;
284
- if (!parentId)
285
- continue;
286
- if ((e.dag_level ?? 0) > 1)
287
- continue;
288
- const list = overflowByParent.get(parentId) ?? [];
289
- list.push(e);
290
- overflowByParent.set(parentId, list);
291
- }
292
- const eligibleParentIds = Array.from(overflowByParent.keys()).filter((pid) => (overflowByParent.get(pid)?.length ?? 0) >= 2 && !baseIds.has(pid));
293
- if (eligibleParentIds.length === 0)
294
- return [];
295
- const parents = loadEntriesByIds(ctx.hippoRoot, eligibleParentIds, ctx.tenantId);
335
+ function substituteOverflow(opts, overflowByParent, parents, limit, own) {
296
336
  const eligibleParents = parents.filter((p) => (p.dag_level ?? 0) === 2 && !p.superseded_by && passesScopeFilterForRecall(p.scope ?? null, opts.scope, own) && inCallerProject(p, opts));
297
337
  const maxSub = Math.max(1, Math.ceil(limit * 0.3));
298
338
  // Most overflowed children first; compareEntryIdentity only breaks a tie, which used to fall to scan order.
@@ -306,6 +346,23 @@ function substituteOverflow(ctx, opts, entries, baseSlice, limit, own) {
306
346
  childIds: (overflowByParent.get(p.id) ?? []).map((e) => e.id),
307
347
  }));
308
348
  }
349
+ /** Overflowed leaf rows by summary parent, kept only for a parent with 2+ of them that is not already in the base band. */
350
+ function overflowGroups(window, limit) {
351
+ // The goal boost only re-scores the base band, so its ids are known before the boost runs.
352
+ const baseIds = new Set(window.baseSlice.map((e) => e.id));
353
+ const overflowByParent = new Map();
354
+ for (const e of window.entries.slice(limit)) {
355
+ const parentId = e.dag_parent_id;
356
+ if (!parentId)
357
+ continue;
358
+ if ((e.dag_level ?? 0) > 1)
359
+ continue;
360
+ const list = overflowByParent.get(parentId) ?? [];
361
+ list.push(e);
362
+ overflowByParent.set(parentId, list);
363
+ }
364
+ return new Map([...overflowByParent].filter(([pid, children]) => children.length >= 2 && !baseIds.has(pid)));
365
+ }
309
366
  function baseItem(r, opts, explainTrace) {
310
367
  const item = {
311
368
  id: r.entry.id,
@@ -341,10 +398,8 @@ function summaryItem(s, opts) {
341
398
  }
342
399
  // The last N raw rows, so "what did I just see" always covers the recent window. A recent row already in the BM25
343
400
  // band is only tagged isFreshTail; new ones are prepended, so every recent row appears exactly once.
344
- function freshTailBand(ctx, opts, baseRanked, summaryRanked, shownEntries, own) {
345
- // The session-id contract was already checked by recallWindowSize's preflight.
346
- const recent = loadFreshRawMemories(ctx.hippoRoot, opts.freshTailCount ?? 0, ctx.tenantId, opts.freshTailSessionId, recallOrigin(opts));
347
- const recentScoped = recent.filter((m) => passesScopeFilterForRecall(m.scope ?? null, opts.scope, own));
401
+ function freshTailBand(opts, recent, baseRanked, summaryRanked, shownEntries, own) {
402
+ const recentScoped = recent.filter((m) => passesScopeFilterForRecall(m.scope ?? null, opts.scope, own) && inCallerProject(m, opts));
348
403
  const recentIdSet = new Set(recentScoped.map((m) => m.id));
349
404
  for (const r of baseRanked) {
350
405
  if (recentIdSet.has(r.id))
@@ -372,58 +427,32 @@ function freshTailBand(ctx, opts, baseRanked, summaryRanked, shownEntries, own)
372
427
  }
373
428
  return freshRanked;
374
429
  }
375
- // The audit row stores a hash of the query, never its text, so an archived memory's words cannot persist there.
376
- // The trace sits beside it as observability, not retrieval state; a caller that traces its own result set suppresses it.
377
- function auditAndTraceRecall(db, ctx, opts, rankedOut) {
378
- appendAuditEvent(db, {
430
+ // The trace is observability, not retrieval state; a caller that traces its own result set suppresses it.
431
+ function recallTrace(ctx, opts, rankedOut) {
432
+ if (opts.suppressRecallTrace)
433
+ return undefined;
434
+ return {
379
435
  tenantId: ctx.tenantId,
380
- actor: ctx.actor.subject,
381
- op: 'recall',
382
- metadata: {
383
- ...auditQueryFields(opts.query),
384
- results: rankedOut.length,
385
- },
386
- });
387
- if (!opts.suppressRecallTrace) {
388
- writeRecallTrace(db, {
389
- tenantId: ctx.tenantId,
390
- sessionId: opts.sessionId ?? null,
391
- pipeline: 'api',
392
- query: opts.query,
393
- explainMode: opts.explain === true,
394
- results: rankedOut.map((r) => ({
395
- memoryId: r.id,
396
- score: r.score,
397
- rerankSteps: r.rerankTrace,
398
- })),
399
- });
400
- }
436
+ sessionId: opts.sessionId ?? null,
437
+ pipeline: 'api',
438
+ query: opts.query,
439
+ explainMode: opts.explain === true,
440
+ results: rankedOut.map((r) => ({
441
+ memoryId: r.id,
442
+ score: r.score,
443
+ rerankSteps: r.rerankTrace,
444
+ })),
445
+ };
401
446
  }
402
- // No active snapshot means no anchor, so no handoff or events: a stale handoff from a closed session never resurfaces.
403
- function loadContinuity(ctx, opts, own) {
404
- // On a shared store the tenant's newest row is another developer's; no project keys to nothing, so the block is empty.
405
- const key = isSharedStore(ctx.hippoRoot)
406
- ? { owner: ownerOrSubject(ctx.actor), project: opts.project ? projectNames(opts.project) : [] }
407
- : undefined;
408
- const snapshot = loadActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId, key);
409
- const sessionId = snapshot?.session_id ?? undefined;
410
- const sessionHandoff = sessionId
411
- ? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, sessionId, {}, key)
412
- : null;
413
- const recentSessionEvents = sessionId
414
- ? listSessionEvents(ctx.hippoRoot, ctx.tenantId, { session_id: sessionId, limit: 5 })
415
- : [];
416
- // The memory-recall scope rule: an explicit scope must match exactly; without one, private and legacy rows are denied.
447
+ // The memory-recall scope rule: an explicit scope must match exactly; without one, private and legacy rows are denied.
448
+ function scopeContinuity(block, opts, own) {
417
449
  const rowScope = (r) => r?.scope ?? null;
418
- const filteredSnapshot = snapshot && passesScopeFilterForRecall(rowScope(snapshot), opts.scope, own) ? snapshot : null;
419
- const filteredHandoff = sessionHandoff && passesScopeFilterForRecall(rowScope(sessionHandoff), opts.scope, own) ? sessionHandoff : null;
420
- const filteredEvents = recentSessionEvents.filter((e) => passesScopeFilterForRecall(rowScope(e), opts.scope, own));
421
- const continuity = {
422
- activeSnapshot: filteredSnapshot,
423
- sessionHandoff: filteredHandoff,
424
- recentSessionEvents: filteredEvents,
450
+ const { activeSnapshot: snapshot, sessionHandoff } = block;
451
+ return {
452
+ activeSnapshot: snapshot && passesScopeFilterForRecall(rowScope(snapshot), opts.scope, own) ? snapshot : null,
453
+ sessionHandoff: sessionHandoff && passesScopeFilterForRecall(rowScope(sessionHandoff), opts.scope, own) ? sessionHandoff : null,
454
+ recentSessionEvents: block.recentSessionEvents.filter((e) => passesScopeFilterForRecall(rowScope(e), opts.scope, own)),
425
455
  };
426
- return { continuity, continuityTokens: continuityTokensOf(continuity) };
427
456
  }
428
457
  function continuityTokensOf(c) {
429
458
  const filteredSnapshot = c.activeSnapshot;
@@ -442,62 +471,26 @@ function continuityTokensOf(c) {
442
471
  tokenize(filteredHandoff?.cardId) +
443
472
  c.recentSessionEvents.reduce((acc, e) => acc + tokenize(e.content), 0);
444
473
  }
445
- /** One audit row on its own short-lived handle, as each bias detector writes it. */
446
- function appendRecallAudit(ctx, event) {
447
- const db = openHippoDb(ctx.hippoRoot);
448
- try {
449
- appendAuditEvent(db, { tenantId: ctx.tenantId, actor: ctx.actor.subject, ...event });
450
- }
451
- finally {
452
- closeHippoDb(db);
453
- }
474
+ function callerOf(ctx) {
475
+ return { tenantId: ctx.tenantId, actor: ctx.actor.subject };
454
476
  }
455
477
  // A pure read of the caller's recallHistory snapshot against this top-1. HIPPO_ANCHORING=off skips even the detect
456
478
  // call; CLI paths pass no history (cmdRecall computes its own hint), so the hint stays absent there.
457
- function detectRecallAnchoring(ctx, opts, topMemoryId) {
479
+ function detectRecallAnchoring(opts, topMemoryId) {
458
480
  if (!biasHintEnabled('anchoring') || !opts.recallHistory)
459
481
  return { anchoringHint: null, suppressedByInterference: 0 };
460
482
  const queryHash = hashQueryText(opts.query);
461
483
  const anchoringHint = detectAnchoring(opts.recallHistory, queryHash, topMemoryId);
462
- if (anchoringHint?.reason === 'memory_dominance') {
463
- appendRecallAudit(ctx, {
464
- op: 'recall_anchor_detected_memory_dominance',
465
- targetId: anchoringHint.memoryId,
466
- metadata: {
467
- memory_id: anchoringHint.memoryId,
468
- query_count: anchoringHint.queryCount ?? null,
469
- },
470
- });
471
- return { anchoringHint, suppressedByInterference: 1 };
472
- }
473
- if (anchoringHint?.reason === 'query_repeat') {
474
- appendRecallAudit(ctx, {
475
- op: 'recall_anchor_detected_query_repeat',
476
- targetId: anchoringHint.memoryId,
477
- metadata: { memory_id: anchoringHint.memoryId },
478
- });
479
- }
480
- return { anchoringHint, suppressedByInterference: 0 };
484
+ return { anchoringHint, suppressedByInterference: anchoringHint?.reason === 'memory_dominance' ? 1 : 0 };
481
485
  }
482
486
  // Compares the returned top-K's ages with the scope-filtered pool it came from, never `all`, whose hidden rows would
483
487
  // leak pool shape. A soft warning only; HIPPO_AVAILABILITY=off or a caller computing its own hint skips it.
484
- function detectRecallAvailability(ctx, opts, baseSlice, entries) {
488
+ function detectRecallAvailability(opts, baseSlice, entries) {
485
489
  if (!biasHintEnabled('availability') || opts.suppressAvailabilityHint)
486
490
  return null;
487
- const availabilityHint = detectAvailabilityBias({
491
+ return detectAvailabilityBias({
488
492
  topK: baseSlice.map((e) => ({ id: e.id, created: e.created })),
489
493
  pool: entries.map((e) => ({ id: e.id, created: e.created })),
490
494
  });
491
- if (availabilityHint) {
492
- appendRecallAudit(ctx, {
493
- op: 'recall_availability_detected',
494
- metadata: {
495
- recent_fraction: availabilityHint.recentFraction,
496
- older_passed_over: availabilityHint.olderCandidatesPassedOver,
497
- returned_count: availabilityHint.returnedCount,
498
- },
499
- });
500
- }
501
- return availabilityHint;
502
495
  }
503
496
  //# sourceMappingURL=recall.js.map
@@ -10,7 +10,7 @@ export declare function recordTokens(ctx: Context, surface: TokenSurface, use: {
10
10
  items: number;
11
11
  tokens: number;
12
12
  sessionId?: string | null;
13
- }): void;
13
+ }): Promise<void>;
14
14
  /**
15
15
  * Token ledger totals for the tenant over the last `days` days (default 30):
16
16
  * tokens sent, skipped as unchanged and re-read by later model calls, per
@@ -1,6 +1,7 @@
1
1
  // Token-use and failure reports.
2
- import { openHippoDb, closeHippoDb } from '../db.js';
3
- import { recordTokenUse, summarizeTokenUse } from '../token-ledger.js';
2
+ import { openHippoDb, closeHippoDb, rethrowIfSqliteBlocked } from '../db.js';
3
+ import { summarizeTokenUse } from '../token-ledger.js';
4
+ import { storeFor } from '../store-port.js';
4
5
  import { summarizeFailures } from '../failure-log.js';
5
6
  import { log } from '../log.js';
6
7
  /**
@@ -8,24 +9,19 @@ import { log } from '../log.js';
8
9
  * Best-effort: never throws, because a ledger failure must not fail the
9
10
  * recall or context call that produced the text.
10
11
  */
11
- export function recordTokens(ctx, surface, use) {
12
+ export async function recordTokens(ctx, surface, use) {
12
13
  try {
13
- const db = openHippoDb(ctx.hippoRoot);
14
- try {
15
- recordTokenUse(db, {
16
- tenantId: ctx.tenantId,
17
- sessionId: use.sessionId ?? null,
18
- surface,
19
- event: 'inject',
20
- items: use.items,
21
- tokens: use.tokens,
22
- });
23
- }
24
- finally {
25
- closeHippoDb(db);
26
- }
14
+ await storeFor(ctx).recordTokens({
15
+ tenantId: ctx.tenantId,
16
+ sessionId: use.sessionId ?? null,
17
+ surface,
18
+ event: 'inject',
19
+ items: use.items,
20
+ tokens: use.tokens,
21
+ });
27
22
  }
28
23
  catch (err) {
24
+ rethrowIfSqliteBlocked(err);
29
25
  log.warnThenDebug('api-token-ledger', `token ledger write failed; the reply is unaffected: ${err instanceof Error ? err.message : String(err)}`);
30
26
  }
31
27
  }
@@ -1,5 +1,5 @@
1
1
  import { type CliFlags, type CommandContext } from './shared.js';
2
- /** Test-only: reset the module-level recall-history Map. Call from beforeEach. */
2
+ /** Test-only: reset the CLI recall rings. Call from beforeEach. */
3
3
  export declare function __resetSessionRecallHistoryCli(): void;
4
4
  /** Runs `hippo recall`: parse, rank, load continuity, fit the printed block to the budget, audit, then write and print. */
5
5
  export declare function cmdRecall(hippoRoot: string, query: string, flags: CliFlags): Promise<void>;