// Shared Ollama embed client. Single capped implementation used by every // process that writes a vector-indexed node outside the memory plugin (admin // MCP PIN setup, scheduling ICS ingest, UI lazy Person create). The memory // plugin's `lib/embeddings.ts` re-exports these so there is one cap constant // and one POST shape across the platform (Task 636). fetch-only — no node:fs — // so it is safe to bundle into the ESM payload. const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434"; const EMBED_MODEL = process.env.EMBED_MODEL ?? "nomic-embed-text"; function envInt(name: string, def: number): number { const raw = process.env[name]; if (raw === undefined || raw === "") return def; const n = Number(raw); return Number.isFinite(n) ? n : def; } // nomic-embed-text's usable embed context is 2048 tokens. The cap has been // tightened twice against real 400s: 8000 (assumed ~4 chars/token) still // overflowed on dense emoji/markup, then 6000 (~3 chars/token) still 400'd on // the densest sections with "input length exceeds the context length" because // that content runs closer to ~2 chars/token. Size the default against a // conservative 2 chars/token worst case, reserving a small token margin for the // tokenizer's specials, so a capped input is provably under context: // (2048 - 48) * 2 = 4000 (Task 1558). Context is env-overridable so raising it // raises the cap; EMBED_INPUT_MAX_CHARS is also directly overridable. const EMBED_MODEL_CONTEXT_TOKENS = Math.max(1, Math.floor(envInt("EMBED_MODEL_CONTEXT_TOKENS", 2048))); const EMBED_CONTEXT_MARGIN_TOKENS = 48; const EMBED_WORST_CASE_CHARS_PER_TOKEN = 2; export const EMBED_INPUT_MAX_CHARS = Math.max( 1, Math.floor( envInt( "EMBED_INPUT_MAX_CHARS", (EMBED_MODEL_CONTEXT_TOKENS - EMBED_CONTEXT_MARGIN_TOKENS) * EMBED_WORST_CASE_CHARS_PER_TOKEN, ), ), ); // Batch pacing (Task 960). On a passively-cooled Pi, embedding a whole // document's sections in one `/api/embed` POST pins ollama's llama-server // across every core for the entire batch with no idle gaps — the SoC climbs // past its soft-temp limit and the board thermally hard-resets. embedBatch // splits an oversized input array into sub-batches and waits a cooldown between // them so the SoC gets cooling gaps. Centralising here means every batch caller // (memory-ingest, memory-archive-write, memory-reindex) inherits the pacing. // The systemd CPUQuota cap on ollama.service is the load-bearing thermal fix; // this pacing is defence-in-depth and faster recovery. Both knobs are // env-overridable so an operator can tune them from on-device thermal // measurement without a code change. // // Count ceiling after adaptive sizing (Task 1558). Since Task 1553 each POST is // sized by a character budget derived from the device's *measured* ms-per-char, // so EMBED_SUBBATCH_SIZE is no longer the primary timeout guard — it is the hard // count cap layered on top. It still matters for the one case the char budget // cannot catch: the budget is derived from the probe's observed rate, so if the // probe runs while the machine is briefly quiet the budget is large and the next // POST packs up to this ceiling; if desktop contention then spikes mid-batch, // that POST runs far slower than measured and can blow EMBED_REQUEST_TIMEOUT_MS. // A ceiling of 4 bounds that worst case (~4 x 17s cold-CPU embeds on a contended // shared desktop ~= 68s, under the 120s timeout) so a fast-probe-then-contention // batch still fits. On a quiet dedicated appliance (a Pi) the char budget, not // this count, is the binding constraint. export const EMBED_SUBBATCH_SIZE = Math.max(1, Math.floor(envInt("EMBED_SUBBATCH_SIZE", 4))); export const EMBED_SUBBATCH_COOLDOWN_MS = Math.max( 0, envInt("EMBED_SUBBATCH_COOLDOWN_MS", 1000), ); // Drift baseline (Task 1553). This was Task 1533's sizing driver — the budgeted // worst-case wall-time for one capped input — but sizing that from a single // fixed reading is exactly what over-provisioned a POST on a device slower than // the reading and aborted the ingest. Sizing is now measured per batch // (nextSubBatchSize), so EMBED_MS_PER_INPUT no longer drives it. It survives as // the reconciliation baseline: each POST compares its observed per-input time // against this budget and warns (op=drift) when the device runs slower, so a // device below budget is surfaced by a standing check rather than by a silent // ingest failure. Env-tunable for other hardware. export const EMBED_MS_PER_INPUT = Math.max(1, Math.floor(envInt("EMBED_MS_PER_INPUT", 6000))); const EMBED_TIMEOUT_UTILISATION = 0.8; // Warn (op=drift) once a POST's observed per-input time exceeds the budget by // this factor. 1.5 tolerates ordinary variance while catching a device that is // materially slower than EMBED_MS_PER_INPUT. const EMBED_DRIFT_WARN_RATIO = 1.5; // Explicit per-request timeout, well under undici's 300000ms headers default. // Without it a slow or wedged ollama returns no headers and the caller hangs a // silent 5 minutes before a bare `fetch failed`. With it the request aborts in // seconds and throws a cause-naming error instead. Env-overridable for // on-device tuning. export const EMBED_REQUEST_TIMEOUT_MS = Math.max( 1, Math.floor(envInt("EMBED_REQUEST_TIMEOUT_MS", 120000)), ); // Model warmth (Task 1533). Keep the embed model resident so an interactive // graph query-embed never cold-loads (~10 s) and blows its 5 s timeout, and so // a query issued while an ingest runs embeds warm instead of degrading to BM25. // Ollama's keep_alive accepts a number (seconds; -1 = keep forever) or a // duration string ("30m"). Pass a numeric value through as a number so "-1" is // the keep-forever sentinel rather than an unparseable duration string. // Treat an empty-string env as unset (matches envInt above), so // `EMBED_KEEP_ALIVE=` does not send an empty keep_alive that Ollama rejects. const EMBED_KEEP_ALIVE_RAW = process.env.EMBED_KEEP_ALIVE === undefined || process.env.EMBED_KEEP_ALIVE === "" ? "-1" : process.env.EMBED_KEEP_ALIVE; export const EMBED_KEEP_ALIVE: number | string = /^-?\d+$/.test(EMBED_KEEP_ALIVE_RAW) ? Number(EMBED_KEEP_ALIVE_RAW) : EMBED_KEEP_ALIVE_RAW; function capInput(text: string): string { if (text.length <= EMBED_INPUT_MAX_CHARS) return text; process.stderr.write( `[embed] op=truncate origBytes=${text.length} cappedBytes=${EMBED_INPUT_MAX_CHARS}\n`, ); return text.slice(0, EMBED_INPUT_MAX_CHARS); } const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); // One POST to the embed endpoint for an already-capped input (a single string // for embed(), an array for a sub-batch). Both paths route through here so the // timeout and the cause-naming error apply on every embed call. The wire shape // of `input` is preserved (string vs array) so the ollama request is unchanged. // On timeout or failure the error's first line names the cause (timeout vs HTTP // status), the input count, and the largest input size, so a slow or failing // ollama is legible immediately rather than after a 5-minute silent hang. async function embedRequest(input: string | string[]): Promise { const inputs = Array.isArray(input) ? input : [input]; const maxChars = inputs.reduce((m, s) => Math.max(m, s.length), 0); const target = `${OLLAMA_URL}/api/embed`; const startedAt = Date.now(); let res: Response; try { res = await fetch(target, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: EMBED_MODEL, input, keep_alive: EMBED_KEEP_ALIVE }), signal: AbortSignal.timeout(EMBED_REQUEST_TIMEOUT_MS), }); } catch (err) { const name = err instanceof Error ? err.name : ""; if (name === "TimeoutError" || name === "AbortError") { process.stderr.write( `[embed] op=timeout ms=${EMBED_REQUEST_TIMEOUT_MS} inputs=${inputs.length} maxChars=${maxChars}\n`, ); throw new Error( `Ollama embed request timed out after ${EMBED_REQUEST_TIMEOUT_MS}ms (inputs=${inputs.length}, largestInputChars=${maxChars}) at ${target}; ollama did not respond in time (slow, cold-loading, or CPU-saturated). Raise EMBED_REQUEST_TIMEOUT_MS or check ollama.`, ); } // undici throws TypeError("fetch failed", { cause }) on a connection error // (ollama down, refused, DNS). The legible cause is on err.cause, not the // "fetch failed" message, so name it so "network is down" reads distinctly // from "ollama is slow". let detail = err instanceof Error ? err.message : String(err); const cause = err instanceof Error ? (err as { cause?: unknown }).cause : undefined; if (cause !== undefined) { detail += `: ${cause instanceof Error ? cause.message : String(cause)}`; } throw new Error( `Ollama embed request failed (inputs=${inputs.length}, largestInputChars=${maxChars}) at ${target}: ${detail}`, ); } if (!res.ok) { const body = await res.text(); throw new Error( `Ollama embed request failed (HTTP ${res.status}, inputs=${inputs.length}, largestInputChars=${maxChars}): ${body}`, ); } const data = (await res.json()) as { embeddings: number[][] }; // Per-POST breadcrumb (Task 1533). One line per successful POST so an // oversized or slow sub-batch is visible before it times out and the // throughput-aware sizing is verifiable from the log. inputs is the POST's // sub-batch size; maxChars its largest input. const ms = Date.now() - startedAt; process.stderr.write( `[embed] op=post inputs=${inputs.length} maxChars=${maxChars} ms=${ms}\n`, ); // Standing reconciliation (Task 1553). Compare this POST's observed per-input // time against the EMBED_MS_PER_INPUT budget and warn on drift, so a device // slower than budget is caught by a check rather than by a later ingest that // aborts. Sizing already adapts to the true rate; this line makes the // slowness legible. const observedMsPerInput = ms / inputs.length; if (inputs.length > 0 && observedMsPerInput > EMBED_MS_PER_INPUT * EMBED_DRIFT_WARN_RATIO) { process.stderr.write( `[embed] op=drift observedMsPerInput=${Math.round(observedMsPerInput)} budgetMsPerInput=${EMBED_MS_PER_INPUT} ratio=${(observedMsPerInput / EMBED_MS_PER_INPUT).toFixed(1)}\n`, ); } return data.embeddings; } // POST one already-capped sub-batch of inputs to the embed endpoint. async function postBatch(inputs: string[]): Promise { return embedRequest(inputs); } export async function embed(text: string): Promise { const data = await embedRequest(capInput(text)); return data[0]; } // The character budget for one POST, decided from the device's *measured* // throughput rather than a fixed constant (Task 1553). Task 1533 sized every // POST by input count against `EMBED_MS_PER_INPUT`, a single optimistic // reading; on a device slower than that constant the derived POST was too large // to finish inside `EMBED_REQUEST_TIMEOUT_MS`, so it aborted and the atomic // ingest landed nothing. Sizing by *count* is also unsafe when inputs differ in // size — an embed's wall-time scales with its characters, so a POST sized from // a short probe (e.g. a document summary) over-provisions when the later inputs // are near-cap sections. Bound each POST by total characters instead: // `floor(EMBED_REQUEST_TIMEOUT_MS * 0.8 / msPerChar)`, floored at 1. A near-zero // (very fast) rate yields an unbounded budget so the thermal count ceiling // (`EMBED_SUBBATCH_SIZE`, Task 960) governs instead. export function subBatchCharBudget(msPerChar: number): number { const perChar = Math.max(msPerChar, Number.MIN_VALUE); // guard div-by-zero return Math.max( 1, Math.floor((EMBED_REQUEST_TIMEOUT_MS * EMBED_TIMEOUT_UTILISATION) / perChar), ); } export async function embedBatch( texts: string[], // Progress callback (Task 1555). Fired after each sub-batch POST resolves // with (done, total): done = cumulative inputs embedded so far, total = // inputs.length. memory-ingest passes a callback that emits an MCP progress // notification per POST, which resets Claude Code's ~30-minute stdio idle // timer so a long single ingest is not aborted mid-write. Kept MCP-agnostic // — a plain (done, total) callback — so embed-client carries no MCP concepts // and every existing one-arg caller is unchanged. onProgress?: (done: number, total: number) => void, ): Promise { const inputs = texts.map(capInput); // 0 or 1 input is exactly one POST: the warm interactive query path is // unaffected, and the empty-array behaviour is unchanged. Report once so the // callback contract ("fires after every POST") holds on this path too. if (inputs.length <= 1) { const vectors = await postBatch(inputs); onProgress?.(inputs.length, inputs.length); return vectors; } // Measure-and-size. Probe with one input, then pack each subsequent POST with // as many inputs as fit the character budget derived from the worst // ms-per-char seen so far, capped by the thermal count ceiling. Using the // running max makes the budget monotonic — a mid-batch slowdown (e.g. the // ingest itself loading the CPU) shrinks the remainder and it never re-expands // into a timeout. Cool between POSTs (not after the last) so the SoC gets gaps // (Task 960). Vectors concatenate in input order so callers keep their // index-aligned `embeddings[i]` ↔ `texts[i]` contract. const out: number[][] = []; let maxMsPerChar: number | null = null; for (let i = 0; i < inputs.length; ) { // Build the next chunk. With no measurement yet, probe with one input. let end = i + 1; if (maxMsPerChar !== null) { const charBudget = subBatchCharBudget(maxMsPerChar); let chars = inputs[i].length; while ( end < inputs.length && end - i < EMBED_SUBBATCH_SIZE && chars + inputs[end].length <= charBudget ) { chars += inputs[end].length; end += 1; } } if (i > 0 && EMBED_SUBBATCH_COOLDOWN_MS > 0) { await sleep(EMBED_SUBBATCH_COOLDOWN_MS); } const chunk = inputs.slice(i, end); const chunkChars = chunk.reduce((s, t) => s + t.length, 0); const startedAt = Date.now(); const vectors = await postBatch(chunk); const observedMsPerChar = (Date.now() - startedAt) / Math.max(chunkChars, 1); maxMsPerChar = maxMsPerChar === null ? observedMsPerChar : Math.max(maxMsPerChar, observedMsPerChar); out.push(...vectors); i = end; // Report progress after each POST resolves. out.length is the cumulative // inputs embedded so far; the final iteration reports out.length === // inputs.length. onProgress?.(out.length, inputs.length); } return out; }