UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

279 lines (278 loc) • 11 kB
import * as fs from "fs"; import { debugLog, runtimeFile } from "../shared.js"; const DEFAULT_OLLAMA_URL = "http://localhost:11434"; const DEFAULT_EMBEDDING_MODEL = "nomic-embed-text"; const DEFAULT_EXTRACT_MODEL = "llama3.2"; const MAX_EMBED_INPUT_CHARS = 6000; const CLOUD_EMBEDDING_TIMEOUT_MS = 15_000; const OLLAMA_HEALTH_TIMEOUT_MS = 2_000; const OLLAMA_EMBEDDING_TIMEOUT_MS = 10_000; const OLLAMA_GENERATE_TIMEOUT_MS = 60_000; /** @internal Exported for tests. */ export function prepareEmbeddingInput(text) { return text .replace(/<!--[\s\S]*?-->/g, " ") .replace(/```[\s\S]*?```/g, (block) => block.replace(/```/g, " ")) .replace(/`([^`]+)`/g, " $1 ") .replace(/\[([^\]]+)\]\([^)]+\)/g, " $1 ") .replace(/\|/g, " ") .replace(/\r\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .replace(/[ \t]+\n/g, "\n") .replace(/\s+/g, " ") .trim() .slice(0, MAX_EMBED_INPUT_CHARS); } /** * Cloud embedding API support (Item 6). * Set PHREN_EMBEDDING_API_URL to an OpenAI-compatible /embeddings endpoint. * Set PHREN_EMBEDDING_API_KEY for the Authorization: Bearer header. * When set, cloud embedding takes priority over Ollama. * * Example (OpenAI): * PHREN_EMBEDDING_API_URL=https://api.openai.com/v1 * PHREN_EMBEDDING_API_KEY=sk-... * PHREN_EMBEDDING_MODEL=text-embedding-3-small */ export function getCloudEmbeddingUrl() { const val = process.env["PHREN_EMBEDDING_API_URL"]; if (!val || ["off", "0", "false", "no"].includes(val.trim().toLowerCase())) return null; return val.trim().replace(/\/$/, ""); // strip trailing slash } function getCloudEmbeddingKey() { return process.env["PHREN_EMBEDDING_API_KEY"] ?? null; } /** Embed text via OpenAI-compatible /embeddings endpoint. */ async function embedTextCloud(input, baseUrl, model, apiKey) { const headers = { "Content-Type": "application/json" }; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; try { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), CLOUD_EMBEDDING_TIMEOUT_MS); const res = await fetch(`${baseUrl}/embeddings`, { method: "POST", headers, body: JSON.stringify({ model, input }), signal: controller.signal, }); clearTimeout(id); if (!res.ok) { debugLog(`embedTextCloud: API returned ${res.status}`); return null; } const data = await res.json(); return data.data?.[0]?.embedding ?? null; } catch (e) { debugLog(`embedTextCloud error: ${e instanceof Error ? e.message : String(e)}`); return null; } } export function getOllamaUrl() { const val = process.env["PHREN_OLLAMA_URL"]; if (val !== undefined && ["off", "0", "false", "no"].includes(val.trim().toLowerCase())) return null; return val ?? DEFAULT_OLLAMA_URL; } export function getEmbeddingModel() { return process.env["PHREN_EMBEDDING_MODEL"] ?? DEFAULT_EMBEDDING_MODEL; } export function getExtractModel() { return process.env["PHREN_EXTRACT_MODEL"] ?? DEFAULT_EXTRACT_MODEL; } export async function checkOllamaAvailable(url, timeoutMs = OLLAMA_HEALTH_TIMEOUT_MS) { // When cloud embedding is configured, report as "available" (skip Ollama probe) if (!url && getCloudEmbeddingUrl()) return true; const baseUrl = url ?? getOllamaUrl(); if (!baseUrl) return false; try { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeoutMs); const res = await fetch(`${baseUrl}/api/tags`, { signal: controller.signal }); clearTimeout(id); return res.ok; } catch { return false; } } // ── Hook-safe reachability gate ───────────────────────────────────────────── // // PHREN_OLLAMA_URL defaults to http://localhost:11434, so a user who never set // up Ollama still has an embedding backend "configured". A socket that accepts // the connection and then never answers — Ollama mid-restart, a model still // loading, an unrelated listener — makes `embedText` sit on its 10s abort // timeout, which is the *entire* UserPromptSubmit budget: the hook is killed // and the prompt gets no phren context at all. Measured on a 1892-file store: // 306ms with the backend off, 368ms with it absent, 10,376ms against a // listening-but-silent socket. // // So probe first, briefly, and remember the answer on disk. Hooks are separate // processes, so an in-process memo would never survive to help the next prompt. const HOOK_PROBE_TIMEOUT_MS = 800; const REACHABILITY_TTL_DEFAULT_MS = 60_000; function reachabilityFile(phrenPath) { return runtimeFile(phrenPath, "embedding-backend-health.json"); } function readReachability(phrenPath, url) { try { const parsed = JSON.parse(fs.readFileSync(reachabilityFile(phrenPath), "utf8")); if (typeof parsed?.url !== "string" || typeof parsed.ok !== "boolean" || typeof parsed.at !== "number") return null; if (parsed.url !== url) return null; // The cost of being wrong is asymmetric and bounded either way: a stale // "down" costs a minute of degraded (not broken) semantic search, a stale // "up" costs one probe. `phren doctor` still probes live, so diagnostics // never read from this marker. if (Date.now() - parsed.at > positiveIntEnv("PHREN_EMBEDDING_HEALTH_TTL_MS", REACHABILITY_TTL_DEFAULT_MS)) return null; return parsed; } catch { return null; } } function writeReachability(phrenPath, marker) { try { fs.writeFileSync(reachabilityFile(phrenPath), JSON.stringify(marker)); } catch (err) { debugLog(`writeReachability: ${err instanceof Error ? err.message : String(err)}`); } } function positiveIntEnv(name, fallback) { const raw = process.env[name]; if (!raw) return fallback; const parsed = Number.parseInt(raw, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } /** Budget for the one query embedding a prompt-time vector search is allowed to wait on. */ export function getVectorQueryTimeoutMs() { return positiveIntEnv("PHREN_VECTOR_QUERY_TIMEOUT_MS", 5_000); } /** * Cheap, cached "can we embed right now?" check for latency-sensitive callers. * * Returns false immediately (no network at all) when a probe within the last * minute already failed, so a machine without Ollama pays at most one 800ms * probe per minute instead of a 10s stall per prompt. A configured cloud * embedding endpoint short-circuits to true — it has its own timeout and is not * expected to be a local process that may simply be missing. */ export async function isEmbeddingBackendReachable(phrenPath) { if (getCloudEmbeddingUrl()) return true; const url = getOllamaUrl(); if (!url) return false; const cached = readReachability(phrenPath, url); if (cached) return cached.ok; const ok = await checkOllamaAvailable(url, HOOK_PROBE_TIMEOUT_MS); writeReachability(phrenPath, { url, ok, at: Date.now() }); return ok; } export async function checkModelAvailable(model, url) { // When cloud embedding is configured, assume model is available (no /api/tags equivalent) if (!url && getCloudEmbeddingUrl()) return true; const baseUrl = url ?? getOllamaUrl(); if (!baseUrl) return false; const modelName = model ?? getEmbeddingModel(); try { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), OLLAMA_HEALTH_TIMEOUT_MS); const res = await fetch(`${baseUrl}/api/tags`, { signal: controller.signal }); clearTimeout(id); if (!res.ok) return false; const data = await res.json(); return (data.models ?? []).some(m => m.name.startsWith(modelName)); } catch { return false; } } export async function embedText(text, model, url, timeoutMs) { const modelName = model ?? getEmbeddingModel(); const input = prepareEmbeddingInput(text); if (!input) return null; // Cloud embedding takes priority when PHREN_EMBEDDING_API_URL is set const cloudUrl = url ? null : getCloudEmbeddingUrl(); if (cloudUrl) { return embedTextCloud(input, cloudUrl, modelName, getCloudEmbeddingKey()); } const baseUrl = url ?? getOllamaUrl(); if (!baseUrl) return null; try { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeoutMs ?? OLLAMA_EMBEDDING_TIMEOUT_MS); const res = await fetch(`${baseUrl}/api/embed`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: modelName, input }), signal: controller.signal, }); clearTimeout(id); if (!res.ok) { debugLog(`embedText: Ollama returned ${res.status}`); return null; } const data = await res.json(); return data.embeddings?.[0] ?? null; } catch (e) { debugLog(`embedText error: ${e instanceof Error ? e.message : String(e)}`); return null; } } export async function generateText(prompt, model, url) { const baseUrl = url ?? getOllamaUrl(); if (!baseUrl) return null; const modelName = model ?? getExtractModel(); try { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), OLLAMA_GENERATE_TIMEOUT_MS); const res = await fetch(`${baseUrl}/api/generate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: modelName, prompt, stream: false }), signal: controller.signal, }); clearTimeout(id); if (!res.ok) { debugLog(`generateText: Ollama returned ${res.status}`); return null; } const data = await res.json(); return data.response ?? null; } catch (e) { debugLog(`generateText error: ${e instanceof Error ? e.message : String(e)}`); return null; } } /** * Probe Ollama availability and model readiness in one call. * Returns a status enum so callers can branch on it without repeating the check logic. */ export async function checkOllamaStatus() { if (!getOllamaUrl()) return "disabled"; const ollamaUp = await checkOllamaAvailable(); if (!ollamaUp) return "not_running"; const modelReady = await checkModelAvailable(); return modelReady ? "ready" : "no_model"; } export { cosineSimilarity } from "../embedding.js";