@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/README.md +46 -6
- package/package.json +3 -1
- package/scripts/aging-worker.ts +4 -1
- package/scripts/setup-index.ts +10 -3
- package/src/client/memory-store.ts +406 -0
- package/src/client/model.ts +10 -10
- package/src/client/providers/local.ts +58 -0
- package/src/client/valkey.ts +9 -0
- package/src/config.ts +25 -2
- package/src/hooks/pre-tool.ts +10 -10
- package/src/hooks/session-end.ts +4 -2
- package/src/hooks/session-start.ts +22 -10
- package/src/index.ts +318 -21
- package/src/mcp/server.ts +62 -42
- package/src/memory/aging.ts +78 -196
- package/src/memory/recall.ts +169 -0
- package/src/memory/retrieval.ts +73 -70
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 {
|
|
7
|
-
import {
|
|
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.
|
|
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
|
-
|
|
41
|
-
|
|
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
|
|
76
|
-
|
|
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
|
|
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: ${
|
|
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
|
|
155
|
+
const store = await getPluginMemoryStore();
|
|
134
156
|
const project = projectInput ?? getCwdProject();
|
|
135
157
|
|
|
136
|
-
const
|
|
158
|
+
const memories = await store.listMemories(project, 0.5);
|
|
137
159
|
const threads = new Set<string>();
|
|
138
160
|
|
|
139
|
-
for (const
|
|
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
|
|
197
|
+
const store = await getPluginMemoryStore();
|
|
178
198
|
|
|
179
199
|
if (!confirmed) {
|
|
180
|
-
const memory = await
|
|
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
|
|
225
|
+
await store.deleteMemory(memory_id);
|
|
206
226
|
|
|
207
227
|
return {
|
|
208
228
|
content: [
|
package/src/memory/aging.ts
CHANGED
|
@@ -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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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(
|
|
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
|
-
// ---
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
flagged++;
|
|
61
|
-
}
|
|
51
|
+
if (candidates.length < CONSOLIDATE_MIN_CANDIDATES) {
|
|
52
|
+
return { consolidated: 0, created: 0, deleted: 0 };
|
|
62
53
|
}
|
|
63
54
|
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
);
|
|
181
|
+
const projects = project
|
|
182
|
+
? [project]
|
|
183
|
+
: await this.allProjects();
|
|
307
184
|
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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
|
+
}
|