hippo-memory 1.56.0 → 1.58.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 (129) hide show
  1. package/README.md +11 -0
  2. package/dist/agent-memories/claude-code.js +1 -1
  3. package/dist/agent-memories/gemini.js +1 -1
  4. package/dist/api-errors.d.ts +27 -0
  5. package/dist/api-errors.js +37 -0
  6. package/dist/api.d.ts +21 -14
  7. package/dist/api.js +97 -71
  8. package/dist/audit.d.ts +4 -0
  9. package/dist/audit.js +11 -0
  10. package/dist/autolearn.d.ts +1 -1
  11. package/dist/autolearn.js +7 -5
  12. package/dist/capture-contract.d.ts +47 -0
  13. package/dist/capture-contract.js +49 -0
  14. package/dist/capture-error.js +2 -1
  15. package/dist/capture.d.ts +0 -13
  16. package/dist/capture.js +5 -66
  17. package/dist/card-detail.d.ts +1 -1
  18. package/dist/card-detail.js +1 -1
  19. package/dist/cli/shared.d.ts +137 -0
  20. package/dist/cli/shared.js +834 -0
  21. package/dist/cli/sleep.d.ts +10 -0
  22. package/dist/cli/sleep.js +171 -0
  23. package/dist/cli.d.ts +0 -7
  24. package/dist/cli.js +322 -1827
  25. package/dist/client.js +9 -0
  26. package/dist/codex-patch.js +1 -1
  27. package/dist/compaction-record.d.ts +1 -1
  28. package/dist/compaction-record.js +3 -2
  29. package/dist/config.d.ts +5 -0
  30. package/dist/config.js +17 -0
  31. package/dist/connectors/github/dlq.js +5 -2
  32. package/dist/connectors/github/octokit-client.js +4 -2
  33. package/dist/connectors/github/webhook.d.ts +19 -0
  34. package/dist/connectors/github/webhook.js +313 -0
  35. package/dist/connectors/slack/dlq.js +6 -2
  36. package/dist/connectors/slack/web-client.js +7 -5
  37. package/dist/connectors/slack/webhook.d.ts +22 -0
  38. package/dist/connectors/slack/webhook.js +203 -0
  39. package/dist/consolidate.d.ts +10 -0
  40. package/dist/consolidate.js +38 -35
  41. package/dist/context-auto.d.ts +3 -0
  42. package/dist/context-auto.js +34 -0
  43. package/dist/customer-notes.js +16 -14
  44. package/dist/dag.js +3 -2
  45. package/dist/dashboard.js +3 -2
  46. package/dist/db.d.ts +12 -0
  47. package/dist/db.js +62 -1
  48. package/dist/decisions.js +11 -9
  49. package/dist/doctor.js +5 -0
  50. package/dist/embedding-provider.js +3 -3
  51. package/dist/embeddings.d.ts +4 -4
  52. package/dist/embeddings.js +72 -16
  53. package/dist/eval-stats.d.ts +58 -0
  54. package/dist/eval-stats.js +111 -0
  55. package/dist/extract.js +3 -2
  56. package/dist/goals.d.ts +49 -25
  57. package/dist/goals.js +39 -22
  58. package/dist/graph-extract.js +1 -1
  59. package/dist/graph-recall.d.ts +1 -1
  60. package/dist/graph-recall.js +1 -1
  61. package/dist/graph.js +1 -1
  62. package/dist/hooks.d.ts +1 -3
  63. package/dist/hooks.js +2 -4
  64. package/dist/http-retry.d.ts +21 -0
  65. package/dist/http-retry.js +50 -0
  66. package/dist/http-util.d.ts +39 -0
  67. package/dist/http-util.js +56 -0
  68. package/dist/importers.d.ts +2 -0
  69. package/dist/importers.js +16 -5
  70. package/dist/incidents.js +13 -11
  71. package/dist/index.d.ts +5 -2
  72. package/dist/index.js +5 -2
  73. package/dist/judgment.js +10 -17
  74. package/dist/log.d.ts +25 -0
  75. package/dist/log.js +48 -0
  76. package/dist/mcp/server.js +224 -308
  77. package/dist/mcp/tool-args.d.ts +21 -0
  78. package/dist/mcp/tool-args.js +80 -0
  79. package/dist/memory.d.ts +19 -0
  80. package/dist/memory.js +41 -2
  81. package/dist/overlap-index.d.ts +7 -0
  82. package/dist/overlap-index.js +38 -0
  83. package/dist/pilot-arm.d.ts +9 -0
  84. package/dist/pilot-arm.js +47 -0
  85. package/dist/policies.js +14 -12
  86. package/dist/predictions.js +11 -9
  87. package/dist/processes.js +16 -14
  88. package/dist/project-briefs.js +19 -16
  89. package/dist/project-identity.d.ts +1 -1
  90. package/dist/project-identity.js +25 -1
  91. package/dist/prompt-recall.js +1 -1
  92. package/dist/raw-archive.js +7 -6
  93. package/dist/recall-history.d.ts +5 -0
  94. package/dist/recall-history.js +9 -0
  95. package/dist/recall-pipeline.d.ts +101 -0
  96. package/dist/recall-pipeline.js +313 -0
  97. package/dist/recall-scope.d.ts +24 -1
  98. package/dist/recall-scope.js +29 -2
  99. package/dist/refine-llm.js +3 -2
  100. package/dist/reject-flow.js +6 -9
  101. package/dist/rejection.d.ts +2 -1
  102. package/dist/rejection.js +2 -1
  103. package/dist/search.d.ts +0 -20
  104. package/dist/search.js +16 -51
  105. package/dist/secret-detect.d.ts +13 -1
  106. package/dist/secret-detect.js +33 -1
  107. package/dist/server.d.ts +3 -1
  108. package/dist/server.js +1854 -2566
  109. package/dist/session-digest.js +2 -1
  110. package/dist/shared.js +7 -6
  111. package/dist/skills.js +17 -15
  112. package/dist/store-cards.d.ts +53 -0
  113. package/dist/store-cards.js +512 -0
  114. package/dist/store.d.ts +2 -89
  115. package/dist/store.js +10 -566
  116. package/dist/tenant.d.ts +22 -0
  117. package/dist/tenant.js +26 -0
  118. package/dist/token-ledger.d.ts +4 -2
  119. package/dist/token-ledger.js +2 -2
  120. package/dist/tokenize.d.ts +2 -0
  121. package/dist/tokenize.js +8 -0
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.js +1 -1
  124. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  125. package/extensions/openclaw-plugin/package.json +1 -1
  126. package/openclaw.plugin.json +1 -1
  127. package/package.json +1 -1
  128. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  129. package/dist/connectors/slack/ratelimit.js +0 -18
@@ -21,8 +21,11 @@
21
21
  * Lifecycle: active -> superseded (a newer version replaces it) or active ->
22
22
  * closed (retired).
23
23
  */
24
+ import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
24
25
  import { openHippoDb, closeHippoDb } from './db.js';
25
- import { writeEntry, assertTenantId, RECALL_DEFAULT_DENY_SCOPES } from './store.js';
26
+ import { writeEntry } from './store.js';
27
+ import { assertTenantId } from './tenant.js';
28
+ import { RECALL_DEFAULT_DENY_SCOPES } from './recall-scope.js';
26
29
  import { markGraphDirty, removeGraphEntitiesForObject } from './graph.js';
27
30
  import { createMemory, Layer } from './memory.js';
28
31
  import { appendAuditEvent } from './audit.js';
@@ -54,21 +57,21 @@ export const MAX_RECEIPT_HEADLINE_LEN = 200;
54
57
  function validateBriefFields(repo, summary, changeSummary) {
55
58
  const normalizedRepo = (repo ?? '').trim();
56
59
  if (normalizedRepo.length === 0)
57
- throw new Error('saveProjectBrief: repo is required');
60
+ throw new BadRequestError('saveProjectBrief: repo is required');
58
61
  if (/[\r\n]/.test(normalizedRepo)) {
59
- throw new Error('saveProjectBrief: repo must be a single line (no newlines)');
62
+ throw new BadRequestError('saveProjectBrief: repo must be a single line (no newlines)');
60
63
  }
61
64
  if (normalizedRepo.length > MAX_REPO_LEN) {
62
- throw new Error(`saveProjectBrief: repo exceeds the ${MAX_REPO_LEN}-char cap`);
65
+ throw new BadRequestError(`saveProjectBrief: repo exceeds the ${MAX_REPO_LEN}-char cap`);
63
66
  }
64
67
  if (!summary || summary.trim().length === 0) {
65
- throw new Error('saveProjectBrief: summary is required');
68
+ throw new BadRequestError('saveProjectBrief: summary is required');
66
69
  }
67
70
  if (summary.length > MAX_BRIEF_SUMMARY_LEN) {
68
- throw new Error(`saveProjectBrief: summary exceeds the ${MAX_BRIEF_SUMMARY_LEN}-char cap`);
71
+ throw new BadRequestError(`saveProjectBrief: summary exceeds the ${MAX_BRIEF_SUMMARY_LEN}-char cap`);
69
72
  }
70
73
  if (changeSummary !== undefined && changeSummary.length > MAX_CHANGE_SUMMARY_LEN) {
71
- throw new Error(`saveProjectBrief: changeSummary exceeds the ${MAX_CHANGE_SUMMARY_LEN}-char cap`);
74
+ throw new BadRequestError(`saveProjectBrief: changeSummary exceeds the ${MAX_CHANGE_SUMMARY_LEN}-char cap`);
72
75
  }
73
76
  return { repo: normalizedRepo };
74
77
  }
@@ -143,10 +146,10 @@ export function saveProjectBrief(hippoRoot, tenantId, opts, actor = 'cli') {
143
146
  // shape for the matching row, or undefined when no brief/tenant pair matches.
144
147
  const pred = db.prepare(`SELECT status, version FROM project_briefs WHERE id = ? AND tenant_id = ?`).get(opts.supersedesBriefId, tenantId);
145
148
  if (!pred) {
146
- throw new Error(`saveProjectBrief: brief ${opts.supersedesBriefId} to supersede not found for tenant ${tenantId}`);
149
+ throw new NotFoundError(`saveProjectBrief: brief ${opts.supersedesBriefId} to supersede not found for tenant ${tenantId}`);
147
150
  }
148
151
  if (pred.status !== 'active') {
149
- throw new Error(`saveProjectBrief: brief ${opts.supersedesBriefId} is not active (status='${pred.status}'); only active briefs can be superseded.`);
152
+ throw new ConflictError(`saveProjectBrief: brief ${opts.supersedesBriefId} is not active (status='${pred.status}'); only active briefs can be superseded.`);
150
153
  }
151
154
  version = pred.version + 1;
152
155
  }
@@ -164,7 +167,7 @@ export function saveProjectBrief(hippoRoot, tenantId, opts, actor = 'cli') {
164
167
  WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
165
168
  `).run(briefId, now, opts.supersedesBriefId, tenantId, briefId);
166
169
  if (sup.changes === 0) {
167
- throw new Error(`saveProjectBrief: brief ${opts.supersedesBriefId} could not be superseded (no longer active or self-reference).`);
170
+ throw new ConflictError(`saveProjectBrief: brief ${opts.supersedesBriefId} could not be superseded (no longer active or self-reference).`);
168
171
  }
169
172
  appendAuditEvent(db, {
170
173
  tenantId,
@@ -232,9 +235,9 @@ export function closeProjectBrief(hippoRoot, tenantId, id, actor = 'cli') {
232
235
  // shape, or undefined when the id/tenant pair doesn't exist.
233
236
  const existing = db.prepare(`SELECT status FROM project_briefs WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
234
237
  if (!existing) {
235
- throw new Error(`closeProjectBrief: brief ${id} not found for tenant ${tenantId}`);
238
+ throw new NotFoundError(`closeProjectBrief: brief ${id} not found for tenant ${tenantId}`);
236
239
  }
237
- throw new Error(`closeProjectBrief: brief ${id} is not active (status='${existing.status}'); only active briefs can be closed.`);
240
+ throw new ConflictError(`closeProjectBrief: brief ${id} is not active (status='${existing.status}'); only active briefs can be closed.`);
238
241
  }
239
242
  // SAFETY: SELECT ${BRIEF_COLS} projects exactly the ProjectBriefRow
240
243
  // columns; .get() returns that row for the just-updated id, or undefined
@@ -242,7 +245,7 @@ export function closeProjectBrief(hippoRoot, tenantId, id, actor = 'cli') {
242
245
  const row = db.prepare(`SELECT ${BRIEF_COLS} FROM project_briefs WHERE id = ? AND tenant_id = ?`)
243
246
  .get(id, tenantId);
244
247
  if (!row)
245
- throw new Error(`closeProjectBrief: brief ${id} not found after UPDATE`);
248
+ throw new NotFoundError(`closeProjectBrief: brief ${id} not found after UPDATE`);
246
249
  appendAuditEvent(db, {
247
250
  tenantId,
248
251
  actor,
@@ -296,7 +299,7 @@ export function loadProjectBriefs(hippoRoot, tenantId, opts = {}) {
296
299
  assertTenantId('loadProjectBriefs', tenantId);
297
300
  const limit = opts.limit ?? 100;
298
301
  if (opts.status && !VALID_BRIEF_STATES.has(opts.status)) {
299
- throw new Error(`loadProjectBriefs: status must be one of ${Array.from(VALID_BRIEF_STATES).join('|')}; got ${opts.status}`);
302
+ throw new BadRequestError(`loadProjectBriefs: status must be one of ${Array.from(VALID_BRIEF_STATES).join('|')}; got ${opts.status}`);
300
303
  }
301
304
  const db = openHippoDb(hippoRoot);
302
305
  try {
@@ -383,7 +386,7 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
383
386
  assertTenantId('assembleBriefFromReceipts', tenantId);
384
387
  const normalizedRepo = (repo ?? '').trim();
385
388
  if (normalizedRepo.length === 0) {
386
- throw new Error('assembleBriefFromReceipts: repo is required');
389
+ throw new BadRequestError('assembleBriefFromReceipts: repo is required');
387
390
  }
388
391
  const tag = `path:${normalizedRepo.toLowerCase()}`;
389
392
  const likeParam = `%"${escapeLike(tag)}"%`;
@@ -476,7 +479,7 @@ export function refreshBrief(hippoRoot, tenantId, repo, actor = 'cli') {
476
479
  assertTenantId('refreshBrief', tenantId);
477
480
  const normalizedRepo = (repo ?? '').trim();
478
481
  if (normalizedRepo.length === 0)
479
- throw new Error('refreshBrief: repo is required');
482
+ throw new BadRequestError('refreshBrief: repo is required');
480
483
  const { markdown, receiptCount } = assembleBriefFromReceipts(hippoRoot, tenantId, normalizedRepo);
481
484
  const active = loadActiveBriefForRepo(hippoRoot, tenantId, normalizedRepo);
482
485
  return saveProjectBrief(hippoRoot, tenantId, {
@@ -19,7 +19,7 @@
19
19
  export interface ProjectIdentity {
20
20
  /** Realpath-resolved root directory of the project (the start dir when not in a project). */
21
21
  root: string;
22
- /** Lowercased basename of the project root; empty string when not in a project. */
22
+ /** Lowercased basename of the project root, or of its repo for a linked worktree with no `.hippo` (a store keeps the name its rows carry); '' outside a project. */
23
23
  name: string;
24
24
  /** True when the directory resolves to the user home working set. */
25
25
  isHome: boolean;
@@ -66,7 +66,8 @@ export function resolveProjectIdentity(cwd, opts) {
66
66
  let identity;
67
67
  const root = hippoRoot ?? gitRoot;
68
68
  if (root !== null) {
69
- identity = { root, name: path.basename(root).toLowerCase(), isHome: false };
69
+ const name = (hippoRoot === null ? linkedWorktreeRepoName(root, home) : null) ?? path.basename(root).toLowerCase();
70
+ identity = { root, name, isHome: false };
70
71
  }
71
72
  else if (reachedHome || isUnder(start, home)) {
72
73
  identity = { root: home, name: '', isHome: true };
@@ -80,6 +81,29 @@ export function resolveProjectIdentity(cwd, opts) {
80
81
  identityCache.set(startInput, identity);
81
82
  return identity;
82
83
  }
84
+ /** The repo name a linked worktree shares with its main checkout (`repo.git` or `repo/.bare` for a bare repo), so one repo is one project; null for any other checkout. */
85
+ function linkedWorktreeRepoName(gitRoot, home) {
86
+ const marker = path.join(gitRoot, '.git');
87
+ if (!fs.existsSync(marker) || isDirectoryAt(marker))
88
+ return null;
89
+ try {
90
+ const gitDir = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(marker, 'utf8'))?.[1]?.trim();
91
+ if (!gitDir)
92
+ return null;
93
+ const linkDir = path.resolve(gitRoot, gitDir);
94
+ const commondir = path.join(linkDir, 'commondir');
95
+ if (!fs.existsSync(commondir))
96
+ return null; // a submodule's or a separate git dir's own checkout
97
+ const common = realpathOrResolve(path.resolve(linkDir, fs.readFileSync(commondir, 'utf8').trim()));
98
+ const repo = ['.git', '.bare'].includes(path.basename(common)) ? path.dirname(common) : common;
99
+ if (samePath(repo, home))
100
+ return null; // a dotfiles repo at home must not make its worktrees user-global
101
+ return path.basename(repo).replace(/\.git$/i, '').toLowerCase() || null;
102
+ }
103
+ catch {
104
+ return null; // an unreadable link is still a checkout, named after its own folder
105
+ }
106
+ }
83
107
  /** Climb from start toward the root; home (never a project) and every stop dir end the walk unchecked. */
84
108
  function walkProjectMarkers(start, home, stopDirs) {
85
109
  let hippoRoot = null;
@@ -1,6 +1,6 @@
1
1
  /** Z1: recall gated on the hook prompt, not the five newest memories (pure, no I/O).
2
2
  * See docs/plans/2026-09-26-z1-prompt-recall.md. */
3
- import { tokenize } from './search.js';
3
+ import { tokenize } from './tokenize.js';
4
4
  import { STOP_WORDS } from './audit.js';
5
5
  // Latency bound, not tuned: fixed in the prereg regardless of gate config.
6
6
  export const PROMPT_RECALL_MAX_CHARS = 4000;
@@ -1,5 +1,6 @@
1
+ import { BadRequestError, NotFoundError } from './api-errors.js';
1
2
  import { isFtsAvailable } from './db.js';
2
- import { appendAuditEvent } from './audit.js';
3
+ import { appendAuditEvent, reportAuditWriteFailure } from './audit.js';
3
4
  import { markSummaryDirtyInTx } from './store.js';
4
5
  export function archiveRawMemory(db, id, opts) {
5
6
  // SAFETY: SELECT * FROM memories returns every column of the memories table; only
@@ -7,9 +8,9 @@ export function archiveRawMemory(db, id, opts) {
7
8
  // null) by the memories schema.
8
9
  const row = db.prepare(`SELECT * FROM memories WHERE id = ?`).get(id);
9
10
  if (!row)
10
- throw new Error(`memory not found: ${id}`);
11
+ throw new NotFoundError(`memory not found: ${id}`);
11
12
  if (row.kind !== 'raw') {
12
- throw new Error(`memory ${id} is not raw (kind=${String(row.kind)})`);
13
+ throw new BadRequestError(`memory ${id} is not raw (kind=${String(row.kind)})`);
13
14
  }
14
15
  // SAVEPOINT (not BEGIN) so this works whether or not we're already inside a
15
16
  // transaction. SQLite refuses BEGIN within a transaction; SAVEPOINT nests safely.
@@ -58,9 +59,9 @@ export function archiveRawMemory(db, id, opts) {
58
59
  metadata: { reason: opts.reason },
59
60
  });
60
61
  }
61
- catch {
62
- // Audit must not crash the archive. Failures here mean the audit table
63
- // is unwritable; the archive itself has already succeeded.
62
+ catch (error) {
63
+ // The archive itself has already succeeded; an unwritable audit table must not undo it.
64
+ reportAuditWriteFailure('archive_raw', String(error), id);
64
65
  }
65
66
  // v0.30 / E2 — DAG live-coupling: archive of a child under a level-2
66
67
  // summary marks parent dirty. Inside the SAVEPOINT so the dirty-mark
@@ -124,4 +124,9 @@ export declare function getOrCreateRing(map: Map<string, RingBuffer>, key: strin
124
124
  export declare function appendRecall(ring: RingBuffer, queryHash: number, topMemoryId: string | null, anchoredOn?: string): void;
125
125
  /** Snapshot a ring as a readonly RecallHistorySnapshot. */
126
126
  export declare function snapshotRing(ring: RingBuffer): RecallHistorySnapshot;
127
+ /**
128
+ * Whether a recall bias hint is enabled. Reads the env at call time, so
129
+ * `HIPPO_ANCHORING=off` or `HIPPO_AVAILABILITY=off` disables only that kind.
130
+ */
131
+ export declare function biasHintEnabled(kind: 'anchoring' | 'availability'): boolean;
127
132
  //# sourceMappingURL=recall-history.d.ts.map
@@ -232,4 +232,13 @@ export function appendRecall(ring, queryHash, topMemoryId, anchoredOn) {
232
232
  export function snapshotRing(ring) {
233
233
  return ring.snapshot();
234
234
  }
235
+ /**
236
+ * Whether a recall bias hint is enabled. Reads the env at call time, so
237
+ * `HIPPO_ANCHORING=off` or `HIPPO_AVAILABILITY=off` disables only that kind.
238
+ */
239
+ export function biasHintEnabled(kind) {
240
+ return kind === 'anchoring'
241
+ ? process.env.HIPPO_ANCHORING !== 'off'
242
+ : process.env.HIPPO_AVAILABILITY !== 'off';
243
+ }
235
244
  //# sourceMappingURL=recall-history.js.map
@@ -0,0 +1,101 @@
1
+ import { type GoalRecallLogRow } from './goals.js';
2
+ import { type MemoryEntry } from './memory.js';
3
+ import type { PhysicsConfig } from './physics-config.js';
4
+ import type { RerankerFn } from './rerankers/types.js';
5
+ import { type ResultCost, type SearchResult } from './search.js';
6
+ /** Stores rankRecall reads and where it sends operator notes. */
7
+ export interface RankRecallCtx {
8
+ hippoRoot: string;
9
+ /** A second store searched beside `hippoRoot`; leave undefined when there is none. */
10
+ globalRoot?: string;
11
+ tenantId: string;
12
+ /** Receives each operator note when the pipeline reaches it, so it interleaves with other stderr in order. */
13
+ note?: (line: string) => void;
14
+ }
15
+ /** Search-engine choice and tuning. */
16
+ export interface RecallSearchOpts {
17
+ usePhysics: boolean;
18
+ physicsConfig: PhysicsConfig;
19
+ multihop: boolean;
20
+ /** Graph-stream rrf fusion over the local store; hop and seed counts fall back to the engine defaults. */
21
+ graphStream?: RecallGraphStream;
22
+ mmr: boolean;
23
+ mmrLambda: number;
24
+ localBump: number;
25
+ minResults?: number;
26
+ /** Score breakdowns for `hippo explain`; explain's physics call also leaves out the bi-temporal flags. */
27
+ explain: boolean;
28
+ }
29
+ /** `--graph-hops` and `--graph-seeds`. */
30
+ export interface RecallGraphStream {
31
+ hops?: number;
32
+ seeds?: number;
33
+ }
34
+ /** `--hops` and `--max-neighbors`. */
35
+ export interface RecallGraphHops {
36
+ hops: number;
37
+ maxNeighbors: number;
38
+ }
39
+ /** A reranker from the registry and how many head rows it sees. */
40
+ export interface RecallReranker {
41
+ fn: RerankerFn;
42
+ topK: number;
43
+ }
44
+ /** A stage rankRecall can stop before, in pipeline order. */
45
+ export type RankStage = 'expand' | 'rerank' | 'salience' | 'outcome' | 'layer';
46
+ /** Everything that shapes one ranking, already parsed and validated. */
47
+ export interface RankRecallOpts {
48
+ query: string;
49
+ /** Tokens the engines may spend, priced by `cost`. */
50
+ budget: number;
51
+ /** Price of one result, usually the tokens its printed line takes. */
52
+ cost: ResultCost;
53
+ limit: number;
54
+ /** Record a rerank trace step for each score change. */
55
+ why?: boolean;
56
+ includeSuperseded: boolean;
57
+ asOf?: string;
58
+ /** `--scope`: unlocks that envelope scope on top of the default-admitted set. */
59
+ explicitScope: string | null;
60
+ /** Scope used for boosting only, never for filtering. */
61
+ activeScope: string | null;
62
+ search: RecallSearchOpts;
63
+ graphHops?: RecallGraphHops;
64
+ evcAdaptive?: boolean;
65
+ filterConflicts?: boolean;
66
+ valueAware?: boolean;
67
+ rerankUtility?: boolean;
68
+ reranker?: RecallReranker;
69
+ /** Explicit goal tag; when set, the session goal stack is skipped. */
70
+ goalTag?: string;
71
+ /** Session whose active goals boost matching rows. */
72
+ sessionId?: string;
73
+ salienceThreshold?: number;
74
+ outcome?: string;
75
+ layer?: string;
76
+ /** The caller rejected a flag this stage reads; ranking stops before it so the notes and goal-log rows
77
+ * produced up to there match the CLI's error path. */
78
+ haltBefore?: RankStage;
79
+ }
80
+ /** Ranked results and what the caller needs to report and persist them. */
81
+ export interface RankRecallResult {
82
+ results: SearchResult[];
83
+ /** The candidate pools after the scope and bi-temporal filters. */
84
+ localEntries: MemoryEntry[];
85
+ globalEntries: MemoryEntry[];
86
+ /** Candidates loaded, before any filter. */
87
+ totalCandidates: number;
88
+ /** Rows dropped by a named filter (scope, bi-temporal, conflicts, outcome, layer). */
89
+ droppedPreRank: number;
90
+ /** Rows graph expansion surfaced that the lexical pool never held. */
91
+ graphAdded: number;
92
+ /** goal_recall_log rows the session goal boost earned; the caller writes them. */
93
+ goalRecallLog: GoalRecallLogRow[];
94
+ /** True when ranking stopped at `haltBefore`. */
95
+ halted: boolean;
96
+ }
97
+ /** Ranks memories for a query as `hippo recall` does, through the `limit` slice. Reads stores, embeddings and
98
+ * physics state; writes nothing and prints nothing (notes go to `ctx.note`). The caller supplies the cost
99
+ * function and reranker in `opts`, and persists the returned goal-log rows. */
100
+ export declare function rankRecall(ctx: RankRecallCtx, opts: RankRecallOpts): Promise<RankRecallResult>;
101
+ //# sourceMappingURL=recall-pipeline.d.ts.map
@@ -0,0 +1,313 @@
1
+ // Ranking core shared by `hippo recall` and `hippo explain`: load, search, expand, re-rank and filter, with no
2
+ // writes and no direct output. Callers own flag parsing, printing, budget fitting and persistence.
3
+ import { evalNow } from './ablation.js';
4
+ import { oneCopyPerMemory } from './api.js';
5
+ import { compareEntryIdentity } from './compare.js';
6
+ import { closeHippoDb, openHippoDb } from './db.js';
7
+ import { isEmbeddingAvailable } from './embeddings.js';
8
+ import { computeGoalStackBoost } from './goals.js';
9
+ import { graphExpandRecall } from './graph-recall.js';
10
+ import { DEFAULT_GRAPH_STREAM_WEIGHT } from './graph-stream.js';
11
+ import { Layer } from './memory.js';
12
+ import { multihopSearch } from './multihop.js';
13
+ import { passesCliRecallScopeFilter } from './recall-scope.js';
14
+ import { hybridSearch, physicsSearch, textOverlap } from './search.js';
15
+ import { searchBothHybrid } from './shared.js';
16
+ import { loadRecallSearchEntries } from './store.js';
17
+ import { tokenize as tokenizeQuery } from './tokenize.js';
18
+ /** Ranks memories for a query as `hippo recall` does, through the `limit` slice. Reads stores, embeddings and
19
+ * physics state; writes nothing and prints nothing (notes go to `ctx.note`). The caller supplies the cost
20
+ * function and reranker in `opts`, and persists the returned goal-log rows. */
21
+ export async function rankRecall(ctx, opts) {
22
+ const pool = loadRecallPool(ctx, opts);
23
+ const state = { results: [], droppedPreRank: pool.dropped, graphAdded: 0, goalRecallLog: [] };
24
+ const done = (halted) => ({
25
+ results: state.results,
26
+ localEntries: pool.local,
27
+ globalEntries: pool.global,
28
+ totalCandidates: pool.total,
29
+ droppedPreRank: state.droppedPreRank,
30
+ graphAdded: state.graphAdded,
31
+ goalRecallLog: state.goalRecallLog,
32
+ halted,
33
+ });
34
+ state.results = await searchPool(ctx, opts, pool);
35
+ if (opts.haltBefore === 'expand')
36
+ return done(true);
37
+ if (opts.graphHops)
38
+ expandGraph(ctx, opts, opts.graphHops, state);
39
+ applyPfcRerankers(opts, state);
40
+ if (opts.haltBefore === 'rerank')
41
+ return done(true);
42
+ if (opts.reranker)
43
+ state.results = await applyReranker(opts, opts.reranker, state.results);
44
+ applyGoalBoosts(ctx, opts, state);
45
+ if (opts.haltBefore === 'salience')
46
+ return done(true);
47
+ if (opts.salienceThreshold !== undefined)
48
+ state.results = applySalience(opts, opts.salienceThreshold, state.results);
49
+ if (opts.haltBefore === 'outcome')
50
+ return done(true);
51
+ if (opts.outcome)
52
+ dropUnless(state, (r) => r.entry.layer !== Layer.Trace || r.entry.trace_outcome === opts.outcome);
53
+ if (opts.haltBefore === 'layer')
54
+ return done(true);
55
+ if (opts.layer)
56
+ dropUnless(state, (r) => r.entry.layer === opts.layer);
57
+ if (opts.limit < state.results.length)
58
+ state.results = state.results.slice(0, opts.limit);
59
+ return done(false);
60
+ }
61
+ function loadRecallPool(ctx, opts) {
62
+ // An explicit --scope unlocks that envelope scope; the regex-only `<source>:private:*` deny is the JS half below.
63
+ const requested = opts.explicitScope || undefined;
64
+ const loadSuperseded = opts.includeSuperseded || Boolean(opts.asOf);
65
+ let local = loadRecallSearchEntries(ctx.hippoRoot, opts.query, undefined, ctx.tenantId, requested, 'additive', loadSuperseded);
66
+ let global = ctx.globalRoot
67
+ ? loadRecallSearchEntries(ctx.globalRoot, opts.query, undefined, ctx.tenantId, requested, 'additive', loadSuperseded)
68
+ : [];
69
+ // SQL-excluded rows are pre-candidate, so the total is taken before the JS filters, which normally drop nothing.
70
+ const total = local.length + global.length;
71
+ const passes = (e) => passesCliRecallScopeFilter(e.scope ?? null, requested);
72
+ local = local.filter(passes);
73
+ global = global.filter(passes);
74
+ if (opts.asOf) {
75
+ local = currentAsOf(local, opts.asOf);
76
+ global = currentAsOf(global, opts.asOf);
77
+ }
78
+ else if (!opts.includeSuperseded) {
79
+ local = local.filter((e) => !e.superseded_by);
80
+ global = global.filter((e) => !e.superseded_by);
81
+ }
82
+ return { local, global, total, dropped: total - (local.length + global.length) };
83
+ }
84
+ /** Rows that were true at `asOf`: valid by then, and not yet replaced by a successor in the same pool. */
85
+ function currentAsOf(entries, asOf) {
86
+ const asOfDate = new Date(asOf);
87
+ const successorValidFrom = new Map();
88
+ for (const e of entries) {
89
+ if (e.superseded_by) {
90
+ const successor = entries.find(s => s.id === e.superseded_by);
91
+ if (successor)
92
+ successorValidFrom.set(e.id, successor.valid_from);
93
+ }
94
+ }
95
+ return entries.filter(e => {
96
+ if (new Date(e.valid_from) > asOfDate)
97
+ return false;
98
+ if (!e.superseded_by)
99
+ return true;
100
+ const succVf = successorValidFrom.get(e.id);
101
+ return succVf ? new Date(succVf) > asOfDate : true;
102
+ });
103
+ }
104
+ async function searchPool(ctx, opts, pool) {
105
+ const { query, budget, cost, includeSuperseded, asOf, activeScope: scope, search } = opts;
106
+ const { mmr, mmrLambda, minResults, explain } = search;
107
+ const globalRoot = pool.global.length > 0 ? ctx.globalRoot : undefined;
108
+ if (search.graphStream) {
109
+ // Without embeddings hybridSearch falls back to BM25-only and the stream is inert; say so rather than no-op.
110
+ if (!isEmbeddingAvailable()) {
111
+ ctx.note?.('[note] --graph-stream needs embeddings (rrf fusion); none available, so the graph stream is inert. Run `hippo embed` first.');
112
+ }
113
+ if (globalRoot)
114
+ ctx.note?.('[note] --graph-stream searches the local store only; global graph fusion is a follow-up.');
115
+ // With seedCount or fewer candidates every one is a seed and the stream degrades to the 2-list fusion.
116
+ return hybridSearch(query, pool.local, {
117
+ budget, cost, hippoRoot: ctx.hippoRoot, mmr, mmrLambda, minResults, scope, includeSuperseded, asOf,
118
+ scoring: 'rrf',
119
+ graphStream: { weight: DEFAULT_GRAPH_STREAM_WEIGHT, tenantId: ctx.tenantId, hops: search.graphStream.hops, seedCount: search.graphStream.seeds },
120
+ });
121
+ }
122
+ if (search.multihop) {
123
+ // Unlike searchBothHybrid below, multihop ranks one pooled list, so a shared memory's two copies both compete.
124
+ const allEntries = oneCopyPerMemory(pool.local, pool.global, evalNow()).flat();
125
+ return multihopSearch(query, allEntries, { budget, cost, hippoRoot: ctx.hippoRoot, minResults, includeSuperseded, asOf });
126
+ }
127
+ if (search.usePhysics && !globalRoot) {
128
+ // Explain has never passed the bi-temporal flags to physics; keeping that holds its output steady.
129
+ const temporal = explain ? {} : { minResults, includeSuperseded, asOf };
130
+ return physicsSearch(query, pool.local, { budget, cost, hippoRoot: ctx.hippoRoot, physicsConfig: search.physicsConfig, scope, explain, ...temporal });
131
+ }
132
+ if (globalRoot) {
133
+ // searchBothHybrid reloads candidates itself, so the scope rule is passed in rather than inherited from the pool.
134
+ return searchBothHybrid(query, ctx.hippoRoot, globalRoot, {
135
+ budget, cost, explain, mmr, mmrLambda, localBump: search.localBump, minResults, scope, tenantId: ctx.tenantId,
136
+ includeSuperseded, asOf,
137
+ recallScope: opts.explicitScope ? { requested: opts.explicitScope, additive: true } : {},
138
+ });
139
+ }
140
+ return hybridSearch(query, pool.local, { budget, cost, hippoRoot: ctx.hippoRoot, explain, mmr, mmrLambda, minResults, scope, includeSuperseded, asOf });
141
+ }
142
+ /** Adds memories reached by walking the entity graph out from the lexical seeds. */
143
+ function expandGraph(ctx, opts, graph, state) {
144
+ if (graph.hops <= 0)
145
+ return;
146
+ // Expansion both adds neighbours and evicts weak base rows, so compare id sets: a net count would hide both.
147
+ const beforeGraphIds = new Set(state.results.map((r) => r.entry.id));
148
+ state.results = graphExpandRecall(state.results, {
149
+ hops: graph.hops,
150
+ maxNeighbors: graph.maxNeighbors,
151
+ hippoRoot: ctx.hippoRoot,
152
+ globalRoot: ctx.globalRoot !== ctx.hippoRoot ? ctx.globalRoot : undefined,
153
+ tenantId: ctx.tenantId,
154
+ includeSuperseded: opts.includeSuperseded,
155
+ asOf: opts.asOf,
156
+ budget: opts.budget,
157
+ cost: opts.cost,
158
+ minResults: opts.search.minResults ?? 1,
159
+ recallScope: opts.explicitScope ? { requested: opts.explicitScope, additive: true } : {},
160
+ });
161
+ for (const r of state.results) {
162
+ if (!beforeGraphIds.has(r.entry.id))
163
+ state.graphAdded++;
164
+ }
165
+ }
166
+ /** Appends one rerank step to a re-scored row when `why` is on. */
167
+ function traced(opts, prev, next, step) {
168
+ if (opts.why)
169
+ next.rerankTrace = [...(prev.rerankTrace ?? []), step];
170
+ return next;
171
+ }
172
+ /** Re-sort after a score change. Plain and stable on purpose: ties keep the prior rank, not a content order. */
173
+ function byScore(results) {
174
+ return results.sort((a, b) => b.score - a.score);
175
+ }
176
+ function applyPfcRerankers(opts, state) {
177
+ if (opts.evcAdaptive && state.results.length >= 2)
178
+ state.results = evcAdaptive(opts.query, state.results);
179
+ if (opts.filterConflicts) {
180
+ dropUnless(state, (r) => !r.entry.superseded_by);
181
+ // Recorded conflicts only: a lexical-overlap gate once wrecked benchmark recall. Down-rank, never delete.
182
+ const presentIds = new Set(state.results.map((r) => r.entry.id));
183
+ state.results = byScore(state.results.map((r) => {
184
+ const hasPeerInResults = (r.entry.conflicts_with || []).some((peerId) => presentIds.has(peerId));
185
+ if (!hasPeerInResults)
186
+ return r;
187
+ const next = { ...r, score: r.score * 0.3 };
188
+ return traced(opts, r, next, { stage: 'interference', multiplier: 0.3, scoreBefore: r.score, scoreAfter: next.score });
189
+ }));
190
+ }
191
+ if (opts.valueAware && state.results.length >= 1) {
192
+ // Wider clamp than the always-on outcome boost, so outcome history can decide the order.
193
+ state.results = byScore(state.results.map((r) => {
194
+ const pos = r.entry.outcome_positive ?? 0;
195
+ const neg = r.entry.outcome_negative ?? 0;
196
+ if (pos === 0 && neg === 0)
197
+ return r;
198
+ const valueMult = Math.max(0.7, Math.min(1.3, 1 + 0.3 * Math.tanh(pos - neg)));
199
+ const next = { ...r, score: r.score * valueMult };
200
+ return traced(opts, r, next, { stage: 'value', multiplier: valueMult, scoreBefore: r.score, scoreAfter: next.score });
201
+ }));
202
+ }
203
+ if (opts.rerankUtility) {
204
+ // utility = score * (0.5 + 0.5 * strength) * (1 - min(0.3, tokens / 10000)); long evidence-rich rows pay for length.
205
+ state.results = byScore(state.results.map((r) => {
206
+ const strength = typeof r.entry.strength === 'number' ? r.entry.strength : 1.0;
207
+ const utilityMult = (0.5 + 0.5 * strength) * (1 - Math.min(0.3, (r.tokens || 0) / 10000));
208
+ const utility = r.score * utilityMult;
209
+ return traced(opts, r, { ...r, score: utility }, { stage: 'utility', multiplier: utilityMult, scoreBefore: r.score, scoreAfter: utility });
210
+ }));
211
+ }
212
+ }
213
+ /** When the top hits are near-duplicates (same topic, different facts), surface the newest on-topic row first. */
214
+ function evcAdaptive(query, results) {
215
+ const slice = results.slice(0, Math.min(3, results.length));
216
+ let pairs = 0;
217
+ let overlapSum = 0;
218
+ for (let i = 0; i < slice.length; i++) {
219
+ for (let j = i + 1; j < slice.length; j++) {
220
+ overlapSum += textOverlap(slice[i].entry.content, slice[j].entry.content);
221
+ pairs++;
222
+ }
223
+ }
224
+ if ((pairs > 0 ? overlapSum / pairs : 0) < 0.4)
225
+ return results;
226
+ const poolSize = Math.min(results.length, Math.max(slice.length * 3, 9));
227
+ const pool = results.slice(0, poolSize);
228
+ const scoreFloor = pool.reduce((m, r) => Math.max(m, r.score), 0) * 0.5;
229
+ // Query coverage catches the differently phrased update a score floor alone would miss.
230
+ const queryTokens = new Set(tokenizeQuery(query));
231
+ const onTopic = [];
232
+ const offTopic = [];
233
+ for (const r of pool) {
234
+ let hits = 0;
235
+ if (queryTokens.size > 0) {
236
+ const candTokens = new Set(tokenizeQuery(r.entry.content));
237
+ for (const t of queryTokens)
238
+ if (candTokens.has(t))
239
+ hits++;
240
+ }
241
+ const queryCoverage = queryTokens.size > 0 ? hits / queryTokens.size : 0;
242
+ (r.score >= scoreFloor || queryCoverage >= 0.6 ? onTopic : offTopic).push(r);
243
+ }
244
+ // Recency is the primary key; identity only breaks exact-timestamp ties.
245
+ onTopic.sort((a, b) => {
246
+ const ta = new Date(a.entry.created).getTime();
247
+ const tb = new Date(b.entry.created).getTime();
248
+ return tb !== ta ? tb - ta : compareEntryIdentity(a.entry, b.entry);
249
+ });
250
+ return [...onTopic, ...offTopic, ...results.slice(poolSize)];
251
+ }
252
+ async function applyReranker(opts, reranker, results) {
253
+ const { fn, topK } = reranker;
254
+ const rerankInput = results.slice(0, topK).map((r, i) => ({ ...r, preRerankRank: i + 1 }));
255
+ const reranked = await fn(opts.query, rerankInput, { topK });
256
+ // The reranker's score becomes `score` so later stages that sort by score keep its order.
257
+ const withPostRank = reranked.map((r, i) => traced(opts, r, { ...r, score: r.rerankScore, postRerankRank: i + 1 }, { stage: 'reranker', scoreBefore: r.score, scoreAfter: r.rerankScore }));
258
+ return [...withPostRank, ...results.slice(topK)];
259
+ }
260
+ function applyGoalBoosts(ctx, opts, state) {
261
+ const goalTag = opts.goalTag ?? '';
262
+ if (goalTag) {
263
+ // Its own trace stage: `goal` is the explicit flag, `goal-boost` the session stack it replaces.
264
+ state.results = byScore(state.results.map((r) => {
265
+ if (!r.entry.tags?.includes(goalTag))
266
+ return r;
267
+ const boosted = { ...r, score: r.score * 1.5 };
268
+ return traced(opts, r, boosted, { stage: 'goal', multiplier: 1.5, scoreBefore: r.score, scoreAfter: r.score * 1.5, note: `--goal ${goalTag}` });
269
+ }));
270
+ return;
271
+ }
272
+ if (!opts.sessionId)
273
+ return;
274
+ const db = openHippoDb(ctx.hippoRoot);
275
+ // The helper re-spreads rows, so its steps come back in a map keyed by entry id.
276
+ const goalBoostTrace = opts.why ? new Map() : undefined;
277
+ try {
278
+ const boost = computeGoalStackBoost(db, state.results, {
279
+ sessionId: opts.sessionId,
280
+ tenantId: ctx.tenantId,
281
+ limit: opts.limit,
282
+ trace: goalBoostTrace,
283
+ });
284
+ state.results = boost.results;
285
+ state.goalRecallLog = boost.log;
286
+ }
287
+ finally {
288
+ closeHippoDb(db);
289
+ }
290
+ if (goalBoostTrace && goalBoostTrace.size > 0) {
291
+ state.results = state.results.map((r) => {
292
+ const step = goalBoostTrace.get(r.entry.id);
293
+ return step ? { ...r, rerankTrace: [...(r.rerankTrace ?? []), step] } : r;
294
+ });
295
+ }
296
+ }
297
+ /** Soft-demotes rarely recalled rows: score *= max(0.5, retrieval_count / T); never drops one. */
298
+ function applySalience(opts, threshold, results) {
299
+ return byScore(results.map((r) => {
300
+ const count = r.entry.retrieval_count ?? 0;
301
+ if (count >= threshold)
302
+ return r;
303
+ const mult = Math.max(0.5, count / threshold);
304
+ const next = { ...r, score: r.score * mult };
305
+ return traced(opts, r, next, { stage: 'retrieval-count-downweight', multiplier: mult, scoreBefore: r.score, scoreAfter: next.score });
306
+ }));
307
+ }
308
+ function dropUnless(state, keep) {
309
+ const before = state.results.length;
310
+ state.results = state.results.filter(keep);
311
+ state.droppedPreRank += before - state.results.length;
312
+ }
313
+ //# sourceMappingURL=recall-pipeline.js.map