hippo-memory 1.52.8 → 1.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +185 -101
  2. package/dist/agent-memories/apply.d.ts +47 -0
  3. package/dist/agent-memories/apply.js +253 -0
  4. package/dist/agent-memories/claude-code.d.ts +11 -0
  5. package/dist/agent-memories/claude-code.js +113 -0
  6. package/dist/agent-memories/codex.d.ts +3 -0
  7. package/dist/agent-memories/codex.js +47 -0
  8. package/dist/agent-memories/copilot.d.ts +3 -0
  9. package/dist/agent-memories/copilot.js +125 -0
  10. package/dist/agent-memories/files.d.ts +37 -0
  11. package/dist/agent-memories/files.js +77 -0
  12. package/dist/agent-memories/folder-store.d.ts +17 -0
  13. package/dist/agent-memories/folder-store.js +44 -0
  14. package/dist/agent-memories/gemini.d.ts +3 -0
  15. package/dist/agent-memories/gemini.js +103 -0
  16. package/dist/agent-memories/git.d.ts +8 -0
  17. package/dist/agent-memories/git.js +11 -0
  18. package/dist/agent-memories/keys.d.ts +9 -0
  19. package/dist/agent-memories/keys.js +20 -0
  20. package/dist/agent-memories/legacy.d.ts +17 -0
  21. package/dist/agent-memories/legacy.js +45 -0
  22. package/dist/agent-memories/markdown.d.ts +13 -0
  23. package/dist/agent-memories/markdown.js +123 -0
  24. package/dist/agent-memories/openclaw.d.ts +3 -0
  25. package/dist/agent-memories/openclaw.js +42 -0
  26. package/dist/agent-memories/plan.d.ts +78 -0
  27. package/dist/agent-memories/plan.js +123 -0
  28. package/dist/agent-memories/qwen-code.d.ts +5 -0
  29. package/dist/agent-memories/qwen-code.js +50 -0
  30. package/dist/agent-memories/report.d.ts +52 -0
  31. package/dist/agent-memories/report.js +88 -0
  32. package/dist/agent-memories/source.d.ts +16 -0
  33. package/dist/agent-memories/source.js +32 -0
  34. package/dist/agent-memories/sync.d.ts +33 -0
  35. package/dist/agent-memories/sync.js +336 -0
  36. package/dist/agent-memories/tools.d.ts +33 -0
  37. package/dist/agent-memories/tools.js +19 -0
  38. package/dist/agent-memories/types.d.ts +42 -0
  39. package/dist/agent-memories/types.js +2 -0
  40. package/dist/api.d.ts +53 -20
  41. package/dist/api.js +141 -97
  42. package/dist/audit.d.ts +2 -1
  43. package/dist/audit.js +68 -2
  44. package/dist/capture.d.ts +48 -22
  45. package/dist/capture.js +186 -161
  46. package/dist/cli.d.ts +0 -2
  47. package/dist/cli.js +750 -797
  48. package/dist/codex-patch.d.ts +12 -0
  49. package/dist/codex-patch.js +71 -0
  50. package/dist/compaction-items.d.ts +18 -0
  51. package/dist/compaction-items.js +60 -0
  52. package/dist/compaction-record.d.ts +94 -0
  53. package/dist/compaction-record.js +546 -0
  54. package/dist/config.d.ts +4 -1
  55. package/dist/config.js +13 -4
  56. package/dist/connectors/slack/types.d.ts +0 -1
  57. package/dist/consolidate.js +87 -34
  58. package/dist/context-render.d.ts +36 -0
  59. package/dist/context-render.js +154 -0
  60. package/dist/dag.js +3 -2
  61. package/dist/db.d.ts +5 -1
  62. package/dist/db.js +46 -14
  63. package/dist/dedupe.d.ts +6 -6
  64. package/dist/dedupe.js +10 -9
  65. package/dist/doctor.d.ts +1 -1
  66. package/dist/doctor.js +69 -4
  67. package/dist/dormant.d.ts +9 -3
  68. package/dist/dormant.js +26 -2
  69. package/dist/embedding-provider.d.ts +2 -1
  70. package/dist/embedding-provider.js +2 -1
  71. package/dist/embeddings.js +23 -3
  72. package/dist/extract.js +5 -1
  73. package/dist/forward-claim-detector.d.ts +1 -1
  74. package/dist/forward-claim-detector.js +1 -1
  75. package/dist/gated-write.d.ts +9 -0
  76. package/dist/gated-write.js +24 -0
  77. package/dist/graph-recall.d.ts +3 -1
  78. package/dist/graph-recall.js +5 -3
  79. package/dist/hooks.d.ts +18 -2
  80. package/dist/hooks.js +128 -32
  81. package/dist/importers.js +5 -12
  82. package/dist/judgment.d.ts +30 -0
  83. package/dist/judgment.js +122 -0
  84. package/dist/mcp/server.js +171 -210
  85. package/dist/memory.d.ts +19 -2
  86. package/dist/memory.js +35 -3
  87. package/dist/merged-row.d.ts +6 -0
  88. package/dist/merged-row.js +35 -0
  89. package/dist/multihop.d.ts +2 -1
  90. package/dist/multihop.js +7 -4
  91. package/dist/physics-state.d.ts +0 -4
  92. package/dist/physics-state.js +0 -6
  93. package/dist/predictions.d.ts +2 -17
  94. package/dist/predictions.js +2 -15
  95. package/dist/reject-flow.d.ts +7 -5
  96. package/dist/reject-flow.js +41 -12
  97. package/dist/salience.js +12 -5
  98. package/dist/same-text.d.ts +17 -0
  99. package/dist/same-text.js +38 -0
  100. package/dist/scheduler.d.ts +4 -0
  101. package/dist/scheduler.js +8 -0
  102. package/dist/search.d.ts +7 -0
  103. package/dist/search.js +16 -32
  104. package/dist/secret-detect.d.ts +2 -0
  105. package/dist/secret-detect.js +6 -0
  106. package/dist/server-detect.js +9 -33
  107. package/dist/server.js +6 -62
  108. package/dist/session-digest.d.ts +79 -0
  109. package/dist/session-digest.js +528 -0
  110. package/dist/shared.d.ts +10 -2
  111. package/dist/shared.js +44 -36
  112. package/dist/store.d.ts +9 -2
  113. package/dist/store.js +25 -2
  114. package/dist/token-ledger.d.ts +46 -8
  115. package/dist/token-ledger.js +140 -21
  116. package/dist/version.d.ts +1 -1
  117. package/dist/version.js +1 -1
  118. package/extensions/openclaw-plugin/README.md +4 -4
  119. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  120. package/extensions/openclaw-plugin/package.json +1 -1
  121. package/openclaw.plugin.json +2 -2
  122. package/package.json +2 -2
package/dist/api.js CHANGED
@@ -17,7 +17,7 @@ import { detectInstruction } from './instruction-detect.js';
17
17
  import { quarantineScopeFor, recordQuarantine, getQuarantineRow, listQuarantineRows, approveQuarantineRow, rejectQuarantineRow, } from './quarantine.js';
18
18
  import { summarizeFailures } from './failure-log.js';
19
19
  import { formatHandoffEvidenceLine } from './handoff.js';
20
- import { createMemory, applyOutcome, calculateStrength, Layer, CHURN_STALE_TAG, } from './memory.js';
20
+ import { createMemory, createSuccessor, applyOutcome, calculateStrength, CHURN_STALE_TAG, COMPACTION_MEMORY_TAG, } from './memory.js';
21
21
  import { appendAuditEvent, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
22
22
  import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './shared.js';
23
23
  import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
@@ -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.
@@ -1192,14 +1202,8 @@ export function supersede(ctx, oldId, newContent) {
1192
1202
  if (old.superseded_by) {
1193
1203
  throw new Error(`Memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`);
1194
1204
  }
1195
- const newEntry = createMemory(newContent, {
1196
- layer: old.layer ?? Layer.Episodic,
1197
- tags: [...old.tags],
1198
- pinned: old.pinned,
1199
- source: old.source,
1200
- confidence: 'verified',
1205
+ const newEntry = createSuccessor(old, newContent, {
1201
1206
  tenantId: ctx.tenantId,
1202
- scope: old.scope,
1203
1207
  baseHalfLifeDays: loadConfig(ctx.hippoRoot).defaultHalfLifeDays,
1204
1208
  });
1205
1209
  // Race-safe transition: open a fresh db handle, BEGIN IMMEDIATE, run all
@@ -1539,13 +1543,13 @@ export async function getContext(ctx, opts = {}) {
1539
1543
  if (budget <= 0) {
1540
1544
  return { entries: [], tokens: 0 };
1541
1545
  }
1542
- // Pinned-only path is allowed against an un-initialised local store (the
1543
- // UserPromptSubmit hook can run in directories without a .hippo). Non-pinned
1544
- // path requires an initialised local store; callers should check first.
1546
+ // Global memories do not establish a project boundary for task state.
1545
1547
  const hasLocal = isInitialized(ctx.hippoRoot);
1546
1548
  const query = (opts.q ?? '').trim() || '*';
1547
1549
  const globalRoot = getGlobalRoot();
1548
1550
  const hasGlobal = isInitialized(globalRoot);
1551
+ const primaryIsGlobal = isGlobalStoreRoot(ctx.hippoRoot);
1552
+ const hasLocalTaskState = hasLocal && !primaryIsGlobal;
1549
1553
  // v39 memory scope isolation (docs/plans/2026-07-01-memory-scope-isolation.md).
1550
1554
  // S2: envelope-filter parity with api.recall for AMBIENT context - private
1551
1555
  // scopes and quarantine buckets never inject. `requested` is deliberately
@@ -1557,11 +1561,6 @@ export async function getContext(ctx, opts = {}) {
1557
1561
  const isolationEnabled = config.contextProjectIsolation !== false;
1558
1562
  const currentProjectName = opts.currentProject ?? resolveProjectIdentity(process.cwd()).name;
1559
1563
  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
1564
  // Z1: decided before the ambient loads so the FTS candidate query below (pinned-only
1566
1565
  // branch) can piggyback on that connection instead of opening its own.
1567
1566
  const promptRecallPending = pinnedOnly && Boolean(opts.prompt?.trim()) && config.pinnedInject.promptRecall === true;
@@ -1571,18 +1570,21 @@ export async function getContext(ctx, opts = {}) {
1571
1570
  const recallRequest = promptRecallTerms.length > 0
1572
1571
  ? { terms: promptRecallTerms, limit: Math.floor(finiteOr(config.pinnedInject.promptRecallCandidates, 100, 1)) }
1573
1572
  : 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;
1573
+ const cost = opts.cost;
1574
+ const price = (entry, isGlobal, promptRecall) => cost
1575
+ ? cost.entry({ entry, isGlobal, promptRecall, origin: entry.origin_project ?? null, category: classifyOriginProject(entry.origin_project, currentProjectName) })
1576
+ : estimateTokens(entry.content);
1577
+ const blockBudget = pinnedOnly && opts.budget === undefined ? config.pinnedInject.budget : budget;
1578
+ let left = cost
1579
+ ? Math.max(0, blockBudget - cost.fixed(blockBudget, { cross: includeCrossProject, promptRecall: promptRecallPending, ambient: !pinnedOnly && config.ambient.enabled }))
1580
+ : blockBudget;
1581
+ // Sections print ahead of the memories, so they are paid first; one that does not fit is dropped, as an oversize entry is.
1582
+ const pays = (tokens) => {
1583
+ if (tokens > left)
1584
+ return false;
1585
+ left -= tokens;
1586
+ return true;
1587
+ };
1586
1588
  // DF1 T2: bounded read — an orphaned snapshot (no later pre-compact
1587
1589
  // superseded it, no session-end closed it) must age out of this ambient
1588
1590
  // surface instead of injecting into every future prompt forever. Owner
@@ -1590,7 +1592,7 @@ export async function getContext(ctx, opts = {}) {
1590
1592
  // unbounded; see loadFreshActiveTaskSnapshot's own doc comment for the
1591
1593
  // exact null/empty-id matching rules.
1592
1594
  const rowScope = (r) => r?.scope ?? null;
1593
- const rawActiveSnapshot = hasLocal
1595
+ const rawActiveSnapshot = hasLocalTaskState
1594
1596
  ? loadFreshActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId, {
1595
1597
  sessionId: opts.currentSessionId,
1596
1598
  })
@@ -1601,7 +1603,7 @@ export async function getContext(ctx, opts = {}) {
1601
1603
  ? rawActiveSnapshot
1602
1604
  : null;
1603
1605
  // Key on the RAW snapshot: a scope-hidden active session must not fall through to another session's ambient handoff.
1604
- const rawSessionHandoff = !hasLocal
1606
+ const rawSessionHandoff = !hasLocalTaskState
1605
1607
  ? null
1606
1608
  : rawActiveSnapshot?.session_id
1607
1609
  ? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, rawActiveSnapshot.session_id)
@@ -1615,18 +1617,49 @@ export async function getContext(ctx, opts = {}) {
1615
1617
  ? rawSessionHandoff
1616
1618
  : null;
1617
1619
  // Raw session id here too: each event is admitted on its own scope, same as recall and the CLI.
1618
- const recentSessionEvents = hasLocal && rawActiveSnapshot?.session_id
1620
+ const recentSessionEvents = hasLocalTaskState && rawActiveSnapshot?.session_id
1619
1621
  ? listSessionEvents(ctx.hippoRoot, ctx.tenantId, {
1620
1622
  session_id: rawActiveSnapshot.session_id,
1621
1623
  limit: 5,
1622
1624
  }).filter((e) => passesScopeFilterForRecall(rowScope(e), undefined))
1623
1625
  : [];
1626
+ const shownSnapshot = activeSnapshot && (!cost || pays(cost.snapshot(activeSnapshot))) ? activeSnapshot : null;
1627
+ const shownHandoff = sessionHandoff && (!cost || pays(cost.handoff(sessionHandoff))) ? sessionHandoff : null;
1628
+ const shownEvents = recentSessionEvents.length > 0 && (!cost || pays(cost.trail(recentSessionEvents))) ? recentSessionEvents : [];
1629
+ const transcriptHandoffSession = shownHandoff?.evidence?.derivedFrom === 'transcript' ? shownHandoff.sessionId : null;
1630
+ let digestHiddenForHandoff = false;
1631
+ const ambientAdmit = (e) => {
1632
+ // A printed handoff already carries the session's closing message, which its digest would print a second time.
1633
+ if (transcriptHandoffSession !== null && e.source_session_id === transcriptHandoffSession && isSessionDigestRow(e)) {
1634
+ digestHiddenForHandoff = true;
1635
+ return false;
1636
+ }
1637
+ return ambientAdmitEntry(e, currentProjectName, includeCrossProject);
1638
+ };
1639
+ const ownSessionId = opts.currentSessionId || '';
1640
+ // Inside admit, not after the load, so the loader's window widens past a session's own items.
1641
+ const isOwnCompactionItem = (e) => ownSessionId !== '' &&
1642
+ e.source_session_id === ownSessionId &&
1643
+ e.tags.includes(COMPACTION_MEMORY_TAG);
1644
+ // Superseded rows never inject; which rows reach ambientAdmitEntry matters because it regex-scans content for secrets.
1645
+ const admit = (e) => !e.superseded_by && !isOwnCompactionItem(e) && ambientAdmit(e);
1646
+ // Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
1647
+ const localLoad = hasLocal
1648
+ ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, recallRequest)
1649
+ : { entries: [] };
1650
+ const globalLoad = hasGlobal && !primaryIsGlobal
1651
+ ? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, recallRequest)
1652
+ : { entries: [] };
1653
+ let localEntries = localLoad.entries;
1654
+ let globalEntries = globalLoad.entries;
1655
+ // Computed after markRetrieved runs, so avgStrength reflects post-retrieval strengths.
1656
+ let ambientState;
1624
1657
  if (!promptRecallPending &&
1625
1658
  localEntries.length === 0 &&
1626
1659
  globalEntries.length === 0 &&
1627
- !activeSnapshot &&
1628
- !sessionHandoff &&
1629
- recentSessionEvents.length === 0) {
1660
+ !shownSnapshot &&
1661
+ !shownHandoff &&
1662
+ shownEvents.length === 0) {
1630
1663
  return { entries: [], tokens: 0 };
1631
1664
  }
1632
1665
  let selectedItems = [];
@@ -1637,8 +1670,8 @@ export async function getContext(ctx, opts = {}) {
1637
1670
  if (!pinnedCfg.pinnedInject.enabled) {
1638
1671
  return { entries: [], tokens: 0 };
1639
1672
  }
1640
- // Effective budget: explicit opts.budget wins over config.
1641
- const effBudget = opts.budget !== undefined ? budget : pinnedCfg.pinnedInject.budget;
1673
+ // Effective budget: explicit opts.budget wins over config, less what the sections took.
1674
+ const effBudget = left;
1642
1675
  const nowP = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
1643
1676
  const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, nowP);
1644
1677
  const selectedIds = new Set();
@@ -1649,7 +1682,7 @@ export async function getContext(ctx, opts = {}) {
1649
1682
  const pinnedLocal = localPool.filter((e) => e.pinned);
1650
1683
  const pinnedGlobal = globalPool.filter((e) => e.pinned);
1651
1684
  const rankedPinned = [
1652
- ...pinnedLocal.map((e) => ({ entry: e, isGlobal: false })),
1685
+ ...pinnedLocal.map((e) => ({ entry: e, isGlobal: primaryIsGlobal })),
1653
1686
  ...pinnedGlobal.map((e) => ({ entry: e, isGlobal: true })),
1654
1687
  ]
1655
1688
  .map(({ entry, isGlobal }) => {
@@ -1658,7 +1691,7 @@ export async function getContext(ctx, opts = {}) {
1658
1691
  return {
1659
1692
  entry,
1660
1693
  score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1) * sBst,
1661
- tokens: estimateTokens(entry.content),
1694
+ tokens: price(entry, isGlobal),
1662
1695
  isGlobal,
1663
1696
  };
1664
1697
  })
@@ -1715,7 +1748,7 @@ export async function getContext(ctx, opts = {}) {
1715
1748
  if (seenCandidateIds.has(e.id))
1716
1749
  continue;
1717
1750
  seenCandidateIds.add(e.id);
1718
- candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: false });
1751
+ candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: primaryIsGlobal });
1719
1752
  }
1720
1753
  for (const e of globalCandidates) {
1721
1754
  if (seenCandidateIds.has(e.id))
@@ -1727,7 +1760,7 @@ export async function getContext(ctx, opts = {}) {
1727
1760
  for (const g of gated) {
1728
1761
  if (selectedIds.has(g.item.id))
1729
1762
  continue;
1730
- const tokens = estimateTokens(g.item.entry.content);
1763
+ const tokens = price(g.item.entry, g.item.isGlobal, true);
1731
1764
  if (usedP + tokens > recentBudget)
1732
1765
  continue;
1733
1766
  selectedItems.push({ entry: g.item.entry, score: g.score, tokens, isGlobal: g.item.isGlobal, promptRecall: true });
@@ -1738,7 +1771,7 @@ export async function getContext(ctx, opts = {}) {
1738
1771
  }
1739
1772
  else if (includeRecent > 0) {
1740
1773
  const recent = [
1741
- ...localPool.map((entry) => ({ entry, isGlobal: false })),
1774
+ ...localPool.map((entry) => ({ entry, isGlobal: primaryIsGlobal })),
1742
1775
  ...globalPool.map((entry) => ({ entry, isGlobal: true })),
1743
1776
  ]
1744
1777
  // T2 (src/compare.ts) note: this already carries an explicit
@@ -1775,7 +1808,7 @@ export async function getContext(ctx, opts = {}) {
1775
1808
  .map(({ entry, isGlobal }) => ({
1776
1809
  entry,
1777
1810
  score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1),
1778
- tokens: estimateTokens(entry.content),
1811
+ tokens: price(entry, isGlobal),
1779
1812
  isGlobal,
1780
1813
  }));
1781
1814
  for (const r of recent) {
@@ -1790,7 +1823,8 @@ export async function getContext(ctx, opts = {}) {
1790
1823
  }
1791
1824
  if (pinnedLocal.length === 0 &&
1792
1825
  pinnedGlobal.length === 0 &&
1793
- selectedItems.length === 0) {
1826
+ selectedItems.length === 0 &&
1827
+ !digestHiddenForHandoff) {
1794
1828
  return { entries: [], tokens: 0 };
1795
1829
  }
1796
1830
  for (const r of rankedPinned) {
@@ -1812,22 +1846,22 @@ export async function getContext(ctx, opts = {}) {
1812
1846
  .map((e) => ({
1813
1847
  entry: e,
1814
1848
  score: calculateStrength(e, now),
1815
- tokens: estimateTokens(e.content),
1816
- isGlobal: false,
1849
+ tokens: price(e, primaryIsGlobal),
1850
+ isGlobal: primaryIsGlobal,
1817
1851
  }))
1818
1852
  .sort(compareScoredResults);
1819
1853
  const globalRanked = globalPool
1820
1854
  .map((e) => ({
1821
1855
  entry: e,
1822
1856
  score: calculateStrength(e, now) * (1 / 1.2),
1823
- tokens: estimateTokens(e.content),
1857
+ tokens: price(e, true),
1824
1858
  isGlobal: true,
1825
1859
  }))
1826
1860
  .sort(compareScoredResults);
1827
1861
  const combined = [...localRanked, ...globalRanked].sort(compareScoredResults);
1828
1862
  let used = 0;
1829
1863
  for (const r of combined) {
1830
- if (used + r.tokens > budget)
1864
+ if (used + r.tokens > left)
1831
1865
  continue;
1832
1866
  selectedItems.push(r);
1833
1867
  used += r.tokens;
@@ -1837,7 +1871,8 @@ export async function getContext(ctx, opts = {}) {
1837
1871
  else {
1838
1872
  // Real query: hybrid search (global + local) or physics+hybrid (local only).
1839
1873
  let results;
1840
- if (hasGlobal) {
1874
+ const minResults = cost ? 0 : undefined; // a priced block skips an oversize top hit too, so the budget bounds it
1875
+ if (hasGlobal && !primaryIsGlobal) {
1841
1876
  // searchBothHybrid loads from the store roots itself, so the ambient
1842
1877
  // filter above never saw its candidates. Admission runs INSIDE the
1843
1878
  // search via the opt-in entryFilter, BEFORE ranking, cross-store
@@ -1845,40 +1880,48 @@ export async function getContext(ctx, opts = {}) {
1845
1880
  // excluded row saturate the budget (codex rounds 1+3) or shadow its
1846
1881
  // admitted duplicate in the dedupe pass (codex round 4). Recall paths
1847
1882
  // never set entryFilter, so their behavior is unchanged.
1883
+ const localIndex = loadIndex(ctx.hippoRoot);
1884
+ const isGlobalHit = (e) => !localIndex.entries[e.id];
1848
1885
  const merged = await searchBothHybrid(query, ctx.hippoRoot, globalRoot, {
1849
- budget,
1886
+ budget: left,
1887
+ minResults,
1888
+ cost: cost && ((r) => price(r.entry, isGlobalHit(r.entry))),
1850
1889
  scope: activeScope,
1851
1890
  tenantId: ctx.tenantId,
1852
1891
  entryFilter: ambientAdmit,
1853
1892
  });
1854
- const localIndex = loadIndex(ctx.hippoRoot);
1855
1893
  results = merged.map((r) => ({
1856
1894
  entry: r.entry,
1857
1895
  score: r.score,
1858
- tokens: r.tokens,
1859
- isGlobal: !localIndex.entries[r.entry.id],
1896
+ tokens: price(r.entry, isGlobalHit(r.entry)),
1897
+ isGlobal: isGlobalHit(r.entry),
1860
1898
  }));
1861
1899
  }
1862
1900
  else {
1863
1901
  const ctxConfig = loadConfig(ctx.hippoRoot);
1864
1902
  const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
1903
+ const localCost = cost && ((r) => price(r.entry, primaryIsGlobal));
1865
1904
  const ctxResults = usePhysicsCtx
1866
1905
  ? await physicsSearch(query, localEntries, {
1867
- budget,
1906
+ budget: left,
1907
+ minResults,
1908
+ cost: localCost,
1868
1909
  hippoRoot: ctx.hippoRoot,
1869
1910
  physicsConfig: ctxConfig.physics,
1870
1911
  scope: activeScope,
1871
1912
  })
1872
1913
  : await hybridSearch(query, localEntries, {
1873
- budget,
1914
+ budget: left,
1915
+ minResults,
1916
+ cost: localCost,
1874
1917
  hippoRoot: ctx.hippoRoot,
1875
1918
  scope: activeScope,
1876
1919
  });
1877
1920
  results = ctxResults.map((r) => ({
1878
1921
  entry: r.entry,
1879
1922
  score: r.score,
1880
- tokens: r.tokens,
1881
- isGlobal: false,
1923
+ tokens: price(r.entry, primaryIsGlobal),
1924
+ isGlobal: primaryIsGlobal,
1882
1925
  }));
1883
1926
  }
1884
1927
  selectedItems = results;
@@ -1905,7 +1948,7 @@ export async function getContext(ctx, opts = {}) {
1905
1948
  closeHippoDb(localDb);
1906
1949
  }
1907
1950
  }
1908
- if (hasGlobal) {
1951
+ if (hasGlobal && !primaryIsGlobal) {
1909
1952
  const globalDb = openHippoDb(globalRoot);
1910
1953
  try {
1911
1954
  appendAuditEvent(globalDb, {
@@ -1922,8 +1965,9 @@ export async function getContext(ctx, opts = {}) {
1922
1965
  }
1923
1966
  if (limit < selectedItems.length) {
1924
1967
  selectedItems = selectedItems.slice(0, limit);
1925
- totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
1926
1968
  }
1969
+ selectedItems = dropHeldCopies(selectedItems, (r) => r.entry); // after the last cut, so a merged row that was cut hides nothing
1970
+ totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
1927
1971
  // v39: annotate every returned entry with its origin and how it relates to
1928
1972
  // the active project, so renderers can demarcate cross-project inclusions.
1929
1973
  selectedItems = selectedItems.map((r) => ({
@@ -1932,9 +1976,9 @@ export async function getContext(ctx, opts = {}) {
1932
1976
  category: classifyOriginProject(r.entry.origin_project, currentProjectName),
1933
1977
  }));
1934
1978
  if (selectedItems.length === 0 &&
1935
- !activeSnapshot &&
1936
- !sessionHandoff &&
1937
- recentSessionEvents.length === 0) {
1979
+ !shownSnapshot &&
1980
+ !shownHandoff &&
1981
+ shownEvents.length === 0) {
1938
1982
  // LC1 F5 fix: this bare early-return used to skip tracing entirely — a
1939
1983
  // query that found nothing is exactly the coverage-gap signal Track LC
1940
1984
  // needs. Write an empty trace (result_count 0, no result rows) so it
@@ -2013,9 +2057,9 @@ export async function getContext(ctx, opts = {}) {
2013
2057
  return {
2014
2058
  entries: selectedItems,
2015
2059
  tokens: totalTokens,
2016
- activeSnapshot: activeSnapshot ?? undefined,
2017
- sessionHandoff: sessionHandoff ?? undefined,
2018
- recentEvents: recentSessionEvents.length > 0 ? recentSessionEvents : undefined,
2060
+ activeSnapshot: shownSnapshot ?? undefined,
2061
+ sessionHandoff: shownHandoff ?? undefined,
2062
+ recentEvents: shownEvents.length > 0 ? shownEvents : undefined,
2019
2063
  ambientState,
2020
2064
  };
2021
2065
  }
@@ -2047,8 +2091,8 @@ export function recordTokens(ctx, surface, use) {
2047
2091
  }
2048
2092
  /**
2049
2093
  * 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.
2094
+ * tokens sent, skipped as unchanged and re-read by later model calls, per
2095
+ * surface, with session counts and mean tokens per session.
2052
2096
  */
2053
2097
  export function tokenSummary(ctx, opts = {}) {
2054
2098
  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", "agent_memory_restore", "agent_memory_set_aside"];
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
@@ -111,9 +111,10 @@ function hasNoSpecificity(text) {
111
111
  }
112
112
  export function auditMemory(entry) {
113
113
  const issue = classifyMemory(entry);
114
- // Error means "auto-remove"; a pinned or raw row is never auto-removed, so it can only warn.
114
+ // Error means "auto-remove"; a pinned, raw or kept-for-good row is never auto-removed, so it can only warn.
115
115
  if (issue?.severity === 'error' && !canAutoDelete(entry)) {
116
- return { ...issue, severity: 'warning', reason: `${issue.reason} (${entry.pinned ? 'pinned' : 'raw'}, kept)` };
116
+ const why = entry.pinned ? 'pinned' : entry.kind === 'raw' ? 'raw' : 'keep rule';
117
+ return { ...issue, severity: 'warning', reason: `${issue.reason} (${why}, kept)` };
117
118
  }
118
119
  return issue;
119
120
  }
@@ -167,6 +168,71 @@ export function isContentWorthStoring(content) {
167
168
  return false;
168
169
  return true;
169
170
  }
171
+ // ---------------------------------------------------------------------------
172
+ // A5 audit log primitives (append-only mutation trail)
173
+ // ---------------------------------------------------------------------------
174
+ // The one list of audit ops: the AuditOp type, `hippo audit list --op` and GET /v1/audit?op= all read it.
175
+ export const AUDIT_OPS = [
176
+ 'remember',
177
+ 'recall',
178
+ 'promote',
179
+ 'supersede',
180
+ 'forget',
181
+ 'archive_raw',
182
+ 'auth_revoke',
183
+ 'auth_create', // emitted by api.authCreate
184
+ 'outcome',
185
+ 'consolidate', // emitted once per api.sleep invocation
186
+ 'audit_prune', // emitted by pruneAuditLog after each retention prune
187
+ 'summary_marked_dirty', // emitted by markSummaryDirty on the 0->1 transition
188
+ 'summary_marked_clean', // emitted by clearSummaryDirtyAfterBuild after the buildDag child-link loop
189
+ 'summary_rebuilt', // emitted by applyRebuildResult on a successful sleep-cycle rebuild
190
+ 'predict_create', // emitted by savePrediction
191
+ 'predict_close', // emitted by closePrediction
192
+ 'predict_baserate', // emitted by computePredictionBaserate
193
+ 'recall_autodebias_hint', // emitted by computePlanningFallacyOutput on success
194
+ 'recall_autodebias_hint_no_class_match', // telemetry: forward-claim detected, no class scored
195
+ 'recall_autodebias_hint_tiebreak', // telemetry: forward-claim detected, two or more classes tied
196
+ 'recall_anchor_detected_query_repeat', // emitted by the anchoring detector when the same query returns the same top-1
197
+ 'recall_anchor_detected_memory_dominance', // emitted by the anchoring detector when one memory wins top-1 across distinct queries
198
+ 'recall_anchor_skipped_no_session', // telemetry: no sessionId, so ring tracking was skipped
199
+ 'recall_availability_detected', // emitted when the availability/recency-bias hint fires
200
+ 'decision_create', // emitted by saveDecision
201
+ 'decision_supersede', // emitted by saveDecision when --supersedes resolves to an active decision
202
+ 'decision_close', // emitted by closeDecision
203
+ 'incident_open', // emitted by saveIncident
204
+ 'incident_resolve', // emitted by resolveIncident
205
+ 'incident_close', // emitted by closeIncident
206
+ 'process_create', // emitted by saveProcess
207
+ 'process_supersede', // emitted by saveProcess on a supersession
208
+ 'process_close', // emitted by closeProcess
209
+ 'policy_create', // emitted by savePolicy
210
+ 'policy_supersede', // emitted by savePolicy on a supersession
211
+ 'policy_close', // emitted by closePolicy
212
+ 'skill_create', // emitted by saveSkill
213
+ 'skill_supersede', // emitted by saveSkill on a supersession
214
+ 'skill_close', // emitted by closeSkill
215
+ 'project_brief_create', // emitted by saveProjectBrief
216
+ 'project_brief_supersede', // emitted by saveProjectBrief on a supersession, including a refresh
217
+ 'project_brief_close', // emitted by closeProjectBrief
218
+ 'customer_note_create', // emitted by saveCustomerNote
219
+ 'customer_note_supersede', // emitted by saveCustomerNote on a supersession
220
+ 'customer_note_close', // emitted by closeCustomerNote
221
+ 'mv_rescue', // emitted by consolidate() per rescued entry when config.memoryValue.enabled
222
+ 'reject_value', // emitted by the `hippo reject` verb
223
+ 'reject_refusal', // emitted when the rejection guard refuses a write
224
+ 'unreject_value', // emitted by the `hippo unreject` verb
225
+ 'conflict_resolve', // emitted by resolveConflict on every resolution path
226
+ 'half_life_migrate', // emitted by migrateDefaultHalfLife with the rescaled ids
227
+ 'dormant_restore', // emitted by api.restoreDormant
228
+ 'auth_grant', // emitted by api.authGrant
229
+ 'auth_ungrant', // emitted by api.authUngrant
230
+ 'quarantine', // emitted by recordQuarantine inside remember's write transaction
231
+ 'quarantine_approve', // emitted by api.quarantineApprove
232
+ 'quarantine_reject', // emitted by api.quarantineReject
233
+ 'agent_memory_restore', // emitted by the agent memory sync when a deleted note comes back
234
+ 'agent_memory_set_aside', // emitted by the agent memory sync when a note is deleted or refused
235
+ ];
170
236
  function isBigIntValue(value) {
171
237
  return typeof value === 'bigint';
172
238
  }