@betterdb/memory 0.1.2 → 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,43 +2,72 @@ 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";
10
+ import { isConfigured } from "../config.js";
8
11
  import type { EpisodicMemory, KnowledgeEntry } from "../memory/schema.js";
9
12
 
13
+ const SETUP_MESSAGE =
14
+ "BetterDB Memory is not configured yet. Run /betterdb-memory:setup to connect to Valkey and create the search index.";
15
+
10
16
  const server = new McpServer({
11
17
  name: "betterdb-memory",
12
- version: "0.1.0",
18
+ version: "0.4.0",
13
19
  });
14
20
 
15
21
  // --- Tool: search_context ---
16
22
 
17
23
  server.tool(
18
24
  "search_context",
19
- "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.",
20
28
  {
21
29
  query: z.string().describe("The search query"),
22
- 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
+ ),
23
45
  },
24
- async ({ query, top_k }) => {
25
- const valkeyClient = await getValkeyClient();
46
+ async ({ query, top_k, scope, tags }) => {
47
+ if (!isConfigured()) {
48
+ return { content: [{ type: "text" as const, text: SETUP_MESSAGE }] };
49
+ }
50
+
26
51
  const modelClient = await createModelClient();
52
+ const store = await getPluginMemoryStore((t) => modelClient.embed(t));
27
53
 
28
- const embedding = await modelClient.embed(query);
29
54
  const project = getCwdProject();
55
+ const branch = getGitBranch();
30
56
  const k = top_k ?? 5;
31
-
32
- const memories = await valkeyClient.searchMemories(embedding, project, k);
33
- 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);
34
68
 
35
69
  return {
36
- content: [
37
- {
38
- type: "text" as const,
39
- text: formatted || "No matching memories found.",
40
- },
41
- ],
70
+ content: [{ type: "text" as const, text: formatted }],
42
71
  };
43
72
  },
44
73
  );
@@ -56,25 +85,17 @@ server.tool(
56
85
  project: z.string().optional().describe("Project name (auto-detected if omitted)"),
57
86
  },
58
87
  async ({ content, category, project: projectInput }) => {
88
+ if (!isConfigured()) {
89
+ return { content: [{ type: "text" as const, text: SETUP_MESSAGE }] };
90
+ }
91
+
59
92
  const valkeyClient = await getValkeyClient();
60
93
  const modelClient = await createModelClient();
94
+ const store = await getPluginMemoryStore((t) => modelClient.embed(t));
61
95
  const project = projectInput ?? getCwdProject();
62
96
 
63
- // Store as KnowledgeEntry
64
- const entry: KnowledgeEntry = {
65
- entryId: crypto.randomUUID(),
66
- project,
67
- topic: category,
68
- fact: content,
69
- confidence: 0.9,
70
- sourceMemoryIds: [],
71
- lastUpdated: new Date().toISOString(),
72
- accessCount: 0,
73
- };
74
- await valkeyClient.storeKnowledge(entry);
75
-
76
- // Also store as EpisodicMemory for vector searchability
77
- 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.
78
99
  const memory: EpisodicMemory = {
79
100
  memoryId: crypto.randomUUID(),
80
101
  project,
@@ -92,13 +113,26 @@ server.tool(
92
113
  accessCount: 0,
93
114
  lastAccessed: new Date().toISOString(),
94
115
  };
95
- 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);
96
130
 
97
131
  return {
98
132
  content: [
99
133
  {
100
134
  type: "text" as const,
101
- text: `Stored ${category}: "${content}" (memory: ${memory.memoryId})`,
135
+ text: `Stored ${category}: "${content}" (memory: ${memoryId})`,
102
136
  },
103
137
  ],
104
138
  };
@@ -114,15 +148,17 @@ server.tool(
114
148
  project: z.string().optional().describe("Project name (auto-detected if omitted)"),
115
149
  },
116
150
  async ({ project: projectInput }) => {
117
- const valkeyClient = await getValkeyClient();
151
+ if (!isConfigured()) {
152
+ return { content: [{ type: "text" as const, text: SETUP_MESSAGE }] };
153
+ }
154
+
155
+ const store = await getPluginMemoryStore();
118
156
  const project = projectInput ?? getCwdProject();
119
157
 
120
- const memoryIds = await valkeyClient.listMemoryIds(project, 0.5);
158
+ const memories = await store.listMemories(project, 0.5);
121
159
  const threads = new Set<string>();
122
160
 
123
- for (const id of memoryIds) {
124
- const memory = await valkeyClient.getMemory(id);
125
- if (!memory) continue;
161
+ for (const memory of memories) {
126
162
  for (const thread of memory.summary.openThreads) {
127
163
  threads.add(thread);
128
164
  }
@@ -154,10 +190,14 @@ server.tool(
154
190
  confirmed: z.boolean().optional().describe("Set to true to confirm deletion"),
155
191
  },
156
192
  async ({ memory_id, confirmed }) => {
157
- const valkeyClient = await getValkeyClient();
193
+ if (!isConfigured()) {
194
+ return { content: [{ type: "text" as const, text: SETUP_MESSAGE }] };
195
+ }
196
+
197
+ const store = await getPluginMemoryStore();
158
198
 
159
199
  if (!confirmed) {
160
- const memory = await valkeyClient.getMemory(memory_id);
200
+ const memory = await store.getMemory(memory_id);
161
201
  if (!memory) {
162
202
  return {
163
203
  content: [
@@ -182,7 +222,7 @@ server.tool(
182
222
  };
183
223
  }
184
224
 
185
- await valkeyClient.deleteMemory(memory_id);
225
+ await store.deleteMemory(memory_id);
186
226
 
187
227
  return {
188
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
  }