@betterdb/memory 0.1.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/LICENSE +21 -0
- package/README.md +70 -0
- package/package.json +60 -0
- package/scripts/aging-worker.ts +24 -0
- package/scripts/check-providers.ts +103 -0
- package/scripts/install-hooks.sh +103 -0
- package/scripts/migrate-embeddings.ts +69 -0
- package/scripts/setup-index.ts +14 -0
- package/scripts/validate-pack.sh +67 -0
- package/src/client/model.ts +281 -0
- package/src/client/providers/_prompt.ts +35 -0
- package/src/client/providers/anthropic.ts +70 -0
- package/src/client/providers/groq.ts +102 -0
- package/src/client/providers/ollama.ts +53 -0
- package/src/client/providers/openai.ts +125 -0
- package/src/client/providers/together.ts +94 -0
- package/src/client/providers/voyage.ts +46 -0
- package/src/client/valkey.ts +448 -0
- package/src/config.ts +67 -0
- package/src/hooks/_utils.ts +53 -0
- package/src/hooks/post-tool.ts +46 -0
- package/src/hooks/pre-tool.ts +59 -0
- package/src/hooks/session-end.ts +194 -0
- package/src/hooks/session-start.ts +43 -0
- package/src/index.ts +435 -0
- package/src/mcp/server.ts +201 -0
- package/src/memory/aging.ts +321 -0
- package/src/memory/capture.ts +122 -0
- package/src/memory/retrieval.ts +114 -0
- package/src/memory/schema.ts +111 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { SessionSummarySchema, type SessionSummary } from "../../memory/schema.js";
|
|
2
|
+
import type { ModelClient, ModelPreset } from "../model.js";
|
|
3
|
+
import { buildSummarizePrompt, stripCodeFences } from "./_prompt.js";
|
|
4
|
+
import { createOpenAI, type OpenAIClient } from "./openai.js";
|
|
5
|
+
|
|
6
|
+
export class TogetherEmbedClient implements ModelClient {
|
|
7
|
+
private client: OpenAIClient | null = null;
|
|
8
|
+
private apiKey: string;
|
|
9
|
+
readonly embedDim = 768;
|
|
10
|
+
readonly preset: ModelPreset = {
|
|
11
|
+
embedModel: "togethercomputer/m2-bert-80M-8k-retrieval",
|
|
12
|
+
summarizeModel: "n/a",
|
|
13
|
+
embedDim: 768,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
constructor(apiKey: string) {
|
|
17
|
+
this.apiKey = apiKey;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private async getClient(): Promise<OpenAIClient> {
|
|
21
|
+
if (!this.client) {
|
|
22
|
+
this.client = await createOpenAI(this.apiKey, "https://api.together.xyz/v1");
|
|
23
|
+
}
|
|
24
|
+
return this.client;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async embed(text: string): Promise<number[]> {
|
|
28
|
+
const client = await this.getClient();
|
|
29
|
+
const response = await client.embeddings.create({
|
|
30
|
+
model: "togethercomputer/m2-bert-80M-8k-retrieval",
|
|
31
|
+
input: text,
|
|
32
|
+
});
|
|
33
|
+
const first = response.data[0];
|
|
34
|
+
if (!first) {
|
|
35
|
+
throw new Error("Together AI embed returned no embeddings");
|
|
36
|
+
}
|
|
37
|
+
return first.embedding;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async summarize(_transcript: string): Promise<SessionSummary> {
|
|
41
|
+
throw new Error("TogetherEmbedClient does not support summarization — use TogetherSummarizeClient");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class TogetherSummarizeClient implements ModelClient {
|
|
46
|
+
private client: OpenAIClient | null = null;
|
|
47
|
+
private apiKey: string;
|
|
48
|
+
readonly embedDim = 0;
|
|
49
|
+
readonly preset: ModelPreset = {
|
|
50
|
+
embedModel: "n/a",
|
|
51
|
+
summarizeModel: "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
|
|
52
|
+
embedDim: 0,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
constructor(apiKey: string) {
|
|
56
|
+
this.apiKey = apiKey;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async getClient(): Promise<OpenAIClient> {
|
|
60
|
+
if (!this.client) {
|
|
61
|
+
this.client = await createOpenAI(this.apiKey, "https://api.together.xyz/v1");
|
|
62
|
+
}
|
|
63
|
+
return this.client;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async embed(_text: string): Promise<number[]> {
|
|
67
|
+
throw new Error("TogetherSummarizeClient does not support embedding — use TogetherEmbedClient");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async summarize(transcript: string): Promise<SessionSummary> {
|
|
71
|
+
const client = await this.getClient();
|
|
72
|
+
const response = await client.chat.completions.create({
|
|
73
|
+
model: "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
|
|
74
|
+
messages: [
|
|
75
|
+
{ role: "user", content: buildSummarizePrompt(transcript) },
|
|
76
|
+
],
|
|
77
|
+
response_format: { type: "json_object" },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const content = response.choices[0]?.message.content;
|
|
81
|
+
if (!content) {
|
|
82
|
+
console.error("[betterdb] Together AI summarization returned empty response");
|
|
83
|
+
return SessionSummarySchema.parse({});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const parsed = SessionSummarySchema.safeParse(JSON.parse(stripCodeFences(content)));
|
|
87
|
+
if (!parsed.success) {
|
|
88
|
+
console.error("[betterdb] Failed to parse Together AI summarization:", parsed.error.message);
|
|
89
|
+
return SessionSummarySchema.parse({});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return parsed.data;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { SessionSummary } from "../../memory/schema.js";
|
|
2
|
+
import type { ModelClient, ModelPreset } from "../model.js";
|
|
3
|
+
|
|
4
|
+
interface VoyageEmbedResponse {
|
|
5
|
+
data: Array<{ embedding: number[] }>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class VoyageEmbedClient implements ModelClient {
|
|
9
|
+
private apiKey: string;
|
|
10
|
+
readonly embedDim = 1024;
|
|
11
|
+
readonly preset: ModelPreset = {
|
|
12
|
+
embedModel: "voyage-3",
|
|
13
|
+
summarizeModel: "n/a",
|
|
14
|
+
embedDim: 1024,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
constructor(apiKey: string) {
|
|
18
|
+
this.apiKey = apiKey;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async embed(text: string): Promise<number[]> {
|
|
22
|
+
const response = await fetch("https://api.voyageai.com/v1/embeddings", {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: {
|
|
25
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify({ model: "voyage-3", input: [text] }),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
throw new Error(`Voyage API error: ${response.status} ${response.statusText}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const data = (await response.json()) as VoyageEmbedResponse;
|
|
36
|
+
const first = data.data[0];
|
|
37
|
+
if (!first) {
|
|
38
|
+
throw new Error("Voyage embed returned no embeddings");
|
|
39
|
+
}
|
|
40
|
+
return first.embedding;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async summarize(_transcript: string): Promise<SessionSummary> {
|
|
44
|
+
throw new Error("Voyage AI does not provide summarization — configure a separate summarize provider");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import Redis from "iovalkey";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import {
|
|
4
|
+
EpisodicMemorySchema,
|
|
5
|
+
KnowledgeEntrySchema,
|
|
6
|
+
type EpisodicMemory,
|
|
7
|
+
type KnowledgeEntry,
|
|
8
|
+
} from "../memory/schema.js";
|
|
9
|
+
|
|
10
|
+
// --- Embedding Serialization ---
|
|
11
|
+
|
|
12
|
+
export function embeddingToBuffer(embedding: number[]): Buffer {
|
|
13
|
+
const buf = Buffer.allocUnsafe(embedding.length * 4);
|
|
14
|
+
embedding.forEach((v, i) => buf.writeFloatLE(v, i * 4));
|
|
15
|
+
return buf;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function bufferToEmbedding(buf: Buffer): number[] {
|
|
19
|
+
return Array.from(
|
|
20
|
+
{ length: buf.length / 4 },
|
|
21
|
+
(_, i) => buf.readFloatLE(i * 4),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// --- Valkey Client ---
|
|
26
|
+
|
|
27
|
+
export class ValkeyClient {
|
|
28
|
+
private client: Redis;
|
|
29
|
+
|
|
30
|
+
constructor(client: Redis) {
|
|
31
|
+
this.client = client;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- Index Management ---
|
|
35
|
+
|
|
36
|
+
async assertEmbedDim(expectedDim: number, providerLabel?: string): Promise<void> {
|
|
37
|
+
const stored = await this.client.get("betterdb:meta:embedDim");
|
|
38
|
+
const storedProvider = await this.client.get("betterdb:meta:embedProvider");
|
|
39
|
+
if (stored === null) {
|
|
40
|
+
await this.client.set("betterdb:meta:embedDim", String(expectedDim));
|
|
41
|
+
if (providerLabel) {
|
|
42
|
+
await this.client.set("betterdb:meta:embedProvider", providerLabel);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (Number(stored) !== expectedDim) {
|
|
47
|
+
const storedLabel = storedProvider ? ` (${storedProvider})` : "";
|
|
48
|
+
const currentLabel = providerLabel ? ` (${providerLabel})` : "";
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Embedding dimension mismatch: index was built with dim=${stored}${storedLabel}, ` +
|
|
51
|
+
`but current provider produces dim=${expectedDim}${currentLabel}. ` +
|
|
52
|
+
`Run 'bun run migrate-embeddings' to re-embed all memories.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
// Update provider label if it changed but dim is the same
|
|
56
|
+
if (providerLabel && providerLabel !== storedProvider) {
|
|
57
|
+
await this.client.set("betterdb:meta:embedProvider", providerLabel);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async ensureIndex(embedDim: number, providerLabel?: string): Promise<void> {
|
|
62
|
+
await this.assertEmbedDim(embedDim, providerLabel);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await this.client.call(
|
|
66
|
+
"FT.CREATE",
|
|
67
|
+
config.valkey.indexName,
|
|
68
|
+
"ON",
|
|
69
|
+
"HASH",
|
|
70
|
+
"PREFIX",
|
|
71
|
+
"1",
|
|
72
|
+
"betterdb:memory:",
|
|
73
|
+
"SCHEMA",
|
|
74
|
+
"embedding",
|
|
75
|
+
"VECTOR",
|
|
76
|
+
"HNSW",
|
|
77
|
+
"6",
|
|
78
|
+
"TYPE",
|
|
79
|
+
"FLOAT32",
|
|
80
|
+
"DIM",
|
|
81
|
+
String(embedDim),
|
|
82
|
+
"DISTANCE_METRIC",
|
|
83
|
+
"COSINE",
|
|
84
|
+
"project",
|
|
85
|
+
"TAG",
|
|
86
|
+
"branch",
|
|
87
|
+
"TAG",
|
|
88
|
+
"oneLineSummary",
|
|
89
|
+
"TAG",
|
|
90
|
+
"importanceScore",
|
|
91
|
+
"NUMERIC",
|
|
92
|
+
"timestamp",
|
|
93
|
+
"NUMERIC",
|
|
94
|
+
"accessCount",
|
|
95
|
+
"NUMERIC",
|
|
96
|
+
);
|
|
97
|
+
} catch (err: unknown) {
|
|
98
|
+
const message = err instanceof Error
|
|
99
|
+
? err.message
|
|
100
|
+
: String(err);
|
|
101
|
+
if (message.includes("already exists")) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async dropIndex(): Promise<void> {
|
|
109
|
+
try {
|
|
110
|
+
await this.client.call("FT.DROPINDEX", config.valkey.indexName);
|
|
111
|
+
} catch {
|
|
112
|
+
// Index may not exist
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Memory CRUD ---
|
|
117
|
+
|
|
118
|
+
async storeMemory(
|
|
119
|
+
memory: EpisodicMemory,
|
|
120
|
+
embedding: number[],
|
|
121
|
+
): Promise<string> {
|
|
122
|
+
const key = `betterdb:memory:${memory.memoryId}`;
|
|
123
|
+
const timestampNum = new Date(memory.timestamp).getTime();
|
|
124
|
+
|
|
125
|
+
await this.client.hset(key, {
|
|
126
|
+
memoryId: memory.memoryId,
|
|
127
|
+
project: memory.project,
|
|
128
|
+
branch: memory.branch,
|
|
129
|
+
timestamp: String(timestampNum),
|
|
130
|
+
summary: JSON.stringify(memory.summary),
|
|
131
|
+
oneLineSummary: memory.summary.oneLineSummary,
|
|
132
|
+
importanceScore: String(memory.importanceScore),
|
|
133
|
+
accessCount: String(memory.accessCount),
|
|
134
|
+
lastAccessed: memory.lastAccessed,
|
|
135
|
+
embedding: embeddingToBuffer(embedding),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return memory.memoryId;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async getMemory(memoryId: string): Promise<EpisodicMemory | null> {
|
|
142
|
+
const key = `betterdb:memory:${memoryId}`;
|
|
143
|
+
const data = await this.client.hgetall(key);
|
|
144
|
+
|
|
145
|
+
if (!data || Object.keys(data).length === 0) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return this.parseMemoryHash(data);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async getMemoryEmbedding(memoryId: string): Promise<number[] | null> {
|
|
153
|
+
const key = `betterdb:memory:${memoryId}`;
|
|
154
|
+
const raw = await this.client.hgetBuffer(key, "embedding");
|
|
155
|
+
if (!raw) return null;
|
|
156
|
+
return bufferToEmbedding(raw);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async updateImportance(memoryId: string, score: number): Promise<void> {
|
|
160
|
+
const key = `betterdb:memory:${memoryId}`;
|
|
161
|
+
await this.client.hset(key, "importanceScore", String(score));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async incrementAccess(memoryId: string): Promise<void> {
|
|
165
|
+
const key = `betterdb:memory:${memoryId}`;
|
|
166
|
+
await this.client.hincrby(key, "accessCount", 1);
|
|
167
|
+
await this.client.hset(key, "lastAccessed", new Date().toISOString());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async deleteMemory(memoryId: string): Promise<void> {
|
|
171
|
+
const key = `betterdb:memory:${memoryId}`;
|
|
172
|
+
await this.client.del(key);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async listMemoryIds(
|
|
176
|
+
project?: string,
|
|
177
|
+
minImportance?: number,
|
|
178
|
+
): Promise<string[]> {
|
|
179
|
+
const ids: string[] = [];
|
|
180
|
+
let cursor = "0";
|
|
181
|
+
|
|
182
|
+
do {
|
|
183
|
+
const [nextCursor, keys] = await this.client.scan(
|
|
184
|
+
cursor,
|
|
185
|
+
"MATCH",
|
|
186
|
+
"betterdb:memory:*",
|
|
187
|
+
"COUNT",
|
|
188
|
+
"100",
|
|
189
|
+
);
|
|
190
|
+
cursor = nextCursor;
|
|
191
|
+
|
|
192
|
+
for (const key of keys) {
|
|
193
|
+
// Skip keys that don't look like memory content hashes
|
|
194
|
+
const memoryId = key.replace("betterdb:memory:", "");
|
|
195
|
+
if (!memoryId || memoryId.includes(":")) continue;
|
|
196
|
+
|
|
197
|
+
if (project || minImportance !== undefined) {
|
|
198
|
+
const data = await this.client.hmget(
|
|
199
|
+
key,
|
|
200
|
+
"project",
|
|
201
|
+
"importanceScore",
|
|
202
|
+
);
|
|
203
|
+
const [proj, importance] = data;
|
|
204
|
+
|
|
205
|
+
if (project && proj !== project) continue;
|
|
206
|
+
if (
|
|
207
|
+
minImportance !== undefined &&
|
|
208
|
+
Number(importance ?? 0) < minImportance
|
|
209
|
+
) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
ids.push(memoryId);
|
|
215
|
+
}
|
|
216
|
+
} while (cursor !== "0");
|
|
217
|
+
|
|
218
|
+
return ids;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- Search ---
|
|
222
|
+
|
|
223
|
+
async searchMemories(
|
|
224
|
+
embedding: number[],
|
|
225
|
+
project: string,
|
|
226
|
+
topK: number,
|
|
227
|
+
): Promise<EpisodicMemory[]> {
|
|
228
|
+
const buf = embeddingToBuffer(embedding);
|
|
229
|
+
|
|
230
|
+
const result = (await this.client.call(
|
|
231
|
+
"FT.SEARCH",
|
|
232
|
+
config.valkey.indexName,
|
|
233
|
+
`@project:{${project.replace(/[^a-zA-Z0-9_-]/g, "\\$&")}}=>[KNN ${topK} @embedding $vec AS score]`,
|
|
234
|
+
"PARAMS",
|
|
235
|
+
"2",
|
|
236
|
+
"vec",
|
|
237
|
+
buf,
|
|
238
|
+
"DIALECT",
|
|
239
|
+
"2",
|
|
240
|
+
)) as unknown[];
|
|
241
|
+
|
|
242
|
+
return this.parseSearchResults(result);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- Knowledge ---
|
|
246
|
+
|
|
247
|
+
async storeKnowledge(entry: KnowledgeEntry): Promise<void> {
|
|
248
|
+
const key = `betterdb:knowledge:${entry.project}:${entry.topic}`;
|
|
249
|
+
await this.client.hset(key, {
|
|
250
|
+
entryId: entry.entryId,
|
|
251
|
+
project: entry.project,
|
|
252
|
+
topic: entry.topic,
|
|
253
|
+
fact: entry.fact,
|
|
254
|
+
confidence: String(entry.confidence),
|
|
255
|
+
sourceMemoryIds: JSON.stringify(entry.sourceMemoryIds),
|
|
256
|
+
lastUpdated: entry.lastUpdated,
|
|
257
|
+
accessCount: String(entry.accessCount),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await this.client.zadd(
|
|
261
|
+
`betterdb:knowledge:${entry.project}:index`,
|
|
262
|
+
Date.now(),
|
|
263
|
+
entry.topic,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async listKnowledge(project: string): Promise<KnowledgeEntry[]> {
|
|
268
|
+
const topics = await this.client.zrevrange(
|
|
269
|
+
`betterdb:knowledge:${project}:index`,
|
|
270
|
+
0,
|
|
271
|
+
-1,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const entries: KnowledgeEntry[] = [];
|
|
275
|
+
for (const topic of topics) {
|
|
276
|
+
const key = `betterdb:knowledge:${project}:${topic}`;
|
|
277
|
+
const data = await this.client.hgetall(key);
|
|
278
|
+
if (!data || Object.keys(data).length === 0) continue;
|
|
279
|
+
|
|
280
|
+
const parsed = KnowledgeEntrySchema.safeParse({
|
|
281
|
+
...data,
|
|
282
|
+
confidence: Number(data["confidence"] ?? 0),
|
|
283
|
+
accessCount: Number(data["accessCount"] ?? 0),
|
|
284
|
+
sourceMemoryIds: JSON.parse(data["sourceMemoryIds"] ?? "[]"),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (parsed.success) {
|
|
288
|
+
entries.push(parsed.data);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return entries;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// --- Queues ---
|
|
296
|
+
|
|
297
|
+
async pushCompressQueue(memoryId: string): Promise<void> {
|
|
298
|
+
await this.client.rpush("betterdb:compress_queue", memoryId);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async popCompressQueue(count: number): Promise<string[]> {
|
|
302
|
+
const items: string[] = [];
|
|
303
|
+
for (let i = 0; i < count; i++) {
|
|
304
|
+
const item = await this.client.lpop("betterdb:compress_queue");
|
|
305
|
+
if (!item) break;
|
|
306
|
+
items.push(item);
|
|
307
|
+
}
|
|
308
|
+
return items;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async pushIngestQueue(
|
|
312
|
+
rawTranscript: string,
|
|
313
|
+
sessionMeta: object,
|
|
314
|
+
): Promise<void> {
|
|
315
|
+
await this.client.rpush(
|
|
316
|
+
"betterdb:ingest_queue",
|
|
317
|
+
JSON.stringify({ transcript: rawTranscript, meta: sessionMeta }),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async popIngestQueue(
|
|
322
|
+
count: number,
|
|
323
|
+
): Promise<Array<{ transcript: string; meta: Record<string, unknown> }>> {
|
|
324
|
+
const items: Array<{
|
|
325
|
+
transcript: string;
|
|
326
|
+
meta: Record<string, unknown>;
|
|
327
|
+
}> = [];
|
|
328
|
+
for (let i = 0; i < count; i++) {
|
|
329
|
+
const raw = await this.client.lpop("betterdb:ingest_queue");
|
|
330
|
+
if (!raw) break;
|
|
331
|
+
items.push(JSON.parse(raw));
|
|
332
|
+
}
|
|
333
|
+
return items;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// --- Aging Metadata ---
|
|
337
|
+
|
|
338
|
+
async getLastAgingRun(): Promise<Date | null> {
|
|
339
|
+
const stored = await this.client.get("betterdb:meta:lastAgingRun");
|
|
340
|
+
if (!stored) return null;
|
|
341
|
+
return new Date(stored);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async setLastAgingRun(timestamp: Date): Promise<void> {
|
|
345
|
+
await this.client.set(
|
|
346
|
+
"betterdb:meta:lastAgingRun",
|
|
347
|
+
timestamp.toISOString(),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// --- Lifecycle ---
|
|
352
|
+
|
|
353
|
+
async quit(): Promise<void> {
|
|
354
|
+
await this.client.quit();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// --- Internal Helpers ---
|
|
358
|
+
|
|
359
|
+
private parseMemoryHash(
|
|
360
|
+
data: Record<string, string>,
|
|
361
|
+
): EpisodicMemory | null {
|
|
362
|
+
try {
|
|
363
|
+
const timestamp = data["timestamp"];
|
|
364
|
+
const parsed = EpisodicMemorySchema.safeParse({
|
|
365
|
+
memoryId: data["memoryId"],
|
|
366
|
+
project: data["project"],
|
|
367
|
+
branch: data["branch"],
|
|
368
|
+
timestamp: timestamp
|
|
369
|
+
? new Date(Number(timestamp)).toISOString()
|
|
370
|
+
: new Date().toISOString(),
|
|
371
|
+
summary: JSON.parse(data["summary"] ?? "{}"),
|
|
372
|
+
importanceScore: Number(data["importanceScore"] ?? 0),
|
|
373
|
+
accessCount: Number(data["accessCount"] ?? 0),
|
|
374
|
+
lastAccessed:
|
|
375
|
+
data["lastAccessed"] ?? new Date().toISOString(),
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
return parsed.success ? parsed.data : null;
|
|
379
|
+
} catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private parseSearchResults(result: unknown[]): EpisodicMemory[] {
|
|
385
|
+
if (!Array.isArray(result) || result.length < 1) return [];
|
|
386
|
+
|
|
387
|
+
const totalCount = result[0] as number;
|
|
388
|
+
if (totalCount === 0) return [];
|
|
389
|
+
|
|
390
|
+
const memories: EpisodicMemory[] = [];
|
|
391
|
+
|
|
392
|
+
// FT.SEARCH returns: [count, key1, [field1, val1, ...], key2, ...]
|
|
393
|
+
for (let i = 1; i < result.length; i += 2) {
|
|
394
|
+
const fields = result[i + 1] as string[] | undefined;
|
|
395
|
+
if (!Array.isArray(fields)) continue;
|
|
396
|
+
|
|
397
|
+
const data: Record<string, string> = {};
|
|
398
|
+
for (let j = 0; j < fields.length; j += 2) {
|
|
399
|
+
const key = fields[j];
|
|
400
|
+
const val = fields[j + 1];
|
|
401
|
+
if (typeof key === "string" && typeof val === "string") {
|
|
402
|
+
data[key] = val;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const memory = this.parseMemoryHash(data);
|
|
407
|
+
if (memory) {
|
|
408
|
+
memories.push(memory);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return memories;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// --- Singleton Factory ---
|
|
417
|
+
|
|
418
|
+
let clientInstance: ValkeyClient | null = null;
|
|
419
|
+
|
|
420
|
+
export async function getValkeyClient(): Promise<ValkeyClient> {
|
|
421
|
+
if (clientInstance) return clientInstance;
|
|
422
|
+
|
|
423
|
+
const retryDelays = [100, 500, 2000];
|
|
424
|
+
let lastError: Error | null = null;
|
|
425
|
+
|
|
426
|
+
for (const delay of retryDelays) {
|
|
427
|
+
try {
|
|
428
|
+
const redis = new Redis(config.valkey.url, {
|
|
429
|
+
maxRetriesPerRequest: 3,
|
|
430
|
+
lazyConnect: true,
|
|
431
|
+
});
|
|
432
|
+
await redis.connect();
|
|
433
|
+
clientInstance = new ValkeyClient(redis);
|
|
434
|
+
return clientInstance;
|
|
435
|
+
} catch (err) {
|
|
436
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
437
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
throw new Error(
|
|
442
|
+
`Failed to connect to Valkey at ${config.valkey.url} after 3 attempts: ${lastError?.message}`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function resetValkeyClient(): void {
|
|
447
|
+
clientInstance = null;
|
|
448
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Load saved config from ~/.betterdb/memory.json as fallback for env vars.
|
|
6
|
+
* This allows compiled binaries (hooks, MCP server) to work without
|
|
7
|
+
* requiring env vars to be set — config is saved during `install`.
|
|
8
|
+
*/
|
|
9
|
+
const _fileConfig: Record<string, string> = (() => {
|
|
10
|
+
const home = process.env["HOME"] ?? process.env["USERPROFILE"] ?? "";
|
|
11
|
+
const p = join(home, ".betterdb", "memory.json");
|
|
12
|
+
if (!existsSync(p)) return {};
|
|
13
|
+
try {
|
|
14
|
+
const data: unknown = JSON.parse(readFileSync(p, "utf-8"));
|
|
15
|
+
if (typeof data !== "object" || data === null) return {};
|
|
16
|
+
const result: Record<string, string> = {};
|
|
17
|
+
for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
|
|
18
|
+
if (typeof v === "string") result[k] = v;
|
|
19
|
+
else if (typeof v === "number") result[k] = String(v);
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
} catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
})();
|
|
26
|
+
|
|
27
|
+
/** Read a config value: env var takes priority, then ~/.betterdb/memory.json. */
|
|
28
|
+
function env(key: string): string | undefined {
|
|
29
|
+
return Bun.env[key] ?? _fileConfig[key];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const config = {
|
|
33
|
+
valkey: {
|
|
34
|
+
url: env("BETTERDB_VALKEY_URL") ?? "redis://localhost:6379",
|
|
35
|
+
indexName: env("BETTERDB_VALKEY_INDEX_NAME") ?? "betterdb-memory-index",
|
|
36
|
+
},
|
|
37
|
+
ollama: {
|
|
38
|
+
url: env("BETTERDB_OLLAMA_URL") ?? "http://localhost:11434",
|
|
39
|
+
embedModel: env("BETTERDB_EMBED_MODEL") ?? "mxbai-embed-large",
|
|
40
|
+
summarizeModel: env("BETTERDB_SUMMARIZE_MODEL") ?? "mistral:7b",
|
|
41
|
+
embedDim: Number(env("BETTERDB_EMBED_DIM") ?? 1024),
|
|
42
|
+
},
|
|
43
|
+
memory: {
|
|
44
|
+
maxContextMemories: Number(env("BETTERDB_MAX_CONTEXT_MEMORIES") ?? 5),
|
|
45
|
+
decayRate: Number(env("BETTERDB_DECAY_RATE") ?? 0.95),
|
|
46
|
+
compressThreshold: Number(env("BETTERDB_COMPRESS_THRESHOLD") ?? 0.3),
|
|
47
|
+
distillMinSessions: Number(env("BETTERDB_DISTILL_MIN_SESSIONS") ?? 5),
|
|
48
|
+
contextFile: env("BETTERDB_CONTEXT_FILE") ?? ".betterdb_context.md",
|
|
49
|
+
agingIntervalHours: Number(env("BETTERDB_AGING_INTERVAL_HOURS") ?? 6),
|
|
50
|
+
},
|
|
51
|
+
allowRemoteFallback: env("BETTERDB_ALLOW_REMOTE_FALLBACK") !== "false",
|
|
52
|
+
providers: {
|
|
53
|
+
embedProvider: env("BETTERDB_EMBED_PROVIDER") as
|
|
54
|
+
| "ollama" | "openai" | "voyage" | "groq" | "together"
|
|
55
|
+
| undefined,
|
|
56
|
+
summarizeProvider: env("BETTERDB_SUMMARIZE_PROVIDER") as
|
|
57
|
+
| "ollama" | "openai" | "anthropic" | "groq" | "together"
|
|
58
|
+
| undefined,
|
|
59
|
+
openaiKey: env("OPENAI_API_KEY"),
|
|
60
|
+
anthropicKey: env("ANTHROPIC_API_KEY"),
|
|
61
|
+
voyageKey: env("VOYAGE_API_KEY"),
|
|
62
|
+
groqKey: env("GROQ_API_KEY"),
|
|
63
|
+
togetherKey: env("TOGETHER_API_KEY"),
|
|
64
|
+
},
|
|
65
|
+
} as const;
|
|
66
|
+
|
|
67
|
+
export type Config = typeof config;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { HookPayload } from "../memory/schema.js";
|
|
2
|
+
import { HookPayloadSchema } from "../memory/schema.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read and parse hook payload from stdin.
|
|
6
|
+
* Claude Code sends JSON on stdin for command hooks.
|
|
7
|
+
*/
|
|
8
|
+
export async function readPayload(): Promise<HookPayload> {
|
|
9
|
+
const chunks: Buffer[] = [];
|
|
10
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
11
|
+
chunks.push(Buffer.from(chunk));
|
|
12
|
+
}
|
|
13
|
+
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
|
14
|
+
if (!raw) {
|
|
15
|
+
throw new Error("No payload received on stdin");
|
|
16
|
+
}
|
|
17
|
+
const json = JSON.parse(raw);
|
|
18
|
+
return HookPayloadSchema.parse(json);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read raw JSON from stdin without schema validation.
|
|
23
|
+
* Used when the hook needs partial fields from a payload
|
|
24
|
+
* that may not match the discriminated union.
|
|
25
|
+
*/
|
|
26
|
+
export async function readRawPayload(): Promise<Record<string, unknown>> {
|
|
27
|
+
const chunks: Buffer[] = [];
|
|
28
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
29
|
+
chunks.push(Buffer.from(chunk));
|
|
30
|
+
}
|
|
31
|
+
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
|
32
|
+
if (!raw) {
|
|
33
|
+
throw new Error("No payload received on stdin");
|
|
34
|
+
}
|
|
35
|
+
return JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Safe wrapper for hook execution.
|
|
40
|
+
* Catches all errors → stderr. Always exits 0 to never crash Claude Code.
|
|
41
|
+
*/
|
|
42
|
+
export async function runHook(fn: () => Promise<void>): Promise<void> {
|
|
43
|
+
try {
|
|
44
|
+
await fn();
|
|
45
|
+
process.exit(0);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error(
|
|
48
|
+
"[betterdb]",
|
|
49
|
+
err instanceof Error ? err.message : String(err),
|
|
50
|
+
);
|
|
51
|
+
process.exit(0); // Always exit 0 — never block Claude Code
|
|
52
|
+
}
|
|
53
|
+
}
|