hippo-memory 1.52.7 → 1.52.9

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 (84) hide show
  1. package/README.md +159 -99
  2. package/dist/api.d.ts +52 -18
  3. package/dist/api.js +155 -86
  4. package/dist/audit.d.ts +2 -1
  5. package/dist/audit.js +63 -0
  6. package/dist/autolearn.js +3 -2
  7. package/dist/capture.d.ts +40 -0
  8. package/dist/capture.js +141 -119
  9. package/dist/churn-git.js +2 -2
  10. package/dist/cli.d.ts +1 -4
  11. package/dist/cli.js +695 -702
  12. package/dist/codex-patch.d.ts +12 -0
  13. package/dist/codex-patch.js +71 -0
  14. package/dist/config.d.ts +0 -1
  15. package/dist/config.js +0 -4
  16. package/dist/connectors/slack/types.d.ts +0 -1
  17. package/dist/consolidate.js +85 -32
  18. package/dist/context-render.d.ts +36 -0
  19. package/dist/context-render.js +154 -0
  20. package/dist/dag.js +3 -2
  21. package/dist/dashboard.js +4 -0
  22. package/dist/db.js +6 -6
  23. package/dist/dedupe.d.ts +6 -6
  24. package/dist/dedupe.js +10 -9
  25. package/dist/doctor.d.ts +1 -1
  26. package/dist/doctor.js +35 -2
  27. package/dist/dormant.d.ts +4 -0
  28. package/dist/dormant.js +17 -2
  29. package/dist/embedding-provider.d.ts +2 -1
  30. package/dist/embedding-provider.js +2 -1
  31. package/dist/embeddings.js +23 -3
  32. package/dist/extensions/openclaw-plugin/index.js +1 -0
  33. package/dist/extract.js +5 -1
  34. package/dist/forward-claim-detector.d.ts +1 -1
  35. package/dist/forward-claim-detector.js +1 -1
  36. package/dist/graph-recall.d.ts +3 -1
  37. package/dist/graph-recall.js +13 -10
  38. package/dist/handoff.d.ts +2 -0
  39. package/dist/hooks.d.ts +19 -5
  40. package/dist/hooks.js +131 -30
  41. package/dist/importers.js +5 -12
  42. package/dist/judgment.d.ts +30 -0
  43. package/dist/judgment.js +122 -0
  44. package/dist/mcp/server.js +174 -213
  45. package/dist/merged-row.d.ts +6 -0
  46. package/dist/merged-row.js +35 -0
  47. package/dist/multihop.d.ts +2 -1
  48. package/dist/multihop.js +7 -4
  49. package/dist/physics-state.d.ts +0 -4
  50. package/dist/physics-state.js +0 -6
  51. package/dist/predictions.d.ts +2 -17
  52. package/dist/predictions.js +2 -15
  53. package/dist/reject-flow.d.ts +7 -5
  54. package/dist/reject-flow.js +41 -12
  55. package/dist/salience.js +12 -5
  56. package/dist/same-text.d.ts +17 -0
  57. package/dist/same-text.js +38 -0
  58. package/dist/scheduler.d.ts +4 -0
  59. package/dist/scheduler.js +8 -0
  60. package/dist/search.d.ts +7 -0
  61. package/dist/search.js +16 -32
  62. package/dist/secret-detect.d.ts +2 -0
  63. package/dist/secret-detect.js +9 -3
  64. package/dist/server-detect.js +9 -33
  65. package/dist/server.js +6 -62
  66. package/dist/session-digest.d.ts +79 -0
  67. package/dist/session-digest.js +528 -0
  68. package/dist/shared.d.ts +11 -3
  69. package/dist/shared.js +41 -31
  70. package/dist/store.d.ts +4 -5
  71. package/dist/store.js +26 -13
  72. package/dist/token-ledger.d.ts +46 -8
  73. package/dist/token-ledger.js +140 -21
  74. package/dist/version.d.ts +1 -1
  75. package/dist/version.js +1 -1
  76. package/dist-ui/assets/index-BhT8RvO6.js +61 -0
  77. package/dist-ui/index.html +1 -1
  78. package/extensions/openclaw-plugin/README.md +4 -4
  79. package/extensions/openclaw-plugin/index.ts +1 -0
  80. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  81. package/extensions/openclaw-plugin/package.json +1 -1
  82. package/openclaw.plugin.json +2 -2
  83. package/package.json +2 -2
  84. package/dist-ui/assets/index-BgmA7Hwe.js +0 -61
package/dist/api.d.ts CHANGED
@@ -95,6 +95,7 @@ export { classifyOriginProject } from './project-identity.js';
95
95
  * flagged rows with no project origin never ambient-inject.
96
96
  */
97
97
  export declare function ambientSecretAdmit(e: MemoryEntry, currentProjectName: string): boolean;
98
+ export declare function oneCopyPerMemory(local: readonly MemoryEntry[], global: readonly MemoryEntry[], now: Date): [MemoryEntry[], MemoryEntry[]];
98
99
  export interface RememberOpts {
99
100
  content: string;
100
101
  kind?: MemoryKind;
@@ -273,6 +274,9 @@ export interface RecallOpts {
273
274
  * callers leave this unset and get the trace.
274
275
  */
275
276
  suppressRecallTrace?: boolean;
277
+ /** Set only by the MCP recall tool, which ranks with its own scorer and drops copies from its own final list: this call
278
+ * then keeps a memory that a merged row in the same result holds word for word. Other callers leave it unset. */
279
+ keepHeldCopies?: boolean;
276
280
  }
277
281
  export interface ContinuityBlock {
278
282
  activeSnapshot: TaskSnapshot | null;
@@ -372,7 +376,7 @@ export interface RecallResult {
372
376
  * the calling agent sees its track record at the moment of forecasting
373
377
  * (Lovallo-Kahneman 2003 inside-vs-outside view).
374
378
  *
375
- * Populated by `api.recall` itself via `computePlanningFallacyHint`.
379
+ * Populated by `api.recall` itself via `computePlanningFallacyOutput`.
376
380
  * Pipeline-invariant: the value depends only on (queryText, tenantId,
377
381
  * predictions table state) — all three are identical regardless of
378
382
  * which downstream search pipeline produces the memory list, so MCP
@@ -552,6 +556,11 @@ export interface AssembleOpts {
552
556
  * is set on the result so the caller knows to widen.
553
557
  */
554
558
  rowCap?: number;
559
+ cost?: AssembleCost;
560
+ }
561
+ export interface AssembleCost {
562
+ item: (it: AssembledContextItem) => number;
563
+ fixed: (widest: number) => number;
555
564
  }
556
565
  export interface AssembledContextItem {
557
566
  id: string;
@@ -622,7 +631,7 @@ export interface DrillDownOpts {
622
631
  /**
623
632
  * Optional token budget. When set, children are appended in chronological
624
633
  * order (created ASC) until adding the next child would exceed the budget.
625
- * Token cost = ceil(content.length / 4) per child.
634
+ * Token cost = the child's printed line under `cost`, else ceil(content.length / 4).
626
635
  *
627
636
  * For depth > 1, the budget is GLOBAL cumulative (NOT per-level).
628
637
  */
@@ -635,22 +644,29 @@ export interface DrillDownOpts {
635
644
  * construction).
636
645
  */
637
646
  depth?: number;
647
+ cost?: DrillDownCost;
648
+ }
649
+ export interface DrillDownSummary {
650
+ id: string;
651
+ content: string;
652
+ descendantCount: number;
653
+ earliestAt: string | null;
654
+ latestAt: string | null;
655
+ }
656
+ export interface DrillDownChild {
657
+ id: string;
658
+ content: string;
659
+ layer: string;
660
+ dagLevel: number;
661
+ created: string;
662
+ }
663
+ export interface DrillDownCost {
664
+ child: (c: DrillDownChild) => number;
665
+ fixed: (summary: DrillDownSummary, widest: number) => number;
638
666
  }
639
667
  export interface DrillDownResult {
640
- summary: {
641
- id: string;
642
- content: string;
643
- descendantCount: number;
644
- earliestAt: string | null;
645
- latestAt: string | null;
646
- };
647
- children: Array<{
648
- id: string;
649
- content: string;
650
- layer: string;
651
- dagLevel: number;
652
- created: string;
653
- }>;
668
+ summary: DrillDownSummary;
669
+ children: DrillDownChild[];
654
670
  totalChildren: number;
655
671
  truncated: boolean;
656
672
  }
@@ -954,10 +970,28 @@ export interface ContextOpts {
954
970
  currentSessionId?: string | null;
955
971
  /** Z1: raw hook-payload prompt; only the pinned-only branch reads it, gated on `pinnedInject.promptRecall`. */
956
972
  prompt?: string;
973
+ /** What the budget pays for, from the caller that renders the block. Absent = the memory text alone. */
974
+ cost?: ContextCost;
975
+ }
976
+ /** Budget prices in the text a caller prints, so the budget bounds what reaches the model. */
977
+ export interface ContextCost {
978
+ /** Tokens of one entry as printed. */
979
+ entry: (item: Pick<ContextResultEntry, 'entry' | 'isGlobal' | 'promptRecall' | 'origin' | 'category'>) => number;
980
+ /** Tokens of the headers and footer the block can print at this budget, reserved before any entry. */
981
+ fixed: (budget: number, can: {
982
+ cross: boolean;
983
+ promptRecall: boolean;
984
+ ambient: boolean;
985
+ }) => number;
986
+ /** Tokens of the sections printed ahead of the memories, each as printed. */
987
+ snapshot: (s: TaskSnapshot) => number;
988
+ handoff: (h: SessionHandoff) => number;
989
+ trail: (events: SessionEvent[]) => number;
957
990
  }
958
991
  export interface ContextResultEntry {
959
992
  entry: MemoryEntry;
960
993
  score: number;
994
+ /** What this entry cost the budget: its printed line under `ContextOpts.cost`, else its memory text. */
961
995
  tokens: number;
962
996
  isGlobal?: boolean;
963
997
  isFreshTail?: boolean;
@@ -1036,8 +1070,8 @@ export declare function recordTokens(ctx: Context, surface: TokenSurface, use: {
1036
1070
  }): void;
1037
1071
  /**
1038
1072
  * Token ledger totals for the tenant over the last `days` days (default 30):
1039
- * tokens sent per surface, blocks skipped as unchanged and the tokens that
1040
- * saved, and mean tokens per session.
1073
+ * tokens sent, skipped as unchanged and re-read by later model calls, per
1074
+ * surface, with session counts and mean tokens per session.
1041
1075
  */
1042
1076
  export declare function tokenSummary(ctx: Context, opts?: {
1043
1077
  days?: number;
package/dist/api.js CHANGED
@@ -27,12 +27,14 @@ import { createApiKey, listApiKeys, revokeApiKey, grantScope, ungrantScope, } fr
27
27
  import { applyGoalStackBoost } from './goals.js';
28
28
  import { markRetrieved, estimateTokens, hybridSearch, physicsSearch, churnStaleFactor } from './search.js';
29
29
  import { compareEntryIdentity, compareScoredResults } from './compare.js';
30
+ import { dropHeldCopies, duplicateKey, storedTextKeys } from './same-text.js';
30
31
  import { scopeMatch } from './scope.js';
31
32
  import { consolidate } from './consolidate.js';
32
33
  import { loadConfig } from './config.js';
33
34
  import { resolveProjectIdentity, classifyOriginProject, isGlobalStoreRoot } from './project-identity.js';
34
35
  import { promptTokens, contentTokens, gatePromptRecall, } from './prompt-recall.js';
35
36
  import { detectSecret } from './secret-detect.js';
37
+ import { isSessionDigestRow } from './session-digest.js';
36
38
  import { deduplicateStore } from './dedupe.js';
37
39
  import { computeAmbientState } from './ambient.js';
38
40
  import { loadPendingExtractionTenants, markPendingProcessedUpTo } from './graph.js';
@@ -142,6 +144,26 @@ function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admi
142
144
  const admitAmbient = (e) => admit(e) && (e.pinned || isContentWorthStoring(e.content));
143
145
  return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient, recall);
144
146
  }
147
+ // Share and promote copy a memory to the global store under a new id, so equal content is the only link.
148
+ // A pinned copy wins, then the stronger one after the ranking's own global discount; a tie keeps the local copy.
149
+ export function oneCopyPerMemory(local, global, now) {
150
+ const score = (e, isGlobal) => calculateStrength(e, now) * (isGlobal ? 1 / 1.2 : 1);
151
+ const best = new Map();
152
+ const offer = (entry, isGlobal) => {
153
+ const held = best.get(entry.content);
154
+ const wins = !held || (held.entry.pinned !== entry.pinned
155
+ ? entry.pinned
156
+ : score(entry, isGlobal) > score(held.entry, held.isGlobal));
157
+ if (wins)
158
+ best.set(entry.content, { entry, isGlobal });
159
+ };
160
+ for (const e of local)
161
+ offer(e, false);
162
+ for (const e of global)
163
+ offer(e, true);
164
+ const kept = new Set([...best.values()].map((b) => b.entry));
165
+ return [local.filter((e) => kept.has(e)), global.filter((e) => kept.has(e))];
166
+ }
145
167
  export function remember(ctx, opts) {
146
168
  const detection = opts.untrusted ? detectInstruction(opts.content) : { flagged: false, reason: null };
147
169
  const requestedScope = opts.scope ?? null;
@@ -405,6 +427,13 @@ function recallFrom(ctx, opts, windowSize, all) {
405
427
  }));
406
428
  }
407
429
  }
430
+ if (!opts.keepHeldCopies) {
431
+ const shownIds = new Set(dropHeldCopies([...baseScored.map((r) => r.entry), ...substituted.map((s) => s.entry)], (e) => e).map((e) => e.id));
432
+ droppedPreRankCount += baseScored.filter((r) => !shownIds.has(r.entry.id)).length;
433
+ baseScored = baseScored.filter((r) => shownIds.has(r.entry.id));
434
+ baseSlice = baseScored.map((r) => r.entry);
435
+ substituted = substituted.filter((s) => shownIds.has(s.entry.id));
436
+ }
408
437
  // v1.12.13 / C5 — WYSIATI summary_substitutions_added counter.
409
438
  summarySubstitutionsCount = substituted.length;
410
439
  // v1.7.4 -- baseScored carries the (possibly boosted) per-row scores. When
@@ -478,9 +507,11 @@ function recallFrom(ctx, opts, windowSize, all) {
478
507
  ...baseRanked.map((r) => r.id),
479
508
  ...summaryRanked.map((r) => r.id),
480
509
  ]);
510
+ const shownKeys = storedTextKeys(opts.keepHeldCopies ? [] : [...baseSlice, ...substituted.map((s) => s.entry)]);
481
511
  for (const m of recentScoped) {
482
- if (seenIds.has(m.id))
512
+ if (seenIds.has(m.id) || shownKeys.has(duplicateKey(m.content)))
483
513
  continue;
514
+ shownKeys.add(duplicateKey(m.content));
484
515
  const item = {
485
516
  id: m.id,
486
517
  content: m.content,
@@ -606,10 +637,7 @@ function recallFrom(ctx, opts, windowSize, all) {
606
637
  // per-pipeline). opts.actor threads through to the inner
607
638
  // computePredictionBaserate call so MCP/HTTP-originated hints attribute
608
639
  // correctly instead of defaulting to 'cli'. Disabled by HIPPO_AUTODEBIAS=off.
609
- // v1.13.4: switched from computePlanningFallacyHint to
610
- // computePlanningFallacyOutput so the no-class-match / tiebreak
611
- // watching variant can also reach the caller surface. The two
612
- // outputs are mutually exclusive; we splat both as optional fields.
640
+ // The hint and the no-class-match / tiebreak watching variant are mutually exclusive; both go out as optional fields.
613
641
  const planningFallacyOutput = computePlanningFallacyOutput(ctx.hippoRoot, ctx.tenantId, opts.query, { actor: ctx.actor.subject });
614
642
  const planningFallacyHint = planningFallacyOutput.hint ?? null;
615
643
  const planningFallacyWatching = planningFallacyOutput.watching ?? null;
@@ -849,9 +877,11 @@ export function assemble(ctx, sessionId, opts = {}) {
849
877
  olderItems.sort((a, b) => cmpIso(a.createdAt, b.createdAt));
850
878
  tailItems.sort((a, b) => cmpIso(a.createdAt, b.createdAt));
851
879
  let items = [...olderItems, ...tailItems];
852
- let tokens = items.reduce((acc, it) => acc + estimateTokens(it.content), 0);
880
+ const itemCost = opts.cost?.item ?? ((it) => estimateTokens(it.content));
881
+ const room = budget - (opts.cost?.fixed(Math.max(budget, totalRaw)) ?? 0);
882
+ let tokens = items.reduce((acc, it) => acc + itemCost(it), 0);
853
883
  let evicted = 0;
854
- while (tokens > budget && items.length > 0) {
884
+ while (tokens > room && items.length > 0) {
855
885
  let worstIdx = -1;
856
886
  let worstStrength = Infinity;
857
887
  for (let i = 0; i < items.length; i++) {
@@ -864,7 +894,7 @@ export function assemble(ctx, sessionId, opts = {}) {
864
894
  }
865
895
  if (worstIdx === -1)
866
896
  break;
867
- const cost = estimateTokens(items[worstIdx].content);
897
+ const cost = itemCost(items[worstIdx]);
868
898
  items = items.filter((_, i) => i !== worstIdx);
869
899
  tokens -= cost;
870
900
  evicted++;
@@ -941,15 +971,32 @@ export function drillDown(ctx, summaryId, opts = {}) {
941
971
  break;
942
972
  frontier = nextFrontier;
943
973
  }
974
+ const summaryOut = {
975
+ id: summary.id,
976
+ content: summary.content,
977
+ // v0.30 / E5: the STORED direct-child count; the legacy fallback counts
978
+ // level-0 children, never the BFS-depth-N total (independent-review MED #4).
979
+ descendantCount: summary.descendant_count ?? level0DirectCount,
980
+ earliestAt: summary.earliest_at ?? null,
981
+ latestAt: summary.latest_at ?? null,
982
+ };
983
+ const all = collected.map((c) => ({
984
+ id: c.id,
985
+ content: c.content,
986
+ layer: c.layer,
987
+ dagLevel: c.dag_level ?? 0,
988
+ created: c.created,
989
+ }));
944
990
  // Apply global cumulative token budget + limit cap on collected.
945
- let children = collected;
991
+ let children = all;
946
992
  let truncated = false;
947
993
  if (opts.budget !== undefined) {
948
994
  const out = [];
949
995
  let used = 0;
950
- for (const c of collected) {
951
- const t = estimateTokens(c.content);
952
- if (out.length > 0 && used + t > opts.budget) {
996
+ const room = opts.budget - (opts.cost?.fixed(summaryOut, all.length) ?? 0);
997
+ for (const c of all) {
998
+ const t = opts.cost ? opts.cost.child(c) : estimateTokens(c.content);
999
+ if (out.length > 0 && used + t > room) {
953
1000
  truncated = true;
954
1001
  break;
955
1002
  }
@@ -963,25 +1010,8 @@ export function drillDown(ctx, summaryId, opts = {}) {
963
1010
  truncated = true;
964
1011
  }
965
1012
  return {
966
- summary: {
967
- id: summary.id,
968
- content: summary.content,
969
- // v0.30 / E5: descendant_count stays the summary's STORED value
970
- // (direct children at creation time). totalChildren below reflects
971
- // the full BFS collection at the requested depth.
972
- // independent-review MED #4 fold: legacy fallback uses level-0 direct
973
- // count (NOT collected.length which is BFS-depth-N total).
974
- descendantCount: summary.descendant_count ?? level0DirectCount,
975
- earliestAt: summary.earliest_at ?? null,
976
- latestAt: summary.latest_at ?? null,
977
- },
978
- children: children.map((c) => ({
979
- id: c.id,
980
- content: c.content,
981
- layer: c.layer,
982
- dagLevel: c.dag_level ?? 0,
983
- created: c.created,
984
- })),
1013
+ summary: summaryOut,
1014
+ children,
985
1015
  // v0.30 / E5: totalChildren = BFS-collected count (depth-aware). For
986
1016
  // depth=1 this equals the eligible direct-children count (backward
987
1017
  // compat). For depth>1 it is the cumulative count across levels.
@@ -1537,11 +1567,6 @@ export async function getContext(ctx, opts = {}) {
1537
1567
  const isolationEnabled = config.contextProjectIsolation !== false;
1538
1568
  const currentProjectName = opts.currentProject ?? resolveProjectIdentity(process.cwd()).name;
1539
1569
  const includeCrossProject = opts.crossProject === true || !isolationEnabled;
1540
- const ambientAdmit = (e) => ambientAdmitEntry(e, currentProjectName, includeCrossProject);
1541
- // Superseded rows never inject, and ambientAdmitEntry regex-scans content for
1542
- // secrets, so WHICH rows reach this predicate is what loadAmbientEntries cares
1543
- // about below.
1544
- const admit = (e) => !e.superseded_by && ambientAdmit(e);
1545
1570
  // Z1: decided before the ambient loads so the FTS candidate query below (pinned-only
1546
1571
  // branch) can piggyback on that connection instead of opening its own.
1547
1572
  const promptRecallPending = pinnedOnly && Boolean(opts.prompt?.trim()) && config.pinnedInject.promptRecall === true;
@@ -1551,18 +1576,21 @@ export async function getContext(ctx, opts = {}) {
1551
1576
  const recallRequest = promptRecallTerms.length > 0
1552
1577
  ? { terms: promptRecallTerms, limit: Math.floor(finiteOr(config.pinnedInject.promptRecallCandidates, 100, 1)) }
1553
1578
  : undefined;
1554
- // Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
1555
- const localLoad = hasLocal
1556
- ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, recallRequest)
1557
- : { entries: [] };
1558
- const globalLoad = hasGlobal
1559
- ? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, !isGlobalStoreRoot(ctx.hippoRoot) ? recallRequest : undefined)
1560
- : { entries: [] };
1561
- let localEntries = localLoad.entries;
1562
- let globalEntries = globalLoad.entries;
1563
- // Computed below, after markRetrieved runs, so avgStrength reflects the
1564
- // post-retrieval strengths rather than a stale pre-mutation snapshot.
1565
- let ambientState;
1579
+ const cost = opts.cost;
1580
+ const price = (entry, isGlobal, promptRecall) => cost
1581
+ ? cost.entry({ entry, isGlobal, promptRecall, origin: entry.origin_project ?? null, category: classifyOriginProject(entry.origin_project, currentProjectName) })
1582
+ : estimateTokens(entry.content);
1583
+ const blockBudget = pinnedOnly && opts.budget === undefined ? config.pinnedInject.budget : budget;
1584
+ let left = cost
1585
+ ? Math.max(0, blockBudget - cost.fixed(blockBudget, { cross: includeCrossProject, promptRecall: promptRecallPending, ambient: !pinnedOnly && config.ambient.enabled }))
1586
+ : blockBudget;
1587
+ // Sections print ahead of the memories, so they are paid first; one that does not fit is dropped, as an oversize entry is.
1588
+ const pays = (tokens) => {
1589
+ if (tokens > left)
1590
+ return false;
1591
+ left -= tokens;
1592
+ return true;
1593
+ };
1566
1594
  // DF1 T2: bounded read — an orphaned snapshot (no later pre-compact
1567
1595
  // superseded it, no session-end closed it) must age out of this ambient
1568
1596
  // surface instead of injecting into every future prompt forever. Owner
@@ -1601,12 +1629,38 @@ export async function getContext(ctx, opts = {}) {
1601
1629
  limit: 5,
1602
1630
  }).filter((e) => passesScopeFilterForRecall(rowScope(e), undefined))
1603
1631
  : [];
1632
+ const shownSnapshot = activeSnapshot && (!cost || pays(cost.snapshot(activeSnapshot))) ? activeSnapshot : null;
1633
+ const shownHandoff = sessionHandoff && (!cost || pays(cost.handoff(sessionHandoff))) ? sessionHandoff : null;
1634
+ const shownEvents = recentSessionEvents.length > 0 && (!cost || pays(cost.trail(recentSessionEvents))) ? recentSessionEvents : [];
1635
+ const transcriptHandoffSession = shownHandoff?.evidence?.derivedFrom === 'transcript' ? shownHandoff.sessionId : null;
1636
+ let digestHiddenForHandoff = false;
1637
+ const ambientAdmit = (e) => {
1638
+ // A printed handoff already carries the session's closing message, which its digest would print a second time.
1639
+ if (transcriptHandoffSession !== null && e.source_session_id === transcriptHandoffSession && isSessionDigestRow(e)) {
1640
+ digestHiddenForHandoff = true;
1641
+ return false;
1642
+ }
1643
+ return ambientAdmitEntry(e, currentProjectName, includeCrossProject);
1644
+ };
1645
+ // Superseded rows never inject; which rows reach ambientAdmitEntry matters because it regex-scans content for secrets.
1646
+ const admit = (e) => !e.superseded_by && ambientAdmit(e);
1647
+ // Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
1648
+ const localLoad = hasLocal
1649
+ ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, recallRequest)
1650
+ : { entries: [] };
1651
+ const globalLoad = hasGlobal
1652
+ ? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, !isGlobalStoreRoot(ctx.hippoRoot) ? recallRequest : undefined)
1653
+ : { entries: [] };
1654
+ let localEntries = localLoad.entries;
1655
+ let globalEntries = globalLoad.entries;
1656
+ // Computed after markRetrieved runs, so avgStrength reflects post-retrieval strengths.
1657
+ let ambientState;
1604
1658
  if (!promptRecallPending &&
1605
1659
  localEntries.length === 0 &&
1606
1660
  globalEntries.length === 0 &&
1607
- !activeSnapshot &&
1608
- !sessionHandoff &&
1609
- recentSessionEvents.length === 0) {
1661
+ !shownSnapshot &&
1662
+ !shownHandoff &&
1663
+ shownEvents.length === 0) {
1610
1664
  return { entries: [], tokens: 0 };
1611
1665
  }
1612
1666
  let selectedItems = [];
@@ -1617,16 +1671,17 @@ export async function getContext(ctx, opts = {}) {
1617
1671
  if (!pinnedCfg.pinnedInject.enabled) {
1618
1672
  return { entries: [], tokens: 0 };
1619
1673
  }
1620
- // Effective budget: explicit opts.budget wins over config.
1621
- const effBudget = opts.budget !== undefined ? budget : pinnedCfg.pinnedInject.budget;
1674
+ // Effective budget: explicit opts.budget wins over config, less what the sections took.
1675
+ const effBudget = left;
1622
1676
  const nowP = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
1677
+ const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, nowP);
1623
1678
  const selectedIds = new Set();
1624
1679
  let usedP = 0;
1625
1680
  // Pinned entries are explicit user intent, the recent-N list an automatic
1626
1681
  // backfill. Both loops share ONE budget and the recent loop runs first, so
1627
1682
  // pins are ranked here and reserve their share before it can spend.
1628
- const pinnedLocal = localEntries.filter((e) => e.pinned);
1629
- const pinnedGlobal = globalEntries.filter((e) => e.pinned);
1683
+ const pinnedLocal = localPool.filter((e) => e.pinned);
1684
+ const pinnedGlobal = globalPool.filter((e) => e.pinned);
1630
1685
  const rankedPinned = [
1631
1686
  ...pinnedLocal.map((e) => ({ entry: e, isGlobal: false })),
1632
1687
  ...pinnedGlobal.map((e) => ({ entry: e, isGlobal: true })),
@@ -1637,7 +1692,7 @@ export async function getContext(ctx, opts = {}) {
1637
1692
  return {
1638
1693
  entry,
1639
1694
  score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1) * sBst,
1640
- tokens: estimateTokens(entry.content),
1695
+ tokens: price(entry, isGlobal),
1641
1696
  isGlobal,
1642
1697
  };
1643
1698
  })
@@ -1683,19 +1738,21 @@ export async function getContext(ctx, opts = {}) {
1683
1738
  const p = promptTokens(opts.prompt ?? '');
1684
1739
  if (p.size > 0) {
1685
1740
  // Candidates came off the ambient load's own connection (recallRequest above), not a fresh open.
1686
- const localCandidates = localLoad.recall ?? [];
1687
- const globalCandidates = globalLoad.recall ?? [];
1741
+ // A candidate carrying a pin's text would inject that memory a second time.
1742
+ const pinnedText = new Set(rankedPinned.map((r) => r.entry.content));
1743
+ const eligible = (e) => admit(e) && !e.pinned && isContentWorthStoring(e.content) && !pinnedText.has(e.content);
1744
+ const [localCandidates, globalCandidates] = oneCopyPerMemory((localLoad.recall ?? []).filter(eligible), (globalLoad.recall ?? []).filter(eligible), nowP);
1688
1745
  const seenCandidateIds = new Set();
1689
1746
  const candidateItems = [];
1690
1747
  // Local wins the id collision (a global row synced into the local store).
1691
1748
  for (const e of localCandidates) {
1692
- if (!admit(e) || e.pinned || !isContentWorthStoring(e.content) || seenCandidateIds.has(e.id))
1749
+ if (seenCandidateIds.has(e.id))
1693
1750
  continue;
1694
1751
  seenCandidateIds.add(e.id);
1695
1752
  candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: false });
1696
1753
  }
1697
1754
  for (const e of globalCandidates) {
1698
- if (!admit(e) || e.pinned || !isContentWorthStoring(e.content) || seenCandidateIds.has(e.id))
1755
+ if (seenCandidateIds.has(e.id))
1699
1756
  continue;
1700
1757
  seenCandidateIds.add(e.id);
1701
1758
  candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: true });
@@ -1704,7 +1761,7 @@ export async function getContext(ctx, opts = {}) {
1704
1761
  for (const g of gated) {
1705
1762
  if (selectedIds.has(g.item.id))
1706
1763
  continue;
1707
- const tokens = estimateTokens(g.item.entry.content);
1764
+ const tokens = price(g.item.entry, g.item.isGlobal, true);
1708
1765
  if (usedP + tokens > recentBudget)
1709
1766
  continue;
1710
1767
  selectedItems.push({ entry: g.item.entry, score: g.score, tokens, isGlobal: g.item.isGlobal, promptRecall: true });
@@ -1715,8 +1772,8 @@ export async function getContext(ctx, opts = {}) {
1715
1772
  }
1716
1773
  else if (includeRecent > 0) {
1717
1774
  const recent = [
1718
- ...localEntries.map((entry) => ({ entry, isGlobal: false })),
1719
- ...globalEntries.map((entry) => ({ entry, isGlobal: true })),
1775
+ ...localPool.map((entry) => ({ entry, isGlobal: false })),
1776
+ ...globalPool.map((entry) => ({ entry, isGlobal: true })),
1720
1777
  ]
1721
1778
  // T2 (src/compare.ts) note: this already carries an explicit
1722
1779
  // per-instance tiebreak (created desc -> id localeCompare) and is
@@ -1752,7 +1809,7 @@ export async function getContext(ctx, opts = {}) {
1752
1809
  .map(({ entry, isGlobal }) => ({
1753
1810
  entry,
1754
1811
  score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1),
1755
- tokens: estimateTokens(entry.content),
1812
+ tokens: price(entry, isGlobal),
1756
1813
  isGlobal,
1757
1814
  }));
1758
1815
  for (const r of recent) {
@@ -1767,7 +1824,8 @@ export async function getContext(ctx, opts = {}) {
1767
1824
  }
1768
1825
  if (pinnedLocal.length === 0 &&
1769
1826
  pinnedGlobal.length === 0 &&
1770
- selectedItems.length === 0) {
1827
+ selectedItems.length === 0 &&
1828
+ !digestHiddenForHandoff) {
1771
1829
  return { entries: [], tokens: 0 };
1772
1830
  }
1773
1831
  for (const r of rankedPinned) {
@@ -1784,26 +1842,27 @@ export async function getContext(ctx, opts = {}) {
1784
1842
  else if (query === '*') {
1785
1843
  // No query: return strongest memories by strength, up to budget.
1786
1844
  const now = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
1787
- const localRanked = localEntries
1845
+ const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, now);
1846
+ const localRanked = localPool
1788
1847
  .map((e) => ({
1789
1848
  entry: e,
1790
1849
  score: calculateStrength(e, now),
1791
- tokens: estimateTokens(e.content),
1850
+ tokens: price(e, false),
1792
1851
  isGlobal: false,
1793
1852
  }))
1794
1853
  .sort(compareScoredResults);
1795
- const globalRanked = globalEntries
1854
+ const globalRanked = globalPool
1796
1855
  .map((e) => ({
1797
1856
  entry: e,
1798
1857
  score: calculateStrength(e, now) * (1 / 1.2),
1799
- tokens: estimateTokens(e.content),
1858
+ tokens: price(e, true),
1800
1859
  isGlobal: true,
1801
1860
  }))
1802
1861
  .sort(compareScoredResults);
1803
1862
  const combined = [...localRanked, ...globalRanked].sort(compareScoredResults);
1804
1863
  let used = 0;
1805
1864
  for (const r of combined) {
1806
- if (used + r.tokens > budget)
1865
+ if (used + r.tokens > left)
1807
1866
  continue;
1808
1867
  selectedItems.push(r);
1809
1868
  used += r.tokens;
@@ -1813,6 +1872,7 @@ export async function getContext(ctx, opts = {}) {
1813
1872
  else {
1814
1873
  // Real query: hybrid search (global + local) or physics+hybrid (local only).
1815
1874
  let results;
1875
+ const minResults = cost ? 0 : undefined; // a priced block skips an oversize top hit too, so the budget bounds it
1816
1876
  if (hasGlobal) {
1817
1877
  // searchBothHybrid loads from the store roots itself, so the ambient
1818
1878
  // filter above never saw its candidates. Admission runs INSIDE the
@@ -1821,39 +1881,47 @@ export async function getContext(ctx, opts = {}) {
1821
1881
  // excluded row saturate the budget (codex rounds 1+3) or shadow its
1822
1882
  // admitted duplicate in the dedupe pass (codex round 4). Recall paths
1823
1883
  // never set entryFilter, so their behavior is unchanged.
1884
+ const localIndex = loadIndex(ctx.hippoRoot);
1885
+ const isGlobalHit = (e) => !localIndex.entries[e.id];
1824
1886
  const merged = await searchBothHybrid(query, ctx.hippoRoot, globalRoot, {
1825
- budget,
1887
+ budget: left,
1888
+ minResults,
1889
+ cost: cost && ((r) => price(r.entry, isGlobalHit(r.entry))),
1826
1890
  scope: activeScope,
1827
1891
  tenantId: ctx.tenantId,
1828
1892
  entryFilter: ambientAdmit,
1829
1893
  });
1830
- const localIndex = loadIndex(ctx.hippoRoot);
1831
1894
  results = merged.map((r) => ({
1832
1895
  entry: r.entry,
1833
1896
  score: r.score,
1834
- tokens: r.tokens,
1835
- isGlobal: !localIndex.entries[r.entry.id],
1897
+ tokens: price(r.entry, isGlobalHit(r.entry)),
1898
+ isGlobal: isGlobalHit(r.entry),
1836
1899
  }));
1837
1900
  }
1838
1901
  else {
1839
1902
  const ctxConfig = loadConfig(ctx.hippoRoot);
1840
1903
  const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
1904
+ const localCost = cost && ((r) => price(r.entry, false));
1841
1905
  const ctxResults = usePhysicsCtx
1842
1906
  ? await physicsSearch(query, localEntries, {
1843
- budget,
1907
+ budget: left,
1908
+ minResults,
1909
+ cost: localCost,
1844
1910
  hippoRoot: ctx.hippoRoot,
1845
1911
  physicsConfig: ctxConfig.physics,
1846
1912
  scope: activeScope,
1847
1913
  })
1848
1914
  : await hybridSearch(query, localEntries, {
1849
- budget,
1915
+ budget: left,
1916
+ minResults,
1917
+ cost: localCost,
1850
1918
  hippoRoot: ctx.hippoRoot,
1851
1919
  scope: activeScope,
1852
1920
  });
1853
1921
  results = ctxResults.map((r) => ({
1854
1922
  entry: r.entry,
1855
1923
  score: r.score,
1856
- tokens: r.tokens,
1924
+ tokens: price(r.entry, false),
1857
1925
  isGlobal: false,
1858
1926
  }));
1859
1927
  }
@@ -1898,8 +1966,9 @@ export async function getContext(ctx, opts = {}) {
1898
1966
  }
1899
1967
  if (limit < selectedItems.length) {
1900
1968
  selectedItems = selectedItems.slice(0, limit);
1901
- totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
1902
1969
  }
1970
+ selectedItems = dropHeldCopies(selectedItems, (r) => r.entry); // after the last cut, so a merged row that was cut hides nothing
1971
+ totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
1903
1972
  // v39: annotate every returned entry with its origin and how it relates to
1904
1973
  // the active project, so renderers can demarcate cross-project inclusions.
1905
1974
  selectedItems = selectedItems.map((r) => ({
@@ -1908,9 +1977,9 @@ export async function getContext(ctx, opts = {}) {
1908
1977
  category: classifyOriginProject(r.entry.origin_project, currentProjectName),
1909
1978
  }));
1910
1979
  if (selectedItems.length === 0 &&
1911
- !activeSnapshot &&
1912
- !sessionHandoff &&
1913
- recentSessionEvents.length === 0) {
1980
+ !shownSnapshot &&
1981
+ !shownHandoff &&
1982
+ shownEvents.length === 0) {
1914
1983
  // LC1 F5 fix: this bare early-return used to skip tracing entirely — a
1915
1984
  // query that found nothing is exactly the coverage-gap signal Track LC
1916
1985
  // needs. Write an empty trace (result_count 0, no result rows) so it
@@ -1989,9 +2058,9 @@ export async function getContext(ctx, opts = {}) {
1989
2058
  return {
1990
2059
  entries: selectedItems,
1991
2060
  tokens: totalTokens,
1992
- activeSnapshot: activeSnapshot ?? undefined,
1993
- sessionHandoff: sessionHandoff ?? undefined,
1994
- recentEvents: recentSessionEvents.length > 0 ? recentSessionEvents : undefined,
2061
+ activeSnapshot: shownSnapshot ?? undefined,
2062
+ sessionHandoff: shownHandoff ?? undefined,
2063
+ recentEvents: shownEvents.length > 0 ? shownEvents : undefined,
1995
2064
  ambientState,
1996
2065
  };
1997
2066
  }
@@ -2023,8 +2092,8 @@ export function recordTokens(ctx, surface, use) {
2023
2092
  }
2024
2093
  /**
2025
2094
  * Token ledger totals for the tenant over the last `days` days (default 30):
2026
- * tokens sent per surface, blocks skipped as unchanged and the tokens that
2027
- * saved, and mean tokens per session.
2095
+ * tokens sent, skipped as unchanged and re-read by later model calls, per
2096
+ * surface, with session counts and mean tokens per session.
2028
2097
  */
2029
2098
  export function tokenSummary(ctx, opts = {}) {
2030
2099
  const db = openHippoDb(ctx.hippoRoot);
package/dist/audit.d.ts CHANGED
@@ -17,7 +17,8 @@ export declare const STOP_WORDS: Set<string>;
17
17
  export declare function auditMemory(entry: MemoryEntry): AuditIssue | null;
18
18
  export declare function auditMemories(entries: MemoryEntry[]): AuditResult;
19
19
  export declare function isContentWorthStoring(content: string): boolean;
20
- export type AuditOp = 'remember' | 'recall' | 'promote' | 'supersede' | 'forget' | 'archive_raw' | 'auth_revoke' | 'auth_create' | 'outcome' | 'consolidate' | 'audit_prune' | 'summary_marked_dirty' | 'summary_marked_clean' | 'summary_rebuilt' | 'predict_create' | 'predict_close' | 'predict_baserate' | 'recall_autodebias_hint' | 'recall_autodebias_hint_no_class_match' | 'recall_autodebias_hint_tiebreak' | 'recall_anchor_detected_query_repeat' | 'recall_anchor_detected_memory_dominance' | 'recall_anchor_skipped_no_session' | 'recall_availability_detected' | 'decision_create' | 'decision_supersede' | 'decision_close' | 'incident_open' | 'incident_resolve' | 'incident_close' | 'process_create' | 'process_supersede' | 'process_close' | 'policy_create' | 'policy_supersede' | 'policy_close' | 'skill_create' | 'skill_supersede' | 'skill_close' | 'project_brief_create' | 'project_brief_supersede' | 'project_brief_close' | 'customer_note_create' | 'customer_note_supersede' | 'customer_note_close' | 'mv_rescue' | 'reject_value' | 'reject_refusal' | 'unreject_value' | 'half_life_migrate' | 'dormant_restore' | 'conflict_resolve' | 'auth_grant' | 'auth_ungrant' | 'quarantine' | 'quarantine_approve' | 'quarantine_reject';
20
+ export declare const AUDIT_OPS: readonly ["remember", "recall", "promote", "supersede", "forget", "archive_raw", "auth_revoke", "auth_create", "outcome", "consolidate", "audit_prune", "summary_marked_dirty", "summary_marked_clean", "summary_rebuilt", "predict_create", "predict_close", "predict_baserate", "recall_autodebias_hint", "recall_autodebias_hint_no_class_match", "recall_autodebias_hint_tiebreak", "recall_anchor_detected_query_repeat", "recall_anchor_detected_memory_dominance", "recall_anchor_skipped_no_session", "recall_availability_detected", "decision_create", "decision_supersede", "decision_close", "incident_open", "incident_resolve", "incident_close", "process_create", "process_supersede", "process_close", "policy_create", "policy_supersede", "policy_close", "skill_create", "skill_supersede", "skill_close", "project_brief_create", "project_brief_supersede", "project_brief_close", "customer_note_create", "customer_note_supersede", "customer_note_close", "mv_rescue", "reject_value", "reject_refusal", "unreject_value", "conflict_resolve", "half_life_migrate", "dormant_restore", "auth_grant", "auth_ungrant", "quarantine", "quarantine_approve", "quarantine_reject"];
21
+ export type AuditOp = (typeof AUDIT_OPS)[number];
21
22
  export interface AppendAuditOpts {
22
23
  tenantId: string;
23
24
  actor: string;