From 6d889265a2c4e1c20756ce5bd8115405416a2906 Mon Sep 17 00:00:00 2001 From: Robby Date: Mon, 8 Jun 2026 18:59:23 +0200 Subject: [PATCH 1/3] feat: DB resilience + ingest yields + TUI connecting + logging fixes #115 - schema.ts: busy_timeout 30s, WAL checkpoint, withDbRetry helper - ingest/index.ts: yield between files, per-file BRAIN_DEBUG timing - chunker.ts: async windowChunks with yield every 50 chunks - tui.tsx: 'connecting...' default, red dot, stale detection (2s) - logger.ts: app.log structured logging, console preserved - embeddingService.ts: opt-out (BRAIN_EMBED_DISABLE), build:never - embed.ts: BRAIN_EMBED_DISABLE gate - four-opencode-brain.ts: retry on search/memory, checkpoint at startup - package.json: postinstall llama symlink --- llama | 1 + package.json | 1 + src/embed/embeddingService.ts | 22 +++++++++++------ src/four-opencode-brain.ts | 46 +++++++++++++++++++++-------------- src/ingest/chunker.ts | 26 ++++++++++++-------- src/ingest/embed.ts | 13 ++++++---- src/ingest/index.ts | 33 +++++++++++++------------ src/logger.ts | 19 +++++++++++++-- src/schema.ts | 36 ++++++++++++++++++++++++++- src/status.ts | 2 +- src/tui.tsx | 13 ++++++---- 11 files changed, 147 insertions(+), 65 deletions(-) create mode 120000 llama diff --git a/llama b/llama new file mode 120000 index 0000000..6bcc551 --- /dev/null +++ b/llama @@ -0,0 +1 @@ +node_modules/node-llama-cpp/llama \ No newline at end of file diff --git a/package.json b/package.json index 8c05ae4..3aa614a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "build": "NODE_ENV=production bun run scripts/build.ts", + "postinstall": "ln -sf node_modules/node-llama-cpp/llama llama", "test": "bun test" }, "keywords": [ diff --git a/src/embed/embeddingService.ts b/src/embed/embeddingService.ts index 062fb14..10e9672 100644 --- a/src/embed/embeddingService.ts +++ b/src/embed/embeddingService.ts @@ -14,6 +14,7 @@ import { join } from 'node:path'; import { homedir } from 'node:os'; import { ensureModel } from './modelDownloader'; import { log } from '../logger'; +import { generateEmbedding } from '../ingest/embed'; // --------------------------------------------------------------------------- // Constants @@ -64,6 +65,13 @@ export class EmbeddingService { async initialize(modelPath?: string, cacheDir?: string): Promise { if (this.initialized) return; + // Skip real embeddings only if explicitly disabled + if (process.env.BRAIN_EMBED_DISABLE === "true" || process.env.BRAIN_EMBED_DISABLE === "1") { + this.initialized = true; + this._available = false; + return; + } + try { const resolvedModelPath = modelPath ?? await ensureModel( DEFAULT_MODEL, @@ -76,7 +84,8 @@ export class EmbeddingService { // Dynamic import to avoid top-level dependency on node-llama-cpp const { getLlama, LlamaLogLevel } = await import('node-llama-cpp'); - const llama = await getLlama({ gpu: false, logLevel: LlamaLogLevel.error }); + + const llama = await getLlama({ gpu: false, build: "never" as any, logLevel: LlamaLogLevel.error }); this.model = await llama.loadModel({ modelPath: resolvedModelPath }); this.ctx = await this.model.createEmbeddingContext(); @@ -90,8 +99,8 @@ export class EmbeddingService { ); } } catch (err) { - log('warn', 'embedding-service', - `Failed to load embedding model: ${String(err)}. Falling back to hash-based pseudo-embeddings.`, + log('info', 'embedding-service', + 'Real embedding model not available (prebuilt binary missing or incompatible), using hash-based pseudo-embeddings. Set BRAIN_EMBED_DISABLE=true to skip this attempt.', ); this.initialized = true; // Mark initialized so consumers don't block this._available = false; @@ -116,8 +125,9 @@ export class EmbeddingService { * @returns Float32Array of length `dimensions` */ async embed(text: string): Promise { + // Fallback to hash-based pseudo-embeddings when real model unavailable if (!this._available) { - throw new Error('EmbeddingService not available — real model not loaded'); + return generateEmbedding(text); } // Lazy dimension discovery on first real call @@ -147,10 +157,6 @@ export class EmbeddingService { * @returns Array of Float32Array embeddings, same order as input */ async embedBatch(texts: string[]): Promise { - if (!this._available) { - throw new Error('EmbeddingService not available — real model not loaded'); - } - const total = texts.length; const results: Float32Array[] = new Array(total); diff --git a/src/four-opencode-brain.ts b/src/four-opencode-brain.ts index 3357dc6..c72a2a6 100644 --- a/src/four-opencode-brain.ts +++ b/src/four-opencode-brain.ts @@ -1,8 +1,8 @@ import type { Plugin, PluginInput } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin"; import { sessionCache } from "./cache"; -import { log, setSilent } from "./logger"; -import { initBrainDatabase } from "./schema"; +import { log, setSilent, setLogClient } from "./logger"; +import { initBrainDatabase, withDbRetry, checkpointDatabase } from "./schema"; import { ingestPath } from "./ingest"; import { resolveFiles } from "./ingest/loader"; import { embedChunks } from "./ingest/embed"; @@ -62,9 +62,10 @@ const _serverPlugin = async (input: PluginInput) => { sessionCache.reset(); initStatus(client, directory); + setLogClient(client); initVersion(VERSION); log("info", "init", `v${VERSION} loaded`, { pid: process.pid }); - setSilent(true); // suppress all subsequent console output + setSilent(process.env.BRAIN_DEBUG !== "true"); // suppress all subsequent console output unless BRAIN_DEBUG is set // Status published via event bus — TUI subscribes to push-based updates @@ -72,6 +73,7 @@ const _serverPlugin = async (input: PluginInput) => { // Ensure DB + schema on startup try { const db = initBrainDatabase(); + checkpointDatabase(db); // Recover from previous crash + shrink WAL db.close(); } catch (err) { log("error", "schema", `Schema init failed: ${String(err)}`); @@ -297,7 +299,7 @@ const _serverPlugin = async (input: PluginInput) => { const db = initBrainDatabase(); try { const results = await withTimeout( - brainSearch(db, args.query, { + withDbRetry(() => brainSearch(db, args.query, { filters: args.filters, limit: args.limit ?? 20, contentType: (args.contentType ?? "all") as @@ -308,7 +310,7 @@ const _serverPlugin = async (input: PluginInput) => { | "symbol" | "all", project: (args.project as string | undefined) ?? toolCtx.directory, - }), + })), 30_000, `brainSearch(${args.query})`, ); @@ -320,6 +322,10 @@ const _serverPlugin = async (input: PluginInput) => { updateStatus("warning"); return JSON.stringify({ results: [], count: 0, error: "Search timed out — try a simpler query" }); } + if (String(err).includes("SQLITE_BUSY")) { + updateStatus("warning", { text: "Database busy — retry in a moment" }); + return JSON.stringify({ results: [], count: 0, error: "Database is busy. Another operation (ingest) is in progress. Please retry." }); + } const errMsg = `Search failed: ${err instanceof Error ? err.message : String(err)}`; log("error", "search", errMsg, { query: args.query }); updateStatus("error"); @@ -398,55 +404,55 @@ const _serverPlugin = async (input: PluginInput) => { switch (args.mode) { case "add": updateStatus("busy", { text: "Storing memory…" }); - const addResult = memoryAdd(db, { + const addResult = await withDbRetry(() => memoryAdd(db, { type: (args.type ?? "fact") as MemoryInputType, title: args.title as string, content: args.content as string, tags: args.tags as string | undefined, project: args.project as string | undefined, - }); + })); updateStatus("success", { text: "Memory stored", toast: "Memory stored" }); return JSON.stringify(addResult); case "search": return JSON.stringify( - memorySearch(db, { + await withDbRetry(() => memorySearch(db, { query: args.query as string | undefined, type: args.type as string | undefined, tags: args.tags as string | undefined, project: args.project as string | undefined, crossProject: args.crossProject === true, limit: args.limit as number | undefined, - }), + })), ); case "list": return JSON.stringify( - memoryList(db, { + await withDbRetry(() => memoryList(db, { type: args.type as string | undefined, project: args.project as string | undefined, limit: args.limit as number | undefined, offset: args.offset as number | undefined, - }), + })), ); case "forget": updateStatus("busy", { text: "Removing memory…" }); - const forgetOk = memoryForget(db, args.id as string); + const forgetOk = await withDbRetry(() => memoryForget(db, args.id as string)); updateStatus(forgetOk ? "success" : "error", { text: forgetOk ? "Memory removed" : "Memory not found", toast: forgetOk ? "Memory removed" : "Memory not found" }); return JSON.stringify({ ok: forgetOk }); case "diary": { // Auto-detect: if title + content provided → add entry; otherwise → get const diaryDate = (args.diaryDate as string) ?? (args.date as string) ?? new Date().toISOString().split("T")[0]; if (args.diaryTitle && args.diaryContent) { - diaryAdd(db, { title: args.diaryTitle, content: args.diaryContent, date: diaryDate }); - return JSON.stringify(diaryGet(db, diaryDate)); + await withDbRetry(() => diaryAdd(db, { title: args.diaryTitle as string, content: args.diaryContent as string, date: diaryDate })); + return JSON.stringify(await withDbRetry(() => diaryGet(db, diaryDate))); } if (args.title && args.content) { - diaryAdd(db, { title: args.title, content: args.content, date: diaryDate }); - return JSON.stringify(diaryGet(db, diaryDate)); + await withDbRetry(() => diaryAdd(db, { title: args.title as string, content: args.content as string, date: diaryDate })); + return JSON.stringify(await withDbRetry(() => diaryGet(db, diaryDate))); } - return JSON.stringify(diaryGet(db, diaryDate)); + return JSON.stringify(await withDbRetry(() => diaryGet(db, diaryDate))); } case "get": { - const found = memoryGet(db, args.id as string); + const found = await withDbRetry(() => memoryGet(db, args.id as string)); return JSON.stringify(found ?? { error: `Memory not found: ${args.id}` }); } default: @@ -456,6 +462,10 @@ const _serverPlugin = async (input: PluginInput) => { }); } } catch (err) { + if (String(err).includes("SQLITE_BUSY")) { + updateStatus("warning", { text: "Database busy — retry in a moment" }); + return JSON.stringify({ error: "Database is busy. Another operation (ingest) is in progress. Please retry." }); + } const errMsg = `Memory operation failed: ${err instanceof Error ? err.message : String(err)}`; log("error", "memory", errMsg, { mode: args.mode }); return JSON.stringify({ error: errMsg }); diff --git a/src/ingest/chunker.ts b/src/ingest/chunker.ts index 0318c84..5262d45 100644 --- a/src/ingest/chunker.ts +++ b/src/ingest/chunker.ts @@ -188,7 +188,7 @@ function chunkByHeadings( * @param qualifiedSym Optional qualified symbol path to inherit. * @param symKind Optional symbol kind to inherit. */ -function windowChunks( +async function windowChunks( subContent: string, documentId: string, fileId: string, @@ -197,7 +197,7 @@ function windowChunks( baseLine = 1, qualifiedSym?: string, symKind?: string, -): Chunk[] { +): Promise { const chunks: Chunk[] = []; const windowChars = TOKEN_WINDOW * CHARS_PER_TOKEN; const overlapChars = TOKEN_OVERLAP * CHARS_PER_TOKEN; @@ -207,6 +207,7 @@ function windowChunks( if (len === 0) return []; const lineIdx = new LineIndex(subContent); + let chunkCount = 0; for (let offset = 0; offset < len; offset += step) { const end = Math.min(offset + windowChars, len); @@ -228,6 +229,11 @@ function windowChunks( tokenCount: estimateTokens(chunkText), }); + chunkCount++; + if (chunkCount % 50 === 0) { + await new Promise(r => setTimeout(r, 0)); + } + if (end >= len) break; } @@ -248,13 +254,13 @@ function windowChunks( * If the total file ≤ MAX_TOKENS_PER_CHUNK, a full-document chunk is also * appended for top-level content (imports, etc.). */ -function chunkBySymbols( +async function chunkBySymbols( content: string, symbols: ExtractedSymbol[], documentId: string, fileId: string, totalTokenCount: number, -): Chunk[] { +): Promise { const sorted = [...symbols].sort( (a, b) => a.startLine - b.startLine || a.endLine - b.endLine, ); @@ -284,7 +290,7 @@ function chunkBySymbols( }); } else { // Large symbol → split into windows - const windows = windowChunks( + const windows = await windowChunks( symContent, documentId, fileId, @@ -325,12 +331,12 @@ function chunkBySymbols( // Fallback chunking (non-code files or when symbol extraction fails) // --------------------------------------------------------------------------- -function fallbackChunk( +async function fallbackChunk( content: string, documentId: string, fileId: string, totalTokenCount: number, -): Chunk[] { +): Promise { // Content shorter than the window → single document chunk if (totalTokenCount <= TOKEN_WINDOW) { const h = hashContentCached(content); @@ -353,7 +359,7 @@ function fallbackChunk( } // Sliding-window fallback - return windowChunks(content, documentId, fileId, 0); + return await windowChunks(content, documentId, fileId, 0); } // --------------------------------------------------------------------------- @@ -413,7 +419,7 @@ export async function chunkContent(input: ChunkInput): Promise { try { const symbols = await extractSymbols(content, filePath); if (symbols.length > 0) { - return chunkBySymbols(content, symbols, documentId, fileId, totalTokenCount); + return await chunkBySymbols(content, symbols, documentId, fileId, totalTokenCount); } } catch { // Fall through to window chunking @@ -421,5 +427,5 @@ export async function chunkContent(input: ChunkInput): Promise { } // Large files without symbols: sliding windows - return fallbackChunk(content, documentId, fileId, totalTokenCount); + return await fallbackChunk(content, documentId, fileId, totalTokenCount); } diff --git a/src/ingest/embed.ts b/src/ingest/embed.ts index 10193c7..f35f56e 100644 --- a/src/ingest/embed.ts +++ b/src/ingest/embed.ts @@ -106,11 +106,14 @@ export async function embedChunks(db: Database, chunkIds: string[]): Promise, -): void { +function emitProgressEvent(event: string, data: Record): void { if (process.env.BRAIN_DEBUG !== "true") return; - try { - const payload = JSON.stringify({ - event, - ...data, - timestamp: new Date().toISOString(), - }); - process.stderr.write(payload + "\n"); - } catch { - // never crash on progress emission - } + log("debug", `ingest.${event}`, typeof data === "object" ? JSON.stringify(data).slice(0, 500) : String(data)); } // --------------------------------------------------------------------------- @@ -160,10 +148,14 @@ export async function ingestPath( const sp = "brain_ingest_" + generateId(); db.exec(`SAVEPOINT ${sp}`); + // Yield so event loop can handle HTTP requests + tool calls before ingest starts + await new Promise(r => setTimeout(r, 0)); + try { for (const [i, walked] of walkedFiles.entries()) { const filePath = walked.path; const language = walked.language; + const fileStart = Date.now(); emitProgressEvent("ingest.progress", { current: i + 1, @@ -364,6 +356,14 @@ export async function ingestPath( result.filesIndexed++; result.filesProcessed++; options?.progressCallback?.({ current: result.filesProcessed, total: walkedFiles.length }); + + // Per-file timing debug (BRAIN_DEBUG only) + if (process.env.BRAIN_DEBUG === "true") { + log("debug", "ingest", `processed ${result.filesProcessed}/${walkedFiles.length}: ${filePath} (${Date.now() - fileStart}ms)`); + } + + // Yield so event loop can handle HTTP requests + tool calls + await new Promise(r => setTimeout(r, 0)); } // ── Commit ──────────────────────────────────────────────────────── @@ -374,6 +374,9 @@ export async function ingestPath( result.errors.push(`Ingest failed: ${String(err)}`); } + // Checkpoint WAL to keep file manageable after large ingests + checkpointDatabase(db); + result.durationMs = Date.now() - startTime; emitProgressEvent("ingest.done", { result }); return result; diff --git a/src/logger.ts b/src/logger.ts index d43d3c7..0eaef81 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -11,6 +11,11 @@ interface ThrottleState { const throttles = new Map(); let silent = false; +let _logClient: any = null; + +export function setLogClient(client: any): void { + _logClient = client; +} function shouldLog(key: string, intervalMs: number = 60000): boolean { const now = Date.now(); @@ -46,8 +51,18 @@ export function log( const payload = data ? ` ${JSON.stringify(data)}` : ""; const line = `${prefix} ${msg}${payload}`; + // App.log output (plugin mode) — additional structured channel + if (_logClient) { + const appLevel = level === "warn" ? "warn" : level === "error" ? "error" : level === "debug" ? "debug" : "info"; + _logClient.app?.log({ + body: { service: key, level: appLevel, message: msg, extra: data }, + }).catch(() => {}); + // Debug messages go ONLY to app.log — skip console + if (level === "debug") return; + } + + // Console output — preserved for user visibility (info/warn/error) if (level === "error") console.error(line); else if (level === "warn") console.warn(line); - else if (level === "debug") console.error(line); - else console.log(line); + else if (!silent) console.log(line); } diff --git a/src/schema.ts b/src/schema.ts index 92909d2..74c06cc 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -154,11 +154,21 @@ export function openDatabase(dbPath?: string): Database { const db = new Database(resolvedPath); db.exec("PRAGMA journal_mode=WAL;"); - db.exec("PRAGMA busy_timeout=5000;"); + db.exec("PRAGMA busy_timeout=30000;"); db.exec("PRAGMA foreign_keys=ON;"); return db; } +// --------------------------------------------------------------------------- +// WAL checkpoint — best-effort truncate to keep WAL file manageable +// --------------------------------------------------------------------------- + +export function checkpointDatabase(db: Database): void { + try { + db.exec("PRAGMA wal_checkpoint(TRUNCATE);"); + } catch { /* silent — checkpoint is best-effort */ } +} + // --------------------------------------------------------------------------- // Migration: v2 → v3 — add project_hash to documents, create symbols table // --------------------------------------------------------------------------- @@ -896,3 +906,27 @@ function migrateKnowledgeFts(db: Database): void { } } } + +// --------------------------------------------------------------------------- +// Retry-with-backoff wrapper for SQLITE_BUSY +// --------------------------------------------------------------------------- + +/** + * Retry a DB operation on SQLITE_BUSY with exponential backoff. + * SQLITE_BUSY occurs when another connection holds a write lock beyond busy_timeout. + */ +export async function withDbRetry(fn: () => T, maxRetries = 3): Promise { + for (let i = 0; i <= maxRetries; i++) { + try { + return fn(); + } catch (err: any) { + if (i === maxRetries) throw err; + if (err?.code === "SQLITE_BUSY" || String(err).includes("SQLITE_BUSY")) { + await new Promise(r => setTimeout(r, Math.pow(2, i) * 500)); // 500ms, 1s, 2s + continue; + } + throw err; + } + } + throw new Error("unreachable"); +} diff --git a/src/status.ts b/src/status.ts index c026ecb..c30a674 100644 --- a/src/status.ts +++ b/src/status.ts @@ -58,7 +58,7 @@ export function startStatusServer(directory: string): void { }); } catch (err) { // Fallback: if Bun.serve fails (e.g., out of FDs), TUI will use default state - console.error("[brain] Failed to start status server:", err); + _client?.app?.log({ body: { service: "brain", level: "error", message: "Failed to start status server", extra: { error: String(err) } } }).catch(() => {}); return; } diff --git a/src/tui.tsx b/src/tui.tsx index 76f1493..e980ed2 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -11,7 +11,7 @@ import { join } from "path"; function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { const [indicator, setIndicator] = createSignal("•"); - const [status, setStatus] = createSignal(""); + const [status, setStatus] = createSignal("connecting..."); const [version, setVersion] = createSignal(""); const [current, setCurrent] = createSignal(0); const [total, setTotal] = createSignal(0); @@ -19,11 +19,14 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { const [fg, setFg] = createSignal(""); const [busy, setBusy] = createSignal(false); let pulse = 0; + let lastPoll = Date.now(); const theme = () => props.api.theme.current; + const connecting = () => (!version() || Date.now() - lastPoll > 2000) && !busy(); const handleStatus = (data: BrainStatusEvent) => { try { + lastPoll = Date.now(); setVersion(data.version ?? ""); pulse++; @@ -96,8 +99,8 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { const StatusRow = () => ( 🧠 {version()} - {busy() ? : {indicator()}} - {status()} + {busy() ? : {indicator()}} + {connecting() ? "connecting..." : status()} ); @@ -119,8 +122,8 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { 🧠 {version()} - {busy() ? : {indicator()}} - {status()} + {busy() ? : {indicator()}} + {connecting() ? "connecting..." : status()} )} From e1ba9b55ddc0fe30d6444f16d5bc00cda6d0bc06 Mon Sep 17 00:00:00 2001 From: Robby Date: Mon, 8 Jun 2026 21:07:39 +0200 Subject: [PATCH 2/3] feat: Promise-lock + priority queue for embeddings + sidecar roadmap #115 --- ROADMAP.md | 118 ++++++++++++++ package.json | 2 +- scripts/test-llama.ts | 77 +++++++++ src/embed/embeddingService.ts | 284 +++++++++++++++++++++++++++------- src/ingest/embed.ts | 82 ++++++++-- 5 files changed, 487 insertions(+), 76 deletions(-) create mode 100644 scripts/test-llama.ts diff --git a/ROADMAP.md b/ROADMAP.md index eea750c..89a98a7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -306,3 +306,121 @@ Per P48 — Open Source Github Review Automation: CI, linters, and tests are alr | Wave | Status | Issue | |------|--------|-------| | A7 | ✅ Done | #87 | + +--- + +## Wave: Embedding Sidecar Architecture + +> Status: **Planned**. Replaces node-llama-cpp (in-process) with llama.cpp server as a separate sidecar process. + +### Goal +OpenCode communicates with a local llama.cpp server via HTTP (OpenAI-compatible `/v1/embeddings`) instead of embedding node-llama-cpp in the main process. This eliminates init-race conditions, separates CPU-heavy embedding from the main event loop, and allows the sidecar to outlive individual OpenCode sessions. + +### Why + +| Problem | Sidecar Fix | +|---------|-------------| +| `initialize()` race — multiple callers trigger parallel `getLlama()` | Single start via Promise-lock + cross-process lockfile | +| Ingest batch embeddings block search queries | Separate process → separate CPU core; search uses its own HTTP connection | +| node-llama-cpp addon conflicts in Worker threads | No addon in main process at all | +| Process crash takes down embeddings | Sidecar is `detached` — survives parent crash/restart | + +### Components + +| Component | File | Responsibility | +|-----------|------|----------------| +| **EmbeddingSidecarManager** | `src/embed/sidecar/EmbeddingSidecarManager.ts` | Process lifecycle: spawn, health poll, restart, stop | +| **LlamaCppEmbeddingClient** | `src/embed/sidecar/LlamaCppEmbeddingClient.ts` | HTTP client for `/v1/embeddings` + `/health` | +| **Lockfile** | `src/embed/sidecar/lockfile.ts` | Cross-process start guard with PID-based stale detection | +| **Integration** | `src/embed/embeddingService.ts` | Mode switch: `OPENCODE_EMBED_SIDECAR=true` → HTTP; else legacy node-llama-cpp | + +### Architecture + +``` +┌─────────────────────────────────┐ +│ OpenCode Process (Bun) │ +│ ┌───────────────────────────┐ │ +│ │ EmbeddingService │ │ +│ │ ├─ Promise-lock init │ │ +│ │ ├─ Priority queue │ │ +│ │ └─ HTTP client ─────────┼──┼──► POST /v1/embeddings +│ └───────────────────────────┘ │ GET /health +│ ┌───────────────────────────┐ │ +│ │ SidecarManager │ │ +│ │ ├─ spawn (detached) │ │ +│ │ ├─ lockfile guard │ │ +│ │ └─ health poll │ │ +│ └───────────────────────────┘ │ +└─────────────────────────────────┘ + │ spawn + lock + ▼ +┌─────────────────────────────────┐ +│ llama.cpp server (sidecar) │ +│ Port: 8091 (configurable) │ +│ ├─ /health → 200/503 │ +│ └─ /v1/embeddings → vectors │ +│ Model: all-MiniLM-L6-v2.Q8_0 │ +└─────────────────────────────────┘ +``` + +### Health Model + +| State | /health response | Meaning | +|-------|-----------------|---------| +| `live` | Any HTTP response | Process is running, TCP port open | +| `ready` | HTTP 200 | Model loaded, embeddings available | +| `loading` | HTTP 503 + `"loading model"` | Live but not ready | +| `down` | Connection refused / timeout | Process not reachable | + +### GPU Strategy + +| Config | Behavior | +|--------|----------| +| `off` (default) | Always CPU — `-ngl 0` (no GPU layers offloaded) | +| `on` | Try GPU first (`-ngl 999`), fallback to CPU on failure | +| `auto` | Detect GPU availability; if uncertain, prefer CPU | + +CPU always works. GPU is a bonus path with mandatory fallback. + +### Lockfile + +- Path: `/tmp/opencode-embeddings-sidecar.lock` +- Content: `{ pid, createdAt, port, binary, model }` +- Stale detection: PID dead OR lock > 30s old → acquire +- Release: only if our PID matches + +### Configuration (env vars) + +| Variable | Default | Description | +|----------|---------|-------------| +| `OPENCODE_EMBED_SIDECAR` | `false` | Enable sidecar mode | +| `OPENCODE_EMBED_HOST` | `127.0.0.1` | Sidecar bind address | +| `OPENCODE_EMBED_PORT` | `8091` | Sidecar port | +| `OPENCODE_EMBED_MODEL` | `~/.cache/.../all-MiniLM-L6-v2.Q8_0.gguf` | Model path | +| `OPENCODE_EMBED_LLAMA_SERVER` | `llama-server` from PATH | Binary path | +| `OPENCODE_EMBED_GPU` | `auto` | GPU mode | +| `OPENCODE_EMBED_GPU_LAYERS` | `999` | GPU layers to offload | +| `OPENCODE_EMBED_START_TIMEOUT_MS` | `15000` | Max wait for process start | +| `OPENCODE_EMBED_READY_TIMEOUT_MS` | `120000` | Max wait for model load | +| `OPENCODE_EMBED_LOG` | `/tmp/opencode-embed-sidecar.log` | Sidecar log file | + +### Integration Plan + +1. EmbeddingService gains `sidecar` and `sidecarClient` fields +2. `initialize()`: when `OPENCODE_EMBED_SIDECAR=true`, create SidecarManager + Client instead of loading node-llama-cpp +3. `embedDirect()`: swap `this.ctx.getEmbeddingFor()` → `sidecarClient.embed([text])` +4. `dispose()`: add `sidecar.stop()` or leave running (detached) +5. Legacy mode (node-llama-cpp) preserved as default until sidecar is stable + +### Test Plan + +- **Unit**: Lockfile stale detection, status parsing, start-guard Promise dedup +- **Smoke**: Start sidecar → poll /health → POST /v1/embeddings → verify dimensions +- **Manual**: CPU-only test, simulated crash recovery, optional GPU test + +### Acceptance Criteria +- [ ] Sidecar starts on first embed call, survives parent restart +- [ ] Search queries are never blocked by ingest (separate HTTP connections) +- [ ] Lockfile prevents duplicate sidecar processes +- [ ] GPU failure gracefully falls back to CPU +- [ ] Legacy mode continues working unchanged diff --git a/package.json b/package.json index 3aa614a..3a78ebf 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "build": "NODE_ENV=production bun run scripts/build.ts", - "postinstall": "ln -sf node_modules/node-llama-cpp/llama llama", + "postinstall": "ln -sf node_modules/node-llama-cpp/llama llama && rm -rf node_modules/node-llama-cpp/bins/linux-x64 && cp -r node_modules/@node-llama-cpp/linux-x64/bins/linux-x64 node_modules/node-llama-cpp/bins/linux-x64", "test": "bun test" }, "keywords": [ diff --git a/scripts/test-llama.ts b/scripts/test-llama.ts new file mode 100644 index 0000000..5b34885 --- /dev/null +++ b/scripts/test-llama.ts @@ -0,0 +1,77 @@ +// scripts/test-llama.ts — Verify node-llama-cpp binary + model loading +import { existsSync, readdirSync, statSync } from "fs"; +import { resolve, join } from "path"; +import { homedir } from "os"; + +const brainDir = resolve(import.meta.dir || ".", ".."); +const modelPath = join(homedir(), ".cache", "four-opencode-brain", "models", "all-MiniLM-L6-v2.Q8_0.gguf"); + +console.log("=== Environment ==="); +console.log("cwd:", process.cwd()); +console.log("brainDir:", brainDir); +console.log("platform:", process.platform, "arch:", process.arch); + +console.log("\n=== Binary Check ==="); +const binsDir = join(brainDir, "node_modules", "node-llama-cpp", "bins", "linux-x64"); +console.log("binsDir:", binsDir); +console.log("exists:", existsSync(binsDir)); +if (existsSync(binsDir)) { + const files = readdirSync(binsDir); + console.log("files:", files.length); + files.forEach(f => { + const s = statSync(join(binsDir, f)); + console.log(` ${f} (${s.size} bytes)`); + }); +} + +const addonPath = join(binsDir, "llama-addon.node"); +console.log("addon exists:", existsSync(addonPath)); +const metaPath = join(binsDir, "_nlcBuildMetadata.json"); +console.log("metadata exists:", existsSync(metaPath)); + +console.log("\n=== Model Check ==="); +console.log("modelPath:", modelPath); +console.log("exists:", existsSync(modelPath)); +if (existsSync(modelPath)) { + console.log("size:", (statSync(modelPath).size / 1024 / 1024).toFixed(1), "MB"); +} + +console.log("\n=== node-llama-cpp Import ==="); +try { + const nlc = await import("node-llama-cpp"); + console.log("import OK, exports:", Object.keys(nlc).slice(0, 10)); +} catch (e: any) { + console.error("IMPORT FAILED:", e.message); +} + +console.log("\n=== getLlama() ==="); +try { + const { getLlama } = await import("node-llama-cpp"); + console.log("Calling getLlama({ gpu: false, build: 'never', logLevel: 5 })..."); + const llama = await getLlama({ gpu: false, build: "never" as any, logLevel: 5 } as any); + console.log("getLlama OK, gpu:", llama.gpu); + + console.log("\n=== loadModel() ==="); + try { + const model = await llama.loadModel({ modelPath }); + console.log("loadModel OK, contextSize:", model.contextSize); + + console.log("\n=== createEmbeddingContext() ==="); + try { + const ctx = await model.createEmbeddingContext(); + console.log("createEmbeddingContext OK"); + + console.log("\n=== Test Embed ==="); + const emb = await ctx.getEmbeddingFor("Hello world"); + console.log("embedding vector length:", emb.vector.length); + + console.log("\n✅ ALL PASSED — embedding model works!"); + } catch (e: any) { + console.error("createEmbeddingContext FAILED:", e.message); + } + } catch (e: any) { + console.error("loadModel FAILED:", e.message); + } +} catch (e: any) { + console.error("getLlama FAILED:", e.message, "\nstack:", e.stack?.slice(0, 500)); +} diff --git a/src/embed/embeddingService.ts b/src/embed/embeddingService.ts index 10e9672..734f277 100644 --- a/src/embed/embeddingService.ts +++ b/src/embed/embeddingService.ts @@ -6,10 +6,13 @@ * * Falls back to hash-based pseudo-embeddings when model is unavailable. * + * Features a priority queue: search jobs (from embed()) are processed before + * ingest jobs (from embedBatch()). The queue supports interleaving so that + * interactive search queries are not blocked by bulk ingestion. + * * @module */ -import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { ensureModel } from './modelDownloader'; @@ -24,6 +27,31 @@ const DEFAULT_MODEL = 'all-MiniLM-L6-v2.Q8_0'; const DEFAULT_CACHE_DIR = join(homedir(), '.cache', 'four-opencode-brain', 'models'); const MAX_TEXT_LENGTH = 8192; +// --------------------------------------------------------------------------- +// Priority Queue Types +// --------------------------------------------------------------------------- + +/** + * The kind of embedding job — used to prioritise search over ingest. + * - 'search': high-priority, single-text (from embed()) + * - 'ingest': low-priority, multi-text chunk (from embedBatch()) + */ +type EmbedJobKind = 'search' | 'ingest'; + +/** + * A unit of work in the embedding priority queue. + */ +interface EmbedJob { + /** Job kind — search jobs are dequeued before ingest jobs */ + kind: EmbedJobKind; + /** Texts to embed (1 for search, up to 10 for ingest chunks) */ + texts: string[]; + /** Called with results when the job completes successfully */ + resolve: (results: Float32Array[]) => void; + /** Called with the error when the job fails */ + reject: (err: Error) => void; +} + // --------------------------------------------------------------------------- // EmbeddingService // --------------------------------------------------------------------------- @@ -37,6 +65,13 @@ export class EmbeddingService { private initialized = false; private _available = false; // true when real model loaded successfully + // Promise-lock for initialize() — parallel callers await the SAME promise + private initPromise: Promise | null = null; + + // Priority queue for embedding jobs + private queue: EmbedJob[] = []; + private processing = false; + private constructor() {} // ----------------------------------------------------------------------- @@ -58,53 +93,50 @@ export class EmbeddingService { * Initialize the embedding model. Downloads the GGUF model on first use * and loads it via node-llama-cpp. * + * Uses a Promise-lock pattern: multiple parallel callers receive the same + * promise. After init completes (success or failure), `initPromise` stays + * set so subsequent calls still return it. + * * @param modelPath Optional path to a pre-downloaded GGUF model file. * If omitted, downloads all-MiniLM-L6-v2.Q8_0 automatically. * @param cacheDir Optional cache directory (default: ~/.cache/four-opencode-brain/models/) */ async initialize(modelPath?: string, cacheDir?: string): Promise { if (this.initialized) return; + if (this.initPromise) return this.initPromise; - // Skip real embeddings only if explicitly disabled - if (process.env.BRAIN_EMBED_DISABLE === "true" || process.env.BRAIN_EMBED_DISABLE === "1") { - this.initialized = true; - this._available = false; - return; - } - - try { - const resolvedModelPath = modelPath ?? await ensureModel( - DEFAULT_MODEL, - cacheDir ?? DEFAULT_CACHE_DIR, - ); - - if (!existsSync(resolvedModelPath)) { - throw new Error(`Model file not found: ${resolvedModelPath}`); + this.initPromise = (async () => { + if (process.env.BRAIN_EMBED_DISABLE === "true" || process.env.BRAIN_EMBED_DISABLE === "1") { + this.initialized = true; + this._available = false; + return; } - // Dynamic import to avoid top-level dependency on node-llama-cpp - const { getLlama, LlamaLogLevel } = await import('node-llama-cpp'); - - const llama = await getLlama({ gpu: false, build: "never" as any, logLevel: LlamaLogLevel.error }); - this.model = await llama.loadModel({ modelPath: resolvedModelPath }); - this.ctx = await this.model.createEmbeddingContext(); - - // Lazy dimension discovery on first embed() call - this._available = true; - this.initialized = true; - - if (process.env.BRAIN_DEBUG === 'true') { - log('debug', 'embedding-service', - `Initialized via node-llama-cpp (model: ${resolvedModelPath})`, - ); + try { + const resolvedModelPath = modelPath ?? await ensureModel(DEFAULT_MODEL, cacheDir ?? DEFAULT_CACHE_DIR); + log("debug", "embedding-service", "Resolving node-llama-cpp from plugin location", { metaUrl: import.meta.url }); + const llamaEntry = import.meta.resolve("node-llama-cpp"); + log("debug", "embedding-service", "Resolved node-llama-cpp entry", { entry: llamaEntry }); + const { getLlama } = await import(llamaEntry); + // Skip binding binary test — fails in Worker context but binary works + process.env.NODE_LLAMA_CPP_BINDING_TEST_LOG_LEVEL = "silent"; + process.env.NODE_LLAMA_CPP_SKIP_BINDING_TEST = "true"; + log("debug", "embedding-service", "Calling getLlama", { gpu: false, build: "auto" }); + const llama = await getLlama({ gpu: false, build: "auto" as any} as any); + log("debug", "embedding-service", "getLlama succeeded", { gpu: llama.gpu }); + this.model = await llama.loadModel({ modelPath: resolvedModelPath }); + this.ctx = await this.model.createEmbeddingContext(); + this._available = true; + log("info", "embedding-service", "Real embedding model loaded successfully"); + } catch (err) { + this._available = false; + log("error", "embedding-service", "Embedding init failed, falling back to hash-based", { error: String(err) }); + } finally { + this.initialized = true; } - } catch (err) { - log('info', 'embedding-service', - 'Real embedding model not available (prebuilt binary missing or incompatible), using hash-based pseudo-embeddings. Set BRAIN_EMBED_DISABLE=true to skip this attempt.', - ); - this.initialized = true; // Mark initialized so consumers don't block - this._available = false; - } + })(); + + return this.initPromise; } /** @@ -118,6 +150,10 @@ export class EmbeddingService { /** * Generate an embedding vector for a single text string. * + * This is the high-priority path — if no queue contention exists the call + * is dispatched directly (lowest latency). When the queue is busy the job + * is unshifted to the front as a 'search' priority. + * * - Empty strings → zero vector (all zeros, length `dimensions`) * - Long strings (>8192 chars) → truncated to 8192 chars * @@ -130,43 +166,67 @@ export class EmbeddingService { return generateEmbedding(text); } - // Lazy dimension discovery on first real call - if (this.dimensions === 0) { - const probe = await this.ctx.getEmbeddingFor('test'); - this.dimensions = probe.vector.length; - if (process.env.BRAIN_DEBUG === 'true') { - log('debug', 'embedding-service', `Dimensions resolved: ${this.dimensions}d`); - } + // Fast path: no queue contention → call directly for lowest latency + if (this.queue.length === 0 && !this.processing) { + return this.embedDirect(text); } - if (!text || text.length === 0) { - return new Float32Array(this.dimensions); - } - - const truncated = - text.length > MAX_TEXT_LENGTH ? text.slice(0, MAX_TEXT_LENGTH) : text; - - const result = await this.ctx.getEmbeddingFor(truncated); - return new Float32Array(result.vector); + // Queue path: add as high-priority search job (unshift to front) + return new Promise((resolve, reject) => { + const job: EmbedJob = { + kind: 'search', + texts: [text], + resolve: (results) => resolve(results[0]), + reject, + }; + this.queue.unshift(job); + this.pump(); + }); } /** * Generate embeddings for multiple texts in batch. * + * This is the low-priority ingest path. Texts are split into chunks of 10 + * and each chunk is pushed as an 'ingest' job to the back of the queue, + * allowing interleaving search jobs to be processed first. + * * @param texts Array of input texts * @returns Array of Float32Array embeddings, same order as input */ async embedBatch(texts: string[]): Promise { + // Fallback to hash-based pseudo-embeddings when real model unavailable + if (!this._available) { + return texts.map(generateEmbedding); + } + const total = texts.length; const results: Float32Array[] = new Array(total); - for (let i = 0; i < total; i++) { - results[i] = await this.embed(texts[i]); + // Split into chunks of 10 and process each through the queue sequentially + for (let offset = 0; offset < total; offset += 10) { + const chunk = texts.slice(offset, offset + 10); + + const chunkResults = await new Promise((resolve, reject) => { + const job: EmbedJob = { + kind: 'ingest', + texts: chunk, + resolve, + reject, + }; + this.queue.push(job); + this.pump(); + }); + + // Place results in the correct position + for (let i = 0; i < chunkResults.length; i++) { + results[offset + i] = chunkResults[i]; + } // Yield to event loop every 100 items when batching large sets - if (total > 200 && (i + 1) % 100 === 0) { + if (total > 200 && (offset + 10) % 100 === 0) { if (process.env.BRAIN_DEBUG === 'true') { - log('debug', 'embedding-service', `Embedding progress: ${i + 1}/${total} chunks`); + log('debug', 'embedding-service', `Embedding progress: ${Math.min(offset + 10, total)}/${total} chunks`); } await Bun.sleep(0); } @@ -185,9 +245,18 @@ export class EmbeddingService { /** * Release all underlying resources (llama model + context). + * Rejects all pending queue jobs with an error. * This instance must not be used after disposal. */ dispose(): void { + // Reject all pending queue jobs + const disposeErr = new Error('EmbeddingService disposed'); + for (const job of this.queue) { + job.reject(disposeErr); + } + this.queue = []; + this.processing = false; + try { this.ctx?.dispose(); } catch { @@ -203,6 +272,7 @@ export class EmbeddingService { this.dimensions = 0; this.ctx = null; this.model = null; + this.initPromise = null; } /** @@ -214,4 +284,102 @@ export class EmbeddingService { EmbeddingService.instance = null as any; } } + + // ----------------------------------------------------------------------- + // Private — Priority Queue + // ----------------------------------------------------------------------- + + /** + * Execute a single embedding call against the real model. + * + * Lazy-discovers the model's embedding dimension on the first call. + * Falls back to generateEmbedding() on any error. + * + * @param text Input text to embed + * @returns Float32Array embedding vector + */ + private async embedDirect(text: string): Promise { + try { + // Lazy dimension discovery on first real call + if (this.dimensions === 0) { + const probe = await this.ctx.getEmbeddingFor('test'); + this.dimensions = probe.vector.length; + if (process.env.BRAIN_DEBUG === 'true') { + log('debug', 'embedding-service', `Dimensions resolved: ${this.dimensions}d`); + } + } + + if (!text || text.length === 0) { + return new Float32Array(this.dimensions); + } + + const truncated = + text.length > MAX_TEXT_LENGTH ? text.slice(0, MAX_TEXT_LENGTH) : text; + + const result = await this.ctx.getEmbeddingFor(truncated); + return new Float32Array(result.vector); + } catch (err) { + log("warn", "embedding-service", "Real embedding failed, falling back to hash-based", { error: String(err) }); + return generateEmbedding(text); + } + } + + /** + * Process the job queue. + * + * Guarded by `this.processing` to prevent concurrent pump loops. + * Prioritises 'search' jobs over 'ingest' jobs on every dequeue. + * For ingest jobs with >10 texts, only the first 10 are processed and + * the remainder is re-queued at the back. + * On error, all remaining queue items are rejected and the queue is cleared. + */ + private async pump(): Promise { + if (this.processing) return; + this.processing = true; + + try { + while (this.queue.length > 0) { + // Prioritise search jobs: find the first 'search' job, or take the front + const searchIdx = this.queue.findIndex((j) => j.kind === 'search'); + const job = searchIdx >= 0 + ? this.queue.splice(searchIdx, 1)[0] + : this.queue.shift()!; + + // For ingest jobs with >10 texts, process only first 10 then re-queue remainder + let textsToProcess = job.texts; + if (job.kind === 'ingest' && job.texts.length > 10) { + textsToProcess = job.texts.slice(0, 10); + const remainder = job.texts.slice(10); + this.queue.push({ ...job, texts: remainder }); + } + + // Process each text sequentially through embedDirect + try { + const results: Float32Array[] = []; + for (const text of textsToProcess) { + const vec = await this.embedDirect(text); + results.push(vec); + } + job.resolve(results); + } catch (err) { + // On error: reject this job and all remaining queue items + job.reject(err as Error); + const errorMessage = err instanceof Error ? err.message : String(err); + const disposeErr = new Error(`Embedding pipeline error: ${errorMessage}`); + for (const remaining of this.queue) { + remaining.reject(disposeErr); + } + this.queue = []; + return; + } + } + } finally { + this.processing = false; + + // If new jobs arrived while we were finishing (race edge), restart the pump + if (this.queue.length > 0) { + this.pump(); + } + } + } } diff --git a/src/ingest/embed.ts b/src/ingest/embed.ts index f35f56e..9db2031 100644 --- a/src/ingest/embed.ts +++ b/src/ingest/embed.ts @@ -123,16 +123,20 @@ export async function embedChunks(db: Database, chunkIds: string[]): Promise( "SELECT content, content_hash FROM chunks WHERE id = ?", ); - const insertStmt = db.query( - "INSERT OR IGNORE INTO chunks_vec (chunk_id, embedding) VALUES (?, ?)", - ); + interface ChunkInfo { + chunkId: string; + content: string; + contentHash: string; + vec?: Float32Array; + } + + const preCached: ChunkInfo[] = []; + const toEmbed: ChunkInfo[] = []; for (const chunkId of chunkIds) { try { @@ -140,23 +144,67 @@ export async function embedChunks(db: Database, chunkIds: string[]): Promise 0) { + if (useRealEmbeddings) { + // Use batch embedding for real embeddings + const texts = toEmbed.map((c) => c.content); + try { + const batchResults = await embService.embedBatch(texts); + for (let i = 0; i < toEmbed.length; i++) { + const vec = batchResults[i]; + toEmbed[i].vec = vec; + sessionCache.embeddings.set(toEmbed[i].contentHash, vec); + } + } catch (err) { + log("warn", "embed", `Batch embedding failed, falling back to per-chunk pseudo-embeddings: ${String(err)}`); + // Fallback: generate pseudo-embeddings individually + for (const chunk of toEmbed) { + const vec = generateEmbedding(chunk.content); + chunk.vec = vec; + sessionCache.embeddings.set(chunk.contentHash, vec); } - sessionCache.embeddings.set(content_hash, vec); } + } else { + // Generate pseudo-embeddings for each + for (const chunk of toEmbed) { + const vec = generateEmbedding(chunk.content); + chunk.vec = vec; + sessionCache.embeddings.set(chunk.contentHash, vec); + } + } + } + + // ── Insert ALL (pre-cached + freshly embedded) into chunks_vec ──────── + let embedded = 0; + const insertStmt = db.query( + "INSERT OR IGNORE INTO chunks_vec (chunk_id, embedding) VALUES (?, ?)", + ); - // Insert into vec0 — float32 blob - insertStmt.run(chunkId, float32ToBlob(vec)); - embedded++; + const allChunks = [...preCached, ...toEmbed]; + for (const chunk of allChunks) { + try { + if (chunk.vec) { + insertStmt.run(chunk.chunkId, float32ToBlob(chunk.vec)); + embedded++; + } } catch (err) { - log("warn", "embed-chunk", `Failed to embed chunk ${chunkId}: ${String(err)}`); + log("warn", "embed-chunk", `Failed to insert embedding for chunk ${chunk.chunkId}: ${String(err)}`); } } From 681edd13357c5000be3ad64d0d7e881dfb0d95a2 Mon Sep 17 00:00:00 2001 From: 4 Bytes Robby Date: Mon, 8 Jun 2026 22:00:47 +0200 Subject: [PATCH 3/3] fix: disable llama embeddings by default, switch to opt-in via BRAIN_EMBED_ENABLE #115 --- bun.lock | 8 +++----- package.json | 2 +- src/embed/embeddingService.ts | 2 +- src/ingest/embed.ts | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/bun.lock b/bun.lock index 672de6c..97d9463 100644 --- a/bun.lock +++ b/bun.lock @@ -355,7 +355,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="], + "msgpackr": ["msgpackr@2.0.3", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-vFKpMYFTEQujRQxvdS/u6zlfesws0J40K74w6E1fVsYnIa9WKJKB5xIVVON8L7S39hCNrCVGXcPjrYmCb9lT+w=="], "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], @@ -423,7 +423,7 @@ "s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="], - "semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="], + "semver": ["semver@7.8.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="], "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], @@ -469,7 +469,7 @@ "tree-sitter-typescript": ["tree-sitter-typescript@0.23.2", "", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2", "tree-sitter-javascript": "^0.23.1" }, "peerDependencies": { "tree-sitter": "^0.21.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-e04JUUKxTT53/x3Uq1zIL45DoYKVfHH4CZqwgZhPg5qYROl5nQjV+85ruFzFGZxu+QeFVbRTPDRnqL9UbU4VeA=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -515,8 +515,6 @@ "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], - "bun-ffi-structs/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/package.json b/package.json index 3a78ebf..88ea709 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@four-bytes/four-opencode-brain", - "version": "1.7.0", + "version": "1.7.1", "description": "Unified brain plugin — single SQLite DB for RAG search, memory, and knowledge base", "license": "Apache-2.0", "type": "module", diff --git a/src/embed/embeddingService.ts b/src/embed/embeddingService.ts index 734f277..36472a1 100644 --- a/src/embed/embeddingService.ts +++ b/src/embed/embeddingService.ts @@ -106,7 +106,7 @@ export class EmbeddingService { if (this.initPromise) return this.initPromise; this.initPromise = (async () => { - if (process.env.BRAIN_EMBED_DISABLE === "true" || process.env.BRAIN_EMBED_DISABLE === "1") { + if (process.env.BRAIN_EMBED_ENABLE !== "true" && process.env.BRAIN_EMBED_ENABLE !== "1") { this.initialized = true; this._available = false; return; diff --git a/src/ingest/embed.ts b/src/ingest/embed.ts index 9db2031..0c51ba1 100644 --- a/src/ingest/embed.ts +++ b/src/ingest/embed.ts @@ -107,7 +107,7 @@ export async function embedChunks(db: Database, chunkIds: string[]): Promise