@openpalm/lib 0.9.4

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,424 @@
1
+ /**
2
+ * Memory LLM & Embedding configuration management.
3
+ */
4
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
5
+ import { loadSecretsEnvFile } from "./secrets.js";
6
+ import {
7
+ LLM_PROVIDERS,
8
+ EMBEDDING_DIMS,
9
+ PROVIDER_DEFAULT_URLS,
10
+ } from "../provider-constants.js";
11
+
12
+ // Re-export shared constants for barrel compatibility
13
+ export { LLM_PROVIDERS, EMBEDDING_DIMS, PROVIDER_DEFAULT_URLS };
14
+
15
+ // ── Types ────────────────────────────────────────────────────────────────
16
+
17
+ export type MemoryConfig = {
18
+ mem0: {
19
+ llm: { provider: string; config: Record<string, unknown> };
20
+ embedder: { provider: string; config: Record<string, unknown> };
21
+ vector_store: {
22
+ provider: "sqlite-vec" | "qdrant";
23
+ config: {
24
+ collection_name: string;
25
+ db_path?: string;
26
+ path?: string;
27
+ embedding_model_dims: number;
28
+ };
29
+ };
30
+ };
31
+ memory: { custom_instructions: string };
32
+ };
33
+
34
+ // ── Constants (module-specific) ─────────────────────────────────────────
35
+
36
+ export const EMBED_PROVIDERS = [
37
+ "openai", "ollama", "huggingface", "lmstudio"
38
+ ] as const;
39
+
40
+ /** Static model list for Anthropic (no listing API available). */
41
+ const ANTHROPIC_MODELS = [
42
+ "claude-opus-4-6",
43
+ "claude-sonnet-4-6",
44
+ "claude-opus-4-20250514",
45
+ "claude-sonnet-4-20250514",
46
+ "claude-haiku-4-5-20251001",
47
+ "claude-3-5-sonnet-20241022",
48
+ "claude-3-5-haiku-20241022",
49
+ ];
50
+
51
+ // ── API Key Resolution ──────────────────────────────────────────────────
52
+
53
+ export function resolveApiKey(apiKeyRef: string, configDir: string): string {
54
+ if (!apiKeyRef) return "";
55
+ if (!apiKeyRef.startsWith("env:")) return apiKeyRef;
56
+
57
+ const varName = apiKeyRef.slice(4);
58
+ if (process.env[varName]) return process.env[varName]!;
59
+
60
+ const secrets = loadSecretsEnvFile(configDir);
61
+ return secrets[varName] ?? "";
62
+ }
63
+
64
+ // ── Provider Model Listing ──────────────────────────────────────────────
65
+
66
+ export type ModelDiscoveryReason =
67
+ | 'none'
68
+ | 'provider_static'
69
+ | 'provider_http'
70
+ | 'missing_base_url'
71
+ | 'timeout'
72
+ | 'network';
73
+
74
+ export type ProviderModelsResult = {
75
+ models: string[];
76
+ status: 'ok' | 'recoverable_error';
77
+ reason: ModelDiscoveryReason;
78
+ error?: string;
79
+ };
80
+
81
+ function describeHttpStatus(status: number): string {
82
+ switch (status) {
83
+ case 401: return 'Invalid or missing API key';
84
+ case 403: return 'Access denied — check API key permissions';
85
+ case 404: return 'Endpoint not found — verify the base URL';
86
+ case 429: return 'Rate limited — try again shortly';
87
+ case 500: return 'Provider internal error';
88
+ case 502: return 'Provider returned a bad gateway error';
89
+ case 503: return 'Provider is temporarily unavailable';
90
+ default: return `HTTP ${status}`;
91
+ }
92
+ }
93
+
94
+ async function extractProviderErrorDetail(res: Response): Promise<string> {
95
+ try {
96
+ const text = await res.text();
97
+ const json = JSON.parse(text) as Record<string, unknown>;
98
+ if (
99
+ typeof json.error === 'object' && json.error !== null &&
100
+ typeof (json.error as Record<string, unknown>).message === 'string'
101
+ ) {
102
+ return (json.error as Record<string, unknown>).message as string;
103
+ }
104
+ if (typeof json.error === 'string') return json.error;
105
+ if (typeof json.message === 'string') return json.message;
106
+ if (typeof json.detail === 'string') return json.detail;
107
+ return '';
108
+ } catch {
109
+ return '';
110
+ }
111
+ }
112
+
113
+ export async function fetchProviderModels(
114
+ provider: string,
115
+ apiKeyRef: string,
116
+ baseUrl: string,
117
+ configDir: string
118
+ ): Promise<ProviderModelsResult> {
119
+ try {
120
+ if (provider === "anthropic") {
121
+ return { models: [...ANTHROPIC_MODELS], status: 'ok', reason: 'provider_static' };
122
+ }
123
+
124
+ const resolvedKey = resolveApiKey(apiKeyRef, configDir);
125
+
126
+ if (provider === "ollama") {
127
+ const base = baseUrl?.trim() || PROVIDER_DEFAULT_URLS.ollama;
128
+ const url = `${base.replace(/\/+$/, "")}/api/tags`;
129
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
130
+ if (!res.ok) {
131
+ return {
132
+ models: [],
133
+ status: 'recoverable_error',
134
+ reason: 'provider_http',
135
+ error: `Ollama API returned ${res.status}: ${describeHttpStatus(res.status)}`,
136
+ };
137
+ }
138
+ const data = (await res.json()) as { models?: { name: string }[] };
139
+ const models = (data.models ?? []).map((m) => m.name).sort();
140
+ return { models, status: 'ok', reason: 'none' };
141
+ }
142
+
143
+ const base = baseUrl?.trim() || PROVIDER_DEFAULT_URLS[provider] || "";
144
+ if (!base) {
145
+ return {
146
+ models: [],
147
+ status: 'recoverable_error',
148
+ reason: 'missing_base_url',
149
+ error: `No base URL configured for provider "${provider}"`,
150
+ };
151
+ }
152
+ const url = `${base.replace(/\/+$/, "")}/v1/models`;
153
+
154
+ const headers: Record<string, string> = {};
155
+ if (resolvedKey) {
156
+ headers["Authorization"] = `Bearer ${resolvedKey}`;
157
+ }
158
+
159
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
160
+ if (!res.ok) {
161
+ const detail = await extractProviderErrorDetail(res);
162
+ return {
163
+ models: [],
164
+ status: 'recoverable_error',
165
+ reason: 'provider_http',
166
+ error: detail
167
+ ? `Provider API returned ${res.status}: ${detail}`
168
+ : `Provider API returned ${res.status}: ${describeHttpStatus(res.status)}`,
169
+ };
170
+ }
171
+ const data = (await res.json()) as { data?: { id: string }[] };
172
+ const models = (data.data ?? []).map((m) => m.id).sort();
173
+ return { models, status: 'ok', reason: 'none' };
174
+ } catch (err) {
175
+ const message =
176
+ err instanceof Error && err.name === "TimeoutError"
177
+ ? "Request timed out after 5s"
178
+ : String(err);
179
+ return {
180
+ models: [],
181
+ status: 'recoverable_error',
182
+ reason: err instanceof Error && err.name === 'TimeoutError' ? 'timeout' : 'network',
183
+ error: message,
184
+ };
185
+ }
186
+ }
187
+
188
+ // ── Default Config ───────────────────────────────────────────────────────
189
+
190
+ export function getDefaultConfig(): MemoryConfig {
191
+ return {
192
+ mem0: {
193
+ llm: {
194
+ provider: "openai",
195
+ config: {
196
+ model: "gpt-4o-mini",
197
+ temperature: 0.1,
198
+ max_tokens: 2000,
199
+ api_key: "env:OPENAI_API_KEY",
200
+ },
201
+ },
202
+ embedder: {
203
+ provider: "openai",
204
+ config: {
205
+ model: "text-embedding-3-small",
206
+ api_key: "env:OPENAI_API_KEY",
207
+ },
208
+ },
209
+ vector_store: {
210
+ provider: "sqlite-vec",
211
+ config: {
212
+ collection_name: "memory",
213
+ db_path: "/data/memory.db",
214
+ embedding_model_dims: 1536,
215
+ },
216
+ },
217
+ },
218
+ memory: { custom_instructions: "" },
219
+ };
220
+ }
221
+
222
+ // ── File I/O ─────────────────────────────────────────────────────────────
223
+
224
+ const CONFIG_FILENAME = "memory/default_config.json";
225
+
226
+ function configPath(dataDir: string): string {
227
+ return `${dataDir}/${CONFIG_FILENAME}`;
228
+ }
229
+
230
+ export function readMemoryConfig(dataDir: string): MemoryConfig {
231
+ const path = configPath(dataDir);
232
+ if (!existsSync(path)) return getDefaultConfig();
233
+ try {
234
+ const raw = readFileSync(path, "utf-8");
235
+ return JSON.parse(raw) as MemoryConfig;
236
+ } catch {
237
+ return getDefaultConfig();
238
+ }
239
+ }
240
+
241
+ export function writeMemoryConfig(
242
+ dataDir: string,
243
+ config: MemoryConfig
244
+ ): void {
245
+ const path = configPath(dataDir);
246
+ mkdirSync(`${dataDir}/memory`, { recursive: true });
247
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
248
+ }
249
+
250
+ export function ensureMemoryConfig(dataDir: string): void {
251
+ const path = configPath(dataDir);
252
+ if (existsSync(path)) return;
253
+ writeMemoryConfig(dataDir, getDefaultConfig());
254
+ }
255
+
256
+ // ── Config Resolution ────────────────────────────────────────────────
257
+
258
+ export function resolveConfigForPush(
259
+ config: MemoryConfig,
260
+ configDir: string
261
+ ): MemoryConfig {
262
+ const resolved = structuredClone(config);
263
+
264
+ if (typeof resolved.mem0.llm.config.api_key === "string") {
265
+ resolved.mem0.llm.config.api_key = resolveApiKey(
266
+ resolved.mem0.llm.config.api_key as string,
267
+ configDir
268
+ );
269
+ }
270
+
271
+ if (typeof resolved.mem0.embedder.config.api_key === "string") {
272
+ resolved.mem0.embedder.config.api_key = resolveApiKey(
273
+ resolved.mem0.embedder.config.api_key as string,
274
+ configDir
275
+ );
276
+ }
277
+
278
+ return resolved;
279
+ }
280
+
281
+ // ── Dimension Checking ──────────────────────────────────────────────
282
+
283
+ export type VectorDimensionResult = {
284
+ match: boolean;
285
+ currentDims?: number;
286
+ expectedDims: number;
287
+ };
288
+
289
+ /** @deprecated Use checkVectorDimensions instead */
290
+ export type QdrantDimensionResult = VectorDimensionResult;
291
+
292
+ export function checkVectorDimensions(
293
+ dataDir: string,
294
+ newConfig: MemoryConfig
295
+ ): VectorDimensionResult {
296
+ const expectedDims = newConfig.mem0.vector_store.config.embedding_model_dims;
297
+ const persisted = readMemoryConfig(dataDir);
298
+ const currentDims = persisted.mem0.vector_store.config.embedding_model_dims;
299
+ return { match: currentDims === expectedDims, currentDims, expectedDims };
300
+ }
301
+
302
+ /** @deprecated Use checkVectorDimensions instead */
303
+ export const checkQdrantDimensions = checkVectorDimensions;
304
+
305
+ export function resetVectorStore(
306
+ dataDir: string
307
+ ): { ok: boolean; error?: string } {
308
+ const persisted = readMemoryConfig(dataDir);
309
+ const configuredPath = persisted.mem0.vector_store.config.db_path;
310
+
311
+ let dbPath: string;
312
+ if (configuredPath && configuredPath.startsWith('/data/')) {
313
+ dbPath = `${dataDir}/memory/${configuredPath.slice('/data/'.length)}`;
314
+ } else if (configuredPath && !configuredPath.startsWith('/')) {
315
+ dbPath = `${dataDir}/memory/${configuredPath}`;
316
+ } else {
317
+ dbPath = `${dataDir}/memory/memory.db`;
318
+ }
319
+ const qdrantPath = `${dataDir}/memory/qdrant`;
320
+ try {
321
+ if (existsSync(dbPath)) {
322
+ rmSync(dbPath, { force: true });
323
+ }
324
+ for (const suffix of ['-wal', '-shm']) {
325
+ const walPath = `${dbPath}${suffix}`;
326
+ if (existsSync(walPath)) rmSync(walPath, { force: true });
327
+ }
328
+ if (existsSync(qdrantPath)) {
329
+ rmSync(qdrantPath, { recursive: true, force: true });
330
+ }
331
+ return { ok: true };
332
+ } catch (err) {
333
+ return { ok: false, error: String(err) };
334
+ }
335
+ }
336
+
337
+ /** @deprecated Use resetVectorStore instead */
338
+ export const resetQdrantCollection = resetVectorStore;
339
+
340
+ // ── Runtime API ──────────────────────────────────────────────────────────
341
+
342
+ function getMemoryApiBases(): string[] {
343
+ const configured =
344
+ process.env.MEMORY_API_URL?.trim() ||
345
+ process.env.OPENPALM_MEMORY_API_URL?.trim();
346
+
347
+ const bases = configured
348
+ ? [configured]
349
+ : ["http://memory:8765", "http://127.0.0.1:8765"];
350
+
351
+ return Array.from(new Set(bases.map((base) => base.replace(/\/+$/, ""))));
352
+ }
353
+
354
+ function getMemoryAuthHeaders(): Record<string, string> {
355
+ const token = process.env.MEMORY_AUTH_TOKEN?.trim();
356
+ return token ? { authorization: `Bearer ${token}` } : {};
357
+ }
358
+
359
+ async function callMemoryApi(
360
+ path: string,
361
+ init?: RequestInit,
362
+ ): Promise<Response> {
363
+ const bases = getMemoryApiBases();
364
+ const authHeaders = getMemoryAuthHeaders();
365
+ let lastError: unknown;
366
+
367
+ for (let i = 0; i < bases.length; i++) {
368
+ const url = `${bases[i]}${path}`;
369
+ try {
370
+ const headers = { ...authHeaders, ...(init?.headers as Record<string, string>) };
371
+ return await fetch(url, { ...init, headers });
372
+ } catch (err) {
373
+ lastError = err;
374
+ if (i === bases.length - 1) throw err;
375
+ }
376
+ }
377
+
378
+ throw lastError ?? new Error("Memory API request failed");
379
+ }
380
+
381
+ export async function pushConfigToMemory(
382
+ config: MemoryConfig
383
+ ): Promise<{ ok: boolean; error?: string }> {
384
+ try {
385
+ const res = await callMemoryApi("/api/v1/config/", {
386
+ method: "PUT",
387
+ headers: { "content-type": "application/json" },
388
+ body: JSON.stringify(config),
389
+ });
390
+ if (!res.ok) {
391
+ const text = await res.text().catch(() => "");
392
+ return { ok: false, error: `HTTP ${res.status}: ${text}` };
393
+ }
394
+ return { ok: true };
395
+ } catch (err) {
396
+ return { ok: false, error: String(err) };
397
+ }
398
+ }
399
+
400
+ export async function fetchConfigFromMemory(): Promise<MemoryConfig | null> {
401
+ try {
402
+ const res = await callMemoryApi("/api/v1/config/");
403
+ if (!res.ok) return null;
404
+ return (await res.json()) as MemoryConfig;
405
+ } catch {
406
+ return null;
407
+ }
408
+ }
409
+
410
+ export async function provisionMemoryUser(
411
+ userId: string,
412
+ ): Promise<{ ok: boolean; error?: string }> {
413
+ try {
414
+ const res = await callMemoryApi("/api/v1/users", {
415
+ method: "POST",
416
+ headers: { "content-type": "application/json" },
417
+ body: JSON.stringify({ user_id: userId }),
418
+ signal: AbortSignal.timeout(5_000),
419
+ });
420
+ return { ok: res.ok };
421
+ } catch (err) {
422
+ return { ok: false, error: String(err) };
423
+ }
424
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Local provider detection for OpenPalm.
3
+ *
4
+ * Probes well-known endpoints for Docker Model Runner, Ollama, and LM Studio.
5
+ */
6
+ import { createLogger } from "../logger.js";
7
+
8
+ const logger = createLogger("local-providers");
9
+
10
+ // ── Types ────────────────────────────────────────────────────────────────
11
+
12
+ export type LocalProviderDetection = {
13
+ provider: string;
14
+ url: string;
15
+ available: boolean;
16
+ };
17
+
18
+ // ── Probe Configuration ──────────────────────────────────────────────────
19
+
20
+ const LOCAL_PROVIDER_PROBES: { provider: string; probes: { url: string; baseUrl: string }[] }[] = [
21
+ {
22
+ provider: "model-runner",
23
+ probes: [
24
+ {
25
+ url: "http://model-runner.docker.internal/engines/v1/models",
26
+ baseUrl: "http://model-runner.docker.internal/engines",
27
+ },
28
+ {
29
+ url: "http://model-runner.docker.internal:12434/engines/v1/models",
30
+ baseUrl: "http://model-runner.docker.internal:12434/engines",
31
+ },
32
+ {
33
+ url: "http://host.docker.internal:12434/engines/v1/models",
34
+ baseUrl: "http://host.docker.internal:12434/engines",
35
+ },
36
+ {
37
+ url: "http://localhost:12434/engines/v1/models",
38
+ baseUrl: "http://localhost:12434/engines",
39
+ },
40
+ ],
41
+ },
42
+ {
43
+ provider: "ollama",
44
+ probes: [
45
+ {
46
+ // In-stack Ollama (compose service on assistant_net)
47
+ url: "http://ollama:11434/api/tags",
48
+ baseUrl: "http://ollama:11434",
49
+ },
50
+ {
51
+ url: "http://host.docker.internal:11434/api/tags",
52
+ baseUrl: "http://host.docker.internal:11434",
53
+ },
54
+ {
55
+ url: "http://localhost:11434/api/tags",
56
+ baseUrl: "http://localhost:11434",
57
+ },
58
+ ],
59
+ },
60
+ {
61
+ provider: "lmstudio",
62
+ probes: [
63
+ {
64
+ url: "http://host.docker.internal:1234/v1/models",
65
+ baseUrl: "http://host.docker.internal:1234",
66
+ },
67
+ {
68
+ url: "http://localhost:1234/v1/models",
69
+ baseUrl: "http://localhost:1234",
70
+ },
71
+ ],
72
+ },
73
+ ];
74
+
75
+ // ── Detection ────────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Detect all available local providers by probing well-known endpoints.
79
+ * Returns results for all providers (available or not) in parallel.
80
+ */
81
+ export async function detectLocalProviders(): Promise<LocalProviderDetection[]> {
82
+ const results = await Promise.all(
83
+ LOCAL_PROVIDER_PROBES.map(async ({ provider, probes }) => {
84
+ for (const { url: probeUrl, baseUrl } of probes) {
85
+ try {
86
+ const res = await fetch(probeUrl, {
87
+ signal: AbortSignal.timeout(3000),
88
+ });
89
+ if (res.ok) {
90
+ logger.debug("detected local provider", { provider, url: baseUrl });
91
+ return { provider, url: baseUrl, available: true };
92
+ }
93
+ } catch {
94
+ // Endpoint not reachable — try next
95
+ }
96
+ }
97
+ return { provider, url: "", available: false };
98
+ })
99
+ );
100
+ return results;
101
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * XDG path resolution and directory setup for the OpenPalm control plane.
3
+ *
4
+ * Directory model (XDG-compliant):
5
+ * CONFIG_HOME (~/.config/openpalm) — user-editable: secrets.env, channels/, assistant/
6
+ * DATA_HOME (~/.local/share/openpalm) — opaque service data (memory, etc.)
7
+ * STATE_HOME (~/.local/state/openpalm) — assembled runtime, audit logs
8
+ */
9
+ import { mkdirSync } from "node:fs";
10
+ import { resolve as resolvePath } from "node:path";
11
+
12
+ export function resolveHome(): string {
13
+ return process.env.HOME ?? "/tmp";
14
+ }
15
+
16
+ export function resolveConfigHome(): string {
17
+ const raw = process.env.OPENPALM_CONFIG_HOME;
18
+ if (!raw) return `${resolveHome()}/.config/openpalm`;
19
+ return resolvePath(raw);
20
+ }
21
+
22
+ export function resolveStateHome(): string {
23
+ const raw = process.env.OPENPALM_STATE_HOME;
24
+ if (!raw) return `${resolveHome()}/.local/state/openpalm`;
25
+ return resolvePath(raw);
26
+ }
27
+
28
+ export function resolveDataHome(): string {
29
+ const raw = process.env.OPENPALM_DATA_HOME;
30
+ if (!raw) return `${resolveHome()}/.local/share/openpalm`;
31
+ return resolvePath(raw);
32
+ }
33
+
34
+ /**
35
+ * Create the full XDG directory tree.
36
+ *
37
+ * CONFIG_HOME (~/.config/openpalm) — user-editable configuration
38
+ * DATA_HOME (~/.local/share/openpalm) — opaque persistent service data
39
+ * STATE_HOME (~/.local/state/openpalm) — generated artifacts, audit logs
40
+ */
41
+ export function ensureXdgDirs(): void {
42
+ const dataHome = resolveDataHome();
43
+ const configHome = resolveConfigHome();
44
+ const stateHome = resolveStateHome();
45
+
46
+ for (const dir of [
47
+ // CONFIG_HOME — user-editable
48
+ configHome,
49
+ `${configHome}/channels`,
50
+ `${configHome}/connections`,
51
+ `${configHome}/assistant`,
52
+ `${configHome}/automations`,
53
+ `${configHome}/stash`,
54
+
55
+ // DATA_HOME — persistent service data (pre-created to avoid root-owned dirs)
56
+ dataHome,
57
+ `${dataHome}/admin`,
58
+ `${dataHome}/memory`,
59
+ `${dataHome}/assistant`,
60
+ `${dataHome}/guardian`,
61
+ `${dataHome}/caddy`,
62
+ `${dataHome}/caddy/data`,
63
+ `${dataHome}/caddy/config`,
64
+ `${dataHome}/automations`,
65
+ `${dataHome}/opencode`,
66
+
67
+ // STATE_HOME — assembled runtime
68
+ stateHome,
69
+ `${stateHome}/artifacts`,
70
+ `${stateHome}/audit`,
71
+ `${stateHome}/artifacts/channels`,
72
+ `${stateHome}/automations`,
73
+ `${stateHome}/opencode`
74
+ ]) {
75
+ mkdirSync(dir, { recursive: true });
76
+ }
77
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * RegistryProvider interface — dependency injection for registry catalog.
3
+ *
4
+ * Admin implements this with Vite import.meta.glob (ViteRegistryProvider).
5
+ * CLI/lib implements this by reading from registry/ directory (FilesystemRegistryProvider).
6
+ */
7
+
8
+ export interface RegistryProvider {
9
+ /** Channel compose overlay YMLs, keyed by channel name. */
10
+ channelYml(): Record<string, string>;
11
+ /** Channel Caddy routes (optional), keyed by channel name. */
12
+ channelCaddy(): Record<string, string>;
13
+ /** Names of available registry channels. */
14
+ channelNames(): string[];
15
+ /** Automation configs, keyed by automation name. */
16
+ automationYml(): Record<string, string>;
17
+ /** Names of available registry automations. */
18
+ automationNames(): string[];
19
+ }