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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ Local mode stores solutions in SQLite and does not call the hosted API. The dire

Keyword, semantic, and hybrid search are available locally by default. `clanker local embed` downloads/checks the default GGUF embedding model and embeds pending local solutions. Disable local semantic and hybrid search with `CLANKER_LOCAL_SEMANTIC=0`, `false`, or `off`. Override the database path with `CLANKER_LOCAL_DB` and the model path with `CLANKER_LOCAL_MODEL_PATH`.

The Docker-isolated e2e check is available on demand:
The Docker-isolated e2e check runs the local-mode suite against Node 22 and Node 24 by default:

```bash
pnpm test:e2e:local
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clankeroverflow",
"version": "1.1.0",
"version": "1.2.0",
"description": "Search-first debugging memory for AI coding agents. Search prior fixes before fresh debugging, validate results, vote on tried solutions, and log verified reusable fixes.",
"author": {
"name": "ClankerOverflow",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clankeroverflow",
"version": "1.1.0",
"version": "1.2.0",
"description": "Search-first debugging memory for AI coding agents. Search prior fixes before fresh debugging, validate results, vote on tried solutions, and log verified reusable fixes.",
"author": {
"name": "ClankerOverflow",
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/e2e/Dockerfile.local-mode
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
FROM node:22-bookworm-slim
ARG NODE_IMAGE=node:22-bookworm-slim
FROM ${NODE_IMAGE}

ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ENV PNPM_HOME=/pnpm
Expand Down
29 changes: 25 additions & 4 deletions packages/cli/e2e/local-mode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,35 @@ async function logDirectSolution(env, fixture) {
async function verifyDirectCli(env) {
logStep("checking native semantic dependencies import");
await import("sqlite-vec");
await import("sqlite-lembed");
await import("node-llama-cpp");

logStep("downloading or checking the local embedding model");
logStep("logging a direct CLI fixture before embeddings are available");
const semanticDisabledEnv = { ...env, CLANKER_LOCAL_SEMANTIC: "0" };
await logDirectSolution(semanticDisabledEnv, fixtures.vite);

logStep("checking local semantic status before embedding pending direct logs");
const pendingStatus = JSON.parse(await runCli(["local", "status", "--json"], env));
assert.equal(pendingStatus.mode, "local");
assert.equal(pendingStatus.semantic.enabled, true);
assert.equal(pendingStatus.semantic.totalSolutions, 1);
assert.equal(pendingStatus.semantic.embeddedSolutions, 0);
assert.equal(pendingStatus.semantic.pendingEmbeddings, 1);
assert.equal(pendingStatus.semantic.sqliteVecAvailable, true);
assert.equal(pendingStatus.semantic.embedderAvailable, true);

logStep("verifying direct keyword search works before local embeddings exist");
const preEmbedKeyword = await runCli(
["search", "EADDRINUSE", "--mode", "keyword", "--limit", "1"],
env,
);
assertTopProblem(preEmbedKeyword, fixtures.vite.problem, "pre-embed direct keyword search");

logStep("downloading or checking the local embedding model and embedding pending solutions");
const embedOutput = await runCli(["local", "embed"], env);
assert.match(embedOutput, /Local embeddings ready/);
assert.match(embedOutput, /1 solution\(s\) embedded/);

logStep("logging direct CLI fixture solutions");
await logDirectSolution(env, fixtures.vite);
logStep("logging direct CLI fixture solutions with immediate embeddings");
await logDirectSolution(env, fixtures.playwright);
await logDirectSolution(env, fixtures.prisma);

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"id": "@bernoussama/clankeroverflow",
"name": "ClankerOverflow",
"description": "Search-first debugging memory for AI coding agents. Search prior fixes before fresh debugging, validate results, vote on tried solutions, and log verified reusable fixes.",
"version": "1.1.0",
"version": "1.2.0",
"configSchema": {
"type": "object",
"additionalProperties": false
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clankeroverflow/cli",
"version": "1.1.0",
"version": "1.2.0",
"description": "ClankerOverflow CLI for logging and searching AI agent solutions",
"license": "MIT",
"repository": {
Expand Down Expand Up @@ -51,6 +51,6 @@
"vitest": "4.0.7"
},
"optionalDependencies": {
"sqlite-lembed": "0.0.1-alpha.8"
"node-llama-cpp": "3.18.1"
}
}
4 changes: 2 additions & 2 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function formatLocalStatus(dbPath: string, status: Awaited<ReturnType<LocalBacke
? pc.green("available")
: pc.red(status.sqliteVecError ?? "unavailable")
}`,
`sqlite-lembed: ${
`node-llama-cpp: ${
status.embedderAvailable
? pc.green("available")
: pc.red(status.embedderError ?? "unavailable")
Expand Down Expand Up @@ -291,7 +291,7 @@ export function createProgram(options: CreateProgramOptions = {}) {
detail: status.sqliteVecError ?? "available",
},
{
name: "sqlite-lembed",
name: "node-llama-cpp",
ok: status.embedderAvailable,
detail: status.embedderError ?? "available",
},
Expand Down
37 changes: 30 additions & 7 deletions packages/cli/src/mcp/local-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ import { afterEach, beforeEach, describe, expect, test, vi, type MockInstance }

import { LocalBackend } from "./local-backend";
import { openLocalDb } from "./local-db";
import { embeddingFingerprintForConfig, type LocalSemanticConfig } from "./local-semantic";
import {
embeddingFingerprintForConfig,
floatVectorToBuffer,
LOCAL_EMBEDDER_ID,
type LocalSemanticConfig,
} from "./local-semantic";

function vector(values: number[]) {
return Buffer.from(new Float32Array(values).buffer);
return floatVectorToBuffer(values, values.length);
}

function writeGguf(modelPath: string, contents: string) {
Expand Down Expand Up @@ -103,7 +108,7 @@ describe("CLI local MCP backend", () => {
dimensions: 4,
};
const embedder = {
embed(text: string) {
async embed(text: string) {
return /oauth/i.test(text) ? vector([1, 0, 0, 0]) : vector([0, 1, 0, 0]);
},
};
Expand Down Expand Up @@ -134,7 +139,7 @@ describe("CLI local MCP backend", () => {
dimensions: 4,
};
const embedder = {
embed(text: string) {
async embed(text: string) {
return /oauth/i.test(text) ? vector([1, 0, 0, 0]) : vector([0, 1, 0, 0]);
},
};
Expand Down Expand Up @@ -167,7 +172,7 @@ describe("CLI local MCP backend", () => {
};
const firstBackend = new LocalBackend(dbPath, {
semantic,
embedder: { embed: () => vector([1, 0, 0, 0]) },
embedder: { embed: async () => vector([1, 0, 0, 0]) },
});
await firstBackend.log({
problem: "OAuth callback timeout",
Expand All @@ -177,7 +182,7 @@ describe("CLI local MCP backend", () => {

const freshBackend = new LocalBackend(dbPath, {
semantic,
embedder: { embed: () => vector([1, 0, 0, 0]) },
embedder: { embed: async () => vector([1, 0, 0, 0]) },
});

await expect(freshBackend.status()).resolves.toMatchObject({
Expand All @@ -201,6 +206,24 @@ describe("CLI local MCP backend", () => {
expect(second).not.toBe(first);
});

test("local embedding metadata uses node-llama-cpp", () => {
expect(LOCAL_EMBEDDER_ID).toBe("node-llama-cpp");
});

test("converts embedding vectors to explicit float32 sqlite blobs", () => {
const buffer = floatVectorToBuffer([1.5, -2.25], 2);

expect(buffer).toHaveLength(8);
expect(buffer.readFloatLE(0)).toBe(1.5);
expect(buffer.readFloatLE(4)).toBe(-2.25);
});

test("rejects local embedding vectors with unexpected dimensions", () => {
expect(() => floatVectorToBuffer([1, 2, 3], 4)).toThrow(
"node-llama-cpp returned 3 embedding dimensions",
);
});

test("replacing a model file at the same path makes existing embeddings pending", async () => {
const semantic: LocalSemanticConfig = {
enabled: true,
Expand All @@ -210,7 +233,7 @@ describe("CLI local MCP backend", () => {
};
const backend = new LocalBackend(dbPath, {
semantic,
embedder: { embed: () => vector([1, 0, 0, 0]) },
embedder: { embed: async () => vector([1, 0, 0, 0]) },
});
await backend.log({
problem: "OAuth callback timeout",
Expand Down
10 changes: 5 additions & 5 deletions packages/cli/src/mcp/local-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
} from "./backend";
import { openLocalDb, type LocalDb } from "./local-db";
import {
createSqliteEmbedder,
createLocalEmbedder,
embeddingFingerprintForConfig,
ensureLocalSemanticSchema,
ensureVecTable,
Expand All @@ -31,7 +31,7 @@ export class LocalSemanticSearchNotConfiguredError extends Error {
}

type SearchRow = SolutionResult & { rank: number };
type Embedder = { embed(text: string): Buffer };
type Embedder = { embed(text: string): Promise<Buffer> };

function nowIso() {
return new Date().toISOString();
Expand Down Expand Up @@ -184,7 +184,7 @@ export class LocalBackend implements SolutionBackend {
if (!this.semantic?.enabled) throw new LocalSemanticSearchNotConfiguredError();
await ensureVecTable(this.db, this.semantic.dimensions);
const embedder = await this.resolveEmbedder();
const embedding = embedder.embed(queryEmbeddingText(queryText));
const embedding = await embedder.embed(queryEmbeddingText(queryText));
const rows = this.db
.prepare(
`SELECT solution_id, distance
Expand Down Expand Up @@ -245,7 +245,7 @@ export class LocalBackend implements SolutionBackend {
await ensureVecTable(this.db, this.semantic.dimensions);
const embedder = await this.resolveEmbedder();
const text = solutionEmbeddingText({ problem, solution, tags });
const embedding = embedder.embed(text);
const embedding = await embedder.embed(text);
insertEmbedding(this.db, {
solutionId: id,
model: this.semantic.modelId,
Expand All @@ -260,7 +260,7 @@ export class LocalBackend implements SolutionBackend {
private async resolveEmbedder() {
if (this.embedder) return this.embedder;
if (!this.semantic?.enabled) throw new LocalSemanticSearchNotConfiguredError();
this.embedder = await createSqliteEmbedder(this.db, this.semantic);
this.embedder = await createLocalEmbedder(this.semantic);
return this.embedder;
Comment on lines 260 to 264

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Serialize embedder initialization to prevent duplicate GGUF model loads.

Line 263 assigns this.embedder only after await createLocalEmbedder(...) resolves. If two requests hit resolveEmbedder() concurrently on cold start, both can create separate embedder/model contexts. That can cause avoidable memory pressure and unstable startup under concurrent MCP traffic.

💡 Suggested fix
 export class LocalBackend implements SolutionBackend {
   private db: LocalDb;
   private semantic?: LocalSemanticConfig;
   private embedder?: Embedder;
+  private embedderPromise?: Promise<Embedder>;
@@
   private async resolveEmbedder() {
     if (this.embedder) return this.embedder;
     if (!this.semantic?.enabled) throw new LocalSemanticSearchNotConfiguredError();
-    this.embedder = await createLocalEmbedder(this.semantic);
-    return this.embedder;
+    if (!this.embedderPromise) {
+      this.embedderPromise = createLocalEmbedder(this.semantic)
+        .then((embedder) => {
+          this.embedder = embedder;
+          return embedder;
+        })
+        .catch((error) => {
+          this.embedderPromise = undefined;
+          throw error;
+        });
+    }
+    return this.embedderPromise;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private async resolveEmbedder() {
if (this.embedder) return this.embedder;
if (!this.semantic?.enabled) throw new LocalSemanticSearchNotConfiguredError();
this.embedder = await createSqliteEmbedder(this.db, this.semantic);
this.embedder = await createLocalEmbedder(this.semantic);
return this.embedder;
private async resolveEmbedder() {
if (this.embedder) return this.embedder;
if (!this.semantic?.enabled) throw new LocalSemanticSearchNotConfiguredError();
if (!this.embedderPromise) {
this.embedderPromise = createLocalEmbedder(this.semantic)
.then((embedder) => {
this.embedder = embedder;
return embedder;
})
.catch((error) => {
this.embedderPromise = undefined;
throw error;
});
}
return this.embedderPromise;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/mcp/local-backend.ts` around lines 260 - 264, The
resolveEmbedder() method has a race condition where concurrent calls can both
pass the initial check and trigger createLocalEmbedder() separately, causing
duplicate model loads. Fix this by assigning the promise from
createLocalEmbedder() to this.embedder immediately before awaiting it, rather
than assigning only after the await completes. This way, subsequent concurrent
calls will detect the in-progress promise and await it instead of starting new
embedder creation.

}

Expand Down
51 changes: 26 additions & 25 deletions packages/cli/src/mcp/local-semantic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const DEFAULT_LOCAL_MODEL_FILE = "bge-small-en-v1.5-q8_0.gguf";
export const DEFAULT_LOCAL_MODEL_DIMENSIONS = 384;
export const DEFAULT_LOCAL_MODEL_URL =
"https://huggingface.co/ggml-org/bge-small-en-v1.5-Q8_0-GGUF/resolve/main/bge-small-en-v1.5-q8_0.gguf";
export const LOCAL_EMBEDDER_ID = "sqlite-lembed";
export const LOCAL_EMBEDDER_ID = "node-llama-cpp";
export const LOCAL_EMBEDDING_FORMAT_VERSION = "solution-v1";
export const LOCAL_QUERY_FORMAT_VERSION = "query-v1";

Expand Down Expand Up @@ -54,7 +54,6 @@ export type LocalSemanticStatus = {
};

const sqliteVecLoaded = new WeakSet<LocalDb>();
const lembedLoaded = new WeakSet<LocalDb>();

export function defaultLocalModelPath(env: NodeJS.ProcessEnv = process.env) {
const cacheRoot = env.XDG_CACHE_HOME || join(homedir(), ".cache");
Expand Down Expand Up @@ -177,40 +176,42 @@ export async function loadSqliteVec(db: LocalDb) {
}
}

async function loadLembed(db: LocalDb) {
if (lembedLoaded.has(db)) return;
export async function checkLocalEmbedderAvailable() {
try {
const sqliteLembed = await import("sqlite-lembed");
sqliteLembed.load(db);
lembedLoaded.add(db);
await import("node-llama-cpp");
} catch (error) {
throw new Error(
`sqlite-lembed extension is unavailable: ${error instanceof Error ? error.message : String(error)}`,
`node-llama-cpp embedder is unavailable: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

function ensureTempModelTable(db: LocalDb, config: LocalSemanticConfig) {
export function floatVectorToBuffer(vector: ArrayLike<number>, dimensions: number) {
if (vector.length !== dimensions) {
throw new Error(
`node-llama-cpp returned ${vector.length} embedding dimensions, but CLANKER_LOCAL_MODEL_DIMENSIONS is ${dimensions}`,
);
}
const buffer = Buffer.allocUnsafe(dimensions * Float32Array.BYTES_PER_ELEMENT);
for (let index = 0; index < dimensions; index += 1) {
buffer.writeFloatLE(vector[index]!, index * Float32Array.BYTES_PER_ELEMENT);
}
return buffer;
}

export async function createLocalEmbedder(config: LocalSemanticConfig) {
const modelValidation = validateGgufFile(config.modelPath);
if (!modelValidation.ok) {
throw new Error(modelValidation.error ?? "model file is not valid");
}
db.prepare(
`INSERT OR REPLACE INTO temp.lembed_models(name, model)
SELECT ?, lembed_model_from_file(?)`,
).run(config.modelId, config.modelPath);
}

export async function createSqliteEmbedder(db: LocalDb, config: LocalSemanticConfig) {
await loadLembed(db);
ensureTempModelTable(db, config);
const { getLlama } = await import("node-llama-cpp");
const llama = await getLlama();
const model = await llama.loadModel({ modelPath: config.modelPath });
const context = await model.createEmbeddingContext();
return {
embed(text: string) {
const row = db.prepare("SELECT lembed(?, ?) AS embedding").get(config.modelId, text) as
| { embedding: Buffer | Uint8Array }
| undefined;
if (!row?.embedding) throw new Error("sqlite-lembed returned no embedding");
return Buffer.from(row.embedding);
async embed(text: string) {
const embedding = await context.getEmbeddingFor(text);
return floatVectorToBuffer(embedding.vector, config.dimensions);
},
};
}
Expand Down Expand Up @@ -375,7 +376,7 @@ export async function getLocalSemanticStatus(db: LocalDb, config: LocalSemanticC
let embedderAvailable = true;
let embedderError: string | undefined;
try {
await loadLembed(db);
await checkLocalEmbedderAvailable();
} catch (error) {
embedderAvailable = false;
embedderError = error instanceof Error ? error.message : String(error);
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,8 @@ export function createMcpServer() {
? "sqlite-vec: available"
: `sqlite-vec: ${status.sqliteVecError}`,
status.embedderAvailable
? "sqlite-lembed: available"
: `sqlite-lembed: ${status.embedderError}`,
? "node-llama-cpp: available"
: `node-llama-cpp: ${status.embedderError}`,
].join("\n"),
},
],
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ describe("packages/cli package metadata", () => {
expect(packageJson.dependencies?.["better-sqlite3"]).toBe("12.10.0");
expect(packageJson.dependencies?.mcplog).toBe("^0.0.5");
expect(packageJson.dependencies?.["sqlite-vec"]).toBe("^0.1.9");
expect(packageJson.optionalDependencies?.["sqlite-lembed"]).toBe("0.0.1-alpha.8");
expect(packageJson.optionalDependencies?.["node-llama-cpp"]).toBe("3.18.1");
expect(packageJson.optionalDependencies?.["sqlite-lembed"]).toBeUndefined();
expect(packageJson.dependencies?.zod).toBe("^4.1.13");
expect(packageJson.dependencies?.["@tobilu/qmd"]).toBeUndefined();
expect(packageJson.dependencies?.["node-llama-cpp"]).toBeUndefined();
Expand Down
Loading
Loading