@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.
@@ -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 };