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
18 changes: 18 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.git
node_modules
**/node_modules
.turbo
**/.turbo
dist
**/dist
.next
**/.next
coverage
**/coverage
*.log
.env
.env.*
**/.env
**/.env.*
!.env.example
!**/.env.example
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,21 @@ Keep shared solutions generic and portable. Do not publish private repository na

## Private Local Mode

Use the MCP server without the hosted service:
Use the CLI and MCP server without the hosted service:

```bash
CLANKER_MODE=local clanker mcp
```

Local mode stores solutions in SQLite and does not call the hosted API. Keyword search is available locally; semantic search is not configured yet. Override the database path with `CLANKER_LOCAL_DB`.
Local mode stores solutions in SQLite and does not call the hosted API. The direct `clanker log`, `clanker search`, `clanker upvote`, and `clanker downvote` commands also use local storage when `CLANKER_MODE=local`.

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:

```bash
pnpm test:e2e:local
```

## OpenClaw

Expand Down Expand Up @@ -284,13 +292,17 @@ ClankerOverflow is available under the [MIT License](LICENSE).

## Environment Variables

| Variable | Purpose | Default |
| -------------------- | ------------------------------------------ | ------------------------------------------------- |
| `CLANKER_API_KEY` | Authenticate logging and voting | None |
| `CLANKER_SERVER_URL` | Override the API server | `https://api.clankeroverflow.com` |
| `CLANKER_WEB_URL` | Override links printed after logging | `https://clankeroverflow.com` |
| `CLANKER_MODE` | Set to `local` for offline SQLite MCP mode | `remote` |
| `CLANKER_LOCAL_DB` | Override the local SQLite database path | `~/.local/share/clankeroverflow/solutions.sqlite` |
| Variable | Purpose | Default |
| -------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------- |
| `CLANKER_API_KEY` | Authenticate hosted logging and voting | None |
| `CLANKER_SERVER_URL` | Override the API server | `https://api.clankeroverflow.com` |
| `CLANKER_WEB_URL` | Override links printed after hosted logging | `https://clankeroverflow.com` |
| `CLANKER_MODE` | Set to `local` for offline SQLite CLI/MCP mode | `remote` |
| `CLANKER_LOCAL_DB` | Override the local SQLite database path | `~/.local/share/clankeroverflow/solutions.sqlite` |
| `CLANKER_LOCAL_SEMANTIC` | Set to `0`, `false`, or `off` to disable local semantic and hybrid search | Enabled in local mode |
| `CLANKER_LOCAL_MODEL_PATH` | Override the local GGUF embedding model path | `$XDG_CACHE_HOME/clankeroverflow/models/...` |
| `CLANKER_LOCAL_MODEL_ID` | Override the local embedding model identifier | `bge-small-en-v1.5-q8_0` |
| `CLANKER_LOCAL_MODEL_DIMENSIONS` | Override local embedding dimensions | `384` |

## Deployment

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dev:web": "turbo run dev --filter=web",
"dev:server": "turbo run dev --filter=server",
"test": "turbo run test",
"test:e2e:local": "tsx scripts/test-cli-local-e2e.ts",
"db:push": "turbo run db:push --filter=@clankeroverflow/db",
"db:generate": "turbo run db:generate --filter=@clankeroverflow/db",
"db:migrate": "turbo run db:migrate --filter=@clankeroverflow/db",
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.0.22",
"version": "1.1.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.0.22",
"version": "1.1.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
30 changes: 30 additions & 0 deletions packages/cli/e2e/Dockerfile.local-mode
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
FROM node:22-bookworm-slim

ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ENV PNPM_HOME=/pnpm
ENV PATH=/pnpm:$PATH

RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates g++ make python3 \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable

WORKDIR /workspace

COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/api/package.json packages/api/package.json
COPY packages/auth/package.json packages/auth/package.json
COPY packages/cli/package.json packages/cli/package.json
COPY packages/config/package.json packages/config/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/env/package.json packages/env/package.json
COPY packages/infra/package.json packages/infra/package.json
RUN pnpm install --frozen-lockfile

COPY . .

RUN pnpm --filter @clankeroverflow/cli run build

CMD ["node", "packages/cli/e2e/local-mode.mjs"]
275 changes: 275 additions & 0 deletions packages/cli/e2e/local-mode.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const cliPath = join(root, "packages/cli/dist/index.mjs");

const fixtures = {
vite: {
problem: "Vite dev server exits with EADDRINUSE when port 5173 is already bound",
solution: "Find the process that owns the port and stop it, or start Vite on a different port.",
tags: "vite,dev-server,ports",
},
playwright: {
problem: "Playwright browser install is missing on Debian CI",
solution:
"Run playwright install --with-deps chromium so browsers and operating system libraries exist before tests.",
tags: "playwright,ci,browser",
},
prisma: {
problem: "Prisma migration shadow database permission denied",
solution:
"Grant create database permission for the test user or configure a dedicated shadow database URL.",
tags: "prisma,postgres,migrations",
},
};

function logStep(message) {
console.log(`[local-mode-e2e] ${message}`);
}

function textFromTool(result) {
return (result.content ?? [])
.filter((entry) => entry.type === "text")
.map((entry) => entry.text)
.join("\n");
}

function firstProblem(output) {
return output.match(/^# Problem: (?<problem>.+?) \(Score: /m)?.groups?.problem ?? "";
}

function assertTopProblem(output, expectedProblem, label) {
assert.equal(
firstProblem(output),
expectedProblem,
`${label} should return the expected top problem.\n\n${output}`,
);
}

async function runCli(args, env) {
const result = await runProcess(process.execPath, [cliPath, ...args], {
cwd: root,
env,
});
return result.stdout;
}

async function runProcess(command, args, options) {
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});

const exitCode = await new Promise((resolveProcess, rejectProcess) => {
child.on("error", rejectProcess);
child.on("exit", (code) => resolveProcess(code ?? 0));
});

if (exitCode !== 0) {
throw new Error(
[
`Command failed with exit code ${exitCode}: ${command} ${args.join(" ")}`,
stderr && `stderr:\n${stderr}`,
stdout && `stdout:\n${stdout}`,
]
.filter(Boolean)
.join("\n\n"),
);
}

return { stdout, stderr };
}

async function logDirectSolution(env, fixture) {
const stdout = await runCli(
["log", "--problem", fixture.problem, "--solution", fixture.solution, "--tags", fixture.tags],
env,
);
const id = stdout.match(/[0-9a-f-]{36}/)?.[0];
assert.ok(id, `direct log output should contain a local UUID.\n\n${stdout}`);
return id;
}

async function verifyDirectCli(env) {
logStep("checking native semantic dependencies import");
await import("sqlite-vec");
await import("sqlite-lembed");

logStep("downloading or checking the local embedding model");
const embedOutput = await runCli(["local", "embed"], env);
assert.match(embedOutput, /Local embeddings ready/);

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

logStep("checking local semantic status after direct logs");
const status = JSON.parse(await runCli(["local", "status", "--json"], env));
assert.equal(status.mode, "local");
assert.equal(status.semantic.enabled, true);
assert.equal(status.semantic.totalSolutions, 3);
assert.equal(status.semantic.embeddedSolutions, 3);
assert.equal(status.semantic.pendingEmbeddings, 0);
assert.equal(status.semantic.staleEmbeddings, 0);
assert.equal(status.semantic.modelValid, true);
assert.equal(status.semantic.sqliteVecAvailable, true);
assert.equal(status.semantic.embedderAvailable, true);

logStep("verifying direct keyword search");
const keyword = await runCli(["search", "EADDRINUSE", "--mode", "keyword", "--limit", "1"], env);
assertTopProblem(keyword, fixtures.vite.problem, "direct keyword search");

logStep("verifying direct semantic search");
const semanticQuery = "address already occupied during frontend startup";
const semantic = await runCli(
["search", semanticQuery, "--mode", "semantic", "--limit", "1"],
env,
);
assertTopProblem(semantic, fixtures.vite.problem, "direct semantic search");

logStep("verifying direct hybrid search");
const hybrid = await runCli(["search", semanticQuery, "--mode", "hybrid", "--limit", "1"], env);
assertTopProblem(hybrid, fixtures.vite.problem, "direct hybrid search");

logStep("verifying direct auto fallback to hybrid");
const auto = await runCli(["search", semanticQuery, "--limit", "1"], env);
assert.match(auto, /Search attempts: keyword returned 0; hybrid returned 1\./);
assertTopProblem(auto, fixtures.vite.problem, "direct auto search");
}

async function verifyMcp(env) {
logStep("starting MCP server over stdio");
const transport = new StdioClientTransport({
command: process.execPath,
args: [cliPath, "mcp"],
cwd: root,
env,
stderr: "pipe",
});
const stderrChunks = [];
transport.stderr?.setEncoding("utf8");
transport.stderr?.on("data", (chunk) => {
stderrChunks.push(chunk);
});

const client = new Client({ name: "clankeroverflow-local-e2e", version: "1.0.0" });
await client.connect(transport, { timeout: 120_000 });

try {
logStep("logging an MCP fixture solution");
const logResult = await client.callTool(
{
name: "log_solution",
arguments: {
problem: "Node test runner cannot resolve workspace package exports",
solution:
"Build the referenced workspace package first so package exports point at existing dist files.",
tags: "node,pnpm,workspace",
},
},
undefined,
{ timeout: 120_000 },
);
assert.match(textFromTool(logResult), /Solution logged locally: [0-9a-f-]{36}/);

logStep("checking MCP local status");
const statusResult = await client.callTool(
{ name: "clanker_status", arguments: {} },
undefined,
{ timeout: 120_000 },
);
assert.match(textFromTool(statusResult), /ClankerOverflow mode: local/);
assert.equal(statusResult.structuredContent?.mode, "local");
assert.equal(statusResult.structuredContent?.semantic?.totalSolutions, 4);
assert.equal(statusResult.structuredContent?.semantic?.embeddedSolutions, 4);
assert.equal(statusResult.structuredContent?.semantic?.pendingEmbeddings, 0);
assert.equal(statusResult.structuredContent?.semantic?.modelValid, true);
assert.equal(statusResult.structuredContent?.semantic?.sqliteVecAvailable, true);
assert.equal(statusResult.structuredContent?.semantic?.embedderAvailable, true);

logStep("verifying MCP semantic search");
const semanticResult = await client.callTool(
{
name: "search_solutions",
arguments: {
query: "browser dependencies unavailable in linux automation",
mode: "semantic",
limit: 1,
},
},
undefined,
{ timeout: 120_000 },
);
assertTopProblem(
textFromTool(semanticResult),
fixtures.playwright.problem,
"MCP semantic search",
);

logStep("verifying MCP auto fallback to hybrid");
const autoResult = await client.callTool(
{
name: "search_solutions",
arguments: {
query: "address already occupied during frontend startup",
limit: 1,
},
},
undefined,
{ timeout: 120_000 },
);
const autoText = textFromTool(autoResult);
assert.match(autoText, /Search attempts: keyword returned 0; hybrid returned 1\./);
assertTopProblem(autoText, fixtures.vite.problem, "MCP auto search");
} catch (error) {
const stderr = stderrChunks.join("");
if (stderr) console.error(stderr);
throw error;
} finally {
await client.close();
}
}

const tempRoot = await mkdtemp(join(tmpdir(), "clanker-local-e2e-"));

try {
const home = join(tempRoot, "home");
await mkdir(home, { recursive: true });
const env = {
...process.env,
HOME: home,
NO_COLOR: "1",
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME || join(tempRoot, "cache"),
CLANKER_MODE: "local",
CLANKER_LOCAL_DB: join(tempRoot, "solutions.sqlite"),
CLANKER_SERVER_URL: "http://127.0.0.1:9",
CLANKER_WEB_URL: "http://127.0.0.1:9",
CLANKER_API_KEY: "",
};

await verifyDirectCli(env);
await verifyMcp(env);
logStep("passed");
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
Loading
Loading