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
@@ -164,471 +164,336 @@ function recallWindowSize(opts) {
164
164
  }
165
165
  function recallFrom(ctx, opts, windowSize, all) {
166
166
  const limit = opts.limit ?? 10;
167
- // v1.7.1 — root-cause fix for the `unknown:legacy` leak. Scope predicate
168
- // is now pushed into `loadSearchRows` SQL via `loadRecallSearchEntries`.
169
- // - opts.scope undefined / '': SQL excludes `unknown:legacy`.
170
- // - opts.scope non-empty: SQL exact-matches m.scope = opts.scope.
171
- // Tenant predicate still runs first, so a tenant-mismatched scope cannot
172
- // surface another tenant's row even when both share the same scope string.
173
- //
174
- // **CALLER CONTRACT:** any future recall-mode loader MUST go through
175
- // `loadRecallSearchEntries` (or invoke the SQL scope predicate equivalently).
176
- // Calling `loadSearchEntries` from this code path re-introduces the v1.6.5
177
- // codex-flagged leak. See `passesScopeFilterForRecall` in this file for
178
- // the canonical recall-side scope rule (kept in sync with the SQL clause
179
- // in loadSearchRows).
180
- //
181
- // Also fixes a latent code smell: pre-v1.7.1 passed `opts.scorerWindow`
182
- // (raw, possibly undefined) where `windowSize` was intended.
183
- // v1.12.13 / C5 — WYSIATI counters. Declared BEFORE the load step so the
184
- // assignments at the existing filter sites (load / scope-filter / limit-
185
- // slice / substitution / fresh-tail) are after declaration. The return at
186
- // end-of-function reads them via buildSuppressionSummary.
187
- let totalCandidatesCount = 0;
188
- let droppedPreRankCount = 0;
189
- let droppedByBudgetCount = 0;
190
- let summarySubstitutionsCount = 0;
191
- let freshTailAddedCount = 0;
192
- // v1.12.13 / C5 — WYSIATI totalCandidates counter (post tenant + SQL scope
193
- // predicate, pre JS scope filter).
194
- totalCandidatesCount = all.length;
167
+ const window = admitCandidates(opts, all, limit);
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);
173
+ auditAndTraceRecall(db, ctx, opts, bands.rankedOut);
174
+ }
175
+ finally {
176
+ closeHippoDb(db);
177
+ }
178
+ const rankedOut = bands.rankedOut;
179
+ const { continuity, continuityTokens } = opts.includeContinuity
180
+ ? loadContinuity(ctx, opts)
181
+ : { continuity: undefined, continuityTokens: undefined };
182
+ // 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);
189
+ const result = {
190
+ results: rankedOut,
191
+ total: window.entries.length,
192
+ tokens: rankedOut.reduce((acc, r) => acc + estimateTokens(r.content), 0),
193
+ continuity,
194
+ continuityTokens,
195
+ windowSize,
196
+ suppressionSummary: buildSuppressionSummary({
197
+ totalCandidates: all.length,
198
+ droppedPreRank: window.droppedPreRank + bands.heldDropped,
199
+ droppedByBudget: window.droppedByBudget,
200
+ summarySubstitutionsAdded: bands.summarySubstitutions,
201
+ freshTailAdded: bands.freshTailAdded,
202
+ suppressedByInterference,
203
+ }),
204
+ };
205
+ if (planningFallacyHint)
206
+ result.planningFallacyHint = planningFallacyHint;
207
+ if (planningFallacyWatching)
208
+ result.planningFallacyWatching = planningFallacyWatching;
209
+ if (anchoringHint)
210
+ result.anchoringHint = anchoringHint;
211
+ if (availabilityHint)
212
+ result.availabilityHint = availabilityHint;
213
+ return result;
214
+ }
215
+ // The SQL load already applied tenant and scope; any recall-mode loader must go through loadRecallSearchEntries.
216
+ function admitCandidates(opts, all, limit) {
195
217
  const current = all.filter((e) => !e.superseded_by);
196
218
  let entries;
197
219
  if (opts.scope !== undefined && opts.scope !== '') {
198
- // SQL already exact-matched in loadRecallSearchEntries; keep the JS
199
- // filter as defense-in-depth so a future SQL-clause regression cannot
200
- // silently surface cross-scope rows.
220
+ // SQL already exact-matched; the JS filter is defense-in-depth against a SQL-clause regression.
201
221
  entries = current.filter((e) => e.scope === opts.scope);
202
222
  }
203
223
  else {
204
- // SQL already excluded `unknown:legacy` AND (v1.25.0) pre-filtered
205
- // ':private:' scopes with a conservative LIKE before the candidate
206
- // window, so private rows can no longer starve admitted rows out of the
207
- // LIMIT (codex review-stage P2). This JS filter stays as the exact
208
- // anchored `<source>:private:*` rule (v1.2.1 generalization) and
209
- // defense-in-depth: connector authors cannot silently surface private
210
- // rows to no-scope callers even if the SQL clause regresses.
224
+ // SQL pre-filtered ':private:' loosely before the window; this is the exact anchored `<source>:private:*` rule.
211
225
  entries = current.filter((e) => !isRestrictedScope(e.scope ?? null));
212
226
  }
213
- // v1.12.13 / C5 — WYSIATI dropped_pre_rank counter (JS scope filter drops
214
- // for api.recall; cmdRecall pipeline rolls --outcome/--layer/--as-of/etc.
215
- // into the same field per the plan's Task 3 mapping table).
216
- droppedPreRankCount = all.length - entries.length;
227
+ const droppedPreRank = all.length - entries.length;
217
228
  entries = entries
218
229
  .map((e, i) => ({ e, s: (1 - i / entries.length) * churnStaleFactor(e) }))
219
230
  .sort((a, b) => b.s - a.s)
220
231
  .map((r) => r.e);
221
232
  // BM25 ordering already comes from loadRecallSearchEntries; cap to `limit`.
222
- // Score is a placeholder — the physics/hybrid scorers in src/search.ts
223
- // produce richer breakdowns and will replace this when wired up.
224
- let baseSlice = entries.slice(0, limit);
225
- // v1.12.13 / C5 — WYSIATI dropped_by_budget counter (candidates loaded but
226
- // excluded by the final limit slice).
227
- droppedByBudgetCount = entries.length - baseSlice.length;
228
- // v1.7.4 -- single db handle for the goal-stack boost AND the audit-event
229
- // emit below (codex P1: do not open a second short-lived handle for the
230
- // appendAuditEvent call). The handle is closed in the matching `finally`
231
- // immediately above the continuity block.
232
- const db = openHippoDb(ctx.hippoRoot);
233
- // v1.7.4 -- declared outside the try so the return statement (which lives
234
- // outside, after the continuity block) can read the final values.
235
- let rankedOut = [];
236
- let tokensOut = 0;
237
- let totalOut = 0;
238
- // v1.7.4 -- dlPFC goal-stack boost on the PRIMARY band only. Appendix paths
239
- // (fresh-tail, summary substitutions) are appended AFTER and keep their
240
- // semantically-special placement.
241
- let baseScored = baseSlice.map((entry, idx) => ({
233
+ const baseSlice = entries.slice(0, limit);
234
+ return { entries, baseSlice, droppedPreRank, droppedByBudget: entries.length - baseSlice.length };
235
+ }
236
+ // 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) {
238
+ let baseScored = window.baseSlice.map((entry, idx) => ({
242
239
  entry,
243
240
  score: Math.max(0, 1 - idx / Math.max(1, limit)),
244
241
  }));
245
- // A7 recall-trace: separate side-channel accumulator, allocated ONLY under
246
- // explain. applyGoalStackBoost writes goal-boost steps here keyed by entry
247
- // id; the baseRanked map reads it. When !explain it stays undefined and is
248
- // never passed → the helper's default-path math is byte-identical.
242
+ // Allocated only under explain, so the boost's default-path math stays byte-identical.
249
243
  const explainTrace = opts.explain ? new Map() : undefined;
250
- try {
251
- if (opts.sessionId && !opts.goalTag) {
252
- baseScored = applyGoalStackBoost(db, baseScored, {
253
- sessionId: opts.sessionId,
254
- tenantId: ctx.tenantId,
255
- limit,
256
- // trace is optional on applyGoalStackBoost; explicitly passing
257
- // undefined when !explain is identical to omitting the key.
258
- trace: explainTrace,
259
- });
260
- baseSlice = baseScored.map((r) => r.entry);
261
- }
262
- // v1.5.0 DAG-aware substitution (Phase 1, Task 2). When entries overflow the
263
- // limit and ≥2 of them share a level-2 parent summary, append the parent
264
- // summary so the user sees a compact pointer to the dropped detail. Capped
265
- // at ceil(limit * 0.3) substitutions so a runaway DAG can't expand results.
266
- // Each substituted summary is tenant-scoped via loadEntriesByIds and
267
- // re-checked against the active scope filter (default-deny on private).
268
- // Drill-down (Task 3) reverses substitution: caller passes substitutedFor[]
269
- // ids back through `drillDown` to recover the children.
270
- const summarizeOverflow = opts.summarizeOverflow ?? true;
271
- let substituted = [];
272
- if (summarizeOverflow && entries.length > limit) {
273
- const overflow = entries.slice(limit);
274
- const baseIds = new Set(baseSlice.map((e) => e.id));
275
- const overflowByParent = new Map();
276
- for (const e of overflow) {
277
- const parentId = e.dag_parent_id;
278
- if (!parentId)
279
- continue;
280
- if ((e.dag_level ?? 0) > 1)
281
- continue;
282
- const list = overflowByParent.get(parentId) ?? [];
283
- list.push(e);
284
- overflowByParent.set(parentId, list);
285
- }
286
- const eligibleParentIds = Array.from(overflowByParent.keys()).filter((pid) => (overflowByParent.get(pid)?.length ?? 0) >= 2 && !baseIds.has(pid));
287
- if (eligibleParentIds.length > 0) {
288
- const parents = loadEntriesByIds(ctx.hippoRoot, eligibleParentIds, ctx.tenantId);
289
- const eligibleParents = parents.filter((p) => (p.dag_level ?? 0) === 2 && !p.superseded_by && passesScopeFilterForRecall(p.scope ?? null, opts.scope));
290
- const maxSub = Math.max(1, Math.ceil(limit * 0.3));
291
- // Order parents by overflow count descending so the most
292
- // information-dense substitutions come first. Overflow count is the
293
- // true primary key (unchanged); compareEntryIdentity is only a TAIL
294
- // for the case two parents overflow the same number of children —
295
- // without it that tie fell to SQLite scan order / loadEntriesByIds
296
- // batch order (T2, deterministic tie keys).
297
- eligibleParents.sort((a, b) => {
298
- const ac = overflowByParent.get(a.id)?.length ?? 0;
299
- const bc = overflowByParent.get(b.id)?.length ?? 0;
300
- return bc !== ac ? bc - ac : compareEntryIdentity(a, b);
301
- });
302
- substituted = eligibleParents.slice(0, maxSub).map((p) => ({
303
- entry: p,
304
- childIds: (overflowByParent.get(p.id) ?? []).map((e) => e.id),
305
- }));
306
- }
307
- }
308
- if (!opts.keepHeldCopies) {
309
- const shownIds = new Set(dropHeldCopies([...baseScored.map((r) => r.entry), ...substituted.map((s) => s.entry)], (e) => e).map((e) => e.id));
310
- droppedPreRankCount += baseScored.filter((r) => !shownIds.has(r.entry.id)).length;
311
- baseScored = baseScored.filter((r) => shownIds.has(r.entry.id));
312
- baseSlice = baseScored.map((r) => r.entry);
313
- substituted = substituted.filter((s) => shownIds.has(s.entry.id));
314
- }
315
- // v1.12.13 / C5 — WYSIATI summary_substitutions_added counter.
316
- summarySubstitutionsCount = substituted.length;
317
- // v1.7.4 -- baseScored carries the (possibly boosted) per-row scores. When
318
- // the goal-stack boost did not run, scores are identical to the original
319
- // positional placeholder; when it did run, scores reflect the boost AND the
320
- // rows are in the boosted order (helper sort()).
321
- const baseRanked = baseScored.map((r) => {
322
- const item = {
323
- id: r.entry.id,
324
- content: r.entry.content,
325
- score: r.score,
326
- layer: r.entry.layer,
327
- strength: r.entry.strength,
328
- };
329
- // A7 recall-trace: under explain, every api band carries rerankPipeline:'api';
330
- // only baseRanked passes through the goal-boost helper, so only it can carry
331
- // a step (and only for rows that actually matched an active goal).
332
- if (opts.explain) {
333
- item.rerankPipeline = 'api';
334
- const step = explainTrace?.get(r.entry.id);
335
- if (step)
336
- item.rerankTrace = [step];
337
- }
338
- return item;
339
- });
340
- // Substituted summaries land at the end with score = 0.5 (mid-rank), so
341
- // they don't outrank top-N strong matches but stay above lowest-rank
342
- // leaves on the consumer side. Caller sorts/filters as it sees fit.
343
- const summaryRanked = substituted.map((s) => {
344
- const item = {
345
- id: s.entry.id,
346
- content: s.entry.content,
347
- score: 0.5,
348
- layer: s.entry.layer,
349
- strength: s.entry.strength,
350
- isSummary: true,
351
- substitutedFor: s.childIds,
352
- descendantCount: s.entry.descendant_count ?? s.childIds.length,
353
- };
354
- // A7 recall-trace: summary band runs no re-ranking, but under explain it
355
- // still carries the pipeline marker (no steps). Absent when !explain.
356
- if (opts.explain)
357
- item.rerankPipeline = 'api';
358
- return item;
244
+ if (opts.sessionId && !opts.goalTag) {
245
+ baseScored = applyGoalStackBoost(db, baseScored, {
246
+ sessionId: opts.sessionId,
247
+ tenantId: ctx.tenantId,
248
+ limit,
249
+ trace: explainTrace,
359
250
  });
360
- // v1.5.2 fresh-tail. Surface the last N kind='raw' rows so an agent's
361
- // "what did I just see" recall path always covers the recent window even
362
- // when the query terms don't match. Tenant + scope filtered.
363
- //
364
- // Dual-membership semantics: `loadSearchEntries` returns all tenant-scoped
365
- // rows scored by BM25 (even rows with no token overlap can surface at
366
- // score≈0), so a row in the recent window often ALSO appears as a BM25
367
- // hit. We don't duplicate. Instead:
368
- // 1. Mark any baseRanked entry that's in the recent set with isFreshTail.
369
- // 2. Prepend genuinely-new recent rows (not in BM25 hits or summaries).
370
- // Net: every recent row carries `isFreshTail=true`, exactly once.
371
- const freshTailCount = opts.freshTailCount ?? 0;
372
- const freshRanked = [];
373
- if (freshTailCount > 0) {
374
- // F5 contract guard fires at recall() preflight (top of function).
375
- // No re-check needed here — by the time we reach this block the
376
- // env/session policy has already been validated.
377
- const recent = loadFreshRawMemories(ctx.hippoRoot, freshTailCount, ctx.tenantId, opts.freshTailSessionId);
378
- const recentScoped = recent.filter((m) => passesScopeFilterForRecall(m.scope ?? null, opts.scope));
379
- const recentIdSet = new Set(recentScoped.map((m) => m.id));
380
- for (const r of baseRanked) {
381
- if (recentIdSet.has(r.id))
382
- r.isFreshTail = true;
383
- }
384
- const seenIds = new Set([
385
- ...baseRanked.map((r) => r.id),
386
- ...summaryRanked.map((r) => r.id),
387
- ]);
388
- const shownKeys = storedTextKeys(opts.keepHeldCopies ? [] : [...baseSlice, ...substituted.map((s) => s.entry)]);
389
- for (const m of recentScoped) {
390
- if (seenIds.has(m.id) || shownKeys.has(duplicateKey(m.content)))
391
- continue;
392
- shownKeys.add(duplicateKey(m.content));
393
- const item = {
394
- id: m.id,
395
- content: m.content,
396
- score: 1.0,
397
- layer: m.layer,
398
- strength: m.strength,
399
- isFreshTail: true,
400
- };
401
- // A7 recall-trace: fresh-tail band runs no re-ranking; under explain
402
- // it carries the pipeline marker (no steps). Absent when !explain.
403
- if (opts.explain)
404
- item.rerankPipeline = 'api';
405
- freshRanked.push(item);
406
- seenIds.add(m.id);
407
- }
408
- }
409
- // v1.12.13 / C5 — WYSIATI fresh_tail_added counter. Captures the new rows
410
- // prepended (NOT rows already in baseRanked that got tagged isFreshTail).
411
- freshTailAddedCount = freshRanked.length;
412
- rankedOut = [...freshRanked, ...baseRanked, ...summaryRanked];
413
- tokensOut = rankedOut.reduce((acc, r) => acc + estimateTokens(r.content), 0);
414
- totalOut = entries.length;
415
- // TODO(a1-task-4): emit via the shared audit hook in store.ts so we don't
416
- // double-emit. Recall does not currently write through writeEntry, so no
417
- // duplicate exists today, but we keep the same shape for symmetry.
418
- // v1.7.4: reuse the `db` handle opened above for the goal-stack boost --
419
- // single open/close spans both side effects.
420
- // GDPR Path A: store a sha256 hash (16 hex chars) of the query text
421
- // instead of the truncated query itself. If a caller queries with content
422
- // that matches an archived (RTBF) memory, the original text must not
423
- // persist in audit_log. query_length is preserved for debugging
424
- // long-prompt patterns and compliance metrics.
425
- appendAuditEvent(db, {
251
+ }
252
+ let substituted = (opts.summarizeOverflow ?? true) && window.entries.length > limit
253
+ ? substituteOverflow(ctx, opts, window.entries, baseScored.map((r) => r.entry), limit)
254
+ : [];
255
+ let heldDropped = 0;
256
+ if (!opts.keepHeldCopies) {
257
+ const shownIds = new Set(dropHeldCopies([...baseScored.map((r) => r.entry), ...substituted.map((s) => s.entry)], (e) => e).map((e) => e.id));
258
+ heldDropped = baseScored.filter((r) => !shownIds.has(r.entry.id)).length;
259
+ baseScored = baseScored.filter((r) => shownIds.has(r.entry.id));
260
+ substituted = substituted.filter((s) => shownIds.has(s.entry.id));
261
+ }
262
+ const baseSlice = baseScored.map((r) => r.entry);
263
+ const baseRanked = baseScored.map((r) => baseItem(r, opts, explainTrace));
264
+ const summaryRanked = substituted.map((s) => summaryItem(s, opts));
265
+ const freshRanked = (opts.freshTailCount ?? 0) > 0
266
+ ? freshTailBand(ctx, opts, baseRanked, summaryRanked, opts.keepHeldCopies ? [] : [...baseSlice, ...substituted.map((s) => s.entry)])
267
+ : [];
268
+ return {
269
+ rankedOut: [...freshRanked, ...baseRanked, ...summaryRanked],
270
+ baseSlice,
271
+ heldDropped,
272
+ summarySubstitutions: substituted.length,
273
+ freshTailAdded: freshRanked.length,
274
+ };
275
+ }
276
+ // When the overflow holds 2+ children of one level-2 summary, that summary stands in for them, capped at 30% of
277
+ // `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) {
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);
296
+ const eligibleParents = parents.filter((p) => (p.dag_level ?? 0) === 2 && !p.superseded_by && passesScopeFilterForRecall(p.scope ?? null, opts.scope));
297
+ const maxSub = Math.max(1, Math.ceil(limit * 0.3));
298
+ // Most overflowed children first; compareEntryIdentity only breaks a tie, which used to fall to scan order.
299
+ eligibleParents.sort((a, b) => {
300
+ const ac = overflowByParent.get(a.id)?.length ?? 0;
301
+ const bc = overflowByParent.get(b.id)?.length ?? 0;
302
+ return bc !== ac ? bc - ac : compareEntryIdentity(a, b);
303
+ });
304
+ return eligibleParents.slice(0, maxSub).map((p) => ({
305
+ entry: p,
306
+ childIds: (overflowByParent.get(p.id) ?? []).map((e) => e.id),
307
+ }));
308
+ }
309
+ function baseItem(r, opts, explainTrace) {
310
+ const item = {
311
+ id: r.entry.id,
312
+ content: r.entry.content,
313
+ score: r.score,
314
+ layer: r.entry.layer,
315
+ strength: r.entry.strength,
316
+ };
317
+ // Only this band passes through the goal boost, so only it can carry a rerank step.
318
+ if (opts.explain) {
319
+ item.rerankPipeline = 'api';
320
+ const step = explainTrace?.get(r.entry.id);
321
+ if (step)
322
+ item.rerankTrace = [step];
323
+ }
324
+ return item;
325
+ }
326
+ // Score 0.5 keeps a summary below the strong top-N matches but above the weakest leaves.
327
+ function summaryItem(s, opts) {
328
+ const item = {
329
+ id: s.entry.id,
330
+ content: s.entry.content,
331
+ score: 0.5,
332
+ layer: s.entry.layer,
333
+ strength: s.entry.strength,
334
+ isSummary: true,
335
+ substitutedFor: s.childIds,
336
+ descendantCount: s.entry.descendant_count ?? s.childIds.length,
337
+ };
338
+ if (opts.explain)
339
+ item.rerankPipeline = 'api';
340
+ return item;
341
+ }
342
+ // The last N raw rows, so "what did I just see" always covers the recent window. A recent row already in the BM25
343
+ // band is only tagged isFreshTail; new ones are prepended, so every recent row appears exactly once.
344
+ function freshTailBand(ctx, opts, baseRanked, summaryRanked, shownEntries) {
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);
347
+ const recentScoped = recent.filter((m) => passesScopeFilterForRecall(m.scope ?? null, opts.scope));
348
+ const recentIdSet = new Set(recentScoped.map((m) => m.id));
349
+ for (const r of baseRanked) {
350
+ if (recentIdSet.has(r.id))
351
+ r.isFreshTail = true;
352
+ }
353
+ const seenIds = new Set([...baseRanked.map((r) => r.id), ...summaryRanked.map((r) => r.id)]);
354
+ const shownKeys = storedTextKeys(shownEntries);
355
+ const freshRanked = [];
356
+ for (const m of recentScoped) {
357
+ if (seenIds.has(m.id) || shownKeys.has(duplicateKey(m.content)))
358
+ continue;
359
+ shownKeys.add(duplicateKey(m.content));
360
+ const item = {
361
+ id: m.id,
362
+ content: m.content,
363
+ score: 1.0,
364
+ layer: m.layer,
365
+ strength: m.strength,
366
+ isFreshTail: true,
367
+ };
368
+ if (opts.explain)
369
+ item.rerankPipeline = 'api';
370
+ freshRanked.push(item);
371
+ seenIds.add(m.id);
372
+ }
373
+ return freshRanked;
374
+ }
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, {
379
+ 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, {
426
389
  tenantId: ctx.tenantId,
427
- actor: ctx.actor.subject,
428
- op: 'recall',
429
- metadata: {
430
- ...auditQueryFields(opts.query),
431
- results: rankedOut.length,
432
- },
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
+ })),
433
399
  });
434
- // LC1 (docs/plans/2026-08-02-lc1-recall-trace-persistence.md): trace the
435
- // returned ids+ranks+scores next to the audit emit, on the SAME open
436
- // handle. v1.11.5 contract lock holds — api.recall does NOT write
437
- // last_trace_id (tests/api-recall-no-side-effects.test.ts); a trace INSERT
438
- // is the same observability class as the audit row it sits beside, not
439
- // retrieval state. F2 fix: suppressed when the caller traces its own,
440
- // different result set (retrieve under showRanked traces the shown list as
441
- // 'mcp'). Fail-soft internally; never throws.
442
- if (!opts.suppressRecallTrace) {
443
- writeRecallTrace(db, {
444
- tenantId: ctx.tenantId,
445
- sessionId: opts.sessionId ?? null,
446
- pipeline: 'api',
447
- query: opts.query,
448
- explainMode: opts.explain === true,
449
- results: rankedOut.map((r) => ({
450
- memoryId: r.id,
451
- score: r.score,
452
- rerankSteps: r.rerankTrace,
453
- })),
454
- });
455
- }
400
+ }
401
+ }
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) {
404
+ const snapshot = loadActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId);
405
+ const sessionId = snapshot?.session_id ?? undefined;
406
+ const sessionHandoff = sessionId
407
+ ? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, sessionId)
408
+ : null;
409
+ const recentSessionEvents = sessionId
410
+ ? listSessionEvents(ctx.hippoRoot, ctx.tenantId, { session_id: sessionId, limit: 5 })
411
+ : [];
412
+ // The memory-recall scope rule: an explicit scope must match exactly; without one, private and legacy rows are denied.
413
+ const rowScope = (r) => r?.scope ?? null;
414
+ const filteredSnapshot = snapshot && passesScopeFilterForRecall(rowScope(snapshot), opts.scope) ? snapshot : null;
415
+ const filteredHandoff = sessionHandoff && passesScopeFilterForRecall(rowScope(sessionHandoff), opts.scope) ? sessionHandoff : null;
416
+ const filteredEvents = recentSessionEvents.filter((e) => passesScopeFilterForRecall(rowScope(e), opts.scope));
417
+ const continuity = {
418
+ activeSnapshot: filteredSnapshot,
419
+ sessionHandoff: filteredHandoff,
420
+ recentSessionEvents: filteredEvents,
421
+ };
422
+ return { continuity, continuityTokens: continuityTokensOf(continuity) };
423
+ }
424
+ function continuityTokensOf(c) {
425
+ const filteredSnapshot = c.activeSnapshot;
426
+ const filteredHandoff = c.sessionHandoff;
427
+ const tokenize = (s) => s ? estimateTokens(s) : 0;
428
+ return tokenize(filteredSnapshot?.task) +
429
+ tokenize(filteredSnapshot?.summary) +
430
+ tokenize(filteredSnapshot?.next_step) +
431
+ tokenize(filteredHandoff?.summary) +
432
+ tokenize(filteredHandoff?.nextAction) +
433
+ (filteredHandoff?.artifacts ?? []).reduce((acc, a) => acc + tokenize(a), 0) +
434
+ (filteredHandoff?.constraints ?? []).reduce((acc, c) => acc + tokenize(c), 0) +
435
+ tokenize(filteredHandoff?.evidence ? formatHandoffEvidenceLine(filteredHandoff.evidence) : null) +
436
+ tokenize(filteredHandoff?.outcome) +
437
+ tokenize(filteredHandoff?.targetRuntime) +
438
+ tokenize(filteredHandoff?.cardId) +
439
+ c.recentSessionEvents.reduce((acc, e) => acc + tokenize(e.content), 0);
440
+ }
441
+ /** One audit row on its own short-lived handle, as each bias detector writes it. */
442
+ function appendRecallAudit(ctx, event) {
443
+ const db = openHippoDb(ctx.hippoRoot);
444
+ try {
445
+ appendAuditEvent(db, { tenantId: ctx.tenantId, actor: ctx.actor.subject, ...event });
456
446
  }
457
447
  finally {
458
448
  closeHippoDb(db);
459
449
  }
460
- let continuity;
461
- let continuityTokens;
462
- if (opts.includeContinuity) {
463
- const snapshot = loadActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId);
464
- // No active snapshot = no anchor = no handoff/events. Avoids resurrecting
465
- // a stale handoff from a deleted/completed session.
466
- const sessionId = snapshot?.session_id ?? undefined;
467
- const sessionHandoff = sessionId
468
- ? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, sessionId)
469
- : null;
470
- const recentSessionEvents = sessionId
471
- ? listSessionEvents(ctx.hippoRoot, ctx.tenantId, { session_id: sessionId, limit: 5 })
472
- : [];
473
- // Scope filtering on continuity. Mirrors the memory-recall path:
474
- // - opts.scope set: EXACT match required (no cross-scope leakage)
475
- // - opts.scope unset: default-deny on ANY `<source>:private:*` AND on
476
- // legacy 'unknown:legacy' rows quarantined by the v23 migration.
477
- // Public and null scopes pass through.
478
- // v1.1.0 wrongly wrote this as `opts.scope || isPublic`, which allowed
479
- // ANY explicit scope to see ALL continuity rows. v1.2 closed the latent
480
- // leak. v1.2.1 generalizes the private check from slack-only to any
481
- // source so v1.3 GitHub (and future Jira/Linear/etc.) cannot leak.
482
- const rowScope = (r) => r?.scope ?? null;
483
- // v1.2: TaskSnapshot / SessionHandoff / SessionEvent now carry scope; the
484
- // wrapper just normalizes null vs undefined. W1: was its own copy of
485
- // passesScopeFilterForRecall (cloned 3x); calls the shared helper now.
486
- const filteredSnapshot = snapshot && passesScopeFilterForRecall(rowScope(snapshot), opts.scope) ? snapshot : null;
487
- const filteredHandoff = sessionHandoff && passesScopeFilterForRecall(rowScope(sessionHandoff), opts.scope) ? sessionHandoff : null;
488
- const filteredEvents = recentSessionEvents.filter((e) => passesScopeFilterForRecall(rowScope(e), opts.scope));
489
- continuity = {
490
- activeSnapshot: filteredSnapshot,
491
- sessionHandoff: filteredHandoff,
492
- recentSessionEvents: filteredEvents,
493
- };
494
- const tokenize = (s) => s ? estimateTokens(s) : 0;
495
- continuityTokens =
496
- tokenize(filteredSnapshot?.task) +
497
- tokenize(filteredSnapshot?.summary) +
498
- tokenize(filteredSnapshot?.next_step) +
499
- tokenize(filteredHandoff?.summary) +
500
- tokenize(filteredHandoff?.nextAction) +
501
- (filteredHandoff?.artifacts ?? []).reduce((acc, a) => acc + tokenize(a), 0) +
502
- (filteredHandoff?.constraints ?? []).reduce((acc, c) => acc + tokenize(c), 0) +
503
- tokenize(filteredHandoff?.evidence ? formatHandoffEvidenceLine(filteredHandoff.evidence) : null) +
504
- tokenize(filteredHandoff?.outcome) +
505
- tokenize(filteredHandoff?.targetRuntime) +
506
- tokenize(filteredHandoff?.cardId) +
507
- filteredEvents.reduce((acc, e) => acc + tokenize(e.content), 0);
450
+ }
451
+ // A pure read of the caller's recallHistory snapshot against this top-1. HIPPO_ANCHORING=off skips even the detect
452
+ // call; CLI paths pass no history (cmdRecall computes its own hint), so the hint stays absent there.
453
+ function detectRecallAnchoring(ctx, opts, topMemoryId) {
454
+ if (!biasHintEnabled('anchoring') || !opts.recallHistory)
455
+ return { anchoringHint: null, suppressedByInterference: 0 };
456
+ const queryHash = hashQueryText(opts.query);
457
+ const anchoringHint = detectAnchoring(opts.recallHistory, queryHash, topMemoryId);
458
+ if (anchoringHint?.reason === 'memory_dominance') {
459
+ appendRecallAudit(ctx, {
460
+ op: 'recall_anchor_detected_memory_dominance',
461
+ targetId: anchoringHint.memoryId,
462
+ metadata: {
463
+ memory_id: anchoringHint.memoryId,
464
+ query_count: anchoringHint.queryCount ?? null,
465
+ },
466
+ });
467
+ return { anchoringHint, suppressedByInterference: 1 };
508
468
  }
509
- // v0.32 / J3.2 — auto-injection of reference-class baserate when the
510
- // query carries a forward-prediction phrase AND the closest matching
511
- // class has closed historical data. Pipeline-invariant (queryText-
512
- // derived), so MCP and CLI both read this as the single source of
513
- // truth instead of recomputing (unlike suppressionSummary which IS
514
- // per-pipeline). opts.actor threads through to the inner
515
- // computePredictionBaserate call so MCP/HTTP-originated hints attribute
516
- // correctly instead of defaulting to 'cli'. Disabled by HIPPO_AUTODEBIAS=off.
517
- // The hint and the no-class-match / tiebreak watching variant are mutually exclusive; both go out as optional fields.
518
- const planningFallacyOutput = computePlanningFallacyOutput(ctx.hippoRoot, ctx.tenantId, opts.query, { actor: ctx.actor.subject });
519
- const planningFallacyHint = planningFallacyOutput.hint ?? null;
520
- const planningFallacyWatching = planningFallacyOutput.watching ?? null;
521
- // v0.33 / J1 (v1.13.2) — recall-recurrence anchoring detection.
522
- // Uses opts.recallHistory (caller-supplied snapshot) + this pipeline's
523
- // own top-1 from rankedOut[0]. PURE read — does NOT mutate the snapshot
524
- // or any caller-side Map. Disabled by HIPPO_ANCHORING=off (which gates
525
- // even the detectAnchoring call so disabled tenants pay zero work on
526
- // this surface). On CLI-routed call paths opts.recallHistory is
527
- // undefined because cmdRecall computes its own hint separately; the
528
- // detect call returns null and api.recall's anchoringHint stays absent.
529
- let anchoringHint = null;
530
- let suppressedByInterferenceCount = 0;
531
- if (biasHintEnabled('anchoring') && opts.recallHistory) {
532
- const queryHash = hashQueryText(opts.query);
533
- const topMemoryId = rankedOut[0]?.id ?? null;
534
- anchoringHint = detectAnchoring(opts.recallHistory, queryHash, topMemoryId);
535
- if (anchoringHint?.reason === 'memory_dominance') {
536
- suppressedByInterferenceCount = 1;
537
- // Emit audit op for the memory-dominance detection.
538
- const db = openHippoDb(ctx.hippoRoot);
539
- try {
540
- appendAuditEvent(db, {
541
- tenantId: ctx.tenantId,
542
- actor: ctx.actor.subject,
543
- op: 'recall_anchor_detected_memory_dominance',
544
- targetId: anchoringHint.memoryId,
545
- metadata: {
546
- memory_id: anchoringHint.memoryId,
547
- query_count: anchoringHint.queryCount ?? null,
548
- },
549
- });
550
- }
551
- finally {
552
- closeHippoDb(db);
553
- }
554
- }
555
- else if (anchoringHint?.reason === 'query_repeat') {
556
- const db = openHippoDb(ctx.hippoRoot);
557
- try {
558
- appendAuditEvent(db, {
559
- tenantId: ctx.tenantId,
560
- actor: ctx.actor.subject,
561
- op: 'recall_anchor_detected_query_repeat',
562
- targetId: anchoringHint.memoryId,
563
- metadata: { memory_id: anchoringHint.memoryId },
564
- });
565
- }
566
- finally {
567
- closeHippoDb(db);
568
- }
569
- }
469
+ if (anchoringHint?.reason === 'query_repeat') {
470
+ appendRecallAudit(ctx, {
471
+ op: 'recall_anchor_detected_query_repeat',
472
+ targetId: anchoringHint.memoryId,
473
+ metadata: { memory_id: anchoringHint.memoryId },
474
+ });
570
475
  }
571
- // v1.13.x / J2 — availability/recency-bias detection. PURE read: compares
572
- // the age distribution of the returned top-K (baseSlice, the post-goal-boost
573
- // slice) against the matched candidate pool it was drawn from (entries, the
574
- // scope/private-FILTERED candidate set baseSlice is sliced from — NOT `all`,
575
- // which still holds private/cross-scope rows the caller is not eligible to see
576
- // and that could never enter the top-K; counting them would leak hidden pool
577
- // shape and inflate the signal). Soft warning only — does NOT filter, reorder,
578
- // or suppress. Disabled by HIPPO_AVAILABILITY=off (gates even the detect call
579
- // so disabled tenants pay zero work). Suppressed via opts.suppressAvailabilityHint
580
- // when the caller computes its own per-pipeline hint (MCP), mirroring the J1
581
- // opts.recallHistory gate above so we never double-emit the audit op. Audit
582
- // emission is pipeline-local, mirroring the J1 block above.
583
- let availabilityHint = null;
584
- if (biasHintEnabled('availability') && !opts.suppressAvailabilityHint) {
585
- availabilityHint = detectAvailabilityBias({
586
- topK: baseSlice.map((e) => ({ id: e.id, created: e.created })),
587
- pool: entries.map((e) => ({ id: e.id, created: e.created })),
476
+ return { anchoringHint, suppressedByInterference: 0 };
477
+ }
478
+ // Compares the returned top-K's ages with the scope-filtered pool it came from, never `all`, whose hidden rows would
479
+ // leak pool shape. A soft warning only; HIPPO_AVAILABILITY=off or a caller computing its own hint skips it.
480
+ function detectRecallAvailability(ctx, opts, baseSlice, entries) {
481
+ if (!biasHintEnabled('availability') || opts.suppressAvailabilityHint)
482
+ return null;
483
+ const availabilityHint = detectAvailabilityBias({
484
+ topK: baseSlice.map((e) => ({ id: e.id, created: e.created })),
485
+ pool: entries.map((e) => ({ id: e.id, created: e.created })),
486
+ });
487
+ if (availabilityHint) {
488
+ appendRecallAudit(ctx, {
489
+ op: 'recall_availability_detected',
490
+ metadata: {
491
+ recent_fraction: availabilityHint.recentFraction,
492
+ older_passed_over: availabilityHint.olderCandidatesPassedOver,
493
+ returned_count: availabilityHint.returnedCount,
494
+ },
588
495
  });
589
- if (availabilityHint) {
590
- const db = openHippoDb(ctx.hippoRoot);
591
- try {
592
- appendAuditEvent(db, {
593
- tenantId: ctx.tenantId,
594
- actor: ctx.actor.subject,
595
- op: 'recall_availability_detected',
596
- metadata: {
597
- recent_fraction: availabilityHint.recentFraction,
598
- older_passed_over: availabilityHint.olderCandidatesPassedOver,
599
- returned_count: availabilityHint.returnedCount,
600
- },
601
- });
602
- }
603
- finally {
604
- closeHippoDb(db);
605
- }
606
- }
607
496
  }
608
- const result = {
609
- results: rankedOut,
610
- total: totalOut,
611
- tokens: tokensOut,
612
- continuity,
613
- continuityTokens,
614
- windowSize,
615
- suppressionSummary: buildSuppressionSummary({
616
- totalCandidates: totalCandidatesCount,
617
- droppedPreRank: droppedPreRankCount,
618
- droppedByBudget: droppedByBudgetCount,
619
- summarySubstitutionsAdded: summarySubstitutionsCount,
620
- freshTailAdded: freshTailAddedCount,
621
- suppressedByInterference: suppressedByInterferenceCount,
622
- }),
623
- };
624
- if (planningFallacyHint)
625
- result.planningFallacyHint = planningFallacyHint;
626
- if (planningFallacyWatching)
627
- result.planningFallacyWatching = planningFallacyWatching;
628
- if (anchoringHint)
629
- result.anchoringHint = anchoringHint;
630
- if (availabilityHint)
631
- result.availabilityHint = availabilityHint;
632
- return result;
497
+ return availabilityHint;
633
498
  }
634
499
  //# sourceMappingURL=recall.js.map