@betterdb/memory 0.2.0 → 0.4.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.
package/src/mcp/server.ts CHANGED
@@ -2,9 +2,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { z } from "zod";
4
4
  import { getValkeyClient } from "../client/valkey.js";
5
+ import { getPluginMemoryStore } from "../client/memory-store.js";
5
6
  import { createModelClient } from "../client/model.js";
6
- import { formatForInjection } from "../memory/retrieval.js";
7
- import { getCwdProject } from "../memory/capture.js";
7
+ import { formatSearchResult } from "../memory/retrieval.js";
8
+ import { escalatingRecall } from "../memory/recall.js";
9
+ import { getCwdProject, getGitBranch } from "../memory/capture.js";
8
10
  import { isConfigured } from "../config.js";
9
11
  import type { EpisodicMemory, KnowledgeEntry } from "../memory/schema.js";
10
12
 
@@ -13,40 +15,59 @@ const SETUP_MESSAGE =
13
15
 
14
16
  const server = new McpServer({
15
17
  name: "betterdb-memory",
16
- version: "0.2.0",
18
+ version: "0.4.0",
17
19
  });
18
20
 
19
21
  // --- Tool: search_context ---
20
22
 
21
23
  server.tool(
22
24
  "search_context",
23
- "Search your past Claude Code sessions for relevant context, decisions, or patterns",
25
+ "Search your past Claude Code sessions for relevant context, decisions, or patterns. " +
26
+ "Escalates automatically (project → wider → cross-project) and gates by relevance, " +
27
+ "so a miss means nothing relevant is stored — never fabricate to fill a miss.",
24
28
  {
25
29
  query: z.string().describe("The search query"),
26
- top_k: z.number().int().min(1).max(20).optional().describe("Max results (default: 5)"),
30
+ top_k: z.number().int().min(1).max(20).optional().describe("Max results shown (default: 5)"),
31
+ scope: z
32
+ .enum(["project", "all"])
33
+ .optional()
34
+ .describe(
35
+ "Search scope. 'project' (default) stays in the current project; " +
36
+ "'all' also searches across every project — use when a project-scoped search found nothing.",
37
+ ),
38
+ tags: z
39
+ .array(z.enum(["decision", "pattern", "problem", "open-thread"]))
40
+ .optional()
41
+ .describe(
42
+ "Filter to memories of these content types — e.g. ['decision'] to " +
43
+ "recall only decisions, ['open-thread'] for unresolved items.",
44
+ ),
27
45
  },
28
- async ({ query, top_k }) => {
46
+ async ({ query, top_k, scope, tags }) => {
29
47
  if (!isConfigured()) {
30
48
  return { content: [{ type: "text" as const, text: SETUP_MESSAGE }] };
31
49
  }
32
50
 
33
- const valkeyClient = await getValkeyClient();
34
51
  const modelClient = await createModelClient();
52
+ const store = await getPluginMemoryStore((t) => modelClient.embed(t));
35
53
 
36
- const embedding = await modelClient.embed(query);
37
54
  const project = getCwdProject();
55
+ const branch = getGitBranch();
38
56
  const k = top_k ?? 5;
39
-
40
- const memories = await valkeyClient.searchMemories(embedding, project, k);
41
- const formatted = formatForInjection(memories);
57
+ // Default (project) scope stays in-project so a miss can *offer* to widen
58
+ // to all projects — the two-step consent flow. An explicit scope="all"
59
+ // requests the cross-project rung; escalatingRecall still gates it on
60
+ // BETTERDB_ALLOW_CROSS_PROJECT and flags the miss honestly if it's off.
61
+ const result = await escalatingRecall(store, query, {
62
+ project,
63
+ ...(branch !== "unknown" ? { branch } : {}),
64
+ ...(tags !== undefined ? { tags } : {}),
65
+ crossProjectRequested: scope === "all",
66
+ });
67
+ const formatted = formatSearchResult(query, result, k);
42
68
 
43
69
  return {
44
- content: [
45
- {
46
- type: "text" as const,
47
- text: formatted || "No matching memories found.",
48
- },
49
- ],
70
+ content: [{ type: "text" as const, text: formatted }],
50
71
  };
51
72
  },
52
73
  );
@@ -70,23 +91,11 @@ server.tool(
70
91
 
71
92
  const valkeyClient = await getValkeyClient();
72
93
  const modelClient = await createModelClient();
94
+ const store = await getPluginMemoryStore((t) => modelClient.embed(t));
73
95
  const project = projectInput ?? getCwdProject();
74
96
 
75
- // Store as KnowledgeEntry
76
- const entry: KnowledgeEntry = {
77
- entryId: crypto.randomUUID(),
78
- project,
79
- topic: category,
80
- fact: content,
81
- confidence: 0.9,
82
- sourceMemoryIds: [],
83
- lastUpdated: new Date().toISOString(),
84
- accessCount: 0,
85
- };
86
- await valkeyClient.storeKnowledge(entry);
87
-
88
- // Also store as EpisodicMemory for vector searchability
89
- const embedding = await modelClient.embed(content);
97
+ // Store as EpisodicMemory for vector searchability. MemoryStore mints the
98
+ // id, so capture it for the knowledge link and the user-facing response.
90
99
  const memory: EpisodicMemory = {
91
100
  memoryId: crypto.randomUUID(),
92
101
  project,
@@ -104,13 +113,26 @@ server.tool(
104
113
  accessCount: 0,
105
114
  lastAccessed: new Date().toISOString(),
106
115
  };
107
- await valkeyClient.storeMemory(memory, embedding);
116
+ const memoryId = await store.storeMemory(memory);
117
+
118
+ // Store as KnowledgeEntry, linked to the episodic memory just written.
119
+ const entry: KnowledgeEntry = {
120
+ entryId: crypto.randomUUID(),
121
+ project,
122
+ topic: category,
123
+ fact: content,
124
+ confidence: 0.9,
125
+ sourceMemoryIds: [memoryId],
126
+ lastUpdated: new Date().toISOString(),
127
+ accessCount: 0,
128
+ };
129
+ await valkeyClient.storeKnowledge(entry);
108
130
 
109
131
  return {
110
132
  content: [
111
133
  {
112
134
  type: "text" as const,
113
- text: `Stored ${category}: "${content}" (memory: ${memory.memoryId})`,
135
+ text: `Stored ${category}: "${content}" (memory: ${memoryId})`,
114
136
  },
115
137
  ],
116
138
  };
@@ -130,15 +152,13 @@ server.tool(
130
152
  return { content: [{ type: "text" as const, text: SETUP_MESSAGE }] };
131
153
  }
132
154
 
133
- const valkeyClient = await getValkeyClient();
155
+ const store = await getPluginMemoryStore();
134
156
  const project = projectInput ?? getCwdProject();
135
157
 
136
- const memoryIds = await valkeyClient.listMemoryIds(project, 0.5);
158
+ const memories = await store.listMemories(project, 0.5);
137
159
  const threads = new Set<string>();
138
160
 
139
- for (const id of memoryIds) {
140
- const memory = await valkeyClient.getMemory(id);
141
- if (!memory) continue;
161
+ for (const memory of memories) {
142
162
  for (const thread of memory.summary.openThreads) {
143
163
  threads.add(thread);
144
164
  }
@@ -174,10 +194,10 @@ server.tool(
174
194
  return { content: [{ type: "text" as const, text: SETUP_MESSAGE }] };
175
195
  }
176
196
 
177
- const valkeyClient = await getValkeyClient();
197
+ const store = await getPluginMemoryStore();
178
198
 
179
199
  if (!confirmed) {
180
- const memory = await valkeyClient.getMemory(memory_id);
200
+ const memory = await store.getMemory(memory_id);
181
201
  if (!memory) {
182
202
  return {
183
203
  content: [
@@ -202,7 +222,7 @@ server.tool(
202
222
  };
203
223
  }
204
224
 
205
- await valkeyClient.deleteMemory(memory_id);
225
+ await store.deleteMemory(memory_id);
206
226
 
207
227
  return {
208
228
  content: [
@@ -1,199 +1,86 @@
1
+ import type { MemoryItem } from "@betterdb/agent-memory";
1
2
  import { config } from "../config.js";
2
3
  import type { ModelClient } from "../client/model.js";
3
4
  import type { ValkeyClient } from "../client/valkey.js";
4
- import { SessionSummarySchema, type EpisodicMemory } from "./schema.js";
5
- import { computeInitialImportance, SessionCapture } from "./capture.js";
6
-
7
- // --- Cosine Similarity ---
8
-
9
- export function cosineSimilarity(a: number[], b: number[]): number {
10
- let dot = 0;
11
- let magA = 0;
12
- let magB = 0;
13
- for (let i = 0; i < a.length; i++) {
14
- const av = a[i] ?? 0;
15
- const bv = b[i] ?? 0;
16
- dot += av * bv;
17
- magA += av * av;
18
- magB += bv * bv;
19
- }
20
- const denom = Math.sqrt(magA) * Math.sqrt(magB);
21
- return denom === 0 ? 0 : dot / denom;
22
- }
5
+ import {
6
+ itemToEpisodic,
7
+ type PluginMemoryStore,
8
+ } from "../client/memory-store.js";
9
+ import type { EpisodicMemory } from "./schema.js";
10
+ import { computeInitialImportance } from "./capture.js";
23
11
 
24
12
  // --- Aging Pipeline ---
13
+ //
14
+ // Recency decay and similarity clustering used to live here as bespoke code;
15
+ // both are now provided by @betterdb/agent-memory's MemoryStore — composite
16
+ // recall scoring handles recency at query time, and consolidate() merges a
17
+ // scope's low-value memories into a single summary. What remains here is the
18
+ // plugin-specific glue: ingest-queue processing, LLM-driven consolidation
19
+ // summarization, and pattern distillation into KnowledgeEntries.
20
+ //
21
+ // Consolidation only runs when a project has at least this many low-importance
22
+ // memories, so a lone low-value memory isn't pointlessly re-summarized (which
23
+ // would discard its structured summary and reset its access stats).
24
+ const CONSOLIDATE_MIN_CANDIDATES = 3;
25
25
 
26
26
  export class AgingPipeline {
27
27
  private valkeyClient: ValkeyClient;
28
+ private store: PluginMemoryStore;
28
29
  private modelClient: ModelClient;
29
30
 
30
- constructor(valkeyClient: ValkeyClient, modelClient: ModelClient) {
31
+ constructor(
32
+ valkeyClient: ValkeyClient,
33
+ store: PluginMemoryStore,
34
+ modelClient: ModelClient,
35
+ ) {
31
36
  this.valkeyClient = valkeyClient;
37
+ this.store = store;
32
38
  this.modelClient = modelClient;
33
39
  }
34
40
 
35
- // --- Decay ---
36
-
37
- async runDecay(
38
- project?: string,
39
- ): Promise<{ processed: number; flagged: number }> {
40
- const memoryIds = await this.valkeyClient.listMemoryIds(project);
41
- let processed = 0;
42
- let flagged = 0;
43
-
44
- for (const id of memoryIds) {
45
- const memory = await this.valkeyClient.getMemory(id);
46
- if (!memory) continue;
47
-
48
- const daysSince =
49
- (Date.now() - new Date(memory.lastAccessed).getTime()) /
50
- (1000 * 60 * 60 * 24);
51
- const newScore =
52
- memory.importanceScore *
53
- Math.pow(config.memory.decayRate, daysSince);
41
+ // --- Consolidation ---
54
42
 
55
- await this.valkeyClient.updateImportance(id, newScore);
56
- processed++;
43
+ async runConsolidation(
44
+ project: string,
45
+ ): Promise<{ consolidated: number; created: number; deleted: number }> {
46
+ const threshold = config.memory.compressThreshold;
47
+ const candidates = (await this.store.listMemories(project)).filter(
48
+ (m) => m.importanceScore <= threshold,
49
+ );
57
50
 
58
- if (newScore < config.memory.compressThreshold) {
59
- await this.valkeyClient.pushCompressQueue(id);
60
- flagged++;
61
- }
51
+ if (candidates.length < CONSOLIDATE_MIN_CANDIDATES) {
52
+ return { consolidated: 0, created: 0, deleted: 0 };
62
53
  }
63
54
 
64
- return { processed, flagged };
55
+ const result = await this.store.consolidate({
56
+ namespace: project,
57
+ maxImportance: threshold,
58
+ summaryImportance: threshold,
59
+ summarize: (items) => this.summarizeCluster(items),
60
+ });
61
+
62
+ return {
63
+ consolidated: result.consolidated,
64
+ created: result.created.length,
65
+ deleted: result.deleted,
66
+ };
65
67
  }
66
68
 
67
- // --- Compression ---
68
-
69
- async runCompression(): Promise<{ merged: number; deleted: number }> {
70
- const ids = await this.valkeyClient.popCompressQueue(50);
71
- if (ids.length === 0) return { merged: 0, deleted: 0 };
72
-
73
- // Fetch memories with embeddings
74
- const entries: Array<{
75
- memory: EpisodicMemory;
76
- embedding: number[];
77
- }> = [];
78
-
79
- for (const id of ids) {
80
- const memory = await this.valkeyClient.getMemory(id);
81
- const embedding = await this.valkeyClient.getMemoryEmbedding(id);
82
- if (memory && embedding) {
83
- entries.push({ memory, embedding });
84
- }
85
- }
86
-
87
- // Group by project
88
- const byProject = new Map<
89
- string,
90
- Array<{ memory: EpisodicMemory; embedding: number[] }>
91
- >();
92
- for (const entry of entries) {
93
- const group = byProject.get(entry.memory.project) ?? [];
94
- group.push(entry);
95
- byProject.set(entry.memory.project, group);
96
- }
97
-
98
- let merged = 0;
99
- let deleted = 0;
100
-
101
- for (const [, group] of byProject) {
102
- // Batch size guard: only process 100 lowest-importance per project
103
- const sorted = group
104
- .sort((a, b) => a.memory.importanceScore - b.memory.importanceScore)
105
- .slice(0, 100);
106
-
107
- if (sorted.length < group.length) {
108
- console.error(
109
- `[betterdb] Project group exceeds 100 memories, processing only lowest-importance 100. Additional runs needed.`,
69
+ private async summarizeCluster(items: MemoryItem[]): Promise<string> {
70
+ const transcript = items
71
+ .map((item) => {
72
+ const memory = itemToEpisodic(item);
73
+ if (!memory) return item.content;
74
+ return (
75
+ `Session: ${memory.summary.oneLineSummary}\n` +
76
+ `Decisions: ${memory.summary.decisions.join("; ")}\n` +
77
+ `Patterns: ${memory.summary.patterns.join("; ")}`
110
78
  );
111
- }
112
-
113
- // Find clusters of similar memories
114
- const used = new Set<number>();
115
- const clusters: Array<
116
- Array<{ memory: EpisodicMemory; embedding: number[] }>
117
- > = [];
118
-
119
- for (let i = 0; i < sorted.length; i++) {
120
- if (used.has(i)) continue;
121
- const cluster = [sorted[i]!];
122
- used.add(i);
123
-
124
- for (let j = i + 1; j < sorted.length; j++) {
125
- if (used.has(j)) continue;
126
- // Check if similar to all cluster members
127
- const similar = cluster.every(
128
- (c) =>
129
- cosineSimilarity(c.embedding, sorted[j]!.embedding) > 0.85,
130
- );
131
- if (similar) {
132
- cluster.push(sorted[j]!);
133
- used.add(j);
134
- }
135
- }
136
-
137
- clusters.push(cluster);
138
- }
139
-
140
- // Process clusters
141
- for (const cluster of clusters) {
142
- if (cluster.length >= 3) {
143
- // Merge cluster into a single memory
144
- const combinedTranscript = cluster
145
- .map(
146
- (c) =>
147
- `Session ${c.memory.memoryId}: ${c.memory.summary.oneLineSummary}\n` +
148
- `Decisions: ${c.memory.summary.decisions.join("; ")}\n` +
149
- `Patterns: ${c.memory.summary.patterns.join("; ")}`,
150
- )
151
- .join("\n\n");
152
-
153
- const mergedSummary =
154
- await this.modelClient.summarize(combinedTranscript);
155
- const mergedEmbedding = await this.modelClient.embed(
156
- mergedSummary.oneLineSummary,
157
- );
158
-
159
- const avgImportance =
160
- cluster.reduce((sum, c) => sum + c.memory.importanceScore, 0) /
161
- cluster.length;
162
-
163
- const newMemory: EpisodicMemory = {
164
- memoryId: crypto.randomUUID(),
165
- project: cluster[0]!.memory.project,
166
- branch: cluster[0]!.memory.branch,
167
- timestamp: new Date().toISOString(),
168
- summary: mergedSummary,
169
- importanceScore: avgImportance,
170
- accessCount: 0,
171
- lastAccessed: new Date().toISOString(),
172
- };
173
-
174
- await this.valkeyClient.storeMemory(newMemory, mergedEmbedding);
175
-
176
- // Delete originals
177
- for (const c of cluster) {
178
- await this.valkeyClient.deleteMemory(c.memory.memoryId);
179
- }
180
-
181
- merged += cluster.length;
182
- } else if (cluster.length === 1) {
183
- const m = cluster[0]!.memory;
184
- const daysSince =
185
- (Date.now() - new Date(m.lastAccessed).getTime()) /
186
- (1000 * 60 * 60 * 24);
187
-
188
- if (m.importanceScore < 0.05 && daysSince > 90) {
189
- await this.valkeyClient.deleteMemory(m.memoryId);
190
- deleted++;
191
- }
192
- }
193
- }
194
- }
79
+ })
80
+ .join("\n\n");
195
81
 
196
- return { merged, deleted };
82
+ const summary = await this.modelClient.summarize(transcript);
83
+ return summary.oneLineSummary;
197
84
  }
198
85
 
199
86
  // --- Distillation ---
@@ -201,13 +88,7 @@ export class AgingPipeline {
201
88
  async runDistillation(
202
89
  project: string,
203
90
  ): Promise<{ distilled: number }> {
204
- const memoryIds = await this.valkeyClient.listMemoryIds(project, 0.5);
205
- const memories: EpisodicMemory[] = [];
206
-
207
- for (const id of memoryIds) {
208
- const memory = await this.valkeyClient.getMemory(id);
209
- if (memory) memories.push(memory);
210
- }
91
+ const memories = await this.store.listMemories(project, 0.5);
211
92
 
212
93
  if (memories.length < config.memory.distillMinSessions) {
213
94
  return { distilled: 0 };
@@ -259,9 +140,6 @@ export class AgingPipeline {
259
140
  for (const item of items) {
260
141
  try {
261
142
  const summary = await this.modelClient.summarize(item.transcript);
262
- const embedding = await this.modelClient.embed(
263
- summary.oneLineSummary,
264
- );
265
143
  const importance = computeInitialImportance(summary);
266
144
 
267
145
  const meta = item.meta as Record<string, string>;
@@ -276,7 +154,7 @@ export class AgingPipeline {
276
154
  lastAccessed: new Date().toISOString(),
277
155
  };
278
156
 
279
- await this.valkeyClient.storeMemory(memory, embedding);
157
+ await this.store.storeMemory(memory);
280
158
  processed++;
281
159
  } catch (err) {
282
160
  console.error("[betterdb] Failed to process queued transcript:", err);
@@ -300,22 +178,26 @@ export class AgingPipeline {
300
178
  const { processed: ingested } = await this.processIngestQueue();
301
179
  console.error(`[betterdb] Ingest queue: processed ${ingested} items`);
302
180
 
303
- const { processed, flagged } = await this.runDecay(project);
304
- console.error(
305
- `[betterdb] Decay: processed ${processed}, flagged ${flagged} for compression`,
306
- );
181
+ const projects = project
182
+ ? [project]
183
+ : await this.allProjects();
307
184
 
308
- const { merged, deleted } = await this.runCompression();
309
- console.error(
310
- `[betterdb] Compression: merged ${merged}, deleted ${deleted}`,
311
- );
185
+ for (const p of projects) {
186
+ const { consolidated, created, deleted } = await this.runConsolidation(p);
187
+ console.error(
188
+ `[betterdb] Consolidation (${p}): merged ${consolidated} into ${created}, deleted ${deleted}`,
189
+ );
312
190
 
313
- if (project) {
314
- const { distilled } = await this.runDistillation(project);
315
- console.error(`[betterdb] Distillation: distilled ${distilled} entries`);
191
+ const { distilled } = await this.runDistillation(p);
192
+ console.error(`[betterdb] Distillation (${p}): distilled ${distilled} entries`);
316
193
  }
317
194
 
318
195
  await this.valkeyClient.setLastAgingRun(new Date());
319
196
  console.error("[betterdb] Aging pipeline complete.");
320
197
  }
198
+
199
+ private async allProjects(): Promise<string[]> {
200
+ const memories = await this.store.listMemories();
201
+ return [...new Set(memories.map((m) => m.project))];
202
+ }
321
203
  }
@@ -0,0 +1,169 @@
1
+ import { config } from "../config.js";
2
+ import type { PluginMemoryStore, ScoredMemory } from "../client/memory-store.js";
3
+
4
+ // Over-fetch → gate → narrow, mirroring the LongMemEval harness. Dense recall
5
+ // is already ~95%; the gain is a real candidate set plus an honest gate, so
6
+ // "found nothing" means "nothing cleared the gate" rather than an empty KNN.
7
+ //
8
+ // The gate is RELATIVE, not an absolute similarity threshold. Embed models
9
+ // compress cosine similarity into different, narrow bands (mxbai-embed-large
10
+ // packs everything into ~0.7–0.88; all-MiniLM differs), so a fixed tau doesn't
11
+ // transfer across models. Instead: loosen the store's own distance gate to a
12
+ // generous floor, drop genuine noise below that floor, then keep only the hits
13
+ // within `margin` of the top match. Confidence comes from the top-vs-next gap,
14
+ // which is scale-independent.
15
+
16
+ export interface RecallResult {
17
+ hits: ScoredMemory[];
18
+ scope: "project" | "all";
19
+ /** 1: project+branch (or project) · 2: project · 3: cross-project · 0: nothing. */
20
+ rung: 0 | 1 | 2 | 3;
21
+ confidence: "high" | "low" | "none";
22
+ /**
23
+ * True on a miss when the caller asked to widen (`crossProjectRequested`) but
24
+ * `BETTERDB_ALLOW_CROSS_PROJECT` is off, so the cross-project rung never ran.
25
+ * Lets the formatter say "cross-project is disabled" instead of falsely
26
+ * offering a scope="all" retry the config would also refuse.
27
+ */
28
+ crossProjectBlocked: boolean;
29
+ }
30
+
31
+ /** Scoping for {@link escalatingRecall}. */
32
+ export interface RecallQuery {
33
+ project: string;
34
+ /** Git branch (native thread scope). Rung 1 narrows to it when present. */
35
+ branch?: string;
36
+ /** Content-type filter (e.g. `["decision"]`) applied at every rung. */
37
+ tags?: string[];
38
+ /**
39
+ * Whether the caller wants to widen past the project (user consent / an
40
+ * explicit scope="all"). The cross-project rung *also* requires
41
+ * `BETTERDB_ALLOW_CROSS_PROJECT`; when requested but globally disabled the
42
+ * result is flagged {@link RecallResult.crossProjectBlocked}.
43
+ */
44
+ crossProjectRequested: boolean;
45
+ }
46
+
47
+ interface Gated {
48
+ hits: ScoredMemory[];
49
+ confidence: "high" | "low" | "none";
50
+ }
51
+
52
+ /** Keep hits within `margin` of the top match above `floor`; grade by gap. */
53
+ function gate(pool: ScoredMemory[]): Gated {
54
+ const { floor, margin, separation } = config.recall;
55
+ const eligible = pool
56
+ .filter((h) => h.relevance >= floor)
57
+ .sort((a, b) => b.relevance - a.relevance);
58
+ if (eligible.length === 0) return { hits: [], confidence: "none" };
59
+
60
+ const top = eligible[0]!.relevance;
61
+ const hits = eligible.filter((h) => h.relevance >= top - margin);
62
+ const second = eligible[1]?.relevance ?? -Infinity;
63
+ // A clear peak above the rest → confident; a bunched cluster (e.g. many
64
+ // near-duplicate file-history entries) → low, honestly.
65
+ const confidence =
66
+ eligible.length === 1 || top - second >= separation ? "high" : "low";
67
+ return { hits, confidence };
68
+ }
69
+
70
+ /** Distance gate to hand the store so its strict default (0.25) doesn't
71
+ * pre-filter everything: similarity `floor` ↔ distance `2·(1 − floor)`. */
72
+ function storeThreshold(): number {
73
+ return 2 * (1 - config.recall.floor);
74
+ }
75
+
76
+ /**
77
+ * Escalating recall, narrow → wide:
78
+ * rung 1 — project + `branch` (when given), pool `poolK`. Same project and
79
+ * branch is the most relevant scope; without a branch this is just
80
+ * project scope.
81
+ * rung 2 — project, any branch, wider pool `poolKWide`.
82
+ * rung 3 — cross-project probe. Only when the caller requested widening AND
83
+ * `BETTERDB_ALLOW_CROSS_PROJECT` is on, since another project's
84
+ * memory is often noise or privacy-sensitive.
85
+ * Every rung is a speculative over-fetch, so all recalls set `reinforce:false`:
86
+ * the store reinforces its whole returned pool, but the gate then drops most of
87
+ * it, so reinforcing pre-gate would bump access counts on candidates the user
88
+ * never sees (and on entire pools of a miss), skewing composite ranking.
89
+ * A `tags` filter, when present, applies at every rung. Stops at the first rung
90
+ * that yields gated hits.
91
+ */
92
+ export async function escalatingRecall(
93
+ store: PluginMemoryStore,
94
+ query: string,
95
+ q: RecallQuery,
96
+ ): Promise<RecallResult> {
97
+ const { poolK, poolKWide } = config.recall;
98
+ const threshold = storeThreshold();
99
+ const { project, branch, tags, crossProjectRequested } = q;
100
+ const crossProjectEnabled =
101
+ crossProjectRequested && config.recall.allowCrossProject;
102
+
103
+ // rung 1 — project + branch (most specific).
104
+ let pool = await store.recall(query, {
105
+ project,
106
+ ...(branch !== undefined ? { branch } : {}),
107
+ tags,
108
+ k: poolK,
109
+ threshold,
110
+ reinforce: false,
111
+ });
112
+ let g = gate(pool);
113
+ if (g.hits.length > 0) {
114
+ return {
115
+ hits: g.hits,
116
+ scope: "project",
117
+ rung: 1,
118
+ confidence: g.confidence,
119
+ crossProjectBlocked: false,
120
+ };
121
+ }
122
+
123
+ // rung 2 — project, any branch, wider pool.
124
+ pool = await store.recall(query, {
125
+ project,
126
+ tags,
127
+ k: poolKWide,
128
+ threshold,
129
+ reinforce: false,
130
+ });
131
+ g = gate(pool);
132
+ if (g.hits.length > 0) {
133
+ return {
134
+ hits: g.hits,
135
+ scope: "project",
136
+ rung: 2,
137
+ confidence: g.confidence,
138
+ crossProjectBlocked: false,
139
+ };
140
+ }
141
+
142
+ // rung 3 — cross-project probe.
143
+ if (crossProjectEnabled) {
144
+ pool = await store.recall(query, {
145
+ tags,
146
+ k: poolKWide,
147
+ threshold,
148
+ reinforce: false,
149
+ });
150
+ g = gate(pool);
151
+ if (g.hits.length > 0) {
152
+ return {
153
+ hits: g.hits,
154
+ scope: "all",
155
+ rung: 3,
156
+ confidence: g.confidence,
157
+ crossProjectBlocked: false,
158
+ };
159
+ }
160
+ }
161
+
162
+ return {
163
+ hits: [],
164
+ scope: crossProjectEnabled ? "all" : "project",
165
+ rung: 0,
166
+ confidence: "none",
167
+ crossProjectBlocked: crossProjectRequested && !config.recall.allowCrossProject,
168
+ };
169
+ }