hippo-memory 1.55.0 → 1.57.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 (77) hide show
  1. package/README.md +11 -0
  2. package/dist/api.d.ts +19 -9
  3. package/dist/api.js +112 -35
  4. package/dist/card-detail.d.ts +1 -1
  5. package/dist/card-detail.js +1 -1
  6. package/dist/cli/shared.d.ts +137 -0
  7. package/dist/cli/shared.js +830 -0
  8. package/dist/cli/sleep.d.ts +10 -0
  9. package/dist/cli/sleep.js +171 -0
  10. package/dist/cli.d.ts +0 -7
  11. package/dist/cli.js +313 -1806
  12. package/dist/config.d.ts +5 -0
  13. package/dist/config.js +21 -0
  14. package/dist/connectors/github/webhook.d.ts +19 -0
  15. package/dist/connectors/github/webhook.js +313 -0
  16. package/dist/connectors/slack/webhook.d.ts +22 -0
  17. package/dist/connectors/slack/webhook.js +203 -0
  18. package/dist/consolidate.js +3 -2
  19. package/dist/context-auto.d.ts +3 -0
  20. package/dist/context-auto.js +34 -0
  21. package/dist/customer-notes.js +2 -1
  22. package/dist/dashboard.js +2 -1
  23. package/dist/db.js +67 -1
  24. package/dist/decisions.js +2 -1
  25. package/dist/delivery-recorder.d.ts +127 -0
  26. package/dist/delivery-recorder.js +218 -0
  27. package/dist/eval-stats.d.ts +58 -0
  28. package/dist/eval-stats.js +111 -0
  29. package/dist/goals.d.ts +49 -25
  30. package/dist/goals.js +39 -22
  31. package/dist/graph-extract.js +1 -1
  32. package/dist/graph-recall.d.ts +1 -1
  33. package/dist/graph-recall.js +1 -1
  34. package/dist/graph.js +1 -1
  35. package/dist/hooks.d.ts +1 -3
  36. package/dist/hooks.js +2 -4
  37. package/dist/http-util.d.ts +31 -0
  38. package/dist/http-util.js +46 -0
  39. package/dist/incidents.js +2 -1
  40. package/dist/index.d.ts +5 -2
  41. package/dist/index.js +5 -2
  42. package/dist/mcp/server.js +173 -285
  43. package/dist/memory.d.ts +19 -0
  44. package/dist/memory.js +38 -0
  45. package/dist/policies.js +2 -1
  46. package/dist/predictions.js +2 -1
  47. package/dist/processes.js +2 -1
  48. package/dist/project-briefs.js +3 -1
  49. package/dist/prompt-recall.js +1 -1
  50. package/dist/recall-history.d.ts +5 -0
  51. package/dist/recall-history.js +9 -0
  52. package/dist/recall-pipeline.d.ts +101 -0
  53. package/dist/recall-pipeline.js +313 -0
  54. package/dist/recall-scope.d.ts +22 -0
  55. package/dist/recall-scope.js +27 -1
  56. package/dist/recall-trace.d.ts +69 -0
  57. package/dist/recall-trace.js +136 -0
  58. package/dist/search.d.ts +0 -20
  59. package/dist/search.js +2 -49
  60. package/dist/server.js +1901 -2384
  61. package/dist/skills.js +2 -1
  62. package/dist/store-cards.d.ts +53 -0
  63. package/dist/store-cards.js +512 -0
  64. package/dist/store.d.ts +2 -89
  65. package/dist/store.js +6 -562
  66. package/dist/tenant.d.ts +22 -0
  67. package/dist/tenant.js +26 -0
  68. package/dist/token-ledger.d.ts +2 -0
  69. package/dist/token-ledger.js +5 -0
  70. package/dist/tokenize.d.ts +2 -0
  71. package/dist/tokenize.js +8 -0
  72. package/dist/version.d.ts +1 -1
  73. package/dist/version.js +1 -1
  74. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  75. package/extensions/openclaw-plugin/package.json +1 -1
  76. package/openclaw.plugin.json +1 -1
  77. package/package.json +2 -1
package/README.md CHANGED
@@ -548,6 +548,17 @@ crashed, shows what was sent only. Re-reads usually bill at the provider's cache
548
548
  a fraction of the full input price. Counts are estimates (characters / 4), the same estimate
549
549
  every budget uses. Rows older than 90 days are pruned.
550
550
 
551
+ **See why a memory did or did not reach the agent.** Turn on the delivery ledger with
552
+ `{"deliveryLedger":{"enabled":true}}` in `.hippo/config.json` (off by default). The flag is
553
+ read from the store the token ledger writes to: the project's local store when it has one,
554
+ else the global store. Each per-prompt hook call then records one event (session, turn
555
+ number, whether the block was sent, reused, empty or disabled, counts and token totals) and
556
+ one row per candidate memory: emitted, reused or rejected, with the stage and the
557
+ reason it was dropped. With prompt recall on, recent memories dropped by the quality filter
558
+ are not recorded yet. It holds ids, hashes, counts and reasons only, never prompt or memory
559
+ text; the prompt hash is unsalted, so a very short prompt can be guessed. A ledger failure prints one stderr line and never changes what the hook prints. Rows
560
+ older than 90 days are pruned; at a heavy 300 prompts a day that is about 190 MB per store.
561
+
551
562
  ---
552
563
 
553
564
  ### Outcome feedback
package/dist/api.d.ts CHANGED
@@ -17,8 +17,9 @@ import { type SessionHandoff } from './handoff.js';
17
17
  import { type MemoryKind, type MemoryEntry } from './memory.js';
18
18
  import { auditMemories, type AuditEvent, type AuditOp } from './audit.js';
19
19
  import { autoShare } from './shared.js';
20
+ import type { DeliveryObserver } from './delivery-recorder.js';
20
21
  import { type ApiKeyListItem } from './auth.js';
21
- import { type RerankStep } from './search.js';
22
+ import { type RerankStep, type SearchResult } from './search.js';
22
23
  import { consolidate } from './consolidate.js';
23
24
  import { loadConfig } from './config.js';
24
25
  import { deduplicateStore } from './dedupe.js';
@@ -91,9 +92,8 @@ export type { TokenSummary, TokenSurface, TokenSurfaceSummary } from './token-le
91
92
  export type { FailureSummary } from './failure-log.js';
92
93
  export { classifyOriginProject } from './project-identity.js';
93
94
  /**
94
- * v39 S4: the secret half of the ambient policy on its own, for surfaces
95
- * with their own scope semantics (MCP hippo_context's explicit-scope
96
- * exact-match). A flagged row is only admitted inside its owning project;
95
+ * v39 S4: the secret half of the ambient policy on its own, for callers
96
+ * that apply their own scope rule. A flagged row is only admitted inside its owning project;
97
97
  * flagged rows with no project origin never ambient-inject.
98
98
  */
99
99
  export declare function ambientSecretAdmit(e: MemoryEntry, currentProjectName: string): boolean;
@@ -269,16 +269,22 @@ export interface RecallOpts {
269
269
  * Mirrors `suppressAvailabilityHint`'s pattern: callers that run their OWN
270
270
  * tracing over a DIFFERENT result set must suppress api.recall's copy so
271
271
  * the training corpus doesn't get a trace mislabeled as 'api' pipeline
272
- * when the caller's actual user-visible results came from elsewhere. The
273
- * MCP handler sets this — its primary ranked band comes from a separate
274
- * physics/hybrid scorer, not this api.recall call's BM25 band (real MCP
275
- * tracing is the reserved 'mcp' pipeline, a follow-up). HTTP / direct SDK
276
- * callers leave this unset and get the trace.
272
+ * when the caller's actual user-visible results came from elsewhere. Under
273
+ * `showRanked` it also drops the 'mcp' trace of the shown list. HTTP /
274
+ * direct SDK callers leave this unset and get the trace.
277
275
  */
278
276
  suppressRecallTrace?: boolean;
279
277
  /** Set only by the MCP recall tool, which ranks with its own scorer and drops copies from its own final list: this call
280
278
  * then keeps a memory that a merged row in the same result holds word for word. Other callers leave it unset. */
281
279
  keepHeldCopies?: boolean;
280
+ /** MCP recall only: `retrieve` ranks the whole scoped store and strengthens and traces (pipeline 'mcp') just the ids this returns; `results` stays the window band. */
281
+ showRanked?: (ranking: StoreRanking, result: RecallResult) => readonly string[];
282
+ }
283
+ /** `ranked`: every scored row, best first, goal boost applied, entries as loaded; `pool`: the store after the scope filter. */
284
+ export interface StoreRanking {
285
+ ranked: SearchResult[];
286
+ pool: MemoryEntry[];
287
+ droppedByScope: number;
282
288
  }
283
289
  export interface ContinuityBlock {
284
290
  activeSnapshot: TaskSnapshot | null;
@@ -945,6 +951,8 @@ export interface ContextOpts {
945
951
  limit?: number;
946
952
  pinnedOnly?: boolean;
947
953
  scope?: string;
954
+ /** Envelope scope to match exactly, as in `recall`: admits that scope even when private, after the actor's scope check. */
955
+ exactScope?: string;
948
956
  /** With `pinnedOnly`, also inject the N most recent writes that pass the
949
957
  * quality floor (`isContentWorthStoring`, DF3). Filtering happens BEFORE
950
958
  * the take-N, so a caller asking for 5 gets 5 qualifying entries rather
@@ -974,6 +982,8 @@ export interface ContextOpts {
974
982
  prompt?: string;
975
983
  /** What the budget pays for, from the caller that renders the block. Absent = the memory text alone. */
976
984
  cost?: ContextCost;
985
+ /** @internal The CLI's delivery-ledger observer; it only reads, so selection is the same with or without it. */
986
+ deliveryObserver?: DeliveryObserver;
977
987
  }
978
988
  /** Budget prices in the text a caller prints, so the budget bounds what reaches the model. */
979
989
  export interface ContextCost {
package/dist/api.js CHANGED
@@ -16,7 +16,7 @@ import { detectInstruction } from './instruction-detect.js';
16
16
  import { quarantineScopeFor, recordQuarantine, getQuarantineRow, listQuarantineRows, approveQuarantineRow, rejectQuarantineRow, } from './quarantine.js';
17
17
  import { summarizeFailures } from './failure-log.js';
18
18
  import { formatHandoffEvidenceLine } from './handoff.js';
19
- import { createMemory, createSuccessor, applyOutcome, calculateStrength, CHURN_STALE_TAG, COMPACTION_MEMORY_TAG, } from './memory.js';
19
+ import { createMemory, createSuccessor, applyOutcome, calculateStrength, markRetrieved, CHURN_STALE_TAG, COMPACTION_MEMORY_TAG, } from './memory.js';
20
20
  import { appendAuditEvent, auditQueryFields, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
21
21
  import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './shared.js';
22
22
  import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
@@ -24,7 +24,7 @@ import { evalNow } from './ablation.js';
24
24
  import { archiveRawMemory } from './raw-archive.js';
25
25
  import { createApiKey, listApiKeys, revokeApiKey, grantScope, ungrantScope, } from './auth.js';
26
26
  import { applyGoalStackBoost } from './goals.js';
27
- import { markRetrieved, estimateTokens, hybridSearch, physicsSearch, churnStaleFactor } from './search.js';
27
+ import { estimateTokens, hybridSearch, physicsSearch, churnStaleFactor } from './search.js';
28
28
  import { compareEntryIdentity, compareScoredResults } from './compare.js';
29
29
  import { dropHeldCopies, duplicateKey, storedTextKeys } from './same-text.js';
30
30
  import { scopeMatch } from './scope.js';
@@ -39,7 +39,7 @@ import { computeAmbientState } from './ambient.js';
39
39
  import { loadPendingExtractionTenants, markPendingProcessedUpTo } from './graph.js';
40
40
  import { extractGraph } from './graph-extract.js';
41
41
  import { computePlanningFallacyOutput, } from './predictions.js';
42
- import { detectAnchoring, hashQueryText, } from './recall-history.js';
42
+ import { detectAnchoring, hashQueryText, biasHintEnabled, } from './recall-history.js';
43
43
  import { detectAvailabilityBias } from './availability.js';
44
44
  /**
45
45
  * Helper for building process-local (admin-by-default) Actor values. v1.12.0
@@ -107,23 +107,22 @@ export { classifyOriginProject } from './project-identity.js';
107
107
  * injects inside its owning project; flagged rows with no project origin
108
108
  * (''/null) never ambient-inject at all. Explicit recall is unaffected -
109
109
  * recalling a secret is a deliberate act.
110
- * - S2 envelope parity: private scopes + quarantine buckets never inject.
110
+ * - S2 envelope parity: private/quarantine scopes never inject unless `exactScope` names one.
111
111
  * - S3 origin partition: other-project rows are excluded unless
112
112
  * `includeCrossProject`.
113
113
  */
114
- function ambientAdmitEntry(e, currentProjectName, includeCrossProject) {
114
+ function ambientAdmitEntry(e, currentProjectName, includeCrossProject, exactScope) {
115
115
  if (!ambientSecretAdmit(e, currentProjectName))
116
116
  return false;
117
- if (!passesScopeFilterForRecall(e.scope ?? null, undefined))
117
+ if (!passesScopeFilterForRecall(e.scope ?? null, exactScope))
118
118
  return false;
119
119
  if (includeCrossProject)
120
120
  return true;
121
121
  return classifyOriginProject(e.origin_project, currentProjectName) !== 'cross-project';
122
122
  }
123
123
  /**
124
- * v39 S4: the secret half of the ambient policy on its own, for surfaces
125
- * with their own scope semantics (MCP hippo_context's explicit-scope
126
- * exact-match). A flagged row is only admitted inside its owning project;
124
+ * v39 S4: the secret half of the ambient policy on its own, for callers
125
+ * that apply their own scope rule. A flagged row is only admitted inside its owning project;
127
126
  * flagged rows with no project origin never ambient-inject.
128
127
  */
129
128
  export function ambientSecretAdmit(e, currentProjectName) {
@@ -135,12 +134,19 @@ export function ambientSecretAdmit(e, currentProjectName) {
135
134
  return origin === currentProjectName;
136
135
  }
137
136
  // The pinned-only branch needs pins and recent-N candidates, not the corpus; `recall` applies there only.
138
- function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit, recall) {
137
+ function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit, recall, onQualityDrop) {
139
138
  if (!pinnedOnly)
140
139
  return { entries: loadAllEntries(hippoRoot, tenantId).filter(admit) };
141
140
  // DF3's quality floor runs on the recent-N slice AFTER this load, so the load
142
141
  // counts by it too, or it stops short of a store whose newest rows are junk.
143
- const admitAmbient = (e) => admit(e) && (e.pinned || isContentWorthStoring(e.content));
142
+ const admitAmbient = (e) => {
143
+ if (!admit(e))
144
+ return false;
145
+ if (e.pinned || isContentWorthStoring(e.content))
146
+ return true;
147
+ onQualityDrop?.(e);
148
+ return false;
149
+ };
144
150
  return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient, recall);
145
151
  }
146
152
  // Share and promote copy a memory to the global store under a new id, so equal content is the only link.
@@ -237,6 +243,8 @@ export function recall(ctx, opts) {
237
243
  export async function retrieve(ctx, opts) {
238
244
  assertScopeRequestAllowed(ctx.actor, opts.scope);
239
245
  const windowSize = recallWindowSize(opts);
246
+ if (opts.showRanked)
247
+ return retrieveFromStore(ctx, opts, windowSize, opts.showRanked);
240
248
  let candidates = loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false);
241
249
  if (opts.mode === 'hybrid' || opts.mode === 'physics') {
242
250
  const searchOpts = { budget: Infinity, hippoRoot: ctx.hippoRoot, scope: opts.scope ?? null };
@@ -250,6 +258,40 @@ export async function retrieve(ctx, opts) {
250
258
  strengthenRetrieved(ctx.hippoRoot, result.results.map((r) => r.id), ctx.tenantId);
251
259
  return result;
252
260
  }
261
+ /** `retrieve` under `showRanked`: physics when `mode` says so, hybrid otherwise, over every admitted row. */
262
+ async function retrieveFromStore(ctx, opts, windowSize, show) {
263
+ const store = loadAllEntries(ctx.hippoRoot, ctx.tenantId);
264
+ const pool = store.filter((e) => passesScopeFilterForRecall(e.scope ?? null, opts.scope));
265
+ // No scope option: the scope boost follows HIPPO_SCOPE and the skill env, as MCP recall always ranked.
266
+ const searchOpts = { budget: Infinity, hippoRoot: ctx.hippoRoot };
267
+ let ranked = opts.mode === 'physics'
268
+ ? await physicsSearch(opts.query, pool, { ...searchOpts, physicsConfig: loadConfig(ctx.hippoRoot).physics })
269
+ : await hybridSearch(opts.query, pool, searchOpts);
270
+ if (opts.sessionId && !opts.goalTag) {
271
+ const db = openHippoDb(ctx.hippoRoot);
272
+ try {
273
+ ranked = applyGoalStackBoost(db, ranked, { sessionId: opts.sessionId, tenantId: ctx.tenantId, limit: ranked.length });
274
+ }
275
+ finally {
276
+ closeHippoDb(db);
277
+ }
278
+ }
279
+ const window = ranked.slice(0, windowSize).map((r) => r.entry);
280
+ const result = recallFrom(ctx, { ...opts, suppressRecallTrace: true }, windowSize, window);
281
+ const shown = show({ ranked, pool, droppedByScope: store.length - pool.length }, result);
282
+ strengthenRetrieved(ctx.hippoRoot, shown, ctx.tenantId);
283
+ if (!opts.suppressRecallTrace) {
284
+ const scores = new Map(ranked.map((r) => [r.entry.id, r.score]));
285
+ writeRecallTraceAtRoot(ctx.hippoRoot, {
286
+ tenantId: ctx.tenantId,
287
+ sessionId: opts.sessionId ?? null,
288
+ pipeline: 'mcp',
289
+ query: opts.query,
290
+ results: shown.map((id) => ({ memoryId: id, score: scores.get(id) ?? 0 })),
291
+ });
292
+ }
293
+ return result;
294
+ }
253
295
  /** Contract preflight: throws before any store-touching work. */
254
296
  function recallWindowSize(opts) {
255
297
  // F5 (v1.6.5) preflight — codex P1: original guard fired AFTER
@@ -557,9 +599,9 @@ function recallFrom(ctx, opts, windowSize, all) {
557
599
  // handle. v1.11.5 contract lock holds — api.recall does NOT write
558
600
  // last_trace_id (tests/api-recall-no-side-effects.test.ts); a trace INSERT
559
601
  // is the same observability class as the audit row it sits beside, not
560
- // retrieval state. F2 fix: suppressed when the caller (currently only the
561
- // MCP handler) traces its own, different result set — see
562
- // opts.suppressRecallTrace JSDoc. Fail-soft internally; never throws.
602
+ // retrieval state. F2 fix: suppressed when the caller traces its own,
603
+ // different result set (retrieve under showRanked traces the shown list as
604
+ // 'mcp'). Fail-soft internally; never throws.
563
605
  if (!opts.suppressRecallTrace) {
564
606
  writeRecallTrace(db, {
565
607
  tenantId: ctx.tenantId,
@@ -649,7 +691,7 @@ function recallFrom(ctx, opts, windowSize, all) {
649
691
  // detect call returns null and api.recall's anchoringHint stays absent.
650
692
  let anchoringHint = null;
651
693
  let suppressedByInterferenceCount = 0;
652
- if (process.env.HIPPO_ANCHORING !== 'off' && opts.recallHistory) {
694
+ if (biasHintEnabled('anchoring') && opts.recallHistory) {
653
695
  const queryHash = hashQueryText(opts.query);
654
696
  const topMemoryId = rankedOut[0]?.id ?? null;
655
697
  anchoringHint = detectAnchoring(opts.recallHistory, queryHash, topMemoryId);
@@ -702,7 +744,7 @@ function recallFrom(ctx, opts, windowSize, all) {
702
744
  // opts.recallHistory gate above so we never double-emit the audit op. Audit
703
745
  // emission is pipeline-local, mirroring the J1 block above.
704
746
  let availabilityHint = null;
705
- if (process.env.HIPPO_AVAILABILITY !== 'off' && !opts.suppressAvailabilityHint) {
747
+ if (biasHintEnabled('availability') && !opts.suppressAvailabilityHint) {
706
748
  availabilityHint = detectAvailabilityBias({
707
749
  topK: baseSlice.map((e) => ({ id: e.id, created: e.created })),
708
750
  pool: entries.map((e) => ({ id: e.id, created: e.created })),
@@ -1544,6 +1586,8 @@ export async function getContext(ctx, opts = {}) {
1544
1586
  const limit = opts.limit ?? Number.POSITIVE_INFINITY;
1545
1587
  const includeRecent = opts.includeRecent ?? 0;
1546
1588
  const activeScope = opts.scope ?? '';
1589
+ assertScopeRequestAllowed(ctx.actor, opts.exactScope);
1590
+ const exactScope = opts.exactScope || undefined;
1547
1591
  if (budget <= 0) {
1548
1592
  return { entries: [], tokens: 0 };
1549
1593
  }
@@ -1555,10 +1599,7 @@ export async function getContext(ctx, opts = {}) {
1555
1599
  const primaryIsGlobal = isGlobalStoreRoot(ctx.hippoRoot);
1556
1600
  const hasLocalTaskState = hasLocal && !primaryIsGlobal;
1557
1601
  // v39 memory scope isolation (docs/plans/2026-07-01-memory-scope-isolation.md).
1558
- // S2: envelope-filter parity with api.recall for AMBIENT context - private
1559
- // scopes and quarantine buckets never inject. `requested` is deliberately
1560
- // undefined: opts.scope is the scope-TAG boost input here, not an
1561
- // envelope-scope request (api.recall's exact-match semantics don't apply).
1602
+ // S2: envelope-filter parity with api.recall; opts.scope is only the tag boost, opts.exactScope the envelope request.
1562
1603
  // S3: origin partition - other-project memories are excluded unless the
1563
1604
  // caller explicitly asks for them (crossProject) or isolation is disabled.
1564
1605
  const config = loadConfig(ctx.hippoRoot);
@@ -1579,6 +1620,10 @@ export async function getContext(ctx, opts = {}) {
1579
1620
  ? cost.entry({ entry, isGlobal, promptRecall, origin: entry.origin_project ?? null, category: classifyOriginProject(entry.origin_project, currentProjectName) })
1580
1621
  : estimateTokens(entry.content);
1581
1622
  const blockBudget = pinnedOnly && opts.budget === undefined ? config.pinnedInject.budget : budget;
1623
+ const obs = opts.deliveryObserver;
1624
+ obs?.facts({ projectName: currentProjectName, budgetTokens: blockBudget, promptRecall: promptRecallPending });
1625
+ if (pinnedOnly && !config.pinnedInject.enabled)
1626
+ obs?.disabled();
1582
1627
  let left = cost
1583
1628
  ? Math.max(0, blockBudget - cost.fixed(blockBudget, { cross: includeCrossProject, promptRecall: promptRecallPending, ambient: !pinnedOnly && config.ambient.enabled }))
1584
1629
  : blockBudget;
@@ -1601,9 +1646,8 @@ export async function getContext(ctx, opts = {}) {
1601
1646
  sessionId: opts.currentSessionId,
1602
1647
  })
1603
1648
  : null;
1604
- // W1: pre-existing leak; same `requested: undefined` ambientAdmitEntry
1605
- // already uses when it scope-filters memory rows above.
1606
- const activeSnapshot = rawActiveSnapshot && passesScopeFilterForRecall(rowScope(rawActiveSnapshot), undefined)
1649
+ // W1: the same envelope rule ambientAdmitEntry applies to memory rows.
1650
+ const activeSnapshot = rawActiveSnapshot && passesScopeFilterForRecall(rowScope(rawActiveSnapshot), exactScope)
1607
1651
  ? rawActiveSnapshot
1608
1652
  : null;
1609
1653
  // Key on the RAW snapshot: a scope-hidden active session must not fall through to another session's ambient handoff.
@@ -1617,7 +1661,7 @@ export async function getContext(ctx, opts = {}) {
1617
1661
  // codex P2: admit scope in SQL so a newer denied row can't hide an older eligible one before LIMIT 1.
1618
1662
  scopeFilter: 'default-deny',
1619
1663
  });
1620
- const sessionHandoff = rawSessionHandoff && passesScopeFilterForRecall(rowScope(rawSessionHandoff), undefined)
1664
+ const sessionHandoff = rawSessionHandoff && passesScopeFilterForRecall(rowScope(rawSessionHandoff), exactScope)
1621
1665
  ? rawSessionHandoff
1622
1666
  : null;
1623
1667
  // Raw session id here too: each event is admitted on its own scope, same as recall and the CLI.
@@ -1625,11 +1669,12 @@ export async function getContext(ctx, opts = {}) {
1625
1669
  ? listSessionEvents(ctx.hippoRoot, ctx.tenantId, {
1626
1670
  session_id: rawActiveSnapshot.session_id,
1627
1671
  limit: 5,
1628
- }).filter((e) => passesScopeFilterForRecall(rowScope(e), undefined))
1672
+ }).filter((e) => passesScopeFilterForRecall(rowScope(e), exactScope))
1629
1673
  : [];
1630
1674
  const shownSnapshot = activeSnapshot && (!cost || pays(cost.snapshot(activeSnapshot))) ? activeSnapshot : null;
1631
1675
  const shownHandoff = sessionHandoff && (!cost || pays(cost.handoff(sessionHandoff))) ? sessionHandoff : null;
1632
1676
  const shownEvents = recentSessionEvents.length > 0 && (!cost || pays(cost.trail(recentSessionEvents))) ? recentSessionEvents : [];
1677
+ obs?.sections(Number(shownSnapshot !== null) + Number(shownHandoff !== null) + Number(shownEvents.length > 0), Number(activeSnapshot !== shownSnapshot) + Number(sessionHandoff !== shownHandoff) + Number(recentSessionEvents.length !== shownEvents.length));
1633
1678
  const transcriptHandoffSession = shownHandoff?.evidence?.derivedFrom === 'transcript' ? shownHandoff.sessionId : null;
1634
1679
  let digestHiddenForHandoff = false;
1635
1680
  const ambientAdmit = (e) => {
@@ -1638,7 +1683,7 @@ export async function getContext(ctx, opts = {}) {
1638
1683
  digestHiddenForHandoff = true;
1639
1684
  return false;
1640
1685
  }
1641
- return ambientAdmitEntry(e, currentProjectName, includeCrossProject);
1686
+ return ambientAdmitEntry(e, currentProjectName, includeCrossProject, exactScope);
1642
1687
  };
1643
1688
  const ownSessionId = opts.currentSessionId || '';
1644
1689
  // Inside admit, not after the load, so the loader's window widens past a session's own items.
@@ -1647,12 +1692,14 @@ export async function getContext(ctx, opts = {}) {
1647
1692
  e.tags.includes(COMPACTION_MEMORY_TAG);
1648
1693
  // Superseded rows never inject; which rows reach ambientAdmitEntry matters because it regex-scans content for secrets.
1649
1694
  const admit = (e) => !e.superseded_by && !isOwnCompactionItem(e) && ambientAdmit(e);
1695
+ const loadAdmit = obs ? obs.watchAdmit(admit) : admit;
1696
+ const qualityDrop = (isGlobal) => obs && !promptRecallPending ? (e) => obs.qualityDropped(e, isGlobal) : undefined;
1650
1697
  // Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
1651
1698
  const localLoad = hasLocal
1652
- ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, recallRequest)
1699
+ ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, recallRequest, qualityDrop(primaryIsGlobal))
1653
1700
  : { entries: [] };
1654
1701
  const globalLoad = hasGlobal && !primaryIsGlobal
1655
- ? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, recallRequest)
1702
+ ? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, recallRequest, qualityDrop(true))
1656
1703
  : { entries: [] };
1657
1704
  let localEntries = localLoad.entries;
1658
1705
  let globalEntries = globalLoad.entries;
@@ -1677,7 +1724,10 @@ export async function getContext(ctx, opts = {}) {
1677
1724
  // Effective budget: explicit opts.budget wins over config, less what the sections took.
1678
1725
  const effBudget = left;
1679
1726
  const nowP = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
1727
+ obs?.offer(localEntries, primaryIsGlobal);
1728
+ obs?.offer(globalEntries, true);
1680
1729
  const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, nowP);
1730
+ obs?.dropMissing([...localEntries, ...globalEntries], [...localPool, ...globalPool], 'load', 'duplicate');
1681
1731
  const selectedIds = new Set();
1682
1732
  let usedP = 0;
1683
1733
  // Pinned entries are explicit user intent, the recent-N list an automatic
@@ -1743,8 +1793,23 @@ export async function getContext(ctx, opts = {}) {
1743
1793
  // Candidates came off the ambient load's own connection (recallRequest above), not a fresh open.
1744
1794
  // A candidate carrying a pin's text would inject that memory a second time.
1745
1795
  const pinnedText = new Set(rankedPinned.map((r) => r.entry.content));
1746
- const eligible = (e) => admit(e) && !e.pinned && isContentWorthStoring(e.content) && !pinnedText.has(e.content);
1747
- const [localCandidates, globalCandidates] = oneCopyPerMemory((localLoad.recall ?? []).filter(eligible), (globalLoad.recall ?? []).filter(eligible), nowP);
1796
+ const ineligibleReason = (e) => !admit(e) ? 'scope'
1797
+ : e.pinned ? 'pinned'
1798
+ : !isContentWorthStoring(e.content) ? 'quality'
1799
+ : pinnedText.has(e.content) ? 'duplicate'
1800
+ : null;
1801
+ const eligible = (e) => {
1802
+ const why = ineligibleReason(e);
1803
+ if (why !== null && why !== 'pinned')
1804
+ obs?.reject(e, 'eligible', why);
1805
+ return why === null;
1806
+ };
1807
+ obs?.offer(localLoad.recall ?? [], primaryIsGlobal, 'prompt-recall');
1808
+ obs?.offer(globalLoad.recall ?? [], true, 'prompt-recall');
1809
+ const localEligible = (localLoad.recall ?? []).filter(eligible);
1810
+ const globalEligible = (globalLoad.recall ?? []).filter(eligible);
1811
+ const [localCandidates, globalCandidates] = oneCopyPerMemory(localEligible, globalEligible, nowP);
1812
+ obs?.dropMissing([...localEligible, ...globalEligible], [...localCandidates, ...globalCandidates], 'eligible', 'duplicate');
1748
1813
  const seenCandidateIds = new Set();
1749
1814
  const candidateItems = [];
1750
1815
  // Local wins the id collision (a global row synced into the local store).
@@ -1761,12 +1826,15 @@ export async function getContext(ctx, opts = {}) {
1761
1826
  candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: true });
1762
1827
  }
1763
1828
  const gated = gatePromptRecall(p, candidateItems, gate);
1829
+ obs?.gated(p, candidateItems, gate, gated);
1764
1830
  for (const g of gated) {
1765
1831
  if (selectedIds.has(g.item.id))
1766
1832
  continue;
1767
1833
  const tokens = price(g.item.entry, g.item.isGlobal, true);
1768
- if (usedP + tokens > recentBudget)
1834
+ if (usedP + tokens > recentBudget) {
1835
+ obs?.reject(g.item.entry, 'budget', 'budget', g.score, tokens);
1769
1836
  continue;
1837
+ }
1770
1838
  selectedItems.push({ entry: g.item.entry, score: g.score, tokens, isGlobal: g.item.isGlobal, promptRecall: true });
1771
1839
  selectedIds.add(g.item.id);
1772
1840
  usedP += tokens;
@@ -1818,8 +1886,10 @@ export async function getContext(ctx, opts = {}) {
1818
1886
  for (const r of recent) {
1819
1887
  if (selectedIds.has(r.entry.id))
1820
1888
  continue;
1821
- if (usedP + r.tokens > recentBudget)
1889
+ if (usedP + r.tokens > recentBudget) {
1890
+ obs?.reject(r.entry, 'budget', 'budget', r.score, r.tokens);
1822
1891
  continue;
1892
+ }
1823
1893
  selectedItems.push(r);
1824
1894
  selectedIds.add(r.entry.id);
1825
1895
  usedP += r.tokens;
@@ -1834,8 +1904,10 @@ export async function getContext(ctx, opts = {}) {
1834
1904
  for (const r of rankedPinned) {
1835
1905
  if (selectedIds.has(r.entry.id))
1836
1906
  continue;
1837
- if (usedP + r.tokens > effBudget)
1907
+ if (usedP + r.tokens > effBudget) {
1908
+ obs?.reject(r.entry, 'budget', 'budget', r.score, r.tokens);
1838
1909
  continue;
1910
+ }
1839
1911
  selectedItems.push(r);
1840
1912
  selectedIds.add(r.entry.id);
1841
1913
  usedP += r.tokens;
@@ -1968,9 +2040,13 @@ export async function getContext(ctx, opts = {}) {
1968
2040
  }
1969
2041
  }
1970
2042
  if (limit < selectedItems.length) {
1971
- selectedItems = selectedItems.slice(0, limit);
2043
+ const cut = selectedItems.slice(0, limit);
2044
+ obs?.dropMissing(selectedItems.map((r) => r.entry), cut.map((r) => r.entry), 'limit', 'limit');
2045
+ selectedItems = cut;
1972
2046
  }
1973
- selectedItems = dropHeldCopies(selectedItems, (r) => r.entry); // after the last cut, so a merged row that was cut hides nothing
2047
+ const heldDropped = dropHeldCopies(selectedItems, (r) => r.entry); // after the last cut, so a merged row that was cut hides nothing
2048
+ obs?.dropMissing(selectedItems.map((r) => r.entry), heldDropped.map((r) => r.entry), 'limit', 'duplicate');
2049
+ selectedItems = heldDropped;
1974
2050
  totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
1975
2051
  // v39: annotate every returned entry with its origin and how it relates to
1976
2052
  // the active project, so renderers can demarcate cross-project inclusions.
@@ -1979,6 +2055,7 @@ export async function getContext(ctx, opts = {}) {
1979
2055
  origin: r.entry.origin_project ?? null,
1980
2056
  category: classifyOriginProject(r.entry.origin_project, currentProjectName),
1981
2057
  }));
2058
+ obs?.selected(selectedItems);
1982
2059
  if (selectedItems.length === 0 &&
1983
2060
  !shownSnapshot &&
1984
2061
  !shownHandoff &&
@@ -1,6 +1,6 @@
1
1
  import type { Card, CardComment, CardRun } from './card.js';
2
2
  import type { SessionHandoff } from './handoff.js';
3
- import { loadCardDeps } from './store.js';
3
+ import { loadCardDeps } from './store-cards.js';
4
4
  /** A card plus everything `hippo card show` prints about it. */
5
5
  export interface CardDetail {
6
6
  card: Card;
@@ -1,4 +1,4 @@
1
- import { loadCard, loadCardComments, loadCardDeps, loadCardRuns, loadLatestHandoffForCard } from './store.js';
1
+ import { loadCard, loadCardComments, loadCardDeps, loadCardRuns, loadLatestHandoffForCard } from './store-cards.js';
2
2
  /** Loads the detail behind `hippo card show` and `GET /api/cards/:id` (null when the tenant has no such card); five separate reads, so a write landing between them can show a mixed view, as `card show` always could. */
3
3
  export function loadCardDetail(hippoRoot, tenantId, id) {
4
4
  const card = loadCard(hippoRoot, tenantId, id);
@@ -0,0 +1,137 @@
1
+ import { TaskSnapshot, SessionEvent } from '../store.js';
2
+ import type { HandoffEvidence, SessionHandoff } from '../handoff.js';
3
+ import { type SearchResult } from '../search.js';
4
+ import { type HippoConfig } from '../config.js';
5
+ import { openHippoDb } from '../db.js';
6
+ import { type ImportReport } from '../agent-memories/report.js';
7
+ import { type ChurnStaleResult } from '../invalidation.js';
8
+ import { type AuditOp } from '../audit.js';
9
+ import { type ServerInfo } from '../server-detect.js';
10
+ import type { RecallSearchOpts } from '../recall-pipeline.js';
11
+ export declare function parseLimitFlag(value: string | boolean | string[] | undefined): number;
12
+ export declare function parseCountFlag(value: string | boolean | string[] | undefined): number;
13
+ export declare function parseBudgetFlag(value: string | boolean | string[] | undefined, fallback: number): number;
14
+ /**
15
+ * Emit an audit event against `hippoRoot`'s db. Opens its own short-lived
16
+ * connection so callers don't have to thread a db handle. Swallows all errors
17
+ * — audit must never crash a CLI command.
18
+ */
19
+ export declare function emitCliAudit(hippoRoot: string, op: AuditOp, targetId?: string, metadata?: Record<string, unknown>): void;
20
+ export declare function requireInit(hippoRoot: string): void;
21
+ /** Runs detectChurnStale against every store this repo's memories can live in. */
22
+ export declare function runChurnStaleForRepo(hippoRoot: string, dryRun: boolean): {
23
+ root: string;
24
+ result: ChurnStaleResult;
25
+ }[];
26
+ /**
27
+ * Run an HTTP-routed command if a `hippo serve` instance is detected for
28
+ * `hippoRoot`. Returns:
29
+ * - true if the HTTP path ran (success OR a structured server error that
30
+ * was already surfaced to stdout/stderr by `httpFn`),
31
+ * - false if no server was detected, or if the detected pidfile turned out
32
+ * to be stale (connection refused). On stale, the pidfile is removed
33
+ * if it still names that dead server (a newer one may have replaced
34
+ * it) and the caller should fall back to the direct path.
35
+ *
36
+ * Stale pidfiles must self-heal, not crash.
37
+ * When HIPPO_REQUIRE_SERVER is set, both fallback paths throw instead of
38
+ * returning false, so a missing server fails loudly rather than silently
39
+ * degrading to direct mode.
40
+ */
41
+ export declare function runViaServerIfAvailable(hippoRoot: string, httpFn: (info: ServerInfo, apiKey: string | undefined) => Promise<void>): Promise<boolean>;
42
+ export declare function fmt(n: number, digits?: number): string;
43
+ export declare function recallEntryText(r: SearchResult, query: string, showWhy: boolean, isGlobal: boolean): string;
44
+ export declare function recallHeading(entries: number, tokens: number, query: string): string;
45
+ /** One line when an agent memory import moved anything; its warnings go to stderr. */
46
+ export declare function printAgentImport(report: ImportReport, indent?: string): void;
47
+ /** The first hippo block in `text` and the agent whose current or shipped text it is; `owner` is undefined for an edited block. */
48
+ export declare function hippoBlock(text: string): {
49
+ start: number;
50
+ end: number;
51
+ eol: string;
52
+ inner: string;
53
+ owner?: string;
54
+ } | null;
55
+ /** Adds hippo's two Codex hooks and says what changed; each install ends on the trust reminder, since Codex skips an untrusted hook. */
56
+ export declare function installCodexMemoryHooks(indent: string): void;
57
+ /**
58
+ * Set up a machine-level daily runner that sweeps all registered Hippo
59
+ * workspaces.
60
+ * Linux/macOS: writes to user crontab.
61
+ * Windows: creates a scheduled task.
62
+ * Skips if already installed.
63
+ */
64
+ export declare function setupDailySchedule(globalRoot: string): void;
65
+ export type CliFlags = Record<string, string | boolean | string[]>;
66
+ export type EngineFlags = Pick<RecallSearchOpts, 'usePhysics' | 'physicsConfig' | 'mmr' | 'mmrLambda' | 'localBump'>;
67
+ export declare function parseAsOfFlag(flags: CliFlags): string | undefined;
68
+ /** --physics forces physics, --classic forces BM25+cosine, else physics unless the config turns it off. */
69
+ export declare function engineFlags(flags: CliFlags, config: HippoConfig): EngineFlags;
70
+ /**
71
+ * Detached worker that counts re-reads, runs sleep, then capture. Invoked via the internal
72
+ * `__session-end-worker` subcommand (not user-facing). Failures in one stage
73
+ * do not block the other.
74
+ */
75
+ export declare function collectHandoffEvidence(cwd: string, testStatus: HandoffEvidence['testStatus']): HandoffEvidence;
76
+ /** A folder without its own store never sleeps at session end, so its project's agent notes go to the global store here. */
77
+ export declare function logSessionEndImport(logFile: string | null, transcriptPath: string | undefined): void;
78
+ /**
79
+ * Best-effort log line for the snapshot-close step in
80
+ * `cmdSessionEndWorker`. `cmdSleep`/`cmdCapture` each tee console output to
81
+ * `logFile` only for their own duration (the tee is restored before this
82
+ * runs), so a plain `console.log` here would be silently discarded under
83
+ * the detached worker's `stdio: 'ignore'` — write straight to the file
84
+ * instead, matching capture.ts's `appendPreCompactLog` convention.
85
+ */
86
+ export declare function appendSessionEndCloseLog(logFile: string | null, message: string, opts?: {
87
+ startFresh?: boolean;
88
+ }): void;
89
+ export declare function printActiveTaskSnapshot(snapshot: TaskSnapshot): void;
90
+ export declare function printSessionEvents(events: SessionEvent[]): void;
91
+ export declare function printHandoff(handoff: SessionHandoff): void;
92
+ export declare function cardStringFlag(flags: Record<string, string | boolean | string[]>, key: string): string | undefined;
93
+ export declare function hostSessionId(): string | undefined;
94
+ /**
95
+ * Compaction drops the pinned blocks the per-prompt hook injected
96
+ * earlier, so record a `reset` for the payload's session and the next prompt
97
+ * injects again even if nothing changed. `requiredSource` limits it to hook
98
+ * payloads with that `source` (SessionStart fires for other reasons too).
99
+ * Best-effort and silent: a malformed payload records nothing.
100
+ */
101
+ export declare function resetHookInjection(hippoRoot: string, stdinText: string | undefined, requiredSource: string | null): void;
102
+ /**
103
+ * Run `fn` with console.log captured; returns the captured lines joined by
104
+ * newlines (what the same calls would have printed, minus the final newline).
105
+ */
106
+ export declare function captureConsole(fn: () => void): string;
107
+ /**
108
+ * The store a Claude Code hook writes to: the project store when there is
109
+ * one, else an existing global store, else the project path (which the hook
110
+ * then skips, since hooks fire in every directory and must not create one).
111
+ * Pre-compact and compact-resume must agree, or a snapshot saved to one store
112
+ * is looked for in the other.
113
+ */
114
+ export declare function hookStoreRoot(hippoRoot: string): string;
115
+ /**
116
+ * Run `fn` against the token ledger's store: the local store when it is
117
+ * initialized, else the global one (the per-prompt hook runs in directories
118
+ * without a local store). Best-effort: returns undefined and never throws,
119
+ * because a ledger failure must not break context or recall.
120
+ */
121
+ export declare function withLedgerDb<T>(hippoRoot: string, fn: (db: ReturnType<typeof openHippoDb>) => T): T | undefined;
122
+ export declare function learnFromRepo(hippoRoot: string, repoPath: string, days: number, label?: string): {
123
+ added: number;
124
+ skipped: number;
125
+ lowInfo: number;
126
+ };
127
+ export declare const HOOK_MARKERS: {
128
+ start: string;
129
+ end: string;
130
+ };
131
+ export declare const HOOKS: Record<string, {
132
+ file: string;
133
+ content: string;
134
+ description: string;
135
+ }>;
136
+ export declare function resolveAuthRoot(hippoRoot: string, flags: Record<string, string | boolean | string[]>): string;
137
+ //# sourceMappingURL=shared.d.ts.map