hippo-memory 0.27.0 → 0.29.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.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * LLM-powered refinement of consolidated semantic memories.
3
+ *
4
+ * The rule-based `mergeContents` in consolidate.ts produces functional but
5
+ * ugly semantic memories — typically "[Consolidated from N related memories]"
6
+ * prepended to the longest source, or a bulleted list. `hippo refine` takes
7
+ * those and asks Claude to synthesize a clean, generalized principle.
8
+ *
9
+ * Design choices:
10
+ * - Separate command (not baked into `hippo sleep`) so API-key users opt in.
11
+ * - Idempotent via the `llm-refined` tag — re-running skips already-refined.
12
+ * - Uses fetch directly so no SDK dependency.
13
+ * - On failure (API error, bad response), the original memory is untouched.
14
+ */
15
+ import { MemoryEntry } from './memory.js';
16
+ export interface RefineOptions {
17
+ apiKey: string;
18
+ model?: string;
19
+ limit?: number;
20
+ dryRun?: boolean;
21
+ /** Ignore the llm-refined tag and re-refine everything eligible. */
22
+ all?: boolean;
23
+ /** Injected for testing — defaults to the real fetch. */
24
+ fetcher?: typeof fetch;
25
+ }
26
+ export interface RefineResult {
27
+ scanned: number;
28
+ refined: number;
29
+ skipped: number;
30
+ failed: number;
31
+ details: Array<{
32
+ id: string;
33
+ status: 'refined' | 'skipped' | 'failed';
34
+ reason?: string;
35
+ }>;
36
+ }
37
+ /**
38
+ * Ask Claude to synthesize a clean semantic memory from the merged content
39
+ * plus the original source memories. Returns the refined content string or
40
+ * `null` when the API call failed.
41
+ */
42
+ export declare function refineSemanticMemory(merged: string, sources: MemoryEntry[], opts: {
43
+ apiKey: string;
44
+ model?: string;
45
+ fetcher?: typeof fetch;
46
+ }): Promise<string | null>;
47
+ /**
48
+ * Scan the store for consolidated semantic memories, refine each with the
49
+ * LLM, and write the refined content back. Tags with `llm-refined` so
50
+ * repeated runs are idempotent (unless `all` is set).
51
+ */
52
+ export declare function refineStore(hippoRoot: string, opts: RefineOptions): Promise<RefineResult>;
53
+ //# sourceMappingURL=refine-llm.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"refine-llm.d.ts","sourceRoot":"","sources":["../src/refine-llm.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,WAAW,EAAS,MAAM,aAAa,CAAC;AASjD,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,oEAAoE;IACpE,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,yDAAyD;IACzD,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;CACxB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC3F;AAED;;;;GAIG;AACH,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,WAAW,EAAE,EACtB,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAA;CAAE,GAC/D,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAqDxB;AAOD;;;;GAIG;AACH,wBAAsB,WAAW,CAC/B,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,YAAY,CAAC,CA+DvB"}
@@ -0,0 +1,147 @@
1
+ /**
2
+ * LLM-powered refinement of consolidated semantic memories.
3
+ *
4
+ * The rule-based `mergeContents` in consolidate.ts produces functional but
5
+ * ugly semantic memories — typically "[Consolidated from N related memories]"
6
+ * prepended to the longest source, or a bulleted list. `hippo refine` takes
7
+ * those and asks Claude to synthesize a clean, generalized principle.
8
+ *
9
+ * Design choices:
10
+ * - Separate command (not baked into `hippo sleep`) so API-key users opt in.
11
+ * - Idempotent via the `llm-refined` tag — re-running skips already-refined.
12
+ * - Uses fetch directly so no SDK dependency.
13
+ * - On failure (API error, bad response), the original memory is untouched.
14
+ */
15
+ import { Layer } from './memory.js';
16
+ import { loadAllEntries, readEntry, writeEntry } from './store.js';
17
+ const REFINED_TAG = 'llm-refined';
18
+ const CONSOLIDATED_MARKERS = [
19
+ '[Consolidated from',
20
+ '[Consolidated pattern from',
21
+ ];
22
+ /**
23
+ * Ask Claude to synthesize a clean semantic memory from the merged content
24
+ * plus the original source memories. Returns the refined content string or
25
+ * `null` when the API call failed.
26
+ */
27
+ export async function refineSemanticMemory(merged, sources, opts) {
28
+ const model = opts.model ?? 'claude-sonnet-4-6';
29
+ const fetchFn = opts.fetcher ?? fetch;
30
+ const sourceBlock = sources
31
+ .slice(0, 8)
32
+ .map((s, i) => `[source ${i + 1}] ${s.content.slice(0, 400)}`)
33
+ .join('\n\n');
34
+ const prompt = `You are refining a semantic memory in an agent's memory store. The rule-based consolidator merged several related episodic memories into one, but the output is clumsy. Produce a single coherent semantic memory that captures the underlying principle.
35
+
36
+ Rules:
37
+ - Output ONLY the refined content — no preamble, no quote marks, no "Here is...".
38
+ - Keep it concise: one paragraph, no headers, no bullet lists unless the sources are inherently a list.
39
+ - Preserve specific facts (names, numbers, paths, IDs) from the sources.
40
+ - Generalize: state the pattern, not each instance.
41
+ - Do NOT include the "[Consolidated from N ...]" marker.
42
+
43
+ Current merged content:
44
+ ${merged}
45
+
46
+ Source memories (up to 8 shown):
47
+ ${sourceBlock}`;
48
+ let res;
49
+ try {
50
+ res = await fetchFn('https://api.anthropic.com/v1/messages', {
51
+ method: 'POST',
52
+ headers: {
53
+ 'content-type': 'application/json',
54
+ 'x-api-key': opts.apiKey,
55
+ 'anthropic-version': '2023-06-01',
56
+ },
57
+ body: JSON.stringify({
58
+ model,
59
+ max_tokens: 800,
60
+ messages: [{ role: 'user', content: prompt }],
61
+ }),
62
+ });
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ if (!res.ok)
68
+ return null;
69
+ try {
70
+ const data = await res.json();
71
+ const text = data.content?.[0]?.text?.trim() ?? '';
72
+ if (text.length < 10)
73
+ return null;
74
+ return text;
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ function isConsolidated(entry) {
81
+ if (entry.layer !== Layer.Semantic)
82
+ return false;
83
+ return CONSOLIDATED_MARKERS.some((m) => entry.content.startsWith(m));
84
+ }
85
+ /**
86
+ * Scan the store for consolidated semantic memories, refine each with the
87
+ * LLM, and write the refined content back. Tags with `llm-refined` so
88
+ * repeated runs are idempotent (unless `all` is set).
89
+ */
90
+ export async function refineStore(hippoRoot, opts) {
91
+ const result = {
92
+ scanned: 0,
93
+ refined: 0,
94
+ skipped: 0,
95
+ failed: 0,
96
+ details: [],
97
+ };
98
+ const entries = loadAllEntries(hippoRoot);
99
+ let processed = 0;
100
+ for (const entry of entries) {
101
+ if (!isConsolidated(entry))
102
+ continue;
103
+ result.scanned++;
104
+ if (!opts.all && entry.tags.includes(REFINED_TAG)) {
105
+ result.skipped++;
106
+ result.details.push({ id: entry.id, status: 'skipped', reason: 'already refined' });
107
+ continue;
108
+ }
109
+ if (opts.limit !== undefined && processed >= opts.limit)
110
+ break;
111
+ processed++;
112
+ // Best-effort: walk parents_json (schema v9) to fetch originals. When
113
+ // parents aren't recorded we still refine using just the merged content.
114
+ const sources = [];
115
+ const parentIds = Array.isArray(entry.parents) ? entry.parents : [];
116
+ for (const pid of parentIds) {
117
+ const p = readEntry(hippoRoot, pid);
118
+ if (p)
119
+ sources.push(p);
120
+ }
121
+ const refined = await refineSemanticMemory(entry.content, sources, {
122
+ apiKey: opts.apiKey,
123
+ model: opts.model,
124
+ fetcher: opts.fetcher,
125
+ });
126
+ if (refined === null) {
127
+ result.failed++;
128
+ result.details.push({ id: entry.id, status: 'failed', reason: 'api error or empty response' });
129
+ continue;
130
+ }
131
+ if (opts.dryRun) {
132
+ result.refined++;
133
+ result.details.push({ id: entry.id, status: 'refined', reason: 'dry-run (no write)' });
134
+ continue;
135
+ }
136
+ const updated = {
137
+ ...entry,
138
+ content: refined,
139
+ tags: entry.tags.includes(REFINED_TAG) ? entry.tags : [...entry.tags, REFINED_TAG],
140
+ };
141
+ writeEntry(hippoRoot, updated);
142
+ result.refined++;
143
+ result.details.push({ id: entry.id, status: 'refined' });
144
+ }
145
+ return result;
146
+ }
147
+ //# sourceMappingURL=refine-llm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"refine-llm.js","sourceRoot":"","sources":["../src/refine-llm.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Replay — biologically-inspired rehearsal during consolidation.
3
+ *
4
+ * Hippocampal replay is internally-driven reactivation of memories during
5
+ * slow-wave sleep: the brain picks important recent experiences and "plays
6
+ * them back" without external input, strengthening them before they decay.
7
+ * In McClelland's complementary-learning-systems framing, replay is also
8
+ * how episodic memories train the neocortex (interleaved rehearsal); in
9
+ * reward-modulated STDP, replay is gated by dopamine so rewarded
10
+ * experiences get preferentially consolidated.
11
+ *
12
+ * This module implements a lightweight, deterministic analog. During each
13
+ * `hippo sleep`, we sample N surviving memories by a priority score that
14
+ * weighs reward feedback, emotional valence, under-rehearsal, and age —
15
+ * then apply the same retrieval-strengthening dynamics `markRetrieved`
16
+ * applies to real queries. The effect is that important memories stay
17
+ * strong even when the user hasn't explicitly queried them recently.
18
+ *
19
+ * Distinct from the other consolidation passes:
20
+ * - decay: removes what's too weak
21
+ * - physics: moves particles in embedding space
22
+ * - merge: collapses near-duplicate episodics into semantics
23
+ * - REPLAY: picks winners and rehearses them (this file)
24
+ */
25
+ import type { MemoryEntry } from './memory.js';
26
+ /**
27
+ * Priority score used to rank survivors for replay. Higher = more likely
28
+ * to be sampled. Pure function of the entry and current time.
29
+ */
30
+ export declare function replayPriority(entry: MemoryEntry, now: Date): number;
31
+ /**
32
+ * Pick `count` memories for replay, weighted by `replayPriority`, without
33
+ * replacement. Deterministic given the same seed.
34
+ *
35
+ * The sampler is weighted but not greedy — picking by priority alone would
36
+ * always pick the top-N, which overfits. Biological replay shows both
37
+ * preferential-for-reward AND stochastic-exploration characteristics; we
38
+ * keep the stochastic element so adjacent survivors aren't always ignored.
39
+ */
40
+ export declare function sampleForReplay(survivors: MemoryEntry[], count: number, now: Date, seed?: number): MemoryEntry[];
41
+ //# sourceMappingURL=replay.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay.d.ts","sourceRoot":"","sources":["../src/replay.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAoB,MAAM,aAAa,CAAC;AASjE;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,WAAW,EAAE,GAAG,EAAE,IAAI,GAAG,MAAM,CAyBpE;AAiBD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,WAAW,EAAE,EACxB,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,IAAI,EACT,IAAI,GAAE,MAAyB,GAC9B,WAAW,EAAE,CAoCf"}
package/dist/replay.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Replay — biologically-inspired rehearsal during consolidation.
3
+ *
4
+ * Hippocampal replay is internally-driven reactivation of memories during
5
+ * slow-wave sleep: the brain picks important recent experiences and "plays
6
+ * them back" without external input, strengthening them before they decay.
7
+ * In McClelland's complementary-learning-systems framing, replay is also
8
+ * how episodic memories train the neocortex (interleaved rehearsal); in
9
+ * reward-modulated STDP, replay is gated by dopamine so rewarded
10
+ * experiences get preferentially consolidated.
11
+ *
12
+ * This module implements a lightweight, deterministic analog. During each
13
+ * `hippo sleep`, we sample N surviving memories by a priority score that
14
+ * weighs reward feedback, emotional valence, under-rehearsal, and age —
15
+ * then apply the same retrieval-strengthening dynamics `markRetrieved`
16
+ * applies to real queries. The effect is that important memories stay
17
+ * strong even when the user hasn't explicitly queried them recently.
18
+ *
19
+ * Distinct from the other consolidation passes:
20
+ * - decay: removes what's too weak
21
+ * - physics: moves particles in embedding space
22
+ * - merge: collapses near-duplicate episodics into semantics
23
+ * - REPLAY: picks winners and rehearses them (this file)
24
+ */
25
+ const VALENCE_WEIGHT = {
26
+ neutral: 1.0,
27
+ positive: 1.3,
28
+ negative: 1.5,
29
+ critical: 2.0,
30
+ };
31
+ /**
32
+ * Priority score used to rank survivors for replay. Higher = more likely
33
+ * to be sampled. Pure function of the entry and current time.
34
+ */
35
+ export function replayPriority(entry, now) {
36
+ const pos = entry.outcome_positive ?? 0;
37
+ const neg = entry.outcome_negative ?? 0;
38
+ // Reward signal: neutral memories get 1, strongly-rewarded memories > 1,
39
+ // negative-dominated memories floor at 0.1 (so they're still eligible, just
40
+ // much less likely to be sampled than neutral peers). Clamp is required
41
+ // because sampleForReplay depends on all weights being positive.
42
+ const rewardSignal = Math.max(0.1, 1 + pos * 0.5 + (pos - neg) * 0.25);
43
+ const valence = VALENCE_WEIGHT[entry.emotional_valence] ?? 1.0;
44
+ // Under-rehearsed memories benefit most from replay.
45
+ const underRehearsed = 1 / (1 + (entry.retrieval_count ?? 0));
46
+ // Idle-time boost: memories that haven't been touched recently need rehearsal more.
47
+ const lastRetrieved = new Date(entry.last_retrieved);
48
+ const deltaMs = now.getTime() - lastRetrieved.getTime();
49
+ const ageHours = Number.isFinite(deltaMs) ? Math.max(0, deltaMs / 3_600_000) : 0;
50
+ const idleBoost = 1 + Math.log1p(ageHours) * 0.1;
51
+ // Weight by current strength so dead-and-decaying memories don't waste replay slots.
52
+ const rawStrength = Number.isFinite(entry.strength) ? entry.strength : 0;
53
+ const strengthFloor = Math.max(0.1, rawStrength);
54
+ return rewardSignal * valence * underRehearsed * idleBoost * strengthFloor;
55
+ }
56
+ /**
57
+ * Deterministic 32-bit RNG (Mulberry32). Same seed → same sequence.
58
+ * Keeps replay reproducible for tests and audit runs without bringing
59
+ * in a random-number dependency.
60
+ */
61
+ function mulberry32(seed) {
62
+ let s = seed >>> 0;
63
+ return () => {
64
+ let t = (s += 0x6D2B79F5);
65
+ t = Math.imul(t ^ (t >>> 15), t | 1);
66
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
67
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
68
+ };
69
+ }
70
+ /**
71
+ * Pick `count` memories for replay, weighted by `replayPriority`, without
72
+ * replacement. Deterministic given the same seed.
73
+ *
74
+ * The sampler is weighted but not greedy — picking by priority alone would
75
+ * always pick the top-N, which overfits. Biological replay shows both
76
+ * preferential-for-reward AND stochastic-exploration characteristics; we
77
+ * keep the stochastic element so adjacent survivors aren't always ignored.
78
+ */
79
+ export function sampleForReplay(survivors, count, now, seed = Date.now() >>> 0) {
80
+ if (count <= 0 || survivors.length === 0)
81
+ return [];
82
+ const rng = mulberry32(seed);
83
+ // Stale memories have been deliberately marked as untrusted; rehearsing
84
+ // them would defeat the purpose of staleness. Skip them entirely.
85
+ const eligible = survivors.filter((e) => e.confidence !== 'stale');
86
+ if (eligible.length === 0)
87
+ return [];
88
+ const pool = eligible.map((entry, idx) => ({
89
+ entry,
90
+ idx,
91
+ weight: replayPriority(entry, now),
92
+ }));
93
+ const want = Math.min(count, pool.length);
94
+ const chosen = [];
95
+ const taken = new Set();
96
+ for (let k = 0; k < want; k++) {
97
+ let totalW = 0;
98
+ for (const p of pool)
99
+ if (!taken.has(p.idx))
100
+ totalW += p.weight;
101
+ if (totalW <= 0)
102
+ break;
103
+ let r = rng() * totalW;
104
+ for (const p of pool) {
105
+ if (taken.has(p.idx))
106
+ continue;
107
+ r -= p.weight;
108
+ if (r <= 0) {
109
+ chosen.push(p.entry);
110
+ taken.add(p.idx);
111
+ break;
112
+ }
113
+ }
114
+ }
115
+ return chosen;
116
+ }
117
+ //# sourceMappingURL=replay.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay.js","sourceRoot":"","sources":["../src/replay.ts"],"names":[],"mappings":"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"}
package/dist/search.d.ts CHANGED
@@ -5,6 +5,18 @@
5
5
  import { MemoryEntry } from './memory.js';
6
6
  import type { PhysicsConfig } from './physics-config.js';
7
7
  export declare function tokenize(text: string): string[];
8
+ /**
9
+ * Tokenized BM25 corpus. Callers can pre-build this with `buildCorpus` once
10
+ * and reuse across many `hybridSearch` calls on the same entry set — the
11
+ * tokenization work is the bulk of per-query cost on large stores.
12
+ */
13
+ export interface BM25Corpus {
14
+ docs: string[][];
15
+ avgLen: number;
16
+ df: Map<string, number>;
17
+ N: number;
18
+ }
19
+ export declare function buildCorpus(texts: string[]): BM25Corpus;
8
20
  /**
9
21
  * Rough token estimate: characters / 4 (works well for English text).
10
22
  */
@@ -81,6 +93,18 @@ export declare function hybridSearch(query: string, entries: MemoryEntry[], opti
81
93
  mmr?: boolean;
82
94
  /** MMR balance: 1.0 = pure relevance, 0.0 = pure diversity. Default 0.7. */
83
95
  mmrLambda?: number;
96
+ /** Scoring mode: 'blend' (weighted sum of BM25+cosine, default) or
97
+ * 'rrf' (reciprocal rank fusion - combines BM25 and cosine ranks
98
+ * instead of scores, more robust for long documents). */
99
+ scoring?: 'blend' | 'rrf';
100
+ /** Pre-built BM25 corpus from `buildCorpus`. Pass this across many
101
+ * queries on the same entry set to skip ~O(N*docLen) tokenization
102
+ * work per call. Must be built from the same `entries` in the same
103
+ * order (content + tags.join(' ')). */
104
+ preparedCorpus?: BM25Corpus;
105
+ /** Minimum number of results to return regardless of budget.
106
+ * Prevents budget saturation when memories are large. Default 1. */
107
+ minResults?: number;
84
108
  }): Promise<SearchResult[]>;
85
109
  /**
86
110
  * MMR (Maximal Marginal Relevance) re-ranking.
@@ -105,6 +129,7 @@ export declare function physicsSearch(query: string, entries: MemoryEntry[], opt
105
129
  physicsConfig?: PhysicsConfig;
106
130
  queryEmbedding?: number[];
107
131
  explain?: boolean;
132
+ minResults?: number;
108
133
  }): Promise<SearchResult[]>;
109
134
  /**
110
135
  * Search entries using BM25 + strength + recency composite score.
@@ -118,6 +143,7 @@ export declare function search(query: string, entries: MemoryEntry[], options?:
118
143
  budget?: number;
119
144
  now?: Date;
120
145
  hippoRoot?: string;
146
+ minResults?: number;
121
147
  }): SearchResult[];
122
148
  /**
123
149
  * Update retrieval metadata on entries that were returned by a search.
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
package/dist/search.js CHANGED
@@ -21,7 +21,7 @@ export function tokenize(text) {
21
21
  }
22
22
  const BM25_K1 = 1.5;
23
23
  const BM25_B = 0.75;
24
- function buildCorpus(texts) {
24
+ export function buildCorpus(texts) {
25
25
  const docs = texts.map(tokenize);
26
26
  const N = docs.length;
27
27
  const df = new Map();
@@ -87,8 +87,10 @@ function recencyBoost(entry, now) {
87
87
  export async function hybridSearch(query, entries, options = {}) {
88
88
  const now = options.now ?? new Date();
89
89
  const budget = options.budget ?? 4000;
90
+ const minResults = options.minResults ?? 1;
90
91
  const embeddingWeight = options.embeddingWeight ?? 0.6;
91
92
  const bm25Weight = 1 - embeddingWeight;
93
+ const scoringMode = options.scoring ?? 'blend';
92
94
  const explain = options.explain ?? false;
93
95
  const mmrEnabled = options.mmr ?? true;
94
96
  const mmrLambda = options.mmrLambda ?? 0.7;
@@ -97,9 +99,9 @@ export async function hybridSearch(query, entries, options = {}) {
97
99
  const queryTerms = tokenize(query);
98
100
  if (queryTerms.length === 0)
99
101
  return [];
100
- // Build BM25 corpus
101
- const texts = entries.map((e) => `${e.content} ${e.tags.join(' ')}`);
102
- const corpus = buildCorpus(texts);
102
+ // Build BM25 corpus (or reuse one the caller already built).
103
+ const corpus = options.preparedCorpus
104
+ ?? buildCorpus(entries.map((e) => `${e.content} ${e.tags.join(' ')}`));
103
105
  // Score all entries with BM25
104
106
  const bm25Scores = entries.map((_, i) => bm25Score(corpus, i, queryTerms));
105
107
  const maxBm25 = bm25Scores.reduce((a, b) => Math.max(a, b), 1e-9);
@@ -122,12 +124,46 @@ export async function hybridSearch(query, entries, options = {}) {
122
124
  // Fall through to BM25-only
123
125
  }
124
126
  }
127
+ // Compute cosine similarities for RRF ranking (need all before scoring)
128
+ const cosineScores = new Array(entries.length).fill(0);
129
+ const hadCachedVecs = new Array(entries.length).fill(false);
130
+ if (useEmbeddings) {
131
+ for (let i = 0; i < entries.length; i++) {
132
+ const cached = embeddingIndex[entries[i].id];
133
+ hadCachedVecs[i] = Boolean(cached && queryVector.length > 0);
134
+ cosineScores[i] = hadCachedVecs[i]
135
+ ? Math.max(0, cosineSimilarity(queryVector, cached))
136
+ : 0;
137
+ }
138
+ }
139
+ // For RRF: build rank maps from BM25 and cosine orderings
140
+ let rrfScores = null;
141
+ if (useEmbeddings && scoringMode === 'rrf') {
142
+ const RRF_K = 60;
143
+ const bm25Ranked = entries.map((_, i) => i).filter(i => bm25Scores[i] > 0 || cosineScores[i] > 0);
144
+ bm25Ranked.sort((a, b) => bm25Scores[b] - bm25Scores[a]);
145
+ const cosineRanked = entries.map((_, i) => i).filter(i => bm25Scores[i] > 0 || cosineScores[i] > 0);
146
+ cosineRanked.sort((a, b) => cosineScores[b] - cosineScores[a]);
147
+ const bm25RankMap = new Map();
148
+ bm25Ranked.forEach((idx, rank) => bm25RankMap.set(idx, rank + 1));
149
+ const cosineRankMap = new Map();
150
+ cosineRanked.forEach((idx, rank) => cosineRankMap.set(idx, rank + 1));
151
+ rrfScores = new Map();
152
+ const allCandidates = new Set([...bm25Ranked, ...cosineRanked]);
153
+ for (const idx of allCandidates) {
154
+ const bm25Rank = bm25RankMap.get(idx) ?? (entries.length + 1);
155
+ const cosineRank = cosineRankMap.get(idx) ?? (entries.length + 1);
156
+ rrfScores.set(idx, bm25Weight / (RRF_K + bm25Rank) + embeddingWeight / (RRF_K + cosineRank));
157
+ }
158
+ }
125
159
  // Score each entry
126
160
  const scored = [];
127
161
  const currentPathTags = extractPathTags(process.cwd());
128
162
  const queryTermSet = new Set(queryTerms);
129
163
  for (let i = 0; i < entries.length; i++) {
130
164
  const rawBm25 = bm25Scores[i];
165
+ const cosineScore = cosineScores[i];
166
+ const hadCachedVec = hadCachedVecs[i];
131
167
  if (!useEmbeddings && rawBm25 <= 0)
132
168
  continue;
133
169
  const normBm25 = rawBm25 / maxBm25;
@@ -136,25 +172,19 @@ export async function hybridSearch(query, entries, options = {}) {
136
172
  const strengthMultiplier = 0.5 + 0.5 * strength;
137
173
  const recencyMultiplier = 0.8 + 0.2 * recency;
138
174
  let compositeScore;
139
- let cosineScore = 0;
140
175
  let base;
141
176
  let modeLabel;
142
- let hadCachedVec = false;
143
177
  if (useEmbeddings) {
144
- const cached = embeddingIndex[entries[i].id];
145
- hadCachedVec = Boolean(cached && queryVector.length > 0);
146
- cosineScore = hadCachedVec
147
- ? Math.max(0, cosineSimilarity(queryVector, cached))
148
- : 0;
149
- base = bm25Weight * normBm25 + embeddingWeight * cosineScore;
178
+ if (rrfScores) {
179
+ base = rrfScores.get(i) ?? 0;
180
+ }
181
+ else {
182
+ base = bm25Weight * normBm25 + embeddingWeight * cosineScore;
183
+ }
150
184
  compositeScore = base * strengthMultiplier * recencyMultiplier;
151
- // Distinguish "real hybrid" from "query embedded but doc has no cached
152
- // vector" so explain can tell users their index is stale without lying
153
- // that the search actually used embeddings.
154
185
  modeLabel = hadCachedVec ? 'hybrid' : 'hybrid-no-vec';
155
186
  }
156
187
  else {
157
- // Pure BM25 path: identical to original behavior
158
188
  base = queryTerms.length > 0 ? rawBm25 / queryTerms.length : rawBm25;
159
189
  compositeScore = base * strengthMultiplier * recencyMultiplier;
160
190
  modeLabel = 'bm25-only';
@@ -218,17 +248,29 @@ export async function hybridSearch(query, entries, options = {}) {
218
248
  // diversity. Only applies when embeddings are loaded (doc-to-doc similarity
219
249
  // is via cosine of cached vectors); otherwise we return the pure-relevance
220
250
  // ordering unchanged.
251
+ //
252
+ // MMR is O(K^2) in cosine similarity ops, which on large corpora (1000+
253
+ // candidates) dominates query time. Cap the re-ranking window to the top
254
+ // relevance-scored candidates — anything below top-K was never going to
255
+ // surface anyway after budget filtering.
256
+ const MMR_CANDIDATE_CAP = 100;
221
257
  const applyMmr = mmrEnabled && useEmbeddings && scored.length > 1 && mmrLambda < 1;
222
- const ordered = applyMmr
223
- ? mmrRerank(scored, embeddingIndex, mmrLambda, explain)
224
- : scored;
225
- // Apply token budget
258
+ let ordered;
259
+ if (applyMmr) {
260
+ const head = scored.slice(0, MMR_CANDIDATE_CAP);
261
+ const tail = scored.slice(MMR_CANDIDATE_CAP);
262
+ ordered = [...mmrRerank(head, embeddingIndex, mmrLambda, explain), ...tail];
263
+ }
264
+ else {
265
+ ordered = scored;
266
+ }
267
+ // Apply token budget (guarantee at least minResults items)
226
268
  const results = [];
227
269
  let usedTokens = 0;
228
270
  for (let i = 0; i < ordered.length; i++) {
229
271
  const tokens = ordered[i].tokens;
230
- if (i > 0 && usedTokens + tokens > budget)
231
- continue; // always include first result
272
+ if (results.length >= minResults && usedTokens + tokens > budget)
273
+ continue;
232
274
  usedTokens += tokens;
233
275
  results.push(ordered[i]);
234
276
  }
@@ -300,6 +342,7 @@ export function mmrRerank(scored, embeddingIndex, lambda, explain) {
300
342
  export async function physicsSearch(query, entries, options = {}) {
301
343
  const now = options.now ?? new Date();
302
344
  const budget = options.budget ?? 4000;
345
+ const minResults = options.minResults ?? 1;
303
346
  const config = options.physicsConfig ?? DEFAULT_PHYSICS_CONFIG;
304
347
  const explain = options.explain ?? false;
305
348
  if (entries.length === 0 || !options.hippoRoot)
@@ -403,8 +446,8 @@ export async function physicsSearch(query, entries, options = {}) {
403
446
  let usedTokens = 0;
404
447
  for (let i = 0; i < merged.length; i++) {
405
448
  const tokens = merged[i].tokens;
406
- if (i > 0 && usedTokens + tokens > budget)
407
- continue; // always include first result
449
+ if (results.length >= minResults && usedTokens + tokens > budget)
450
+ continue;
408
451
  usedTokens += tokens;
409
452
  results.push(merged[i]);
410
453
  }
@@ -435,6 +478,7 @@ export function search(query, entries, options = {}) {
435
478
  // Synchronous path: BM25 only (no async hybrid)
436
479
  const now = options.now ?? new Date();
437
480
  const budget = options.budget ?? 4000;
481
+ const minResults = options.minResults ?? 1;
438
482
  if (entries.length === 0)
439
483
  return [];
440
484
  const queryTerms = tokenize(query);
@@ -474,8 +518,8 @@ export function search(query, entries, options = {}) {
474
518
  let usedTokens = 0;
475
519
  for (let i = 0; i < scored.length; i++) {
476
520
  const tokens = scored[i].tokens;
477
- if (i > 0 && usedTokens + tokens > budget)
478
- continue; // always include first result
521
+ if (results.length >= minResults && usedTokens + tokens > budget)
522
+ continue;
479
523
  usedTokens += tokens;
480
524
  results.push(scored[i]);
481
525
  }