hippo-memory 1.52.8 → 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 (74) hide show
  1. package/README.md +158 -98
  2. package/dist/api.d.ts +51 -18
  3. package/dist/api.js +121 -76
  4. package/dist/audit.d.ts +2 -1
  5. package/dist/audit.js +63 -0
  6. package/dist/capture.d.ts +37 -0
  7. package/dist/capture.js +111 -81
  8. package/dist/cli.js +627 -654
  9. package/dist/codex-patch.d.ts +12 -0
  10. package/dist/codex-patch.js +71 -0
  11. package/dist/config.d.ts +0 -1
  12. package/dist/config.js +0 -4
  13. package/dist/connectors/slack/types.d.ts +0 -1
  14. package/dist/consolidate.js +85 -32
  15. package/dist/context-render.d.ts +36 -0
  16. package/dist/context-render.js +154 -0
  17. package/dist/dag.js +3 -2
  18. package/dist/db.js +6 -6
  19. package/dist/dedupe.d.ts +6 -6
  20. package/dist/dedupe.js +10 -9
  21. package/dist/doctor.d.ts +1 -1
  22. package/dist/doctor.js +35 -2
  23. package/dist/dormant.d.ts +4 -0
  24. package/dist/dormant.js +17 -2
  25. package/dist/embedding-provider.d.ts +2 -1
  26. package/dist/embedding-provider.js +2 -1
  27. package/dist/embeddings.js +23 -3
  28. package/dist/extract.js +5 -1
  29. package/dist/forward-claim-detector.d.ts +1 -1
  30. package/dist/forward-claim-detector.js +1 -1
  31. package/dist/graph-recall.d.ts +3 -1
  32. package/dist/graph-recall.js +5 -3
  33. package/dist/hooks.d.ts +15 -1
  34. package/dist/hooks.js +122 -27
  35. package/dist/importers.js +5 -12
  36. package/dist/judgment.d.ts +30 -0
  37. package/dist/judgment.js +122 -0
  38. package/dist/mcp/server.js +171 -210
  39. package/dist/merged-row.d.ts +6 -0
  40. package/dist/merged-row.js +35 -0
  41. package/dist/multihop.d.ts +2 -1
  42. package/dist/multihop.js +7 -4
  43. package/dist/physics-state.d.ts +0 -4
  44. package/dist/physics-state.js +0 -6
  45. package/dist/predictions.d.ts +2 -17
  46. package/dist/predictions.js +2 -15
  47. package/dist/reject-flow.d.ts +7 -5
  48. package/dist/reject-flow.js +41 -12
  49. package/dist/salience.js +12 -5
  50. package/dist/same-text.d.ts +17 -0
  51. package/dist/same-text.js +38 -0
  52. package/dist/scheduler.d.ts +4 -0
  53. package/dist/scheduler.js +8 -0
  54. package/dist/search.d.ts +7 -0
  55. package/dist/search.js +16 -32
  56. package/dist/secret-detect.d.ts +2 -0
  57. package/dist/secret-detect.js +6 -0
  58. package/dist/server-detect.js +9 -33
  59. package/dist/server.js +6 -62
  60. package/dist/session-digest.d.ts +79 -0
  61. package/dist/session-digest.js +528 -0
  62. package/dist/shared.d.ts +10 -2
  63. package/dist/shared.js +35 -30
  64. package/dist/store.d.ts +1 -0
  65. package/dist/store.js +4 -0
  66. package/dist/token-ledger.d.ts +46 -8
  67. package/dist/token-ledger.js +140 -21
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/extensions/openclaw-plugin/README.md +4 -4
  71. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  72. package/extensions/openclaw-plugin/package.json +1 -1
  73. package/openclaw.plugin.json +2 -2
  74. package/package.json +2 -2
package/dist/api.d.ts CHANGED
@@ -274,6 +274,9 @@ export interface RecallOpts {
274
274
  * callers leave this unset and get the trace.
275
275
  */
276
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;
277
280
  }
278
281
  export interface ContinuityBlock {
279
282
  activeSnapshot: TaskSnapshot | null;
@@ -373,7 +376,7 @@ export interface RecallResult {
373
376
  * the calling agent sees its track record at the moment of forecasting
374
377
  * (Lovallo-Kahneman 2003 inside-vs-outside view).
375
378
  *
376
- * Populated by `api.recall` itself via `computePlanningFallacyHint`.
379
+ * Populated by `api.recall` itself via `computePlanningFallacyOutput`.
377
380
  * Pipeline-invariant: the value depends only on (queryText, tenantId,
378
381
  * predictions table state) — all three are identical regardless of
379
382
  * which downstream search pipeline produces the memory list, so MCP
@@ -553,6 +556,11 @@ export interface AssembleOpts {
553
556
  * is set on the result so the caller knows to widen.
554
557
  */
555
558
  rowCap?: number;
559
+ cost?: AssembleCost;
560
+ }
561
+ export interface AssembleCost {
562
+ item: (it: AssembledContextItem) => number;
563
+ fixed: (widest: number) => number;
556
564
  }
557
565
  export interface AssembledContextItem {
558
566
  id: string;
@@ -623,7 +631,7 @@ export interface DrillDownOpts {
623
631
  /**
624
632
  * Optional token budget. When set, children are appended in chronological
625
633
  * order (created ASC) until adding the next child would exceed the budget.
626
- * Token cost = ceil(content.length / 4) per child.
634
+ * Token cost = the child's printed line under `cost`, else ceil(content.length / 4).
627
635
  *
628
636
  * For depth > 1, the budget is GLOBAL cumulative (NOT per-level).
629
637
  */
@@ -636,22 +644,29 @@ export interface DrillDownOpts {
636
644
  * construction).
637
645
  */
638
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;
639
666
  }
640
667
  export interface DrillDownResult {
641
- summary: {
642
- id: string;
643
- content: string;
644
- descendantCount: number;
645
- earliestAt: string | null;
646
- latestAt: string | null;
647
- };
648
- children: Array<{
649
- id: string;
650
- content: string;
651
- layer: string;
652
- dagLevel: number;
653
- created: string;
654
- }>;
668
+ summary: DrillDownSummary;
669
+ children: DrillDownChild[];
655
670
  totalChildren: number;
656
671
  truncated: boolean;
657
672
  }
@@ -955,10 +970,28 @@ export interface ContextOpts {
955
970
  currentSessionId?: string | null;
956
971
  /** Z1: raw hook-payload prompt; only the pinned-only branch reads it, gated on `pinnedInject.promptRecall`. */
957
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;
958
990
  }
959
991
  export interface ContextResultEntry {
960
992
  entry: MemoryEntry;
961
993
  score: number;
994
+ /** What this entry cost the budget: its printed line under `ContextOpts.cost`, else its memory text. */
962
995
  tokens: number;
963
996
  isGlobal?: boolean;
964
997
  isFreshTail?: boolean;
@@ -1037,8 +1070,8 @@ export declare function recordTokens(ctx: Context, surface: TokenSurface, use: {
1037
1070
  }): void;
1038
1071
  /**
1039
1072
  * Token ledger totals for the tenant over the last `days` days (default 30):
1040
- * tokens sent per surface, blocks skipped as unchanged and the tokens that
1041
- * 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.
1042
1075
  */
1043
1076
  export declare function tokenSummary(ctx: Context, opts?: {
1044
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';
@@ -425,6 +427,13 @@ function recallFrom(ctx, opts, windowSize, all) {
425
427
  }));
426
428
  }
427
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
+ }
428
437
  // v1.12.13 / C5 — WYSIATI summary_substitutions_added counter.
429
438
  summarySubstitutionsCount = substituted.length;
430
439
  // v1.7.4 -- baseScored carries the (possibly boosted) per-row scores. When
@@ -498,9 +507,11 @@ function recallFrom(ctx, opts, windowSize, all) {
498
507
  ...baseRanked.map((r) => r.id),
499
508
  ...summaryRanked.map((r) => r.id),
500
509
  ]);
510
+ const shownKeys = storedTextKeys(opts.keepHeldCopies ? [] : [...baseSlice, ...substituted.map((s) => s.entry)]);
501
511
  for (const m of recentScoped) {
502
- if (seenIds.has(m.id))
512
+ if (seenIds.has(m.id) || shownKeys.has(duplicateKey(m.content)))
503
513
  continue;
514
+ shownKeys.add(duplicateKey(m.content));
504
515
  const item = {
505
516
  id: m.id,
506
517
  content: m.content,
@@ -626,10 +637,7 @@ function recallFrom(ctx, opts, windowSize, all) {
626
637
  // per-pipeline). opts.actor threads through to the inner
627
638
  // computePredictionBaserate call so MCP/HTTP-originated hints attribute
628
639
  // correctly instead of defaulting to 'cli'. Disabled by HIPPO_AUTODEBIAS=off.
629
- // v1.13.4: switched from computePlanningFallacyHint to
630
- // computePlanningFallacyOutput so the no-class-match / tiebreak
631
- // watching variant can also reach the caller surface. The two
632
- // 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.
633
641
  const planningFallacyOutput = computePlanningFallacyOutput(ctx.hippoRoot, ctx.tenantId, opts.query, { actor: ctx.actor.subject });
634
642
  const planningFallacyHint = planningFallacyOutput.hint ?? null;
635
643
  const planningFallacyWatching = planningFallacyOutput.watching ?? null;
@@ -869,9 +877,11 @@ export function assemble(ctx, sessionId, opts = {}) {
869
877
  olderItems.sort((a, b) => cmpIso(a.createdAt, b.createdAt));
870
878
  tailItems.sort((a, b) => cmpIso(a.createdAt, b.createdAt));
871
879
  let items = [...olderItems, ...tailItems];
872
- 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);
873
883
  let evicted = 0;
874
- while (tokens > budget && items.length > 0) {
884
+ while (tokens > room && items.length > 0) {
875
885
  let worstIdx = -1;
876
886
  let worstStrength = Infinity;
877
887
  for (let i = 0; i < items.length; i++) {
@@ -884,7 +894,7 @@ export function assemble(ctx, sessionId, opts = {}) {
884
894
  }
885
895
  if (worstIdx === -1)
886
896
  break;
887
- const cost = estimateTokens(items[worstIdx].content);
897
+ const cost = itemCost(items[worstIdx]);
888
898
  items = items.filter((_, i) => i !== worstIdx);
889
899
  tokens -= cost;
890
900
  evicted++;
@@ -961,15 +971,32 @@ export function drillDown(ctx, summaryId, opts = {}) {
961
971
  break;
962
972
  frontier = nextFrontier;
963
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
+ }));
964
990
  // Apply global cumulative token budget + limit cap on collected.
965
- let children = collected;
991
+ let children = all;
966
992
  let truncated = false;
967
993
  if (opts.budget !== undefined) {
968
994
  const out = [];
969
995
  let used = 0;
970
- for (const c of collected) {
971
- const t = estimateTokens(c.content);
972
- 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) {
973
1000
  truncated = true;
974
1001
  break;
975
1002
  }
@@ -983,25 +1010,8 @@ export function drillDown(ctx, summaryId, opts = {}) {
983
1010
  truncated = true;
984
1011
  }
985
1012
  return {
986
- summary: {
987
- id: summary.id,
988
- content: summary.content,
989
- // v0.30 / E5: descendant_count stays the summary's STORED value
990
- // (direct children at creation time). totalChildren below reflects
991
- // the full BFS collection at the requested depth.
992
- // independent-review MED #4 fold: legacy fallback uses level-0 direct
993
- // count (NOT collected.length which is BFS-depth-N total).
994
- descendantCount: summary.descendant_count ?? level0DirectCount,
995
- earliestAt: summary.earliest_at ?? null,
996
- latestAt: summary.latest_at ?? null,
997
- },
998
- children: children.map((c) => ({
999
- id: c.id,
1000
- content: c.content,
1001
- layer: c.layer,
1002
- dagLevel: c.dag_level ?? 0,
1003
- created: c.created,
1004
- })),
1013
+ summary: summaryOut,
1014
+ children,
1005
1015
  // v0.30 / E5: totalChildren = BFS-collected count (depth-aware). For
1006
1016
  // depth=1 this equals the eligible direct-children count (backward
1007
1017
  // compat). For depth>1 it is the cumulative count across levels.
@@ -1557,11 +1567,6 @@ export async function getContext(ctx, opts = {}) {
1557
1567
  const isolationEnabled = config.contextProjectIsolation !== false;
1558
1568
  const currentProjectName = opts.currentProject ?? resolveProjectIdentity(process.cwd()).name;
1559
1569
  const includeCrossProject = opts.crossProject === true || !isolationEnabled;
1560
- const ambientAdmit = (e) => ambientAdmitEntry(e, currentProjectName, includeCrossProject);
1561
- // Superseded rows never inject, and ambientAdmitEntry regex-scans content for
1562
- // secrets, so WHICH rows reach this predicate is what loadAmbientEntries cares
1563
- // about below.
1564
- const admit = (e) => !e.superseded_by && ambientAdmit(e);
1565
1570
  // Z1: decided before the ambient loads so the FTS candidate query below (pinned-only
1566
1571
  // branch) can piggyback on that connection instead of opening its own.
1567
1572
  const promptRecallPending = pinnedOnly && Boolean(opts.prompt?.trim()) && config.pinnedInject.promptRecall === true;
@@ -1571,18 +1576,21 @@ export async function getContext(ctx, opts = {}) {
1571
1576
  const recallRequest = promptRecallTerms.length > 0
1572
1577
  ? { terms: promptRecallTerms, limit: Math.floor(finiteOr(config.pinnedInject.promptRecallCandidates, 100, 1)) }
1573
1578
  : undefined;
1574
- // Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
1575
- const localLoad = hasLocal
1576
- ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, recallRequest)
1577
- : { entries: [] };
1578
- const globalLoad = hasGlobal
1579
- ? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, !isGlobalStoreRoot(ctx.hippoRoot) ? recallRequest : undefined)
1580
- : { entries: [] };
1581
- let localEntries = localLoad.entries;
1582
- let globalEntries = globalLoad.entries;
1583
- // Computed below, after markRetrieved runs, so avgStrength reflects the
1584
- // post-retrieval strengths rather than a stale pre-mutation snapshot.
1585
- 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
+ };
1586
1594
  // DF1 T2: bounded read — an orphaned snapshot (no later pre-compact
1587
1595
  // superseded it, no session-end closed it) must age out of this ambient
1588
1596
  // surface instead of injecting into every future prompt forever. Owner
@@ -1621,12 +1629,38 @@ export async function getContext(ctx, opts = {}) {
1621
1629
  limit: 5,
1622
1630
  }).filter((e) => passesScopeFilterForRecall(rowScope(e), undefined))
1623
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;
1624
1658
  if (!promptRecallPending &&
1625
1659
  localEntries.length === 0 &&
1626
1660
  globalEntries.length === 0 &&
1627
- !activeSnapshot &&
1628
- !sessionHandoff &&
1629
- recentSessionEvents.length === 0) {
1661
+ !shownSnapshot &&
1662
+ !shownHandoff &&
1663
+ shownEvents.length === 0) {
1630
1664
  return { entries: [], tokens: 0 };
1631
1665
  }
1632
1666
  let selectedItems = [];
@@ -1637,8 +1671,8 @@ export async function getContext(ctx, opts = {}) {
1637
1671
  if (!pinnedCfg.pinnedInject.enabled) {
1638
1672
  return { entries: [], tokens: 0 };
1639
1673
  }
1640
- // Effective budget: explicit opts.budget wins over config.
1641
- 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;
1642
1676
  const nowP = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
1643
1677
  const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, nowP);
1644
1678
  const selectedIds = new Set();
@@ -1658,7 +1692,7 @@ export async function getContext(ctx, opts = {}) {
1658
1692
  return {
1659
1693
  entry,
1660
1694
  score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1) * sBst,
1661
- tokens: estimateTokens(entry.content),
1695
+ tokens: price(entry, isGlobal),
1662
1696
  isGlobal,
1663
1697
  };
1664
1698
  })
@@ -1727,7 +1761,7 @@ export async function getContext(ctx, opts = {}) {
1727
1761
  for (const g of gated) {
1728
1762
  if (selectedIds.has(g.item.id))
1729
1763
  continue;
1730
- const tokens = estimateTokens(g.item.entry.content);
1764
+ const tokens = price(g.item.entry, g.item.isGlobal, true);
1731
1765
  if (usedP + tokens > recentBudget)
1732
1766
  continue;
1733
1767
  selectedItems.push({ entry: g.item.entry, score: g.score, tokens, isGlobal: g.item.isGlobal, promptRecall: true });
@@ -1775,7 +1809,7 @@ export async function getContext(ctx, opts = {}) {
1775
1809
  .map(({ entry, isGlobal }) => ({
1776
1810
  entry,
1777
1811
  score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1),
1778
- tokens: estimateTokens(entry.content),
1812
+ tokens: price(entry, isGlobal),
1779
1813
  isGlobal,
1780
1814
  }));
1781
1815
  for (const r of recent) {
@@ -1790,7 +1824,8 @@ export async function getContext(ctx, opts = {}) {
1790
1824
  }
1791
1825
  if (pinnedLocal.length === 0 &&
1792
1826
  pinnedGlobal.length === 0 &&
1793
- selectedItems.length === 0) {
1827
+ selectedItems.length === 0 &&
1828
+ !digestHiddenForHandoff) {
1794
1829
  return { entries: [], tokens: 0 };
1795
1830
  }
1796
1831
  for (const r of rankedPinned) {
@@ -1812,7 +1847,7 @@ export async function getContext(ctx, opts = {}) {
1812
1847
  .map((e) => ({
1813
1848
  entry: e,
1814
1849
  score: calculateStrength(e, now),
1815
- tokens: estimateTokens(e.content),
1850
+ tokens: price(e, false),
1816
1851
  isGlobal: false,
1817
1852
  }))
1818
1853
  .sort(compareScoredResults);
@@ -1820,14 +1855,14 @@ export async function getContext(ctx, opts = {}) {
1820
1855
  .map((e) => ({
1821
1856
  entry: e,
1822
1857
  score: calculateStrength(e, now) * (1 / 1.2),
1823
- tokens: estimateTokens(e.content),
1858
+ tokens: price(e, true),
1824
1859
  isGlobal: true,
1825
1860
  }))
1826
1861
  .sort(compareScoredResults);
1827
1862
  const combined = [...localRanked, ...globalRanked].sort(compareScoredResults);
1828
1863
  let used = 0;
1829
1864
  for (const r of combined) {
1830
- if (used + r.tokens > budget)
1865
+ if (used + r.tokens > left)
1831
1866
  continue;
1832
1867
  selectedItems.push(r);
1833
1868
  used += r.tokens;
@@ -1837,6 +1872,7 @@ export async function getContext(ctx, opts = {}) {
1837
1872
  else {
1838
1873
  // Real query: hybrid search (global + local) or physics+hybrid (local only).
1839
1874
  let results;
1875
+ const minResults = cost ? 0 : undefined; // a priced block skips an oversize top hit too, so the budget bounds it
1840
1876
  if (hasGlobal) {
1841
1877
  // searchBothHybrid loads from the store roots itself, so the ambient
1842
1878
  // filter above never saw its candidates. Admission runs INSIDE the
@@ -1845,39 +1881,47 @@ export async function getContext(ctx, opts = {}) {
1845
1881
  // excluded row saturate the budget (codex rounds 1+3) or shadow its
1846
1882
  // admitted duplicate in the dedupe pass (codex round 4). Recall paths
1847
1883
  // never set entryFilter, so their behavior is unchanged.
1884
+ const localIndex = loadIndex(ctx.hippoRoot);
1885
+ const isGlobalHit = (e) => !localIndex.entries[e.id];
1848
1886
  const merged = await searchBothHybrid(query, ctx.hippoRoot, globalRoot, {
1849
- budget,
1887
+ budget: left,
1888
+ minResults,
1889
+ cost: cost && ((r) => price(r.entry, isGlobalHit(r.entry))),
1850
1890
  scope: activeScope,
1851
1891
  tenantId: ctx.tenantId,
1852
1892
  entryFilter: ambientAdmit,
1853
1893
  });
1854
- const localIndex = loadIndex(ctx.hippoRoot);
1855
1894
  results = merged.map((r) => ({
1856
1895
  entry: r.entry,
1857
1896
  score: r.score,
1858
- tokens: r.tokens,
1859
- isGlobal: !localIndex.entries[r.entry.id],
1897
+ tokens: price(r.entry, isGlobalHit(r.entry)),
1898
+ isGlobal: isGlobalHit(r.entry),
1860
1899
  }));
1861
1900
  }
1862
1901
  else {
1863
1902
  const ctxConfig = loadConfig(ctx.hippoRoot);
1864
1903
  const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
1904
+ const localCost = cost && ((r) => price(r.entry, false));
1865
1905
  const ctxResults = usePhysicsCtx
1866
1906
  ? await physicsSearch(query, localEntries, {
1867
- budget,
1907
+ budget: left,
1908
+ minResults,
1909
+ cost: localCost,
1868
1910
  hippoRoot: ctx.hippoRoot,
1869
1911
  physicsConfig: ctxConfig.physics,
1870
1912
  scope: activeScope,
1871
1913
  })
1872
1914
  : await hybridSearch(query, localEntries, {
1873
- budget,
1915
+ budget: left,
1916
+ minResults,
1917
+ cost: localCost,
1874
1918
  hippoRoot: ctx.hippoRoot,
1875
1919
  scope: activeScope,
1876
1920
  });
1877
1921
  results = ctxResults.map((r) => ({
1878
1922
  entry: r.entry,
1879
1923
  score: r.score,
1880
- tokens: r.tokens,
1924
+ tokens: price(r.entry, false),
1881
1925
  isGlobal: false,
1882
1926
  }));
1883
1927
  }
@@ -1922,8 +1966,9 @@ export async function getContext(ctx, opts = {}) {
1922
1966
  }
1923
1967
  if (limit < selectedItems.length) {
1924
1968
  selectedItems = selectedItems.slice(0, limit);
1925
- totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
1926
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);
1927
1972
  // v39: annotate every returned entry with its origin and how it relates to
1928
1973
  // the active project, so renderers can demarcate cross-project inclusions.
1929
1974
  selectedItems = selectedItems.map((r) => ({
@@ -1932,9 +1977,9 @@ export async function getContext(ctx, opts = {}) {
1932
1977
  category: classifyOriginProject(r.entry.origin_project, currentProjectName),
1933
1978
  }));
1934
1979
  if (selectedItems.length === 0 &&
1935
- !activeSnapshot &&
1936
- !sessionHandoff &&
1937
- recentSessionEvents.length === 0) {
1980
+ !shownSnapshot &&
1981
+ !shownHandoff &&
1982
+ shownEvents.length === 0) {
1938
1983
  // LC1 F5 fix: this bare early-return used to skip tracing entirely — a
1939
1984
  // query that found nothing is exactly the coverage-gap signal Track LC
1940
1985
  // needs. Write an empty trace (result_count 0, no result rows) so it
@@ -2013,9 +2058,9 @@ export async function getContext(ctx, opts = {}) {
2013
2058
  return {
2014
2059
  entries: selectedItems,
2015
2060
  tokens: totalTokens,
2016
- activeSnapshot: activeSnapshot ?? undefined,
2017
- sessionHandoff: sessionHandoff ?? undefined,
2018
- recentEvents: recentSessionEvents.length > 0 ? recentSessionEvents : undefined,
2061
+ activeSnapshot: shownSnapshot ?? undefined,
2062
+ sessionHandoff: shownHandoff ?? undefined,
2063
+ recentEvents: shownEvents.length > 0 ? shownEvents : undefined,
2019
2064
  ambientState,
2020
2065
  };
2021
2066
  }
@@ -2047,8 +2092,8 @@ export function recordTokens(ctx, surface, use) {
2047
2092
  }
2048
2093
  /**
2049
2094
  * Token ledger totals for the tenant over the last `days` days (default 30):
2050
- * tokens sent per surface, blocks skipped as unchanged and the tokens that
2051
- * 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.
2052
2097
  */
2053
2098
  export function tokenSummary(ctx, opts = {}) {
2054
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;
package/dist/audit.js CHANGED
@@ -167,6 +167,69 @@ export function isContentWorthStoring(content) {
167
167
  return false;
168
168
  return true;
169
169
  }
170
+ // ---------------------------------------------------------------------------
171
+ // A5 audit log primitives (append-only mutation trail)
172
+ // ---------------------------------------------------------------------------
173
+ // The one list of audit ops: the AuditOp type, `hippo audit list --op` and GET /v1/audit?op= all read it.
174
+ export const AUDIT_OPS = [
175
+ 'remember',
176
+ 'recall',
177
+ 'promote',
178
+ 'supersede',
179
+ 'forget',
180
+ 'archive_raw',
181
+ 'auth_revoke',
182
+ 'auth_create', // emitted by api.authCreate
183
+ 'outcome',
184
+ 'consolidate', // emitted once per api.sleep invocation
185
+ 'audit_prune', // emitted by pruneAuditLog after each retention prune
186
+ 'summary_marked_dirty', // emitted by markSummaryDirty on the 0->1 transition
187
+ 'summary_marked_clean', // emitted by clearSummaryDirtyAfterBuild after the buildDag child-link loop
188
+ 'summary_rebuilt', // emitted by applyRebuildResult on a successful sleep-cycle rebuild
189
+ 'predict_create', // emitted by savePrediction
190
+ 'predict_close', // emitted by closePrediction
191
+ 'predict_baserate', // emitted by computePredictionBaserate
192
+ 'recall_autodebias_hint', // emitted by computePlanningFallacyOutput on success
193
+ 'recall_autodebias_hint_no_class_match', // telemetry: forward-claim detected, no class scored
194
+ 'recall_autodebias_hint_tiebreak', // telemetry: forward-claim detected, two or more classes tied
195
+ 'recall_anchor_detected_query_repeat', // emitted by the anchoring detector when the same query returns the same top-1
196
+ 'recall_anchor_detected_memory_dominance', // emitted by the anchoring detector when one memory wins top-1 across distinct queries
197
+ 'recall_anchor_skipped_no_session', // telemetry: no sessionId, so ring tracking was skipped
198
+ 'recall_availability_detected', // emitted when the availability/recency-bias hint fires
199
+ 'decision_create', // emitted by saveDecision
200
+ 'decision_supersede', // emitted by saveDecision when --supersedes resolves to an active decision
201
+ 'decision_close', // emitted by closeDecision
202
+ 'incident_open', // emitted by saveIncident
203
+ 'incident_resolve', // emitted by resolveIncident
204
+ 'incident_close', // emitted by closeIncident
205
+ 'process_create', // emitted by saveProcess
206
+ 'process_supersede', // emitted by saveProcess on a supersession
207
+ 'process_close', // emitted by closeProcess
208
+ 'policy_create', // emitted by savePolicy
209
+ 'policy_supersede', // emitted by savePolicy on a supersession
210
+ 'policy_close', // emitted by closePolicy
211
+ 'skill_create', // emitted by saveSkill
212
+ 'skill_supersede', // emitted by saveSkill on a supersession
213
+ 'skill_close', // emitted by closeSkill
214
+ 'project_brief_create', // emitted by saveProjectBrief
215
+ 'project_brief_supersede', // emitted by saveProjectBrief on a supersession, including a refresh
216
+ 'project_brief_close', // emitted by closeProjectBrief
217
+ 'customer_note_create', // emitted by saveCustomerNote
218
+ 'customer_note_supersede', // emitted by saveCustomerNote on a supersession
219
+ 'customer_note_close', // emitted by closeCustomerNote
220
+ 'mv_rescue', // emitted by consolidate() per rescued entry when config.memoryValue.enabled
221
+ 'reject_value', // emitted by the `hippo reject` verb
222
+ 'reject_refusal', // emitted when the rejection guard refuses a write
223
+ 'unreject_value', // emitted by the `hippo unreject` verb
224
+ 'conflict_resolve', // emitted by resolveConflict on every resolution path
225
+ 'half_life_migrate', // emitted by migrateDefaultHalfLife with the rescaled ids
226
+ 'dormant_restore', // emitted by api.restoreDormant
227
+ 'auth_grant', // emitted by api.authGrant
228
+ 'auth_ungrant', // emitted by api.authUngrant
229
+ 'quarantine', // emitted by recordQuarantine inside remember's write transaction
230
+ 'quarantine_approve', // emitted by api.quarantineApprove
231
+ 'quarantine_reject', // emitted by api.quarantineReject
232
+ ];
170
233
  function isBigIntValue(value) {
171
234
  return typeof value === 'bigint';
172
235
  }