Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 3 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions llama
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
{
"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",
"scripts": {
"build": "NODE_ENV=production bun run scripts/build.ts",
"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": [
Expand Down
77 changes: 77 additions & 0 deletions scripts/test-llama.ts
Original file line number Diff line number Diff line change
@@ -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));
}
Loading
Loading