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.
- package/README.md +28 -53
- package/dist/agent-memories/apply.d.ts +1 -1
- package/dist/agent-memories/claude-code.d.ts +3 -1
- package/dist/agent-memories/claude-code.js +53 -9
- package/dist/agent-memories/sync.d.ts +3 -3
- package/dist/agent-memories/sync.js +14 -6
- package/dist/agent-memories/types.d.ts +0 -2
- package/dist/api/assemble.js +55 -54
- package/dist/api/context-select.d.ts +49 -0
- package/dist/api/context-select.js +344 -0
- package/dist/api/context.d.ts +2 -2
- package/dist/api/context.js +195 -522
- package/dist/api/drill-down.js +36 -33
- package/dist/api/promote.js +55 -66
- package/dist/api/recall.js +303 -438
- package/dist/api/sleep.js +203 -218
- package/dist/capture/compact.d.ts +1 -1
- package/dist/capture/compact.js +2 -2
- package/dist/cli/briefs.js +324 -306
- package/dist/cli/context.js +44 -34
- package/dist/cli/continuity.js +283 -271
- package/dist/cli/curate.js +35 -34
- package/dist/cli/decisions.js +333 -333
- package/dist/cli/explain.js +66 -60
- package/dist/cli/maintenance.js +62 -51
- package/dist/cli/playbooks.js +387 -370
- package/dist/cli/projects.js +8 -5
- package/dist/cli/recall.js +28 -43
- package/dist/cli/remember.js +113 -70
- package/dist/cli/session-hooks.js +100 -90
- package/dist/cli/setup.js +267 -246
- package/dist/cli/status.js +73 -64
- package/dist/cli/transfer.js +85 -99
- package/dist/compaction-record.d.ts +0 -2
- package/dist/compaction-record.js +1 -1
- package/dist/customer-notes.js +77 -68
- package/dist/dag.js +222 -186
- package/dist/decisions.js +93 -76
- package/dist/doctor.js +11 -7
- package/dist/goals.js +99 -86
- package/dist/incidents.js +45 -38
- package/dist/policies.js +85 -68
- package/dist/processes.js +87 -71
- package/dist/project-briefs.js +135 -108
- package/dist/project-merge.d.ts +12 -4
- package/dist/project-merge.js +130 -45
- package/dist/shared.d.ts +9 -0
- package/dist/shared.js +10 -8
- package/dist/skills.js +81 -65
- package/dist/store/search-rows.d.ts +2 -2
- package/dist/store/search-rows.js +15 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/api/recall.js
CHANGED
|
@@ -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
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
:
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
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
|