@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,281 @@
|
|
|
1
|
+
import { Ollama } from "ollama";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import type { SessionSummary } from "../memory/schema.js";
|
|
4
|
+
|
|
5
|
+
// --- Model Presets ---
|
|
6
|
+
|
|
7
|
+
export interface ModelPreset {
|
|
8
|
+
embedModel: string;
|
|
9
|
+
summarizeModel: string;
|
|
10
|
+
embedDim: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const PRESET_CLEAN: ModelPreset = {
|
|
14
|
+
embedModel: "mxbai-embed-large",
|
|
15
|
+
summarizeModel: "mistral:7b",
|
|
16
|
+
embedDim: 1024,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const PRESET_ATTRIBUTION: ModelPreset = {
|
|
20
|
+
embedModel: "nomic-embed-text",
|
|
21
|
+
summarizeModel: "qwen2.5:7b",
|
|
22
|
+
embedDim: 768,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const PRESET_LIGHTWEIGHT: ModelPreset = {
|
|
26
|
+
embedModel: "all-minilm",
|
|
27
|
+
summarizeModel: "qwen2.5:3b",
|
|
28
|
+
embedDim: 384,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// --- ModelClient Interface ---
|
|
32
|
+
|
|
33
|
+
export interface ModelClient {
|
|
34
|
+
embed(text: string): Promise<number[]>;
|
|
35
|
+
summarize(transcript: string): Promise<SessionSummary>;
|
|
36
|
+
readonly embedDim: number;
|
|
37
|
+
readonly preset: ModelPreset;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- Composite Model Client ---
|
|
41
|
+
|
|
42
|
+
export class CompositeModelClient implements ModelClient {
|
|
43
|
+
constructor(
|
|
44
|
+
private readonly embedClient: ModelClient,
|
|
45
|
+
private readonly summarizeClient: ModelClient,
|
|
46
|
+
) {}
|
|
47
|
+
|
|
48
|
+
embed(text: string): Promise<number[]> {
|
|
49
|
+
return this.embedClient.embed(text);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
summarize(transcript: string): Promise<SessionSummary> {
|
|
53
|
+
return this.summarizeClient.summarize(transcript);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get embedDim(): number {
|
|
57
|
+
return this.embedClient.embedDim;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get preset(): ModelPreset {
|
|
61
|
+
return {
|
|
62
|
+
embedModel: this.embedClient.preset.embedModel,
|
|
63
|
+
summarizeModel: this.summarizeClient.preset.summarizeModel,
|
|
64
|
+
embedDim: this.embedClient.embedDim,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Re-exports ---
|
|
70
|
+
|
|
71
|
+
export { OllamaModelClient } from "./providers/ollama.js";
|
|
72
|
+
export { OpenAIEmbedClient, OpenAISummarizeClient } from "./providers/openai.js";
|
|
73
|
+
export { AnthropicSummarizeClient } from "./providers/anthropic.js";
|
|
74
|
+
export { VoyageEmbedClient } from "./providers/voyage.js";
|
|
75
|
+
export { GroqEmbedClient, GroqSummarizeClient } from "./providers/groq.js";
|
|
76
|
+
export { TogetherEmbedClient, TogetherSummarizeClient } from "./providers/together.js";
|
|
77
|
+
export { buildSummarizePrompt } from "./providers/_prompt.js";
|
|
78
|
+
|
|
79
|
+
// --- Provider Detection ---
|
|
80
|
+
|
|
81
|
+
async function detectOllamaModels(): Promise<Set<string>> {
|
|
82
|
+
try {
|
|
83
|
+
const ollama = new Ollama({ host: config.ollama.url });
|
|
84
|
+
const listResponse = await ollama.list();
|
|
85
|
+
return new Set(listResponse.models.map((m) => m.name.split(":")[0]!));
|
|
86
|
+
} catch {
|
|
87
|
+
return new Set();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- Factory ---
|
|
92
|
+
|
|
93
|
+
export async function createModelClient(): Promise<ModelClient> {
|
|
94
|
+
const p = config.providers;
|
|
95
|
+
|
|
96
|
+
const embedClient = await resolveEmbedProvider(p);
|
|
97
|
+
const summarizeClient = await resolveSummarizeProvider(p);
|
|
98
|
+
|
|
99
|
+
console.error(
|
|
100
|
+
`[betterdb] embed=${embedClient.preset.embedModel} summarize=${summarizeClient.preset.summarizeModel}`,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return new CompositeModelClient(embedClient, summarizeClient);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function resolveEmbedProvider(
|
|
107
|
+
p: typeof config.providers,
|
|
108
|
+
): Promise<ModelClient> {
|
|
109
|
+
// Explicit override
|
|
110
|
+
if (p.embedProvider) {
|
|
111
|
+
return createExplicitEmbedProvider(p.embedProvider, p);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Auto-detect: Ollama first
|
|
115
|
+
const ollamaModels = await detectOllamaModels();
|
|
116
|
+
const presets = [PRESET_CLEAN, PRESET_ATTRIBUTION, PRESET_LIGHTWEIGHT];
|
|
117
|
+
for (const preset of presets) {
|
|
118
|
+
const base = preset.embedModel.split(":")[0]!;
|
|
119
|
+
if (ollamaModels.has(base)) {
|
|
120
|
+
const { OllamaModelClient } = await import("./providers/ollama.js");
|
|
121
|
+
return new OllamaModelClient(preset, config.ollama.url);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Voyage
|
|
126
|
+
if (p.voyageKey) {
|
|
127
|
+
const { VoyageEmbedClient } = await import("./providers/voyage.js");
|
|
128
|
+
return new VoyageEmbedClient(p.voyageKey);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// OpenAI
|
|
132
|
+
if (p.openaiKey) {
|
|
133
|
+
const { OpenAIEmbedClient } = await import("./providers/openai.js");
|
|
134
|
+
return new OpenAIEmbedClient(p.openaiKey);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Groq
|
|
138
|
+
if (p.groqKey) {
|
|
139
|
+
const { GroqEmbedClient } = await import("./providers/groq.js");
|
|
140
|
+
return new GroqEmbedClient(p.groqKey);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Together
|
|
144
|
+
if (p.togetherKey) {
|
|
145
|
+
const { TogetherEmbedClient } = await import("./providers/together.js");
|
|
146
|
+
return new TogetherEmbedClient(p.togetherKey);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
throw new Error(
|
|
150
|
+
`No embedding provider available. Options:\n` +
|
|
151
|
+
` 1. Install Ollama and run: ollama pull mxbai-embed-large\n` +
|
|
152
|
+
` 2. Set VOYAGE_API_KEY for Voyage AI (voyage-3, dim=1024)\n` +
|
|
153
|
+
` 3. Set OPENAI_API_KEY for OpenAI (text-embedding-3-small, dim=1536)\n` +
|
|
154
|
+
` 4. Set GROQ_API_KEY for Groq (nomic-embed-text-v1_5, dim=768)\n` +
|
|
155
|
+
` 5. Set TOGETHER_API_KEY for Together AI (m2-bert-80M-8k-retrieval, dim=768)\n\n` +
|
|
156
|
+
`Note: ANTHROPIC_API_KEY does not provide embeddings — pair it with another embed provider.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function resolveSummarizeProvider(
|
|
161
|
+
p: typeof config.providers,
|
|
162
|
+
): Promise<ModelClient> {
|
|
163
|
+
// Explicit override
|
|
164
|
+
if (p.summarizeProvider) {
|
|
165
|
+
return createExplicitSummarizeProvider(p.summarizeProvider, p);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Auto-detect: Ollama first
|
|
169
|
+
const ollamaModels = await detectOllamaModels();
|
|
170
|
+
const presets = [PRESET_CLEAN, PRESET_ATTRIBUTION, PRESET_LIGHTWEIGHT];
|
|
171
|
+
for (const preset of presets) {
|
|
172
|
+
const base = preset.summarizeModel.split(":")[0]!;
|
|
173
|
+
if (ollamaModels.has(base)) {
|
|
174
|
+
const { OllamaModelClient } = await import("./providers/ollama.js");
|
|
175
|
+
return new OllamaModelClient(preset, config.ollama.url);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Anthropic
|
|
180
|
+
if (p.anthropicKey) {
|
|
181
|
+
const { AnthropicSummarizeClient } = await import("./providers/anthropic.js");
|
|
182
|
+
return new AnthropicSummarizeClient(p.anthropicKey);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// OpenAI
|
|
186
|
+
if (p.openaiKey) {
|
|
187
|
+
const { OpenAISummarizeClient } = await import("./providers/openai.js");
|
|
188
|
+
return new OpenAISummarizeClient(p.openaiKey);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Groq
|
|
192
|
+
if (p.groqKey) {
|
|
193
|
+
const { GroqSummarizeClient } = await import("./providers/groq.js");
|
|
194
|
+
return new GroqSummarizeClient(p.groqKey);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Together
|
|
198
|
+
if (p.togetherKey) {
|
|
199
|
+
const { TogetherSummarizeClient } = await import("./providers/together.js");
|
|
200
|
+
return new TogetherSummarizeClient(p.togetherKey);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
throw new Error(
|
|
204
|
+
`No summarization provider available. Options:\n` +
|
|
205
|
+
` 1. Install Ollama and run: ollama pull mistral:7b\n` +
|
|
206
|
+
` 2. Set ANTHROPIC_API_KEY for Anthropic (claude-haiku-4-5)\n` +
|
|
207
|
+
` 3. Set OPENAI_API_KEY for OpenAI (gpt-4o-mini)\n` +
|
|
208
|
+
` 4. Set GROQ_API_KEY for Groq (llama-3.1-8b-instant)\n` +
|
|
209
|
+
` 5. Set TOGETHER_API_KEY for Together AI (Meta-Llama-3.1-8B-Instruct-Turbo)`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- Explicit Provider Constructors ---
|
|
214
|
+
|
|
215
|
+
function createExplicitEmbedProvider(
|
|
216
|
+
name: string,
|
|
217
|
+
p: typeof config.providers,
|
|
218
|
+
): ModelClient {
|
|
219
|
+
switch (name) {
|
|
220
|
+
case "ollama": {
|
|
221
|
+
const { OllamaModelClient } = require("./providers/ollama.js");
|
|
222
|
+
return new OllamaModelClient(PRESET_CLEAN, config.ollama.url);
|
|
223
|
+
}
|
|
224
|
+
case "openai": {
|
|
225
|
+
if (!p.openaiKey) throw new Error("BETTERDB_EMBED_PROVIDER=openai but OPENAI_API_KEY is not set");
|
|
226
|
+
const { OpenAIEmbedClient } = require("./providers/openai.js");
|
|
227
|
+
return new OpenAIEmbedClient(p.openaiKey);
|
|
228
|
+
}
|
|
229
|
+
case "voyage": {
|
|
230
|
+
if (!p.voyageKey) throw new Error("BETTERDB_EMBED_PROVIDER=voyage but VOYAGE_API_KEY is not set");
|
|
231
|
+
const { VoyageEmbedClient } = require("./providers/voyage.js");
|
|
232
|
+
return new VoyageEmbedClient(p.voyageKey);
|
|
233
|
+
}
|
|
234
|
+
case "groq": {
|
|
235
|
+
if (!p.groqKey) throw new Error("BETTERDB_EMBED_PROVIDER=groq but GROQ_API_KEY is not set");
|
|
236
|
+
const { GroqEmbedClient } = require("./providers/groq.js");
|
|
237
|
+
return new GroqEmbedClient(p.groqKey);
|
|
238
|
+
}
|
|
239
|
+
case "together": {
|
|
240
|
+
if (!p.togetherKey) throw new Error("BETTERDB_EMBED_PROVIDER=together but TOGETHER_API_KEY is not set");
|
|
241
|
+
const { TogetherEmbedClient } = require("./providers/together.js");
|
|
242
|
+
return new TogetherEmbedClient(p.togetherKey);
|
|
243
|
+
}
|
|
244
|
+
default:
|
|
245
|
+
throw new Error(`Unknown embed provider: ${name}. Valid: ollama, openai, voyage, groq, together`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function createExplicitSummarizeProvider(
|
|
250
|
+
name: string,
|
|
251
|
+
p: typeof config.providers,
|
|
252
|
+
): ModelClient {
|
|
253
|
+
switch (name) {
|
|
254
|
+
case "ollama": {
|
|
255
|
+
const { OllamaModelClient } = require("./providers/ollama.js");
|
|
256
|
+
return new OllamaModelClient(PRESET_CLEAN, config.ollama.url);
|
|
257
|
+
}
|
|
258
|
+
case "openai": {
|
|
259
|
+
if (!p.openaiKey) throw new Error("BETTERDB_SUMMARIZE_PROVIDER=openai but OPENAI_API_KEY is not set");
|
|
260
|
+
const { OpenAISummarizeClient } = require("./providers/openai.js");
|
|
261
|
+
return new OpenAISummarizeClient(p.openaiKey);
|
|
262
|
+
}
|
|
263
|
+
case "anthropic": {
|
|
264
|
+
if (!p.anthropicKey) throw new Error("BETTERDB_SUMMARIZE_PROVIDER=anthropic but ANTHROPIC_API_KEY is not set");
|
|
265
|
+
const { AnthropicSummarizeClient } = require("./providers/anthropic.js");
|
|
266
|
+
return new AnthropicSummarizeClient(p.anthropicKey);
|
|
267
|
+
}
|
|
268
|
+
case "groq": {
|
|
269
|
+
if (!p.groqKey) throw new Error("BETTERDB_SUMMARIZE_PROVIDER=groq but GROQ_API_KEY is not set");
|
|
270
|
+
const { GroqSummarizeClient } = require("./providers/groq.js");
|
|
271
|
+
return new GroqSummarizeClient(p.groqKey);
|
|
272
|
+
}
|
|
273
|
+
case "together": {
|
|
274
|
+
if (!p.togetherKey) throw new Error("BETTERDB_SUMMARIZE_PROVIDER=together but TOGETHER_API_KEY is not set");
|
|
275
|
+
const { TogetherSummarizeClient } = require("./providers/together.js");
|
|
276
|
+
return new TogetherSummarizeClient(p.togetherKey);
|
|
277
|
+
}
|
|
278
|
+
default:
|
|
279
|
+
throw new Error(`Unknown summarize provider: ${name}. Valid: ollama, openai, anthropic, groq, together`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip markdown code fences that LLMs sometimes wrap around JSON output.
|
|
3
|
+
*/
|
|
4
|
+
export function stripCodeFences(text: string): string {
|
|
5
|
+
return text.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shared summarization prompt used by all providers.
|
|
10
|
+
* Ensures consistent structured data extraction regardless of which model runs it.
|
|
11
|
+
*/
|
|
12
|
+
export const buildSummarizePrompt = (transcript: string): string =>
|
|
13
|
+
`
|
|
14
|
+
You are extracting structured data from a Claude Code session transcript.
|
|
15
|
+
Return ONLY valid JSON matching this exact structure, with no explanation:
|
|
16
|
+
{
|
|
17
|
+
"decisions": [],
|
|
18
|
+
"patterns": [],
|
|
19
|
+
"problemsSolved": [],
|
|
20
|
+
"openThreads": [],
|
|
21
|
+
"filesChanged": [],
|
|
22
|
+
"oneLineSummary": ""
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Fields:
|
|
26
|
+
- decisions: max 10 specific technical decisions made
|
|
27
|
+
- patterns: max 5 reusable approaches or code patterns used
|
|
28
|
+
- problemsSolved: max 5 objects with "problem" and "resolution" keys
|
|
29
|
+
- openThreads: max 5 unresolved questions or TODOs discovered
|
|
30
|
+
- filesChanged: all file paths modified or created
|
|
31
|
+
- oneLineSummary: single sentence — what did this session accomplish?
|
|
32
|
+
|
|
33
|
+
Transcript:
|
|
34
|
+
${transcript}
|
|
35
|
+
`.trim();
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
|
|
5
|
+
interface AnthropicClient {
|
|
6
|
+
messages: {
|
|
7
|
+
create(params: {
|
|
8
|
+
model: string;
|
|
9
|
+
max_tokens: number;
|
|
10
|
+
messages: Array<{ role: string; content: string }>;
|
|
11
|
+
}): Promise<{
|
|
12
|
+
content: Array<{ type: string; text?: string }>;
|
|
13
|
+
}>;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class AnthropicSummarizeClient implements ModelClient {
|
|
18
|
+
private client: AnthropicClient | null = null;
|
|
19
|
+
private apiKey: string;
|
|
20
|
+
readonly embedDim = 0;
|
|
21
|
+
readonly preset: ModelPreset = {
|
|
22
|
+
embedModel: "n/a",
|
|
23
|
+
summarizeModel: "claude-haiku-4-5",
|
|
24
|
+
embedDim: 0,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
constructor(apiKey: string) {
|
|
28
|
+
this.apiKey = apiKey;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private async getClient(): Promise<AnthropicClient> {
|
|
32
|
+
if (!this.client) {
|
|
33
|
+
const { default: Anthropic } = await import("@anthropic-ai/sdk");
|
|
34
|
+
this.client = new Anthropic({ apiKey: this.apiKey }) as unknown as AnthropicClient;
|
|
35
|
+
}
|
|
36
|
+
return this.client;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async embed(_text: string): Promise<number[]> {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"Anthropic does not provide embeddings — configure a separate embed provider",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async summarize(transcript: string): Promise<SessionSummary> {
|
|
46
|
+
const client = await this.getClient();
|
|
47
|
+
const response = await client.messages.create({
|
|
48
|
+
model: "claude-haiku-4-5",
|
|
49
|
+
max_tokens: 2048,
|
|
50
|
+
messages: [
|
|
51
|
+
{ role: "user", content: buildSummarizePrompt(transcript) },
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const textBlock = response.content.find((b) => b.type === "text");
|
|
56
|
+
const content = textBlock?.text;
|
|
57
|
+
if (!content) {
|
|
58
|
+
console.error("[betterdb] Anthropic summarization returned empty response");
|
|
59
|
+
return SessionSummarySchema.parse({});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const parsed = SessionSummarySchema.safeParse(JSON.parse(stripCodeFences(content)));
|
|
63
|
+
if (!parsed.success) {
|
|
64
|
+
console.error("[betterdb] Failed to parse Anthropic summarization:", parsed.error.message);
|
|
65
|
+
return SessionSummarySchema.parse({});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return parsed.data;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
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 GroqEmbedClient implements ModelClient {
|
|
7
|
+
private client: OpenAIClient | null = null;
|
|
8
|
+
private apiKey: string;
|
|
9
|
+
readonly embedDim = 768;
|
|
10
|
+
readonly preset: ModelPreset = {
|
|
11
|
+
embedModel: "nomic-embed-text-v1_5",
|
|
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.groq.com/openai/v1");
|
|
23
|
+
}
|
|
24
|
+
return this.client;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async embed(text: string): Promise<number[]> {
|
|
28
|
+
const client = await this.getClient();
|
|
29
|
+
try {
|
|
30
|
+
const response = await client.embeddings.create({
|
|
31
|
+
model: "nomic-embed-text-v1_5",
|
|
32
|
+
input: text,
|
|
33
|
+
});
|
|
34
|
+
const first = response.data[0];
|
|
35
|
+
if (!first) {
|
|
36
|
+
throw new Error("Groq embed returned no embeddings");
|
|
37
|
+
}
|
|
38
|
+
return first.embedding;
|
|
39
|
+
} catch (err: unknown) {
|
|
40
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
41
|
+
if (message.includes("404") || message.includes("not found")) {
|
|
42
|
+
throw new Error("Groq embedding API unavailable — try a different embed provider");
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async summarize(_transcript: string): Promise<SessionSummary> {
|
|
49
|
+
throw new Error("GroqEmbedClient does not support summarization — use GroqSummarizeClient");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class GroqSummarizeClient implements ModelClient {
|
|
54
|
+
private client: OpenAIClient | null = null;
|
|
55
|
+
private apiKey: string;
|
|
56
|
+
readonly embedDim = 0;
|
|
57
|
+
readonly preset: ModelPreset = {
|
|
58
|
+
embedModel: "n/a",
|
|
59
|
+
summarizeModel: "llama-3.1-8b-instant",
|
|
60
|
+
embedDim: 0,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
constructor(apiKey: string) {
|
|
64
|
+
this.apiKey = apiKey;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async getClient(): Promise<OpenAIClient> {
|
|
68
|
+
if (!this.client) {
|
|
69
|
+
this.client = await createOpenAI(this.apiKey, "https://api.groq.com/openai/v1");
|
|
70
|
+
}
|
|
71
|
+
return this.client;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async embed(_text: string): Promise<number[]> {
|
|
75
|
+
throw new Error("GroqSummarizeClient does not support embedding — use GroqEmbedClient");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async summarize(transcript: string): Promise<SessionSummary> {
|
|
79
|
+
const client = await this.getClient();
|
|
80
|
+
const response = await client.chat.completions.create({
|
|
81
|
+
model: "llama-3.1-8b-instant",
|
|
82
|
+
messages: [
|
|
83
|
+
{ role: "user", content: buildSummarizePrompt(transcript) },
|
|
84
|
+
],
|
|
85
|
+
response_format: { type: "json_object" },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const content = response.choices[0]?.message.content;
|
|
89
|
+
if (!content) {
|
|
90
|
+
console.error("[betterdb] Groq summarization returned empty response");
|
|
91
|
+
return SessionSummarySchema.parse({});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const parsed = SessionSummarySchema.safeParse(JSON.parse(stripCodeFences(content)));
|
|
95
|
+
if (!parsed.success) {
|
|
96
|
+
console.error("[betterdb] Failed to parse Groq summarization:", parsed.error.message);
|
|
97
|
+
return SessionSummarySchema.parse({});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return parsed.data;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Ollama } from "ollama";
|
|
2
|
+
import { config } from "../../config.js";
|
|
3
|
+
import { SessionSummarySchema, type SessionSummary } from "../../memory/schema.js";
|
|
4
|
+
import type { ModelClient, ModelPreset } from "../model.js";
|
|
5
|
+
import { buildSummarizePrompt, stripCodeFences } from "./_prompt.js";
|
|
6
|
+
|
|
7
|
+
export class OllamaModelClient implements ModelClient {
|
|
8
|
+
private ollama: Ollama;
|
|
9
|
+
readonly preset: ModelPreset;
|
|
10
|
+
readonly embedDim: number;
|
|
11
|
+
|
|
12
|
+
constructor(preset: ModelPreset, ollamaUrl?: string) {
|
|
13
|
+
this.ollama = new Ollama({ host: ollamaUrl ?? config.ollama.url });
|
|
14
|
+
this.preset = preset;
|
|
15
|
+
this.embedDim = preset.embedDim;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async embed(text: string): Promise<number[]> {
|
|
19
|
+
const response = await this.ollama.embed({
|
|
20
|
+
model: this.preset.embedModel,
|
|
21
|
+
input: text,
|
|
22
|
+
});
|
|
23
|
+
const first = response.embeddings[0];
|
|
24
|
+
if (!first) {
|
|
25
|
+
throw new Error("Ollama embed returned no embeddings");
|
|
26
|
+
}
|
|
27
|
+
return first;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async summarize(transcript: string): Promise<SessionSummary> {
|
|
31
|
+
const response = await this.ollama.chat({
|
|
32
|
+
model: this.preset.summarizeModel,
|
|
33
|
+
messages: [
|
|
34
|
+
{ role: "user", content: buildSummarizePrompt(transcript) },
|
|
35
|
+
],
|
|
36
|
+
format: "json",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const parsed = SessionSummarySchema.safeParse(
|
|
40
|
+
JSON.parse(stripCodeFences(response.message.content)),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (!parsed.success) {
|
|
44
|
+
console.error(
|
|
45
|
+
"[betterdb] Failed to parse Ollama summarization response:",
|
|
46
|
+
parsed.error.message,
|
|
47
|
+
);
|
|
48
|
+
return SessionSummarySchema.parse({});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return parsed.data;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
|
|
5
|
+
interface OpenAIClient {
|
|
6
|
+
embeddings: {
|
|
7
|
+
create(params: {
|
|
8
|
+
model: string;
|
|
9
|
+
input: string;
|
|
10
|
+
}): Promise<{ data: Array<{ embedding: number[] }> }>;
|
|
11
|
+
};
|
|
12
|
+
chat: {
|
|
13
|
+
completions: {
|
|
14
|
+
create(params: {
|
|
15
|
+
model: string;
|
|
16
|
+
messages: Array<{ role: string; content: string }>;
|
|
17
|
+
response_format: { type: string };
|
|
18
|
+
}): Promise<{
|
|
19
|
+
choices: Array<{ message: { content: string | null } }>;
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function createOpenAI(apiKey: string, baseURL?: string): Promise<OpenAIClient> {
|
|
26
|
+
// @ts-expect-error — openai is an optional dependency, lazy-loaded
|
|
27
|
+
const { default: OpenAI } = await import("openai");
|
|
28
|
+
return new (OpenAI as new (opts: { apiKey: string; baseURL?: string }) => OpenAIClient)({
|
|
29
|
+
apiKey,
|
|
30
|
+
...(baseURL ? { baseURL } : {}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class OpenAIEmbedClient implements ModelClient {
|
|
35
|
+
private client: OpenAIClient | null = null;
|
|
36
|
+
private apiKey: string;
|
|
37
|
+
readonly embedDim = 1536;
|
|
38
|
+
readonly preset: ModelPreset = {
|
|
39
|
+
embedModel: "text-embedding-3-small",
|
|
40
|
+
summarizeModel: "n/a",
|
|
41
|
+
embedDim: 1536,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
constructor(apiKey: string) {
|
|
45
|
+
this.apiKey = apiKey;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async getClient(): Promise<OpenAIClient> {
|
|
49
|
+
if (!this.client) {
|
|
50
|
+
this.client = await createOpenAI(this.apiKey);
|
|
51
|
+
}
|
|
52
|
+
return this.client;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async embed(text: string): Promise<number[]> {
|
|
56
|
+
const client = await this.getClient();
|
|
57
|
+
const response = await client.embeddings.create({
|
|
58
|
+
model: "text-embedding-3-small",
|
|
59
|
+
input: text,
|
|
60
|
+
});
|
|
61
|
+
const first = response.data[0];
|
|
62
|
+
if (!first) {
|
|
63
|
+
throw new Error("OpenAI embed returned no embeddings");
|
|
64
|
+
}
|
|
65
|
+
return first.embedding;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async summarize(_transcript: string): Promise<SessionSummary> {
|
|
69
|
+
throw new Error("OpenAIEmbedClient does not support summarization — use OpenAISummarizeClient");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class OpenAISummarizeClient implements ModelClient {
|
|
74
|
+
private client: OpenAIClient | null = null;
|
|
75
|
+
private apiKey: string;
|
|
76
|
+
readonly embedDim = 0;
|
|
77
|
+
readonly preset: ModelPreset = {
|
|
78
|
+
embedModel: "n/a",
|
|
79
|
+
summarizeModel: "gpt-4o-mini",
|
|
80
|
+
embedDim: 0,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
constructor(apiKey: string) {
|
|
84
|
+
this.apiKey = apiKey;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async getClient(): Promise<OpenAIClient> {
|
|
88
|
+
if (!this.client) {
|
|
89
|
+
this.client = await createOpenAI(this.apiKey);
|
|
90
|
+
}
|
|
91
|
+
return this.client;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async embed(_text: string): Promise<number[]> {
|
|
95
|
+
throw new Error("OpenAISummarizeClient does not support embedding — use OpenAIEmbedClient");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async summarize(transcript: string): Promise<SessionSummary> {
|
|
99
|
+
const client = await this.getClient();
|
|
100
|
+
const response = await client.chat.completions.create({
|
|
101
|
+
model: "gpt-4o-mini",
|
|
102
|
+
messages: [
|
|
103
|
+
{ role: "user", content: buildSummarizePrompt(transcript) },
|
|
104
|
+
],
|
|
105
|
+
response_format: { type: "json_object" },
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const content = response.choices[0]?.message.content;
|
|
109
|
+
if (!content) {
|
|
110
|
+
console.error("[betterdb] OpenAI summarization returned empty response");
|
|
111
|
+
return SessionSummarySchema.parse({});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const parsed = SessionSummarySchema.safeParse(JSON.parse(stripCodeFences(content)));
|
|
115
|
+
if (!parsed.success) {
|
|
116
|
+
console.error("[betterdb] Failed to parse OpenAI summarization:", parsed.error.message);
|
|
117
|
+
return SessionSummarySchema.parse({});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return parsed.data;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Re-export the helper for Groq and Together providers
|
|
125
|
+
export { createOpenAI, type OpenAIClient };
|