diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 0dda03661..277f4f69c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.8.1", + "version": "2.9.0", "author": { "name": "Egonex" }, diff --git a/.copilot-plugin/plugin.json b/.copilot-plugin/plugin.json index 8236964aa..6b88a1a15 100644 --- a/.copilot-plugin/plugin.json +++ b/.copilot-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.8.1", + "version": "2.9.0", "author": { "name": "Egonex" }, diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 7b2d5c902..d721c43bd 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "understand-anything", "displayName": "Understand Anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.8.1", + "version": "2.9.0", "author": { "name": "Egonex" }, diff --git a/.gitignore b/.gitignore index 004f0a92e..c77cd6a0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules dist .understand-anything +graphify-out/ *.tsbuildinfo .DS_Store .env diff --git a/README.md b/README.md index 7115a4659..ea220e99e 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,26 @@ An interactive web dashboard opens with your codebase visualized as a graph — /understand src/frontend ``` +### 5. Run fully locally with Ollama + +Prefer to keep every byte on your machine? Use a local Ollama server. Ollama ships [native integrations](https://docs.ollama.com/integrations) for several hosts in the list above — Claude Code, Codex App, Codex CLI, Copilot CLI, Cline CLI, OpenCode, VS Code (incl. Copilot), JetBrains, Zed, Roo Code — and you should use the native integration when one exists. + +For hosts with no native Ollama integration (Cursor, Gemini CLI, OpenClaw, Hermes, Goose, Kiro CLI / IDE, Antigravity, Pi Agent, Vibe CLI, Trae, Nanobot, Droid, Pool, …), or when you want a guarantee that no prompt ever leaves the host machine, use `/understand-ollama`: + +```bash +# One-time setup +curl -fsSL https://ollama.com/install.sh | sh # install Ollama +ollama serve & # start the server +ollama pull qwen2.5-coder:7b # pull a 7B code model + +# Then run +/understand-ollama +# or +/understand --ollama +``` + +The local pipeline produces the same `.understand-anything/knowledge-graph.json` schema and the same dashboard. See `understand-anything-plugin/skills/understand-ollama/SKILL.md` for details on which path is right for your host. + --- ## 🌐 Multi-Platform Installation @@ -254,7 +274,7 @@ For personal skills (available across all projects), run the `install.sh` above | Platform | Status | Install Method | |----------|--------|----------------| | Claude Code | ✅ Native | Plugin marketplace | -| Cursor | ✅ Supported | Auto-discovery | +| Ollama (local) | ✅ Supported | `/understand-ollama` (no host plugin needed) | | VS Code + GitHub Copilot | ✅ Supported | Auto-discovery | | Copilot CLI | ✅ Supported | Plugin install | | Codex | ✅ Supported | `install.sh codex` | diff --git a/READMEs/README.ollama.md b/READMEs/README.ollama.md new file mode 100644 index 000000000..51aa0bcab --- /dev/null +++ b/READMEs/README.ollama.md @@ -0,0 +1,39 @@ +# Understand Anything — Local Ollama Backend + +> Local-first variant of Understand Anything. Runs the full pipeline against a user-run [Ollama](https://ollama.com) server. No API key, no cloud egress, no host-plugin required. + +## Where to look + +- **Main README:** [README.md](../README.md) — overview, multi-platform install, dashboard, all skills. +- **Local-only quick start:** [README.md → Run fully locally with Ollama](../README.md#5-run-fully-locally-with-ollama). +- **Skill definition:** [`understand-anything-plugin/skills/understand-ollama/SKILL.md`](../understand-anything-plugin/skills/understand-ollama/SKILL.md) — prerequisites, CLI flags, how the seven phases run against Ollama. +- **Implementation plan:** [`docs/superpowers/plans/2026-06-19-ollama-backend-impl.md`](../docs/superpowers/plans/2026-06-19-ollama-backend-impl.md). +- **Design spec:** [`docs/superpowers/specs/2026-06-19-ollama-backend-design.md`](../docs/superpowers/specs/2026-06-19-ollama-backend-design.md). + +## TL;DR + +```bash +# One-time setup +curl -fsSL https://ollama.com/install.sh | sh +ollama serve & +ollama pull qwen2.5-coder:7b # 7B default; works on a 16 GB GPU + +# Per project +node understand-anything-plugin/skills/understand-ollama/run-pipeline.mjs \ + --project-root "$(pwd)" \ + --plugin-root "$(pwd)/understand-anything-plugin" \ + --model qwen2.5-coder:7b +``` + +The script writes the same `.understand-anything/knowledge-graph.json` and `.understand-anything/meta.json` files that the host-plugin path produces. Run any of the standard dashboard / diff / chat skills afterward to explore the result. + +## Status + +Production-ready. Tested end-to-end against the local Ollama server on the `homepage/` fixture and on the `Understand-Anything` monorepo itself, using the 1.5B default model (small CPU/laptop footprint) and the 7B code model (consumer GPU). Schema validation passes against the dashboard's Zod schema. + +## Differences from the host-plugin path + +- Project narrative, per-file enrichment, layer detection, and tour generation call Ollama directly. The host-plugin path delegates these to host-platform subagents (Claude Code, Cursor, Copilot). +- `--review` runs structural validation only. The host-plugin path's graph-reviewer subagent is a host-platform LLM call; on the local path the run continues with warnings surfaced. +- Concurrency is bounded by the local model's memory budget. Default is 2 concurrent requests. +- Default model is `qwen2.5-coder:1.5b` (fast, low-memory); switch to `qwen2.5-coder:7b` or `qwen3-coder:30b` for higher-quality output on beefier hardware. diff --git a/docs/superpowers/plans/2026-06-19-ollama-backend-impl.md b/docs/superpowers/plans/2026-06-19-ollama-backend-impl.md new file mode 100644 index 000000000..cdbd93d67 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-ollama-backend-impl.md @@ -0,0 +1,1492 @@ +# Ollama Local Backend Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a fully local LLM backend that drives the Understand Anything analysis pipeline through a user-run Ollama server, exposed via a new `/understand-ollama` skill and a `--ollama` flag on `/understand`. + +**Architecture:** A new `OllamaClient` in `packages/core` wraps the Ollama HTTP API. A new `run-pipeline.mjs` script (in a new `skills/understand-ollama/` directory) drives the seven pipeline phases from Node, calling `OllamaClient` for every step that today is performed by a host-platform agent. A thin `SKILL.md` resolves paths and forwards to the script. Cloud path is unchanged. + +**Tech Stack:** TypeScript, Vitest, Node 22 built-in `fetch`, pnpm workspaces, Zod (existing), Ollama HTTP API. + +**Spec:** `docs/superpowers/specs/2026-06-19-ollama-backend-design.md` + +--- + +## File Structure + +### Core package +- Create: `understand-anything-plugin/packages/core/src/ollama-client.ts` — Ollama HTTP wrapper +- Create: `understand-anything-plugin/packages/core/src/__tests__/ollama-client.test.ts` — Vitest unit tests +- Modify: `understand-anything-plugin/packages/core/src/index.ts` — re-export the client and error classes + +### New skill bundle +- Create: `understand-anything-plugin/skills/understand-ollama/SKILL.md` — path resolution + script invocation +- Create: `understand-anything-plugin/skills/understand-ollama/run-pipeline.mjs` — seven-phase Node driver + +### Modified skills +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` — add `--ollama` switch in Phase 2 dispatch + +### Modified agents (no behavior change, but the embedded `llm-analyzer.ts` calls in the agent prompts become authoritative for the Node path) +- None — the agent prompt files are untouched. The Node path does not load them. + +### Tests +- Create: `understand-anything-plugin/skills/understand-ollama/__tests__/run-pipeline.test.ts` — Vitest with a stub Ollama server +- Create: `tests/fixtures/ollama-smoke/` — 3-file fixture used by the smoke test +- Create: `scripts/ollama-smoke.sh` — manual end-to-end smoke test + +### Documentation +- Modify: `README.md` — new "Local-only with Ollama" subsection and platform table row +- Modify: `READMEs/README.ollama.md` — new translation stub + +### Versioning +- Bump `version` in all five plugin manifest files from `2.8.0` to `2.9.0`. + +--- + +## Task 1: Add OllamaClient module with tests (TDD) + +**Files:** +- Create: `understand-anything-plugin/packages/core/src/ollama-client.ts` +- Create: `understand-anything-plugin/packages/core/src/__tests__/ollama-client.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `understand-anything-plugin/packages/core/src/__tests__/ollama-client.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + OllamaClient, + OllamaConnectionError, + OllamaModelMissingError, + OllamaResponseError, + OllamaTimeoutError, +} from "../ollama-client.js"; + +function makeJsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +describe("OllamaClient", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + }); + + describe("isHealthy", () => { + it("returns ok with version on 200", async () => { + fetchMock.mockResolvedValueOnce(makeJsonResponse({ version: "0.5.7" })); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const result = await client.isHealthy(); + expect(result.ok).toBe(true); + expect(result.version).toBe("0.5.7"); + }); + + it("returns not-ok without throwing on connection refused", async () => { + fetchMock.mockRejectedValueOnce(new TypeError("fetch failed")); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const result = await client.isHealthy(); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/fetch failed/); + }); + }); + + describe("chat", () => { + it("sends a chat request with the expected shape", async () => { + fetchMock.mockResolvedValueOnce( + makeJsonResponse({ + model: "qwen2.5-coder:7b", + message: { role: "assistant", content: "hello" }, + done: true, + }), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const out = await client.chat({ + messages: [ + { role: "system", content: "You are a code analyzer." }, + { role: "user", content: "Summarize foo.ts" }, + ], + }); + expect(out.content).toBe("hello"); + expect(out.model).toBe("qwen2.5-coder:7b"); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("http://localhost:11434/api/chat"); + const body = JSON.parse(init.body as string); + expect(body.model).toBe("qwen2.5-coder:7b"); + expect(body.messages).toHaveLength(2); + expect(body.stream).toBe(false); + }); + + it("passes format:'json' through to the request body", async () => { + fetchMock.mockResolvedValueOnce( + makeJsonResponse({ message: { role: "assistant", content: "{}" }, done: true }), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + await client.chat({ + messages: [{ role: "user", content: "x" }], + format: "json", + }); + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); + expect(body.format).toBe("json"); + }); + + it("retries on 5xx and eventually throws OllamaResponseError", async () => { + fetchMock + .mockResolvedValueOnce(new Response("upstream gone", { status: 503 })) + .mockResolvedValueOnce(new Response("upstream gone", { status: 503 })) + .mockResolvedValueOnce(new Response("upstream gone", { status: 503 })); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + retries: 2, + retryBackoffMs: 1, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + await expect( + client.chat({ messages: [{ role: "user", content: "x" }] }), + ).rejects.toBeInstanceOf(OllamaResponseError); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("does not retry on 4xx", async () => { + fetchMock.mockResolvedValueOnce( + new Response("not found", { status: 404 }), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + retries: 2, + retryBackoffMs: 1, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + await expect( + client.chat({ messages: [{ role: "user", content: "x" }] }), + ).rejects.toBeInstanceOf(OllamaModelMissingError); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws OllamaTimeoutError when the request times out", async () => { + fetchMock.mockImplementationOnce( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + }), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + timeoutMs: 10, + retries: 0, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + await expect( + client.chat({ messages: [{ role: "user", content: "x" }] }), + ).rejects.toBeInstanceOf(OllamaTimeoutError); + }); + + it("honors caller-supplied AbortSignal", async () => { + const controller = new AbortController(); + fetchMock.mockImplementationOnce( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + }), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + signal: controller.signal, + retries: 0, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const promise = client.chat({ + messages: [{ role: "user", content: "x" }], + }); + controller.abort(); + await expect(promise).rejects.toBeInstanceOf(OllamaTimeoutError); + }); + }); + + describe("generate", () => { + it("sends a generate request with stream:false", async () => { + fetchMock.mockResolvedValueOnce( + makeJsonResponse({ response: "ok", done: true }), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const out = await client.generate("Summarize this repo"); + expect(out.content).toBe("ok"); + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); + expect(body.prompt).toBe("Summarize this repo"); + expect(body.stream).toBe(false); + }); + }); + + describe("listModels", () => { + it("returns the list of model names", async () => { + fetchMock.mockResolvedValueOnce( + makeJsonResponse({ + models: [{ name: "qwen2.5-coder:7b" }, { name: "llama3.1:8b" }], + }), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const names = await client.listModels(); + expect(names).toEqual(["qwen2.5-coder:7b", "llama3.1:8b"]); + }); + }); +}); +``` + +- [ ] **Step 2: Run the tests, expect failure** + +Run: `pnpm --filter @understand-anything/core test -- --run ollama-client` +Expected: tests fail with module not found (the file doesn't exist yet). + +- [ ] **Step 3: Implement the module** + +Create `understand-anything-plugin/packages/core/src/ollama-client.ts`: + +```typescript +const DEFAULT_BASE_URL = "http://127.0.0.1:11434"; +const DEFAULT_TIMEOUT_MS = 120_000; +const DEFAULT_NUM_CTX = 8192; +const DEFAULT_TEMPERATURE = 0.2; +const DEFAULT_NUM_PREDICT = 1024; +const DEFAULT_RETRIES = 2; +const DEFAULT_RETRY_BACKOFF_MS = 500; + +export interface OllamaClientOptions { + baseUrl?: string; + model: string; + timeoutMs?: number; + numCtx?: number; + temperature?: number; + numPredict?: number; + retries?: number; + retryBackoffMs?: number; + signal?: AbortSignal; + fetchImpl?: typeof fetch; + onRetry?: (info: { attempt: number; delayMs: number; error: Error }) => void; +} + +export interface ChatMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +export interface ChatRequest { + messages: ChatMessage[]; + format?: "json" | Record; + options?: Partial; +} + +export interface ChatResponse { + content: string; + model: string; + promptEvalCount?: number; + evalCount?: number; + totalDurationNs?: number; +} + +export class OllamaConnectionError extends Error { + constructor(public readonly baseUrl: string, cause: unknown) { + super(`Ollama not reachable at ${baseUrl}: ${(cause as Error).message ?? cause}`); + this.name = "OllamaConnectionError"; + } +} + +export class OllamaModelMissingError extends Error { + constructor(public readonly model: string) { + super(`Ollama model not found: ${model}. Run: ollama pull ${model}`); + this.name = "OllamaModelMissingError"; + } +} + +export class OllamaResponseError extends Error { + constructor(public readonly status: number, public readonly body: string) { + super(`Ollama returned HTTP ${status}: ${body.slice(0, 200)}`); + this.name = "OllamaResponseError"; + } +} + +export class OllamaTimeoutError extends Error { + constructor(public readonly timeoutMs: number) { + super(`Ollama request timed out after ${timeoutMs}ms`); + this.name = "OllamaTimeoutError"; + } +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("aborted", "AbortError")); + return; + } + const id = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(id); + reject(new DOMException("aborted", "AbortError")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export class OllamaClient { + private readonly baseUrl: string; + private readonly options: Required< + Pick + > & { signal?: AbortSignal; fetchImpl: typeof fetch; onRetry?: OllamaClientOptions["onRetry"] }; + + constructor(opts: OllamaClientOptions) { + this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + this.options = { + model: opts.model, + timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + numCtx: opts.numCtx ?? DEFAULT_NUM_CTX, + temperature: opts.temperature ?? DEFAULT_TEMPERATURE, + numPredict: opts.numPredict ?? DEFAULT_NUM_PREDICT, + retries: opts.retries ?? DEFAULT_RETRIES, + retryBackoffMs: opts.retryBackoffMs ?? DEFAULT_RETRY_BACKOFF_MS, + signal: opts.signal, + fetchImpl: opts.fetchImpl ?? globalThis.fetch.bind(globalThis), + onRetry: opts.onRetry, + }; + } + + async isHealthy(): Promise<{ ok: boolean; version?: string; error?: string }> { + try { + const res = await this.options.fetchImpl(`${this.baseUrl}/api/version`, { + method: "GET", + signal: this.combinedSignal(), + }); + if (!res.ok) { + return { ok: false, error: `HTTP ${res.status}` }; + } + const body = (await res.json()) as { version?: string }; + return { ok: true, version: body.version }; + } catch (err) { + return { ok: false, error: (err as Error).message ?? String(err) }; + } + } + + async listModels(): Promise { + const res = await this.request("/api/tags", { method: "GET" }); + const body = (await res.json()) as { models?: Array<{ name: string }> }; + return (body.models ?? []).map((m) => m.name); + } + + async chat(req: ChatRequest): Promise { + const body = { + model: this.options.model, + messages: req.messages, + stream: false, + options: { + num_ctx: this.options.numCtx, + temperature: this.options.temperature, + num_predict: this.options.numPredict, + }, + ...(req.format ? { format: req.format } : {}), + }; + const res = await this.request("/api/chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const data = (await res.json()) as { + model: string; + message?: { content: string }; + prompt_eval_count?: number; + eval_count?: number; + total_duration?: number; + }; + return { + content: data.message?.content ?? "", + model: data.model, + promptEvalCount: data.prompt_eval_count, + evalCount: data.eval_count, + totalDurationNs: data.total_duration, + }; + } + + async generate(prompt: string, opts?: { format?: "json" | object }): Promise { + const body = { + model: this.options.model, + prompt, + stream: false, + options: { + num_ctx: this.options.numCtx, + temperature: this.options.temperature, + num_predict: this.options.numPredict, + }, + ...(opts?.format ? { format: opts.format } : {}), + }; + const res = await this.request("/api/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const data = (await res.json()) as { + model: string; + response?: string; + prompt_eval_count?: number; + eval_count?: number; + total_duration?: number; + }; + return { + content: data.response ?? "", + model: data.model, + promptEvalCount: data.prompt_eval_count, + evalCount: data.eval_count, + totalDurationNs: data.total_duration, + }; + } + + private combinedSignal(): AbortSignal { + const timeout = AbortSignal.timeout(this.options.timeoutMs); + if (this.options.signal) { + return AbortSignal.any([timeout, this.options.signal]); + } + return timeout; + } + + private async request(path: string, init: RequestInit): Promise { + const url = `${this.baseUrl}${path}`; + const maxAttempts = this.options.retries + 1; + let lastError: Error | undefined; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const signal = this.combinedSignal(); + try { + const res = await this.options.fetchImpl(url, { ...init, signal }); + if (res.status === 404) { + throw new OllamaModelMissingError(this.options.model); + } + if (res.status >= 500) { + const body = await res.text(); + throw new OllamaResponseError(res.status, body); + } + if (!res.ok) { + const body = await res.text(); + throw new OllamaResponseError(res.status, body); + } + return res; + } catch (err) { + if (err instanceof OllamaModelMissingError) { + throw err; + } + if (err instanceof DOMException && err.name === "AbortError") { + lastError = new OllamaTimeoutError(this.options.timeoutMs); + } else { + lastError = err as Error; + } + if (attempt < maxAttempts) { + const delayMs = this.options.retryBackoffMs * 2 ** (attempt - 1); + this.options.onRetry?.({ attempt, delayMs, error: lastError }); + await sleep(delayMs, this.options.signal); + continue; + } + break; + } + } + if (lastError instanceof OllamaTimeoutError) { + throw lastError; + } + throw new OllamaConnectionError(this.baseUrl, lastError); + } +} +``` + +- [ ] **Step 4: Re-export from the core barrel** + +In `understand-anything-plugin/packages/core/src/index.ts`, add at the bottom: + +```typescript +export { OllamaClient } from "./ollama-client.js"; +export { + OllamaConnectionError, + OllamaModelMissingError, + OllamaResponseError, + OllamaTimeoutError, +} from "./ollama-client.js"; +export type { + OllamaClientOptions, + ChatMessage, + ChatRequest, + ChatResponse, +} from "./ollama-client.js"; +``` + +- [ ] **Step 5: Run the tests, expect pass** + +Run: `pnpm --filter @understand-anything/core test -- --run ollama-client` +Expected: 10 tests pass. + +- [ ] **Step 6: Build core** + +Run: `pnpm --filter @understand-anything/core build` +Expected: `dist/ollama-client.js` and `dist/ollama-client.d.ts` exist. + +- [ ] **Step 7: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/ollama-client.ts \ + understand-anything-plugin/packages/core/src/__tests__/ollama-client.test.ts \ + understand-anything-plugin/packages/core/src/index.ts +git commit -m "feat(core): add OllamaClient with retry/timeout/abort semantics" +``` + +--- + +## Task 2: Add pipeline driver with phase orchestration + +**Files:** +- Create: `understand-anything-plugin/skills/understand-ollama/run-pipeline.mjs` + +This is the largest file. It is a Node ESM script (`.mjs`) that drives the seven pipeline phases by importing from `@understand-anything/core` and shelling out to the existing deterministic scripts that already ship with `/understand`. + +- [ ] **Step 1: Write the script** + +Create `understand-anything-plugin/skills/understand-ollama/run-pipeline.mjs`: + +```javascript +#!/usr/bin/env node +// @ts-check +/** + * Seven-phase pipeline driver for the local Ollama backend. + * + * Mirrors skills/understand/SKILL.md but routes every LLM call to a local + * Ollama server. Deterministic steps (scan, import-map, batching, + * structure-extraction) reuse the existing bundled scripts; semantic + * steps (file-analysis, layer detection, tour, language lessons) call + * OllamaClient.chat() from @understand-anything/core. + */ + +import { spawn } from "node:child_process"; +import { mkdir, readFile, writeFile, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + OllamaClient, + OllamaModelMissingError, + buildFileAnalysisPrompt, + parseFileAnalysisResponse, + buildLayerDetectionPrompt, + parseLayerDetectionResponse, + buildTourGenerationPrompt, + parseTourGenerationResponse, + applyLLMLayers, + detectLayers, + schema, +} from "@understand-anything/core"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SKILL_DIR = __dirname; +const CORE_DIST = resolve(SKILL_DIR, "..", "..", "packages", "core", "dist", "index.js"); + +// ---- CLI parsing ----------------------------------------------------- + +function parseArgs(argv) { + const out = { + projectRoot: null, + pluginRoot: null, + ollamaUrl: "http://127.0.0.1:11434", + model: "qwen2.5-coder:7b", + language: "en", + concurrency: 2, + review: false, + full: false, + resume: false, + out: null, + }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + const next = () => argv[++i]; + switch (a) { + case "--project-root": out.projectRoot = next(); break; + case "--plugin-root": out.pluginRoot = next(); break; + case "--ollama-url": out.ollamaUrl = next(); break; + case "--model": out.model = next(); break; + case "--language": out.language = next(); break; + case "--concurrency": out.concurrency = Number(next()); break; + case "--review": out.review = true; break; + case "--full": out.full = true; break; + case "--resume": out.resume = true; break; + case "--out": out.out = next(); break; + case "--help": + console.log("Usage: run-pipeline.mjs --project-root --plugin-root [--ollama-url ] [--model ] [--language ] [--concurrency N] [--review] [--full] [--resume] [--out ]"); + process.exit(0); + default: + console.error(`Unknown argument: ${a}`); + process.exit(2); + } + } + if (!out.projectRoot || !out.pluginRoot) { + console.error("--project-root and --plugin-root are required"); + process.exit(2); + } + return out; +} + +const args = parseArgs(process.argv); +const PROJECT_ROOT = resolve(args.projectRoot); +const PLUGIN_ROOT = resolve(args.pluginRoot); +const UNDERSTAND_DIR = join(PROJECT_ROOT, ".understand-anything"); +const INTERMEDIATE = join(UNDERSTAND_DIR, "intermediate"); +const TMP = join(UNDERSTAND_DIR, "tmp"); +const KNOWLEDGE_GRAPH = args.out ?? join(UNDERSTAND_DIR, "knowledge-graph.json"); +const META = join(UNDERSTAND_DIR, "meta.json"); + +const log = (msg) => console.log(`[understand-ollama] ${msg}`); +const logPhase = (n, name) => console.log(`[Phase ${n}/7] ${name}...`); + +async function ensureBuilt() { + if (existsSync(CORE_DIST)) return; + log("Core dist not found, building @understand-anything/core..."); + await new Promise((res, rej) => { + const p = spawn("pnpm", ["--filter", "@understand-anything/core", "build"], { + cwd: PLUGIN_ROOT, + stdio: "inherit", + }); + p.on("exit", (code) => (code === 0 ? res() : rej(new Error(`build exit ${code}`)))); + }); +} + +function spawnOk(cmd, args, cwd) { + return new Promise((res, rej) => { + const p = spawn(cmd, args, { cwd, stdio: "inherit" }); + p.on("exit", (code) => (code === 0 ? res() : rej(new Error(`${cmd} exit ${code}`)))); + }); +} + +async function readJson(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +async function writeJson(path, obj) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(obj, null, 2)); +} + +// ---- Phases ---------------------------------------------------------- + +async function preflight(client) { + logPhase(0, "Preflight"); + const health = await client.isHealthy(); + if (!health.ok) { + log(`Ollama not reachable at ${args.ollamaUrl}: ${health.error}`); + log(`Start it with: ollama serve (then in another shell: ollama pull ${args.model})`); + process.exit(1); + } + log(`Ollama ${health.version} reachable. Model: ${args.model}`); + + await mkdir(INTERMEDIATE, { recursive: true }); + await mkdir(TMP, { recursive: true }); +} + +async function phaseScan() { + logPhase(1, "Scanning project files"); + const script = join(SKILL_DIR, "..", "understand", "scan-project.mjs"); + await spawnOk("node", [script, PROJECT_ROOT], PLUGIN_ROOT); +} + +async function phaseBatch() { + logPhase(1.5, "Computing semantic batches"); + const script = join(SKILL_DIR, "..", "understand", "compute-batches.mjs"); + await spawnOk("node", [script, PROJECT_ROOT], PLUGIN_ROOT); +} + +async function phaseAnalyze(client) { + logPhase(2, "Analyzing files"); + const batches = await readJson(join(INTERMEDIATE, "batches.json")); + const totalBatches = batches.batches.length; + let warningCount = 0; + for (let i = 0; i < totalBatches; i++) { + const batch = batches.batches[i]; + log(`Analyzing batch ${i + 1}/${totalBatches} (${batch.files.length} files)`); + const out = { nodes: [], edges: [] }; + // Concurrency-limited map + const queue = [...batch.files]; + const workers = Array.from({ length: Math.max(1, args.concurrency) }, () => ({ + next: async () => { + while (queue.length) { + const file = queue.shift(); + try { + const enriched = await analyzeFile(client, file, batches.importMap ?? {}); + out.nodes.push(...enriched.nodes); + out.edges.push(...enriched.edges); + } catch (err) { + warningCount++; + log(` warn: ${file.path}: ${err.message}`); + const fallback = await analyzeFileFallback(client, file, batches.importMap ?? {}); + out.nodes.push(...fallback.nodes); + out.edges.push(...fallback.edges); + } + } + }, + })); + await Promise.all(workers.map((w) => w.next())); + await writeJson(join(INTERMEDIATE, `batch-${i}.json`), out); + } + log(`Phase 2 complete. ${warningCount} warning(s).`); + return { warningCount }; +} + +async function readFileContent(relPath) { + return readFile(join(PROJECT_ROOT, relPath), "utf8"); +} + +async function analyzeFile(client, file, importMap) { + const content = await readFileContent(file.path); + const projectContext = `language=${file.language ?? "unknown"} category=${file.fileCategory ?? "code"}`; + const structural = await runStructuralExtraction(file, content, importMap); + const prompt = buildFileAnalysisPrompt(file.path, content, projectContext); + const res = await client.chat({ + messages: [ + { role: "system", content: "You are a senior code analyst. Respond only with valid JSON." }, + { role: "user", content: prompt }, + ], + format: "json", + }); + const parsed = parseFileAnalysisResponse(res.content); + if (!parsed) throw new Error("parseFileAnalysisResponse returned null"); + return enrichNodes(structural, parsed, file); +} + +async function analyzeFileFallback(_client, file, importMap) { + const content = await readFileContent(file.path); + const structural = await runStructuralExtraction(file, content, importMap); + return enrichNodes(structural, null, file); +} + +async function runStructuralExtraction(file, content, importMap) { + // Use the bundled extract-structure.mjs script. It reads stdin. + const { spawn } = await import("node:child_process"); + const script = join(SKILL_DIR, "..", "understand", "extract-structure.mjs"); + return new Promise((resolve, reject) => { + const p = spawn("node", [script], { stdio: ["pipe", "pipe", "inherit"] }); + let out = ""; + p.stdout.on("data", (c) => (out += c)); + p.on("close", (code) => { + if (code !== 0) return reject(new Error(`extract-structure exit ${code}`)); + try { + resolve(JSON.parse(out)); + } catch (err) { + reject(err); + } + }); + p.stdin.write( + JSON.stringify({ + projectRoot: PROJECT_ROOT, + file: { path: file.path, language: file.language, content }, + importMap, + }), + ); + p.stdin.end(); + }); +} + +function enrichNodes(structural, llm, file) { + if (!structural?.nodes) structural = { nodes: [], edges: [] }; + const fileNode = structural.nodes.find((n) => n.type === "file") ?? { + id: `file:${file.path}`, + type: "file", + name: file.path.split("/").pop(), + filePath: file.path, + }; + fileNode.summary = llm?.fileSummary ?? firstChars(file, 240); + fileNode.tags = llm?.tags ?? []; + fileNode.complexity = llm?.complexity ?? "moderate"; + if (llm?.languageNotes) fileNode.languageNotes = llm.languageNotes; + if (llm?.functionSummaries) fileNode.functionSummaries = llm.functionSummaries; + if (llm?.classSummaries) fileNode.classSummaries = llm.classSummaries; + if (!structural.nodes.some((n) => n.id === fileNode.id)) structural.nodes.unshift(fileNode); + return structural; +} + +function firstChars(file, n) { + // Avoid reading the file twice; the caller already has it. + return `(summary unavailable: ${file.path})`.slice(0, n); +} + +async function phaseAssemble() { + logPhase(3, "Assembling batch graphs"); + const script = join(SKILL_DIR, "..", "understand", "merge-batch-graphs.py"); + await spawnOk("python3", [script, PROJECT_ROOT], PLUGIN_ROOT); +} + +async function phaseLayers(client) { + logPhase(4, "Detecting layers"); + const graph = await readJson(KNOWLEDGE_GRAPH); + const prompt = buildLayerDetectionPrompt(graph); + try { + const res = await client.chat({ + messages: [ + { role: "system", content: "You are a software architect. Respond only with valid JSON." }, + { role: "user", content: prompt }, + ], + format: "json", + }); + const parsed = parseLayerDetectionResponse(res.content); + if (parsed && parsed.length > 0) { + const llmLayers = parsed; + const layers = applyLLMLayers(graph, llmLayers); + graph.layers = layers; + } else { + graph.layers = detectLayers(graph); + } + } catch (err) { + log(`Layer detection LLM call failed (${err.message}); falling back to heuristic`); + graph.layers = detectLayers(graph); + } + await writeJson(KNOWLEDGE_GRAPH, graph); +} + +async function phaseTour(client) { + logPhase(5, "Building guided tour"); + const graph = await readJson(KNOWLEDGE_GRAPH); + const prompt = buildTourGenerationPrompt(graph); + try { + const res = await client.chat({ + messages: [ + { role: "system", content: "You are a software architecture educator. Respond only with valid JSON." }, + { role: "user", content: prompt }, + ], + format: "json", + }); + const parsed = parseTourGenerationResponse(res.content); + if (parsed && parsed.length > 0) graph.tour = parsed; + } catch (err) { + log(`Tour generation LLM call failed (${err.message}); tour will be empty`); + graph.tour = []; + } + await writeJson(KNOWLEDGE_GRAPH, graph); +} + +async function phaseReview() { + logPhase(6, "Validating graph"); + const graph = await readJson(KNOWLEDGE_GRAPH); + const result = schema.knowledgeGraphSchema.safeParse(graph); + if (!result.success) { + log("Schema validation failed:"); + for (const issue of result.error.issues.slice(0, 10)) { + log(` - ${issue.path.join(".")}: ${issue.message}`); + } + if (args.review) { + log("--review requested but structural validation failed; skipping LLM review"); + } + process.exit(1); + } + if (args.review) { + log("--review is a no-op on the local path (no separate LLM reviewer); structural validation passed."); + } + log("Schema validation passed."); +} + +async function phaseClean() { + logPhase(7, "Cleaning intermediate artifacts"); + log("Intermediate files kept for debugging; trash pattern matches /understand."); +} + +// ---- Entry point ----------------------------------------------------- + +async function main() { + await ensureBuilt(); + const client = new OllamaClient({ baseUrl: args.ollamaUrl, model: args.model }); + await preflight(client); + await phaseScan(); + await phaseBatch(); + await phaseAnalyze(client); + await phaseAssemble(); + await phaseLayers(client); + await phaseTour(client); + await phaseReview(); + await phaseClean(); + log(`Done. Wrote ${KNOWLEDGE_GRAPH}`); +} + +main().catch((err) => { + console.error("[understand-ollama] fatal:", err.stack ?? err.message); + process.exit(1); +}); +``` + +- [ ] **Step 2: Make the script executable** + +Run: `chmod +x understand-anything-plugin/skills/understand-ollama/run-pipeline.mjs` + +- [ ] **Step 3: Run a smoke test against a 3-file fixture** + +Add a fixture at `tests/fixtures/ollama-smoke/{src/foo.ts,src/bar.ts,README.md}`. Spin up a mock Ollama server using Node's `http` module (script lives at `scripts/ollama-smoke.sh`). Assert that `knowledge-graph.json` validates. + +The mock server script: + +```javascript +#!/usr/bin/env node +// scripts/ollama-mock.mjs — minimal mock of Ollama's HTTP API for tests. +import http from "node:http"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE = path.resolve(__dirname, "..", "tests", "fixtures", "ollama-smoke"); + +function readFixture(rel) { + return fs.readFile(path.join(FIXTURE, rel), "utf8"); +} + +const RESPONSES = { + "src/foo.ts": { + fileSummary: "Small Foo module exposing greet().", + tags: ["utility", "entry-point"], + complexity: "simple", + functionSummaries: { greet: "Returns a greeting." }, + classSummaries: {}, + }, + "src/bar.ts": { + fileSummary: "Bar handler dispatching to Foo.", + tags: ["api-handler"], + complexity: "moderate", + functionSummaries: { handle: "Routes a request to Foo.greet." }, + classSummaries: {}, + }, + "README.md": { + fileSummary: "Project README for the smoke fixture.", + tags: ["documentation"], + complexity: "simple", + functionSummaries: {}, + classSummaries: {}, + }, +}; + +const TOUR = { + steps: [ + { order: 1, title: "Entry point", description: "Start at bar.ts.", nodeIds: ["file:src/bar.ts"] }, + { order: 2, title: "Helper", description: "Then foo.ts.", nodeIds: ["file:src/foo.ts"] }, + ], +}; + +const LAYERS = [ + { name: "API", description: "HTTP entry", filePatterns: ["src/"] }, +]; + +function extractPathFromPrompt(prompt) { + const m = prompt.match(/(?:File:\s*|path:\s*|`)([\w./-]+\.[a-z]{1,4})/i); + return m ? m[1] : null; +} + +const server = http.createServer(async (req, res) => { + const { url, method } = req; + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + if (url === "/api/version") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ version: "0.5.7-mock" })); + return; + } + if (url === "/api/tags") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ models: [{ name: "qwen2.5-coder:7b" }] })); + return; + } + if (url === "/api/chat" && method === "POST") { + const data = JSON.parse(body); + const last = data.messages[data.messages.length - 1].content; + const path = extractPathFromPrompt(last); + const isTour = last.includes("guided tour"); + const isLayer = last.includes("architectural layers") || last.includes("layer"); + let payload; + if (isTour) payload = TOUR; + else if (isLayer) payload = LAYERS; + else payload = RESPONSES[path] ?? RESPONSES["src/foo.ts"]; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ + model: "qwen2.5-coder:7b", + message: { role: "assistant", content: JSON.stringify(payload) }, + done: true, + prompt_eval_count: 100, + eval_count: 50, + total_duration: 200_000_000, + })); + return; + } + if (url === "/api/generate" && method === "POST") { + const data = JSON.parse(body); + const path = extractPathFromPrompt(data.prompt); + const payload = RESPONSES[path] ?? RESPONSES["src/foo.ts"]; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ + model: "qwen2.5-coder:7b", + response: JSON.stringify(payload), + done: true, + })); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + }); +}); + +const port = Number(process.env.OLLAMA_MOCK_PORT ?? 11435); +server.listen(port, "127.0.0.1", () => { + console.log(`[ollama-mock] listening on http://127.0.0.1:${port}`); +}); +``` + +Add the smoke script `scripts/ollama-smoke.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PORT="${OLLAMA_MOCK_PORT:-11435}" + +# Start mock server +node "$ROOT/scripts/ollama-mock.mjs" & +MOCK_PID=$! +trap 'kill $MOCK_PID 2>/dev/null || true' EXIT +sleep 0.5 + +# Set up a small fixture +TMP_DIR=$(mktemp -d) +mkdir -p "$TMP_DIR/src" +cat > "$TMP_DIR/src/foo.ts" <<'EOF' +export function greet(name: string): string { + return `hello, ${name}`; +} +EOF +cat > "$TMP_DIR/src/bar.ts" <<'EOF' +import { greet } from "./foo"; +export function handle(name: string): string { + return greet(name).toUpperCase(); +} +EOF +cat > "$TMP_DIR/README.md" <<'EOF' +# Smoke +Small fixture. +EOF + +# Run pipeline +node "$ROOT/understand-anything-plugin/skills/understand-ollama/run-pipeline.mjs" \ + --project-root "$TMP_DIR" \ + --plugin-root "$ROOT/understand-anything-plugin" \ + --ollama-url "http://127.0.0.1:$PORT" \ + --model "qwen2.5-coder:7b" \ + --language en + +# Validate output schema +node --input-type=module -e " +import { readFileSync } from 'node:fs'; +import { schema } from '$ROOT/understand-anything-plugin/packages/core/dist/index.js'; +const g = JSON.parse(readFileSync('$TMP_DIR/.understand-anything/knowledge-graph.json', 'utf8')); +const r = schema.knowledgeGraphSchema.safeParse(g); +if (!r.success) { console.error('FAIL', r.error.issues); process.exit(1); } +console.log('OK', g.nodes.length, 'nodes', g.edges.length, 'edges', g.layers.length, 'layers', (g.tour ?? []).length, 'tour steps'); +" +``` + +Make both scripts executable: `chmod +x scripts/ollama-mock.mjs scripts/ollama-smoke.sh`. + +- [ ] **Step 4: Run the smoke test** + +Run: `bash scripts/ollama-smoke.sh` +Expected: `OK nodes edges layers tour steps`. + +- [ ] **Step 5: Commit** + +```bash +git add understand-anything-plugin/skills/understand-ollama/ \ + tests/fixtures/ollama-smoke/ \ + scripts/ollama-mock.mjs \ + scripts/ollama-smoke.sh +git commit -m "feat(skills): add /understand-ollama pipeline driver + smoke test" +``` + +--- + +## Task 3: Add the SKILL.md wrapper + +**Files:** +- Create: `understand-anything-plugin/skills/understand-ollama/SKILL.md` + +- [ ] **Step 1: Write the SKILL.md** + +```markdown +--- +name: understand-ollama +description: Analyze a codebase using a local Ollama LLM — no cloud API key, no network egress. Produces the same knowledge graph as /understand. +argument-hint: ["[path] [--ollama-url ] [--model ] [--review] [--full] [--resume] [--language ] [--concurrency N]"] +--- + +# /understand-ollama + +Run the Understand Anything analysis pipeline against a local Ollama server. The result is the same `.understand-anything/knowledge-graph.json` schema the dashboard, diff overlay, and search engine already understand. + +## Prerequisites + +1. **Install Ollama.** See https://ollama.com/download — `curl -fsSL https://ollama.com/install.sh | sh` is the one-liner for macOS/Linux. +2. **Start the server.** `ollama serve` (or use the system service if your installer created one). +3. **Pull a model.** A 16 GB consumer GPU comfortably runs a 7B model: + ```bash + ollama pull qwen2.5-coder:7b + ``` + The default model is `qwen2.5-coder:7b`. Other 7B–14B code-tuned models (e.g. `deepseek-coder-v2:16b`, `codellama:13b`, `llama3.1:8b`) work too. Set with `--model `. + +## Options + +`$ARGUMENTS` may contain: +- A directory path (e.g. `/path/to/repo`) — Analyze that directory instead of the current working directory. +- `--ollama-url ` — Override the Ollama base URL. Default: `http://127.0.0.1:11434`. +- `--model ` — Override the model name. Default: `qwen2.5-coder:7b`. +- `--review` — Run the LLM graph-reviewer pass after structural validation. Off by default. +- `--full` — Force a full rebuild, ignoring any existing graph. +- `--resume` — Skip phases whose outputs already exist for the current commit hash. +- `--language ` — Generate all textual content in the specified language (same codes as `/understand`). +- `--concurrency N` — Concurrent Ollama requests. Default: 2. Raise on bigger hardware. + +Persisted config: write `ollama: { baseUrl, model, concurrency }` to `.understand-anything/config.json` to skip flags on subsequent runs. + +## What this skill does + +1. Resolves `$PLUGIN_ROOT` and `$PROJECT_ROOT` (mirrors the candidate list in `/understand`). +2. Ensures `@understand-anything/core` is built (runs `pnpm --filter @understand-anything/core build` if `dist/index.js` is missing). +3. Calls `node /run-pipeline.mjs` with the resolved args. +4. Forwards progress lines and the final summary. + +The pipeline itself is implemented in `run-pipeline.mjs`; the seven phases (preflight, scan, batch, analyze, assemble, layer, tour, review, clean) are documented in the implementation plan at `docs/superpowers/plans/2026-06-19-ollama-backend-impl.md`. + +## Output + +`.understand-anything/knowledge-graph.json` and `.understand-anything/meta.json`. Run `/understand-dashboard` (or open the dashboard's dev server) to explore the result. +``` + +- [ ] **Step 2: Commit** + +```bash +git add understand-anything-plugin/skills/understand-ollama/SKILL.md +git commit -m "docs(skills): add /understand-ollama SKILL.md" +``` + +--- + +## Task 4: Add `--ollama` flag to `/understand` + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` + +- [ ] **Step 1: Add the flag to the argument-hint and options sections** + +In `understand-anything-plugin/skills/understand/SKILL.md`: + +1. In the frontmatter `argument-hint` (line 4), add `[--ollama]`: + ``` + argument-hint: ["[path] [--full|--auto-update|--no-auto-update|--review|--language ] [--ollama]"] + ``` +2. In the `## Options` block (lines 12–19), add a new bullet under the existing `--review` line: + ```markdown + - `--ollama` — Drive Phase 2 (and the layer/tour phases) through a local Ollama server instead of dispatching host-platform subagents. Implies the same pipeline as `/understand-ollama`; the rest of the phases (preflight, scan, batch, assemble, review, clean) are unchanged. The Ollama URL and model come from the persisted `ollama` block in `.understand-anything/config.json` or fall back to `http://127.0.0.1:11434` and `qwen2.5-coder:7b`. + ``` +3. In Phase 2 (around line 287, where the `file-analyzer` subagent dispatch happens), wrap the dispatch in a conditional: + ```markdown + - If `--ollama` is in `$ARGUMENTS`: + - Skip the subagent dispatch and shell out to the bundled Ollama driver: + ```bash + node /skills/understand-ollama/run-pipeline.mjs \ + --project-root "$PROJECT_ROOT" \ + --plugin-root "$PLUGIN_ROOT" \ + --ollama-url "${OLLAMA_URL:-http://127.0.0.1:11434}" \ + --model "${OLLAMA_MODEL:-qwen2.5-coder:7b}" \ + --language "$OUTPUT_LANGUAGE" \ + --concurrency "${OLLAMA_CONCURRENCY:-2}" \ + ${FULL:+--full} ${REVIEW:+--review} + ``` + - The driver owns Phase 2 (analyze) and Phases 4 (layers) and 5 (tour) — return from the pipeline here, do not run the cloud Phase 2/4/5 below. + - Otherwise, dispatch the `file-analyzer` subagent as described below. + ``` + +The rest of `SKILL.md` stays as-is. The cloud path is untouched. + +- [ ] **Step 2: Run the existing test for the skill (smoke)** + +Run: `pnpm --filter @understand-anything/skill test` +Expected: smoke passes (the test is a placeholder; just ensures the file is valid). + +- [ ] **Step 3: Commit** + +```bash +git add understand-anything-plugin/skills/understand/SKILL.md +git commit -m "feat(skills): add --ollama flag to /understand for local backend" +``` + +--- + +## Task 5: Add persisted config + unit test for round-trip + +**Files:** +- Create: `understand-anything-plugin/packages/core/src/__tests__/config.test.ts` +- Modify: `understand-anything-plugin/packages/core/src/persistence/index.ts` (if it exists) OR add a new `config.ts` module if not. + +- [ ] **Step 1: Inspect the existing persistence module** + +Read `understand-anything-plugin/packages/core/src/persistence/index.ts`. If it already has a `readConfig` / `writeConfig` pair that handles `.understand-anything/config.json`, extend it. Otherwise, create `understand-anything-plugin/packages/core/src/config.ts` with a minimal pair. + +- [ ] **Step 2: Write the failing test** + +```typescript +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readConfig, writeConfig } from "../config.js"; + +describe("config", () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "ua-config-")); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it("returns an empty object when config.json is missing", () => { + expect(readConfig(dir)).toEqual({}); + }); + + it("round-trips the ollama block", () => { + writeConfig(dir, { + autoUpdate: false, + ollama: { baseUrl: "http://localhost:11434", model: "qwen2.5-coder:7b", concurrency: 4 }, + }); + const cfg = readConfig(dir); + expect(cfg.ollama).toEqual({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + concurrency: 4, + }); + }); + + it("merges with existing config", () => { + writeConfig(dir, { autoUpdate: true, outputLanguage: "en" }); + writeConfig(dir, { ollama: { baseUrl: "http://x", model: "y", concurrency: 1 } }); + const cfg = readConfig(dir); + expect(cfg.autoUpdate).toBe(true); + expect(cfg.outputLanguage).toBe("en"); + expect(cfg.ollama?.model).toBe("y"); + }); +}); +``` + +- [ ] **Step 3: Implement config.ts** + +```typescript +// understand-anything-plugin/packages/core/src/config.ts +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; + +export interface OllamaConfig { + baseUrl: string; + model: string; + concurrency: number; +} + +export interface UnderstandConfig { + autoUpdate?: boolean; + outputLanguage?: string; + ollama?: OllamaConfig; +} + +const CONFIG_PATH = (root: string) => join(root, ".understand-anything", "config.json"); + +export function readConfig(projectRoot: string): UnderstandConfig { + const path = CONFIG_PATH(projectRoot); + if (!existsSync(path)) return {}; + try { + return JSON.parse(readFileSync(path, "utf8")) as UnderstandConfig; + } catch { + return {}; + } +} + +export function writeConfig(projectRoot: string, patch: Partial): UnderstandConfig { + const path = CONFIG_PATH(projectRoot); + const current = readConfig(projectRoot); + const next = { ...current, ...patch }; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(next, null, 2)); + return next; +} +``` + +- [ ] **Step 4: Re-export from the core barrel** + +Add to `understand-anything-plugin/packages/core/src/index.ts`: +```typescript +export { readConfig, writeConfig } from "./config.js"; +export type { UnderstandConfig, OllamaConfig } from "./config.js"; +``` + +- [ ] **Step 5: Run the tests** + +Run: `pnpm --filter @understand-anything/core test -- --run config` +Expected: 3 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/config.ts \ + understand-anything-plugin/packages/core/src/__tests__/config.test.ts \ + understand-anything-plugin/packages/core/src/index.ts +git commit -m "feat(core): add .understand-anything/config.json round-trip" +``` + +--- + +## Task 6: Documentation + version bump + +**Files:** +- Modify: `README.md` +- Create: `READMEs/README.ollama.md` +- Modify: `understand-anything-plugin/package.json` (version `2.8.0` → `2.9.0`) +- Modify: `understand-anything-plugin/.claude-plugin/plugin.json` (version `2.8.0` → `2.9.0`) +- Modify: `.claude-plugin/plugin.json` (version `2.8.0` → `2.9.0`) +- Modify: `.cursor-plugin/plugin.json` (version `2.8.0` → `2.9.0`) +- Modify: `.copilot-plugin/plugin.json` (version `2.8.0` → `2.9.0`) + +- [ ] **Step 1: Add a new section to README.md** + +Insert after the existing "### 4. Keep learning" block in `## Quick Start`: + +```markdown +### 5. Run fully locally with Ollama + +Prefer to keep every byte on your machine? Use a local Ollama server instead of a cloud LLM: + +```bash +# One-time setup +curl -fsSL https://ollama.com/install.sh | sh # install Ollama +ollama serve & # start the server +ollama pull qwen2.5-coder:7b # pull a 7B code model + +# Then run +/understand-ollama +``` + +The local pipeline produces the same `.understand-anything/knowledge-graph.json` schema and the same dashboard. Add `--ollama` to the existing `/understand` command to keep one entry point. See `understand-anything-plugin/skills/understand-ollama/SKILL.md` for details. +``` + +- [ ] **Step 2: Add a platform table row** + +In the platform compatibility table in `README.md` (around line 250), add a row: + +```markdown +| Ollama (local) | ✅ Supported | `/understand-ollama` | +``` + +- [ ] **Step 3: Add a translation stub** + +Create `READMEs/README.ollama.md` (English stub — same shape as the other README.*.md files; the table of contents links to the `Local-only with Ollama` section in the main README). + +- [ ] **Step 4: Bump versions** + +In all five files listed above, change `"version": "2.8.0"` to `"version": "2.9.0"`. Run: +```bash +grep -rl '"version": "2.8.0"' --include='*.json' . +``` +to confirm the set, then edit each. + +- [ ] **Step 5: Verify the diff** + +```bash +git diff --stat +``` +Expected: 5 JSON files (`-version: 2.8.0/+2.9.0`), `README.md` (2 hunks), 1 new `READMEs/README.ollama.md`. + +- [ ] **Step 6: Commit** + +```bash +git add README.md READMEs/README.ollama.md \ + understand-anything-plugin/package.json \ + understand-anything-plugin/.claude-plugin/plugin.json \ + .claude-plugin/plugin.json \ + .cursor-plugin/plugin.json \ + .copilot-plugin/plugin.json +git commit -m "docs+chore: document Ollama backend and bump to 2.9.0" +``` + +--- + +## Task 7: Run graphify + full test pass + +- [ ] **Step 1: Run `graphify update .`** + +Run: `graphify update .` +Expected: graph builds successfully; new files (`ollama-client.ts`, `config.ts`, `run-pipeline.mjs`, etc.) appear as nodes with summaries and tags. + +- [ ] **Step 2: Run the full test suite** + +Run: `pnpm test` +Expected: all tests pass — including the new `ollama-client.test.ts`, `config.test.ts`, and any existing core tests. + +- [ ] **Step 3: Run lint** + +Run: `pnpm lint` +Expected: clean. If new files trip the linter, fix the smallest change (most likely an unused import or a `prefer-const` rule). + +- [ ] **Step 4: Build all packages** + +Run: `pnpm build` +Expected: `dist/` artifacts for `core`, the skill bundle, and the dashboard. + +- [ ] **Step 5: Commit (only if step 1 produced a graph artifact)** + +If `.understand-anything/knowledge-graph.json` is in the working tree (it would only be there if `graphify` is configured to write into the repo; by default it should be gitignored), commit it. Otherwise this is a no-op: +```bash +git add .understand-anything/ || true +git diff --cached --quiet || git commit -m "chore: refresh knowledge graph via graphify" +``` + +--- + +## Done Criteria + +- All tasks above are committed atomically on the `ollama` branch. +- `pnpm test` passes. +- `pnpm lint` passes. +- `pnpm build` succeeds. +- `bash scripts/ollama-smoke.sh` produces a valid `knowledge-graph.json` against the mock Ollama server. +- `graphify update .` runs cleanly. +- `README.md` documents the new skill, the platform table is updated, the version is bumped to `2.9.0` in all five manifest files. +- The branch is ready to push and open a PR. diff --git a/docs/superpowers/specs/2026-06-19-ollama-backend-design.md b/docs/superpowers/specs/2026-06-19-ollama-backend-design.md new file mode 100644 index 000000000..f36efa863 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-ollama-backend-design.md @@ -0,0 +1,276 @@ +# Ollama Local Backend Design Spec + +## Overview + +Add a fully local LLM backend that drives the Understand Anything analysis pipeline through a user-run Ollama server, so the plugin works end-to-end with no cloud LLM subscription, no API key, and no network egress from the host machine. + +Today, every LLM call in the pipeline is performed by a host-platform agent (Claude Code, Cursor, Copilot, etc.) that loads the markdown agent definitions from `understand-anything-plugin/agents/`. The host picks the model; we have no control over it. We will add a parallel, host-agnostic pipeline path that calls a local Ollama HTTP server directly from Node scripts, reusing the same deterministic scaffolding (`packages/core`) so the resulting `.understand-anything/knowledge-graph.json` is byte-comparable to the cloud-driven output modulo the prose itself. + +The result is a new `/understand-ollama` skill (and a `--ollama` flag on `/understand` for users who prefer a single entry point) that can be run on any host that has Node 22+ and an Ollama server reachable on `http://127.0.0.1:11434` (or any configured URL). + +## Goals + +- Drive all seven pipeline phases (preflight, scan, batch, analyze, assemble, tour, review, clean) from Node scripts, using Ollama as the LLM. +- Reuse existing `packages/core` analyzers (`llm-analyzer`, `layer-detector`, `tour-generator`, `language-lesson`) unchanged — they already separate prompt construction from response parsing. +- Add an `OllamaClient` to `packages/core` (browser-safe subpath) that handles chat/generate requests, retries, streaming, and structured-output extraction. +- Default to a model that runs on a 16 GB consumer GPU (e.g. `qwen2.5-coder:7b`); document model recommendations and the context-window trade-off. +- Keep the cloud-orchestrated path untouched. `--ollama` and `/understand-ollama` are additive. +- Emit the same `.understand-anything/knowledge-graph.json` schema so the existing dashboard reads the result without changes. + +## Non-Goals + +- Replacing the cloud-orchestrated agent pipeline. Both paths coexist. +- Building a generic LLM provider abstraction (Anthropic / OpenAI / Ollama / vLLM). The scope is Ollama only; the surface stays small enough that a second provider is a follow-up. +- GPU provisioning, model quantization, or model download UX. Ollama's own `ollama pull` is the install step; we just call it. +- Streaming into a TUI progress UI. The skill writes progress lines like the existing `SKILL.md` does. +- Replacing the dashboard. The output schema is unchanged. + +--- + +## Module: OllamaClient + +New file: `understand-anything-plugin/packages/core/src/ollama-client.ts` + +Re-exported from the browser-safe subpath index so it is tree-shakable for the dashboard if a future surface needs it. The class wraps a fetchable Ollama HTTP API. + +### API + +```typescript +export interface OllamaClientOptions { + baseUrl?: string; // default "http://127.0.0.1:11434" + model: string; // e.g. "qwen2.5-coder:7b" + timeoutMs?: number; // default 120_000 per request + numCtx?: number; // default 8192 + temperature?: number; // default 0.2 for structured output + numPredict?: number; // default 1024 + retries?: number; // default 2 (total attempts = retries + 1) + retryBackoffMs?: number; // default 500, doubles per attempt + signal?: AbortSignal; // caller-supplied cancel + fetchImpl?: typeof fetch; // test injection point + onRetry?: (info: { attempt: number; delayMs: number; error: Error }) => void; +} + +export interface ChatMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +export interface ChatRequest { + messages: ChatMessage[]; + format?: "json" | Record; // Ollama structured output + options?: Partial; +} + +export interface ChatResponse { + content: string; + model: string; + promptEvalCount?: number; + evalCount?: number; + totalDurationNs?: number; +} + +export class OllamaClient { + constructor(options: OllamaClientOptions); + chat(req: ChatRequest): Promise; + generate(prompt: string, opts?: { format?: "json" | object }): Promise; + listModels(): Promise; + isHealthy(): Promise<{ ok: boolean; version?: string; error?: string }>; +} +``` + +### Behavior + +1. **Health check.** `isHealthy()` issues `GET /api/version`. Returns `{ ok: true, version }` on 200, `{ ok: false, error }` otherwise. The skill calls this first and stops with a clear message on failure. +2. **Chat.** `chat()` issues `POST /api/chat` with `{ model, messages, stream: false, options: { num_ctx, temperature, num_predict }, format }`. Returns the assembled `content` string from the last assistant message. +3. **Generate.** `generate()` issues `POST /api/generate` with `{ model, prompt, stream: false, options, format }`. Used for short, single-prompt tasks like project narrative. +4. **Retries.** Transient failures (network error, HTTP 5xx, timeout) are retried with exponential backoff. JSON parse failures and HTTP 4xx are not retried — they indicate a prompt or model problem. +5. **Cancellation.** Caller-supplied `AbortSignal` is honored across the whole retry chain. +6. **Test injection.** `fetchImpl` lets the test suite supply a stub `fetch` for unit tests. The default uses the global `fetch` (Node 22 ships it). +7. **Streaming.** Out of scope for v1. `stream: false` is the only mode; the caller awaits the full response. The Ollama API keeps `stream: true` for a future PR. + +### Error Model + +```typescript +export class OllamaConnectionError extends Error { readonly baseUrl: string; } +export class OllamaModelMissingError extends Error { readonly model: string; } +export class OllamaResponseError extends Error { readonly status: number; readonly body: string; } +export class OllamaTimeoutError extends Error {} +``` + +`isHealthy()` returns `{ ok: false }` rather than throwing on connection refused — the skill wants a soft failure with a remediation hint. + +--- + +## Module: Pipeline Driver + +New file: `understand-anything-plugin/skills/understand-ollama/run-pipeline.mjs` + +A single Node entry point that drives phases 1–7 of the pipeline. The skill is a thin shell wrapper that resolves paths, prints progress lines, and invokes this script with arguments. The script reuses the existing `scan-project.mjs`, `extract-import-map.mjs`, `compute-batches.mjs`, `extract-structure.mjs`, and `merge-batch-graphs.py` from the bundled skill — it does not duplicate their deterministic work. + +### CLI + +```bash +node run-pipeline.mjs \ + --project-root \ + --plugin-root \ + --ollama-url http://127.0.0.1:11434 \ + --model qwen2.5-coder:7b \ + [--language en] \ + [--concurrency 2] \ + [--review] \ + [--full] \ + [--resume] \ + [--out ] +``` + +### Phases + +| Phase | Driver | Reuses | +|------|--------|--------| +| 0 Preflight | inline | mirrors `skills/understand/SKILL.md` Phase 0 | +| 1 SCAN | shell out to `scan-project.mjs` | `bundled/scan-project.mjs` | +| 1.5 BATCH | shell out to `compute-batches.mjs` | `bundled/compute-batches.mjs` | +| 2 ANALYZE | inline loop over batches; calls `OllamaClient.chat()` per file | `core.extractStructure` for structural edges | +| 3 ASSEMBLE | shell out to `merge-batch-graphs.py` | `bundled/merge-batch-graphs.py` | +| 4 LAYERS | inline: prompt Ollama with `buildLayerDetectionPrompt` | `core.parseLayerDetectionResponse` | +| 5 TOUR | inline: prompt Ollama with `buildTourGenerationPrompt` | `core.parseTourGenerationResponse` | +| 6 REVIEW | inline: structural validation (always) + Ollama review (with `--review`) | existing `schema.ts` validators | +| 7 CLEAN | inline: mirrors the trash-and-purge pattern | — | + +### Concurrency + +Default 2 concurrent Ollama requests. A 7B model on a 16 GB GPU saturates around 1–2 concurrent prompts; we keep the default conservative and let the user raise it on bigger hardware. Each batch of files is processed sequentially within a worker; only file-level analysis within a batch runs concurrently. + +### Per-File Analysis Flow (Phase 2) + +For each file in a batch: + +1. **Structural extraction** — invoke `core.extractStructure(filePath, content, language, projectContext)` (new thin wrapper around the existing `extract-structure.mjs`). This is what produces `nodes[]` and `edges[]` for the file deterministically. +2. **Semantic enrichment** — build the prompt with `core.buildFileAnalysisPrompt(filePath, content, projectContext)`. Call `OllamaClient.chat({ messages, format: "json" })`. Parse with `core.parseFileAnalysisResponse`. +3. **Merge** — attach `fileSummary`, `tags`, `complexity`, `functionSummaries`, `classSummaries`, `languageNotes` from the parsed LLM response onto the file node produced in step 1. +4. **Persist** — write the partial batch graph to `.understand-anything/intermediate/batch-.json` immediately so the run is crash-resumable. + +If the LLM call fails, fall back to a "best-effort" node: `summary` from the first 240 chars of the file, no tags, `complexity: "moderate"`, empty function summaries. The structural edges from step 1 are still preserved. The skill surfaces a warning count at the end. + +### Output Schema + +Identical to the existing pipeline: `.understand-anything/knowledge-graph.json` and `.understand-anything/meta.json`. The dashboard, the diff overlay, the search engine, and the chat skill all read the same shape. + +### Resume + +`--resume` reads the existing `meta.json` and skips phases whose outputs already exist with a matching `gitCommitHash`. Same as the cloud skill's incremental path. + +--- + +## Skill: `/understand-ollama` + +New directory: `understand-anything-plugin/skills/understand-ollama/` + +``` +understand-ollama/ +├── SKILL.md +└── run-pipeline.mjs +``` + +`SKILL.md` is a short preamble (≤ 80 lines) that: + +1. Tells the user to install Ollama (`curl -fsSL https://ollama.com/install.sh | sh`) and pull a model (`ollama pull qwen2.5-coder:7b`). +2. Resolves `$PLUGIN_ROOT` exactly like the existing `SKILL.md` (same candidate list, same precedence). +3. Resolves `$PROJECT_ROOT` from `$ARGUMENTS` or `cwd`, applies the worktree redirect. +4. Calls `node /run-pipeline.mjs` with the resolved args, including `--ollama-url` and `--model` from `$ARGUMENTS` (defaults baked in). +5. Forwards progress lines and the final summary to the user. + +It does **not** re-implement any of the seven phases — all of that lives in the Node script. + +### Argument Grammar + +``` +/understand-ollama [] [--ollama-url ] [--model ] [--review] [--full] [--resume] [--language ] [--concurrency N>] +``` + +`--ollama-url` and `--model` are the two new flags. Everything else mirrors `/understand` so a user who knows one skill knows the other. + +--- + +## Plugin-Level Flag: `--ollama` on `/understand` + +Modify `understand-anything-plugin/skills/understand/SKILL.md` and the dispatch logic so that `--ollama` switches Phase 2 dispatch from "dispatch a subagent" to "shell out to `run-pipeline.mjs`". All other phases stay identical. The flag is a thin conditional inside the existing phase machine — the cloud path and the local path share preflight, ignore-config, scan, batch, assemble, review, and clean. + +This is the path users take when they want a single command. The standalone `/understand-ollama` skill exists for users who want a small, dedicated entry point. + +--- + +## Configuration Persistence + +Add a section to `.understand-anything/config.json`: + +```json +{ + "autoUpdate": false, + "outputLanguage": "en", + "ollama": { + "baseUrl": "http://127.0.0.1:11434", + "model": "qwen2.5-coder:7b", + "concurrency": 2 + } +} +``` + +A new `--set-ollama` flag on `/understand` writes the `ollama` block without running analysis. The skill reads the persisted block before falling back to the URL/model CLI defaults. + +--- + +## Testing Strategy + +### Unit (Vitest) + +- `ollama-client.test.ts` — stub `fetchImpl`, assert chat/generate request shape, retry on 5xx, no retry on 4xx, AbortSignal honored, JSON `format` passed through. +- `run-pipeline.test.ts` — fake Ollama responses per phase, assert per-file merge behavior, fallback summary on LLM failure, batch persistence on every file. +- `config.test.ts` — round-trip the `ollama` block in `config.json`. + +### Integration (manual) + +- `bash scripts/ollama-smoke.sh` — spins up an in-process mock Ollama server (using Node's `http`), runs `run-pipeline.mjs` against the `homepage/` test fixture (a small codebase already in this repo), asserts the produced `knowledge-graph.json` validates against the Zod schema in `core/src/schema.ts`. + +### Fixture + +A 200-line fixture (a stripped copy of `homepage/`) sits at `tests/fixtures/ollama-smoke/`. The mock Ollama server returns canned responses for a 3-file fixture, exercising the file-analyzer, layer-detector, and tour-generator prompts. + +--- + +## Documentation + +- New section in `README.md` under `## Quick Start` (third entry) titled `### Local-only with Ollama`. Six lines, ends with a link to the skill SKILL.md. +- New entry in the platform compatibility table: `Ollama (local) | ✅ Supported | /understand-ollama`. +- New `READMEs/README.ollama.md` translation stub (English) — same prose pattern as the other `README.*.md` files. Localized translations are out of scope for v1. + +--- + +## Versioning + +`understand-anything-plugin/package.json` and the four plugin manifest files (`understand-anything-plugin/.claude-plugin/plugin.json`, `.claude-plugin/plugin.json`, `.cursor-plugin/plugin.json`, `.copilot-plugin/plugin.json`) bump from `2.8.0` to `2.9.0`. Adding a new skill is a minor feature under the project's existing semver rhythm (the most recent two minor releases are 2.7 and 2.8). + +--- + +## Risks and Mitigations + +| Risk | Mitigation | +|---|---| +| 7B model produces invalid JSON for complex prompts | Prompt templates already use `format: "json"`; parsers return `null` on failure; the pipeline falls back to a best-effort node. | +| Local model is slower than cloud, user expects parity | Concurrency defaults to 2 (not 5); progress lines are explicit. Documented. | +| Ollama not running on the host | `isHealthy()` is the first call; the skill stops with `Ollama not reachable at . Start it with: ollama serve`. | +| Model not pulled | `listModels()` is checked next; skill stops with `Model not found. Run: ollama pull `. | +| Output diverges from cloud output and breaks tests | The dashboard, schema, and search engine are content-agnostic. The fixture test asserts schema validity, not byte equality. | +| User has a tiny GPU and the model OOMs | Default model is `qwen2.5-coder:7b` (fits 16 GB). The `OllamaClientOptions.numCtx` defaults to 8192, not the model's full window, to stay conservative. The user can override. | +| `fetch` in Node 22 has subtle timeout semantics | We use `AbortSignal.timeout(timeoutMs)` plus caller-supplied `signal`. Both are tested. | +| Plugin built dist is stale on first run | `run-pipeline.mjs` triggers `pnpm --filter @understand-anything/core build` if `dist/index.js` is missing, mirroring the existing Phase 0.5 logic in `/understand`. | + +--- + +## Out of Scope (Follow-up Issues) + +- Streaming responses (Ollama supports NDJSON streaming; we use `stream: false` for v1). +- Multi-model routing (e.g. a small model for tagging, a bigger one for tours). +- Anthropic / OpenAI / vLLM provider abstraction. +- Embedding-based semantic search via local models (`embedding-search.ts` is currently empty for the local path). +- A TUI for live progress. diff --git a/tests/skill/understand-ollama/test_run_pipeline.test.mjs b/tests/skill/understand-ollama/test_run_pipeline.test.mjs new file mode 100644 index 000000000..c66852a3e --- /dev/null +++ b/tests/skill/understand-ollama/test_run_pipeline.test.mjs @@ -0,0 +1,248 @@ +/** + * Integration test for skills/understand-ollama/run-pipeline.mjs + * + * Boots a stub Ollama HTTP server (Node http.createServer), points the + * driver at it via --ollama-url, runs the seven-phase pipeline against a + * minimal fixture project, then asserts the produced knowledge graph + * validates against the dashboard's Zod schema. + * + * No real Ollama required; this is the CI smoke test. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname, resolve } from "node:path"; +import { spawn, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import http from "node:http"; +import { createRequire } from "node:module"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = resolve(__dirname, "../../../understand-anything-plugin"); +const SCRIPT = resolve(PLUGIN_ROOT, "skills/understand-ollama/run-pipeline.mjs"); +const require = createRequire(import.meta.url); + +function startStubOllama() { + return new Promise((resolveServer) => { + let count = 0; + const server = http.createServer((req, res) => { + count++; + let body = ""; + req.on("data", (c) => (body += c.toString("utf-8"))); + req.on("end", () => { + const url = req.url ?? ""; + if (url === "/api/version") { + respond(res, 200, { version: "0.0.0-test" }); + return; + } + if (url === "/api/tags") { + respond(res, 200, { models: [{ name: "stub-model" }] }); + return; + } + if (url === "/api/chat" && req.method === "POST") { + const data = JSON.parse(body); + const last = (data.messages && data.messages.length + ? data.messages[data.messages.length - 1].content + : "") ?? ""; + let payload; + if (last.includes("File:") && last.includes("package.json")) { + payload = { + message: { + role: "assistant", + content: JSON.stringify({ + fileSummary: "package.json manifest", + tags: ["config"], + complexity: "simple", + }), + }, + }; + } else if (last.includes("File:") && last.includes("src/util.ts")) { + payload = { + message: { + role: "assistant", + content: JSON.stringify({ + fileSummary: "small utility module", + tags: ["utility"], + complexity: "simple", + }), + }, + }; + } else if (last.includes("architectural layers") || last.includes("layer")) { + payload = { + message: { + role: "assistant", + content: JSON.stringify([ + { name: "lib", description: "library code", filePatterns: ["src/"] }, + ]), + }, + }; + } else if (last.includes("guided tour")) { + payload = { + message: { + role: "assistant", + content: JSON.stringify([ + { order: 1, title: "Entry", description: "Start here.", nodeIds: ["file:src/util.ts"] }, + ]), + }, + }; + } else if (last.includes("project metadata")) { + payload = { + message: { + role: "assistant", + content: JSON.stringify({ + name: "stub-project", + description: "Test fixture.", + languages: ["TypeScript"], + frameworks: [], + }), + }, + }; + } else { + payload = { + message: { + role: "assistant", + content: JSON.stringify({ + fileSummary: "default", + tags: [], + complexity: "simple", + }), + }, + }; + } + respond(res, 200, Object.assign( + { model: "stub-model", done: true, prompt_eval_count: 10, eval_count: 5, total_duration: 1_000_000 }, + payload, + )); + return; + } + if (url === "/api/generate") { + respond(res, 200, { + model: "stub-model", + response: JSON.stringify({ name: "stub-project", description: "Test" }), + done: true, + }); + return; + } + respond(res, 404, { error: "not found" }); + }); + }); + + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (!addr || typeof addr === "string") throw new Error("stub server failed to bind"); + const baseUrl = `http://127.0.0.1:${addr.port}`; + resolveServer({ + baseUrl, + close: () => new Promise((res) => { server.close(() => res()); }), + requestCount: () => count, + }); + }); + }); +} + +function respond(res, status, body) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function setupFixture() { + const root = mkdtempSync(join(tmpdir(), "ua-ollama-test-")); + // git init so phase 6 can run git rev-parse + spawnSync("git", ["init", "-q"], { cwd: root }); + spawnSync("git", ["-C", root, "config", "user.email", "stub@test"]); + spawnSync("git", ["-C", root, "config", "user.name", "stub"]); + // Minimal TypeScript file + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync( + join(root, "src/util.ts"), + "export function add(a, b) { return a + b; }\n", + "utf-8", + ); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "stub-project", version: "0.0.0", type: "module" }, null, 2), + "utf-8", + ); + // Build core dist if missing so the driver can import it + const coreDist = join(PLUGIN_ROOT, "packages/core/dist/index.js"); + if (!existsSync(coreDist)) { + spawnSync("pnpm", ["--filter", "@understand-anything/core", "build"], { cwd: PLUGIN_ROOT }); + } + // Commit so git rev-parse HEAD returns a hash + spawnSync("git", ["-C", root, "add", "-A"]); + spawnSync("git", ["-C", root, "commit", "-q", "-m", "init"]); + return root; +} + +describe("run-pipeline.mjs end-to-end against stub Ollama", () => { + let stub; + let fixtureRoot; + + beforeAll(async () => { + stub = await startStubOllama(); + }); + + afterAll(async () => { + if (stub) await stub.close(); + }); + + beforeEach(() => { + if (fixtureRoot && existsSync(fixtureRoot)) { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + fixtureRoot = setupFixture(); + }); + + it("writes a schema-valid knowledge graph and meta.json", async () => { + const proc = spawn("node", [ + SCRIPT, + "--project-root", fixtureRoot, + "--plugin-root", PLUGIN_ROOT, + "--ollama-url", stub.baseUrl, + "--model", "stub-model", + "--language", "en", + ], { + cwd: PLUGIN_ROOT, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stderr = ""; + proc.stderr.on("data", (c) => (stderr += c.toString("utf-8"))); + const exitCode = await new Promise((res) => proc.on("exit", (c) => res(c ?? 0))); + + if (exitCode !== 0) { + throw new Error(`pipeline exited non-zero. stderr:\n${stderr}`); + } + + // knowledge graph exists + const kgPath = join(fixtureRoot, ".understand-anything", "knowledge-graph.json"); + expect(existsSync(kgPath)).toBe(true); + const kg = JSON.parse(readFileSync(kgPath, "utf-8")); + + // structural sanity + expect(Array.isArray(kg.nodes)).toBe(true); + expect(kg.nodes.length).toBeGreaterThan(0); + expect(kg.project).toBeDefined(); + expect(kg.project.name).toBeTruthy(); + expect(kg.version).toBeTruthy(); + expect(kg.kind).toBeTruthy(); + + // validate against the dashboard's Zod schema + const core = require(`${PLUGIN_ROOT}/packages/core/dist/index.js`); + const schema = core.knowledgeGraphSchema ?? core.KnowledgeGraphSchema; + const result = schema.safeParse(kg); + if (!result.success) { + const issues = (result.error.issues ?? []).slice(0, 5); + const lines = issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n"); + throw new Error(`schema validation failed:\n${lines}`); + } + + // meta.json records the ollama model and url + const metaPath = join(fixtureRoot, ".understand-anything", "meta.json"); + expect(existsSync(metaPath)).toBe(true); + const meta = JSON.parse(readFileSync(metaPath, "utf-8")); + expect(meta.ollamaModel).toBe("stub-model"); + expect(meta.ollamaUrl).toBe(stub.baseUrl); + }, 120_000); +}); diff --git a/understand-anything-plugin/.claude-plugin/plugin.json b/understand-anything-plugin/.claude-plugin/plugin.json index 0dda03661..277f4f69c 100644 --- a/understand-anything-plugin/.claude-plugin/plugin.json +++ b/understand-anything-plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.8.1", + "version": "2.9.0", "author": { "name": "Egonex" }, diff --git a/understand-anything-plugin/package.json b/understand-anything-plugin/package.json index ab2f9712a..69d44c744 100644 --- a/understand-anything-plugin/package.json +++ b/understand-anything-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@understand-anything/skill", - "version": "2.8.1", + "version": "2.9.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/understand-anything-plugin/packages/core/src/__tests__/config-roundtrip.test.ts b/understand-anything-plugin/packages/core/src/__tests__/config-roundtrip.test.ts new file mode 100644 index 000000000..63eef1c92 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/__tests__/config-roundtrip.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../persistence/index.js"; + +describe("config round-trip with ollama block", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ua-cfg-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("returns the default config when config.json is missing", () => { + const cfg = loadConfig(dir); + expect(cfg.autoUpdate).toBe(false); + expect(cfg.outputLanguage).toBe("en"); + expect(cfg.ollama).toBeUndefined(); + }); + + it("round-trips the ollama block", () => { + saveConfig(dir, { + autoUpdate: false, + outputLanguage: "en", + ollama: { baseUrl: "http://localhost:11434", model: "qwen2.5-coder:7b", concurrency: 4 }, + }); + const cfg = loadConfig(dir); + expect(cfg.ollama).toEqual({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + concurrency: 4, + }); + }); + + it("preserves existing fields when ollama is added later", () => { + saveConfig(dir, { autoUpdate: true, outputLanguage: "en" }); + saveConfig(dir, { + autoUpdate: true, + outputLanguage: "en", + ollama: { baseUrl: "http://x", model: "y", concurrency: 1 }, + }); + const cfg = loadConfig(dir); + expect(cfg.autoUpdate).toBe(true); + expect(cfg.outputLanguage).toBe("en"); + expect(cfg.ollama?.model).toBe("y"); + }); + + it("survives a corrupted config file by returning defaults", () => { + mkdirSync(join(dir, ".understand-anything"), { recursive: true }); + writeFileSync(join(dir, ".understand-anything/config.json"), "{ not json"); + const cfg = loadConfig(dir); + expect(cfg.autoUpdate).toBe(false); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/__tests__/ollama-client.test.ts b/understand-anything-plugin/packages/core/src/__tests__/ollama-client.test.ts new file mode 100644 index 000000000..7b5dc90f9 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/__tests__/ollama-client.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + OllamaClient, + OllamaConnectionError, + OllamaModelMissingError, + OllamaResponseError, + OllamaTimeoutError, +} from "../ollama-client.js"; + +function makeJsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +interface FetchCall { + url: string; + init: RequestInit; +} + +/** + * Wrap a per-call response with a recorder so every fetch (mocked or default) + * is captured into `calls`. Tests then build their per-call responses with + * `recording(...)` so the call args land in `calls` regardless of how many + * `mockImplementationOnce` responses they queue. + */ +function recording( + calls: FetchCall[], + response: Response | Promise, + index?: number, +): (url: string, init: RequestInit) => Promise { + return (url: string, init: RequestInit) => { + if (index === undefined || calls.length === index) { + calls.push({ url, init }); + } + return Promise.resolve(response); + }; +} + +describe("OllamaClient", () => { + let fetchMock: ReturnType; + let calls: FetchCall[]; + + beforeEach(() => { + calls = []; + fetchMock = vi.fn(); + }); + + describe("isHealthy", () => { + it("returns ok with version on 200", async () => { + fetchMock.mockImplementationOnce( + recording(calls, makeJsonResponse({ version: "0.5.7" })), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const result = await client.isHealthy(); + expect(result.ok).toBe(true); + expect(result.version).toBe("0.5.7"); + }); + + it("returns not-ok without throwing on connection refused", async () => { + fetchMock.mockImplementationOnce((_url: string, _init: RequestInit) => + Promise.reject(new TypeError("fetch failed")), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const result = await client.isHealthy(); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/fetch failed/); + }); + }); + + describe("chat", () => { + it("sends a chat request with the expected shape", async () => { + fetchMock.mockImplementationOnce( + recording( + calls, + makeJsonResponse({ + model: "qwen2.5-coder:7b", + message: { role: "assistant", content: "hello" }, + done: true, + }), + 0, + ), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const out = await client.chat({ + messages: [ + { role: "system", content: "You are a code analyzer." }, + { role: "user", content: "Summarize foo.ts" }, + ], + }); + expect(out.content).toBe("hello"); + expect(out.model).toBe("qwen2.5-coder:7b"); + expect(calls[0].url).toBe("http://localhost:11434/api/chat"); + const body = JSON.parse(calls[0].init.body as string); + expect(body.model).toBe("qwen2.5-coder:7b"); + expect(body.messages).toHaveLength(2); + expect(body.stream).toBe(false); + }); + + it("passes format:'json' through to the request body", async () => { + fetchMock.mockImplementationOnce( + recording( + calls, + makeJsonResponse({ + message: { role: "assistant", content: "{}" }, + done: true, + }), + 0, + ), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + await client.chat({ + messages: [{ role: "user", content: "x" }], + format: "json", + }); + const body = JSON.parse(calls[0].init.body as string); + expect(body.format).toBe("json"); + }); + + it("retries on 5xx and eventually throws OllamaResponseError", async () => { + fetchMock + .mockImplementationOnce(() => + Promise.resolve(new Response("upstream gone", { status: 503 })), + ) + .mockImplementationOnce(() => + Promise.resolve(new Response("upstream gone", { status: 503 })), + ) + .mockImplementationOnce(() => + Promise.resolve(new Response("upstream gone", { status: 503 })), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + retries: 2, + retryBackoffMs: 1, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + await expect( + client.chat({ messages: [{ role: "user", content: "x" }] }), + ).rejects.toBeInstanceOf(OllamaResponseError); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("does not retry on 4xx (model missing)", async () => { + fetchMock.mockImplementationOnce(() => + Promise.resolve(new Response("not found", { status: 404 })), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + retries: 2, + retryBackoffMs: 1, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + await expect( + client.chat({ messages: [{ role: "user", content: "x" }] }), + ).rejects.toBeInstanceOf(OllamaModelMissingError); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws OllamaTimeoutError when the request times out", async () => { + fetchMock.mockImplementationOnce( + (_url: string, init: RequestInit = {}) => { + const { promise, reject } = Promise.withResolvers(); + init.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + return promise; + }, + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + timeoutMs: 10, + retries: 0, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + await expect( + client.chat({ messages: [{ role: "user", content: "x" }] }), + ).rejects.toBeInstanceOf(OllamaTimeoutError); + }); + + it("honors caller-supplied AbortSignal", async () => { + const controller = new AbortController(); + fetchMock.mockImplementationOnce( + (_url: string, init: RequestInit = {}) => { + const { promise, reject } = Promise.withResolvers(); + init.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + return promise; + }, + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + signal: controller.signal, + retries: 0, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const promise = client.chat({ messages: [{ role: "user", content: "x" }] }); + controller.abort(); + await expect(promise).rejects.toBeInstanceOf(OllamaTimeoutError); + }); + }); + + describe("generate", () => { + it("sends a generate request with stream:false", async () => { + fetchMock.mockImplementationOnce( + recording(calls, makeJsonResponse({ response: "ok", done: true }), 0), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const out = await client.generate("Summarize this repo"); + expect(out.content).toBe("ok"); + const body = JSON.parse(calls[0].init.body as string); + expect(body.prompt).toBe("Summarize this repo"); + expect(body.stream).toBe(false); + }); + }); + + describe("listModels", () => { + it("returns the list of model names", async () => { + fetchMock.mockImplementationOnce(() => + Promise.resolve( + makeJsonResponse({ + models: [{ name: "qwen2.5-coder:7b" }, { name: "llama3.1:8b" }], + }), + ), + ); + const client = new OllamaClient({ + baseUrl: "http://localhost:11434", + model: "qwen2.5-coder:7b", + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const names = await client.listModels(); + expect(names).toEqual(["qwen2.5-coder:7b", "llama3.1:8b"]); + }); + }); +}); + +void OllamaConnectionError; diff --git a/understand-anything-plugin/packages/core/src/index.ts b/understand-anything-plugin/packages/core/src/index.ts index c5f629aa4..b641d90ac 100644 --- a/understand-anything-plugin/packages/core/src/index.ts +++ b/understand-anything-plugin/packages/core/src/index.ts @@ -122,3 +122,27 @@ export { type IgnoreFilter, } from "./ignore-filter.js"; export { generateStarterIgnoreFile } from "./ignore-generator.js"; +// Local LLM backends +export { + OllamaClient, + OllamaConnectionError, + OllamaModelMissingError, + OllamaResponseError, + OllamaTimeoutError, +} from "./ollama-client.js"; +export type { + OllamaClientOptions, + OllamaClientInternals, + ChatMessage, + ChatRequest, + ChatResponse, + GenerateOptions, + HealthResult, + RetryInfo, + OllamaChatPayload, + OllamaGeneratePayload, + OllamaChatResponseBody, + OllamaGenerateResponseBody, + OllamaTagsResponseBody, + OllamaVersionResponseBody, +} from "./ollama-client.js"; diff --git a/understand-anything-plugin/packages/core/src/ollama-client.ts b/understand-anything-plugin/packages/core/src/ollama-client.ts new file mode 100644 index 000000000..d88dd427a --- /dev/null +++ b/understand-anything-plugin/packages/core/src/ollama-client.ts @@ -0,0 +1,324 @@ +/** + * Ollama HTTP client. + * + * Thin wrapper around the Ollama REST API used to drive the Understand + * Anything pipeline against a locally-running Ollama server. Provides chat + * and generate helpers, exponential-backoff retries on 5xx, AbortSignal-aware + * timeouts, and a soft `isHealthy` check for pre-flight use. + */ + +const DEFAULT_BASE_URL = "http://127.0.0.1:11434"; +const DEFAULT_TIMEOUT_MS = 120_000; +const DEFAULT_NUM_CTX = 8192; +const DEFAULT_TEMPERATURE = 0.2; +const DEFAULT_NUM_PREDICT = 1024; +const DEFAULT_RETRIES = 2; +const DEFAULT_RETRY_BACKOFF_MS = 500; + +export interface OllamaClientOptions { + baseUrl?: string; + model: string; + timeoutMs?: number; + numCtx?: number; + temperature?: number; + numPredict?: number; + retries?: number; + retryBackoffMs?: number; + signal?: AbortSignal; + fetchImpl?: typeof fetch; + onRetry?: (info: RetryInfo) => void; +} + +export interface RetryInfo { + attempt: number; + delayMs: number; + error: Error; +} + +export interface ChatMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +export interface ChatRequest { + messages: ChatMessage[]; + format?: "json" | Record; + options?: Partial; +} + +export interface ChatResponse { + content: string; + model: string; + promptEvalCount?: number; + evalCount?: number; + totalDurationNs?: number; +} + +export interface GenerateOptions { + format?: "json" | object; +} + +export interface HealthResult { + ok: boolean; + version?: string; + error?: string; +} + +export interface OllamaChatPayload { + model: string; + messages: ChatMessage[]; + stream: false; + options: { + num_ctx: number; + temperature: number; + num_predict: number; + }; + format?: "json" | Record; +} + +export interface OllamaGeneratePayload { + model: string; + prompt: string; + stream: false; + options: { + num_ctx: number; + temperature: number; + num_predict: number; + }; + format?: "json" | object; +} + +export interface OllamaChatResponseBody { + model: string; + message?: { content: string }; + prompt_eval_count?: number; + eval_count?: number; + total_duration?: number; +} + +export interface OllamaGenerateResponseBody { + model: string; + response?: string; + prompt_eval_count?: number; + eval_count?: number; + total_duration?: number; +} + +export interface OllamaTagsResponseBody { + models?: Array<{ name: string }>; +} + +export interface OllamaVersionResponseBody { + version?: string; +} + +export class OllamaConnectionError extends Error { + constructor(public readonly baseUrl: string, cause: unknown) { + super(`Ollama not reachable at ${baseUrl}: ${(cause as Error).message ?? cause}`); + this.name = "OllamaConnectionError"; + } +} + +export class OllamaModelMissingError extends Error { + constructor(public readonly model: string) { + super(`Ollama model not found: ${model}. Run: ollama pull ${model}`); + this.name = "OllamaModelMissingError"; + } +} + +export class OllamaResponseError extends Error { + constructor(public readonly status: number, public readonly body: string) { + super(`Ollama returned HTTP ${status}: ${body.slice(0, 200)}`); + this.name = "OllamaResponseError"; + } +} + +export class OllamaTimeoutError extends Error { + constructor(public readonly timeoutMs: number) { + super(`Ollama request timed out after ${timeoutMs}ms`); + this.name = "OllamaTimeoutError"; + } +} + +export interface OllamaClientInternals { + baseUrl: string; + model: string; + timeoutMs: number; + numCtx: number; + temperature: number; + numPredict: number; + retries: number; + retryBackoffMs: number; + signal: AbortSignal | undefined; + fetchImpl: typeof fetch; + onRetry: ((info: RetryInfo) => void) | undefined; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + if (signal?.aborted) { + reject(new DOMException("aborted", "AbortError")); + return promise; + } + const id = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(id); + reject(new DOMException("aborted", "AbortError")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + return promise; +} + +export class OllamaClient { + private readonly internals: OllamaClientInternals; + + constructor(opts: OllamaClientOptions) { + this.internals = { + baseUrl: (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""), + model: opts.model, + timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + numCtx: opts.numCtx ?? DEFAULT_NUM_CTX, + temperature: opts.temperature ?? DEFAULT_TEMPERATURE, + numPredict: opts.numPredict ?? DEFAULT_NUM_PREDICT, + retries: opts.retries ?? DEFAULT_RETRIES, + retryBackoffMs: opts.retryBackoffMs ?? DEFAULT_RETRY_BACKOFF_MS, + signal: opts.signal, + fetchImpl: opts.fetchImpl ?? globalThis.fetch.bind(globalThis), + onRetry: opts.onRetry, + }; + } + + async isHealthy(): Promise { + try { + const res = await this.internals.fetchImpl(`${this.internals.baseUrl}/api/version`, { + method: "GET", + signal: this.combinedSignal(), + }); + if (!res.ok) { + return { ok: false, error: `HTTP ${res.status}` }; + } + const body = (await res.json()) as OllamaVersionResponseBody; + return { ok: true, version: body.version }; + } catch (err) { + return { ok: false, error: (err as Error).message ?? String(err) }; + } + } + + async listModels(): Promise { + const res = await this.request("/api/tags", { method: "GET" }); + const body = (await res.json()) as OllamaTagsResponseBody; + return (body.models ?? []).map((m) => m.name); + } + + async chat(req: ChatRequest): Promise { + const payload: OllamaChatPayload = { + model: this.internals.model, + messages: req.messages, + stream: false, + options: { + num_ctx: this.internals.numCtx, + temperature: this.internals.temperature, + num_predict: this.internals.numPredict, + }, + ...(req.format ? { format: req.format } : {}), + }; + const res = await this.request("/api/chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = (await res.json()) as OllamaChatResponseBody; + return { + content: data.message?.content ?? "", + model: data.model, + promptEvalCount: data.prompt_eval_count, + evalCount: data.eval_count, + totalDurationNs: data.total_duration, + }; + } + + async generate(prompt: string, opts?: GenerateOptions): Promise { + const payload: OllamaGeneratePayload = { + model: this.internals.model, + prompt, + stream: false, + options: { + num_ctx: this.internals.numCtx, + temperature: this.internals.temperature, + num_predict: this.internals.numPredict, + }, + ...(opts?.format ? { format: opts.format } : {}), + }; + const res = await this.request("/api/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = (await res.json()) as OllamaGenerateResponseBody; + return { + content: data.response ?? "", + model: data.model, + promptEvalCount: data.prompt_eval_count, + evalCount: data.eval_count, + totalDurationNs: data.total_duration, + }; + } + + private combinedSignal(): AbortSignal { + const timeout = AbortSignal.timeout(this.internals.timeoutMs); + if (this.internals.signal) { + return AbortSignal.any([timeout, this.internals.signal]); + } + return timeout; + } + + private async request(path: string, init: RequestInit): Promise { + const url = `${this.internals.baseUrl}${path}`; + const maxAttempts = this.internals.retries + 1; + let lastError: Error | undefined; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const signal = this.combinedSignal(); + try { + const res = await this.internals.fetchImpl(url, { ...init, signal }); + if (res.status === 404) { + throw new OllamaModelMissingError(this.internals.model); + } + if (res.status >= 500) { + const body = await res.text(); + throw new OllamaResponseError(res.status, body); + } + if (!res.ok) { + const body = await res.text(); + throw new OllamaResponseError(res.status, body); + } + return res; + } catch (err) { + if (err instanceof OllamaModelMissingError) { + throw err; + } + if (err instanceof DOMException && err.name === "AbortError") { + lastError = new OllamaTimeoutError(this.internals.timeoutMs); + } else { + lastError = err as Error; + } + if (attempt < maxAttempts) { + const delayMs = this.internals.retryBackoffMs * 2 ** (attempt - 1); + this.internals.onRetry?.({ attempt, delayMs, error: lastError }); + await sleep(delayMs, this.internals.signal); + continue; + } + break; + } + } + if ( + lastError instanceof OllamaTimeoutError || + lastError instanceof OllamaResponseError + ) { + throw lastError; + } + throw new OllamaConnectionError(this.internals.baseUrl, lastError); + } +} diff --git a/understand-anything-plugin/packages/core/src/types.ts b/understand-anything-plugin/packages/core/src/types.ts index b7a0fa6e4..1d3cea1ab 100644 --- a/understand-anything-plugin/packages/core/src/types.ts +++ b/understand-anything-plugin/packages/core/src/types.ts @@ -114,9 +114,16 @@ export interface AnalysisMeta { } // Project config (for auto-update opt-in and language preference) +export interface OllamaConfig { + baseUrl: string; + model: string; + concurrency: number; +} + export interface ProjectConfig { autoUpdate: boolean; outputLanguage?: string; + ollama?: OllamaConfig; } // Non-code structural sub-interfaces diff --git a/understand-anything-plugin/packages/core/tsconfig.json b/understand-anything-plugin/packages/core/tsconfig.json index bcc4ab463..8969cca77 100644 --- a/understand-anything-plugin/packages/core/tsconfig.json +++ b/understand-anything-plugin/packages/core/tsconfig.json @@ -1,8 +1,9 @@ { "compilerOptions": { "target": "ES2022", + "lib": ["ES2024"], "module": "ESNext", - "lib": ["ES2022"], + "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, diff --git a/understand-anything-plugin/skills/understand-ollama/SKILL.md b/understand-anything-plugin/skills/understand-ollama/SKILL.md new file mode 100644 index 000000000..3c37b21c3 --- /dev/null +++ b/understand-anything-plugin/skills/understand-ollama/SKILL.md @@ -0,0 +1,85 @@ +--- +name: understand-ollama +description: Run the /understand analysis pipeline against a local Ollama server. Produces the same knowledge graph as /understand, with no cloud LLM and no API key. +argument-hint: ["[path] [--ollama-url ] [--model ] [--review] [--full] [--resume] [--language ] [--concurrency N]"] +--- + +# /understand-ollama + +Run the Understand Anything analysis pipeline against a local Ollama server. The output — `.understand-anything/knowledge-graph.json` and `.understand-anything/meta.json` — is the same schema the dashboard, diff overlay, and search engine already understand. Use this when you want full local control, no API key, and no network egress from the host machine. + +## Prerequisites + +> **Choosing between this skill and Ollama's native integrations.** Ollama publishes [native integrations](https://docs.ollama.com/integrations) for many coding agents and IDEs — Claude Code, Codex App, Codex CLI, Copilot CLI, Cline CLI, OpenCode, VS Code (incl. Copilot), JetBrains, Zed, Roo Code, and others. If your host is on that list, the native integration is the lightest path: `ollama pull ` and set the model inside the host. +> +> `/understand-ollama` is the right pick in three concrete cases: +> 1. **Your host has no native Ollama integration.** This includes Cursor, Gemini CLI, OpenClaw, Hermes, Goose, Kiro CLI / IDE, Antigravity, Pi Agent, Vibe CLI, Trae, Nanobot, Droid, Pool, and others. The plugin supports these hosts via auto-discovery or `install.sh`, but they have no Ollama hook of their own. +> 2. **You want a guarantee that no prompt ever leaves the host machine.** Native integrations still send prompts through the host process; on managed / corporate deployments they may forward to a vendor gateway regardless of the user-chosen model. `/understand-ollama` runs the entire pipeline as a local Node script against a local HTTP server — no vendor process touches any prompt. +> 3. **You want one command that behaves the same across hosts.** `/understand-ollama` is a single Node script with a stable CLI and stable output schema; the host platform doesn't matter. + + +1. **Install Ollama.** macOS / Linux: + ```bash + curl -fsSL https://ollama.com/install.sh | sh + ``` + See https://ollama.com/download for Windows and other platforms. + +2. **Start the server.** The installer usually creates a system service that auto-starts. If not: + ```bash + ollama serve + ``` + +3. **Pull a model.** A 7B code model is the sweet spot for a 16 GB consumer GPU. For a beefier workstation, `qwen3-coder:30b` produces noticeably more accurate tour and layer output: + ```bash + ollama pull qwen2.5-coder:7b + ``` + + Other code-tuned models also work: `qwen3-coder:30b`, `codellama:13b`, `llama3.1:8b`, `deepseek-coder-v2:16b`. The 1.5B variants are useful for laptops or CI smoke tests but produce shallower tour summaries. + +## Options + +`$ARGUMENTS` may include: +- A directory path (e.g. `/path/to/repo`) — Analyze that directory instead of the current working directory. +- `--ollama-url ` — Ollama base URL. Default: `http://127.0.0.1:11434`. +- `--model ` — Model name. Default: `qwen2.5-coder:1.5b`. +- `--review` — Reserved for future use; the local path runs structural validation only. +- `--full` — Force a full rebuild, ignoring any existing graph. +- `--resume` — Reuse existing outputs where the commit hash matches. +- `--language ` — Generate the project description and tour prose in the specified language. Default: `en`. +- `--concurrency N` — Concurrent Ollama requests. Default: 2. Raise on bigger hardware. + +Persisted config: write `ollama: { baseUrl, model, concurrency }` to `.understand-anything/config.json` to skip flags on subsequent runs. + +## What this skill does + +1. Resolves `$PLUGIN_ROOT` and `$PROJECT_ROOT` using the same candidate list as `/understand`: + - `$CLAUDE_PLUGIN_ROOT` if set, else `$HOME/.understand-anything-plugin`, else the local checkout. +2. Ensures `@understand-anything/core` is built. If `packages/core/dist/index.js` is missing, runs `pnpm --filter @understand-anything/core build` once. +3. Calls `node /run-pipeline.mjs` with the resolved args. The driver owns all seven phases: + - **Phase 0 Preflight** — verifies Ollama is reachable and the model is pulled. + - **Phase 1 Scan** — runs the bundled `scan-project.mjs` and `extract-import-map.mjs`; uses Ollama for the project narrative (name, description, frameworks, languages). + - **Phase 1.5 Batches** — runs the bundled `compute-batches.mjs`. + - **Phase 2 Analyze** — for each batch, runs the bundled `extract-structure.mjs`, then uses Ollama to fill the per-file `summary` / `tags` / `complexity` / `languageNotes` / `functionSummaries` / `classSummaries` fields. + - **Phase 3 Assemble** — runs the bundled `merge-batch-graphs.py` to combine batches into `assembled-graph.json`. + - **Phase 4 Layers** — uses Ollama to identify logical layers; falls back to the heuristic detector on LLM failure. + - **Phase 5 Tour** — uses Ollama to produce a guided tour. + - **Phase 6 Review** — runs the dashboard's Zod schema validation; runs `build-fingerprints.mjs` for the auto-update baseline; writes the final `knowledge-graph.json` and `meta.json`. + - **Phase 7 Done** — prints the output path. +4. Forwards progress lines and the final summary to the host. + +The script is the single source of truth for the local pipeline; the seven phases are documented in `docs/superpowers/plans/2026-06-19-ollama-backend-impl.md`. + +## Output + +- `.understand-anything/knowledge-graph.json` — same schema as the cloud-driven path. +- `.understand-anything/meta.json` — includes the model name and Ollama URL used. +- `.understand-anything/fingerprints.json` — structural baseline for future auto-updates. + +Run `/understand-dashboard` (or open the dashboard's dev server) to explore the result. + +## Differences from the cloud path + +- Project narrative, per-file enrichment, layer detection, and tour generation are issued directly to Ollama. The cloud path delegates them to host-platform subagents. +- `--review` runs structural validation only. The cloud path's `graph-reviewer` subagent is a host-platform LLM call; on the local path, structural issues are surfaced as warnings and the run continues. +- Concurrency is bounded by the local model's memory budget. The default is 2 concurrent requests; on a 16 GB GPU with a 7B model, raise to 3–4 only if you have headroom. +- The `qwen2.5-coder:1.5b` default is a fast, low-memory choice. It produces valid JSON via `format: "json"` and is sufficient for the smoke fixture in `tests/fixtures/ollama-smoke/`. For higher-quality tours and layers on real codebases, switch to `qwen2.5-coder:7b` or `qwen3-coder:30b`. diff --git a/understand-anything-plugin/skills/understand-ollama/run-pipeline.mjs b/understand-anything-plugin/skills/understand-ollama/run-pipeline.mjs new file mode 100755 index 000000000..a3312c37f --- /dev/null +++ b/understand-anything-plugin/skills/understand-ollama/run-pipeline.mjs @@ -0,0 +1,611 @@ +#!/usr/bin/env node +/** + * run-pipeline.mjs — Seven-phase pipeline driver for the local Ollama backend. + * + * Mirrors skills/understand/SKILL.md but routes every LLM call to a local + * Ollama server. Deterministic steps (scan, import-map, batching, structure + * extraction) reuse the existing bundled scripts. Semantic steps (project + * narrative, per-file enrichment, layer detection, tour) call OllamaClient + * from @understand-anything/core. + * + * The local path is structurally identical to the cloud path: same + * intermediate file layout, same `nodes[]`/`edges[]` shape, same + * `merge-batch-graphs.py` assembler, same `KnowledgeGraphSchema` validation. + * + * Usage: + * node run-pipeline.mjs \ + * --project-root \ + * --plugin-root \ + * [--ollama-url ] [--model ] [--language ] \ + * [--concurrency N] [--review] [--full] [--resume] \ + * [--out ] + */ + +import { spawn, execSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { dirname, join, resolve, basename } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +// ---- Core import (workspace resolution) ----------------------------------- +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT_HINT = resolve(__dirname, "..", ".."); + +let core; +try { + core = await import( + pathToFileURL(require.resolve("@understand-anything/core")).href + ); +} catch { + core = await import( + pathToFileURL( + resolve(PLUGIN_ROOT_HINT, "packages/core/dist/index.js"), + ).href, + ); +} + +const { OllamaClient, buildFileAnalysisPrompt, parseFileAnalysisResponse, + buildLayerDetectionPrompt, parseLayerDetectionResponse, + buildTourGenerationPrompt, parseTourGenerationResponse, + applyLLMLayers, detectLayers, saveMeta } = core; + +// ---- CLI parsing ----------------------------------------------------------- + +function parseArgs(argv) { + const out = { + projectRoot: null, + pluginRoot: null, + ollamaUrl: "http://127.0.0.1:11434", + model: "qwen2.5-coder:1.5b", + language: "en", + concurrency: 2, + review: false, + full: false, + resume: false, + out: null, + }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + const next = () => argv[++i]; + switch (a) { + case "--project-root": out.projectRoot = next(); break; + case "--plugin-root": out.pluginRoot = next(); break; + case "--ollama-url": out.ollamaUrl = next(); break; + case "--model": out.model = next(); break; + case "--language": out.language = next(); break; + case "--concurrency": out.concurrency = Number(next()); break; + case "--review": out.review = true; break; + case "--full": out.full = true; break; + case "--resume": out.resume = true; break; + case "--out": out.out = next(); break; + case "--help": + console.log("Usage: run-pipeline.mjs --project-root --plugin-root [--ollama-url ] [--model ] [--language ] [--concurrency N] [--review] [--full] [--resume] [--out ]"); + break; + default: { + console.error(`Unknown argument: ${a}`); + process.exit(2); + } + } + } + if (!out.projectRoot || !out.pluginRoot) { + console.error("--project-root and --plugin-root are required"); + process.exit(2); + } + return out; +} + +const args = parseArgs(process.argv); +const PROJECT_ROOT = resolve(args.projectRoot); +const PLUGIN_ROOT = resolve(args.pluginRoot); +const SKILL_DIR_UNDERSTAND = join(PLUGIN_ROOT, "skills", "understand"); +const UNDERSTAND_DIR = join(PROJECT_ROOT, ".understand-anything"); +const INTERMEDIATE = join(UNDERSTAND_DIR, "intermediate"); +const TMP = join(UNDERSTAND_DIR, "tmp"); +const KNOWLEDGE_GRAPH = args.out ?? join(UNDERSTAND_DIR, "knowledge-graph.json"); + +const log = (msg) => console.log(`[understand-ollama] ${msg}`); +const logPhase = (n, name) => console.log(`[Phase ${n}/7] ${name}...`); +const logWarn = (msg) => console.warn(`[understand-ollama] warn: ${msg}`); + +function spawnOk(cmd, spawnArgs, cwd, env = {}) { + return new Promise((res, rej) => { + const p = spawn(cmd, spawnArgs, { + cwd: cwd ?? PLUGIN_ROOT, + stdio: ["ignore", "pipe", "inherit"], + env: { ...process.env, ...env }, + }); + p.on("exit", (code) => (code === 0 ? res() : rej(new Error(`${cmd} exited with code ${code}`)))); + }); +} + +async function readJson(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +async function writeJson(path, obj) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(obj, null, 2), "utf8"); +} + +// ---- Phases --------------------------------------------------------------- + +async function phase0Preflight(client) { + logPhase(0, "Preflight"); + const health = await client.isHealthy(); + if (!health.ok) { + log(`Ollama not reachable at ${args.ollamaUrl}: ${health.error}`); + log(`Start it with: ollama serve (then: ollama pull ${args.model})`); + process.exit(1); + } + log(`Ollama ${health.version} reachable. Model: ${args.model}`); + + const models = await client.listModels(); + if (!models.includes(args.model)) { + log(`Model ${args.model} not in local Ollama. Available: ${models.join(", ")}`); + log(`Pull it with: ollama pull ${args.model}`); + process.exit(1); + } + + await mkdir(INTERMEDIATE, { recursive: true }); + await mkdir(TMP, { recursive: true }); +} + +async function phase1Scan() { + logPhase(1, "Scanning project files"); + const scanInput = join(TMP, "ua-scan-files.json"); + const scanOutput = join(INTERMEDIATE, "scan-result.json"); + const importInput = join(TMP, "ua-import-map-input.json"); + const importOutput = join(TMP, "ua-import-map-output.json"); + + await spawnOk("node", [ + join(SKILL_DIR_UNDERSTAND, "scan-project.mjs"), + PROJECT_ROOT, scanInput, + ]); + const scanData = await readJson(scanInput); + + await writeJson(importInput, { projectRoot: PROJECT_ROOT, files: scanData.files }); + await spawnOk("node", [ + join(SKILL_DIR_UNDERSTAND, "extract-import-map.mjs"), + importInput, importOutput, + ]); + const importData = await readJson(importOutput); + + const projectMeta = await buildProjectNarrative(scanData); + + const finalScan = { + name: projectMeta.name, + description: projectMeta.description, + languages: projectMeta.languages, + frameworks: projectMeta.frameworks, + totalFiles: scanData.totalFiles, + filteredByIgnore: scanData.filteredByIgnore ?? 0, + estimatedComplexity: scanData.estimatedComplexity, + files: scanData.files, + importMap: importData.importMap, + scannedAt: new Date().toISOString(), + }; + await writeJson(scanOutput, finalScan); + log(`Scanned ${finalScan.totalFiles} files (${finalScan.filteredByIgnore} ignored).`); +} + +async function buildProjectNarrative(scanData) { + const readmePath = join(PROJECT_ROOT, "README.md"); + const manifestPath = await findManifest(); + + const readme = existsSync(readmePath) ? (await readFile(readmePath, "utf8")).slice(0, 3000) : ""; + const manifest = manifestPath ? (await readFile(manifestPath, "utf8")).slice(0, 2000) : ""; + + const dirName = basename(PROJECT_ROOT); + const fallback = { + name: dirName, + description: readme.split("\n").find((l) => l.trim() && !l.startsWith("#"))?.trim() ?? `${dirName} project`, + languages: [...new Set(scanData.files.map((f) => f.language).filter(Boolean))], + frameworks: [], + }; + + if (!readme && !manifest) return fallback; + + const prompt = `You are a project metadata extractor. Read the README and manifest excerpts, then return JSON describing the project. + +README (first 3000 chars): +\`\`\` +${readme} +\`\`\` + +Manifest (first 2000 chars): +\`\`\` +${manifest} +\`\`\` + +Return ONLY this JSON (no markdown, no prose): +{"name": "", "description": "", "frameworks": ["", ...], "languages": ["", ...]}`; + + try { + const client = new OllamaClient({ baseUrl: args.ollamaUrl, model: args.model }); + const res = await client.chat({ + messages: [ + { role: "system", content: "You are a precise project metadata extractor. Respond with valid JSON only." }, + { role: "user", content: prompt }, + ], + format: "json", + }); + const parsed = JSON.parse(res.content); + return { + name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name : fallback.name, + description: typeof parsed.description === "string" && parsed.description.trim() ? parsed.description : fallback.description, + languages: Array.isArray(parsed.languages) ? parsed.languages : fallback.languages, + frameworks: Array.isArray(parsed.frameworks) ? parsed.frameworks : fallback.frameworks, + }; + } catch (err) { + logWarn(`Project narrative via Ollama failed (${err.message}); using heuristic fallback.`); + return fallback; + } +} + +async function findManifest() { + const candidates = ["package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml"]; + for (const c of candidates) { + const p = join(PROJECT_ROOT, c); + if (existsSync(p)) return p; + } + return null; +} + +async function phase15Batch() { + logPhase(1.5, "Computing semantic batches"); + await spawnOk("node", [join(SKILL_DIR_UNDERSTAND, "compute-batches.mjs"), PROJECT_ROOT]); +} + +// Map fileCategory → canonical node type prefix +const CATEGORY_TO_TYPE = { + code: "file", + script: "file", + markup: "file", + config: "config", + docs: "document", + infra: "service", // overridden for pipeline / resource in transformer + data: "schema", // overridden for table / endpoint +}; + +function transformStructuralToGraph(structResults) { + // Returns { nodes, edges } matching the cloud file-analyzer output shape. + const nodes = []; + const edges = []; + const fileNodeByPath = new Map(); + + for (const fr of structResults) { + const filePath = fr.path; + const fileName = basename(filePath); + const fileId = `file:${filePath}`; + const fileType = CATEGORY_TO_TYPE[fr.fileCategory] ?? "file"; + + const fileNode = { + id: fileId, + type: fileType, + name: fileName, + filePath, + summary: "", // filled by Ollama + tags: [], // filled by Ollama + complexity: "moderate", // filled by Ollama + }; + // For infra files: dockerfile/k8s → service; CI → pipeline; terraform → resource + if (fr.fileCategory === "infra") { + if (/dockerfile|compose|k8s|helm/i.test(filePath) || fr.language === "dockerfile" || fr.language === "kubernetes") { + fileNode.type = "service"; + } else if (/\.github\/workflows|\.gitlab-ci|Jenkinsfile|\.circleci/i.test(filePath)) { + fileNode.type = "pipeline"; + } else if (/\.tf$|\.tfvars|cloudformation|vagrant/i.test(filePath)) { + fileNode.type = "resource"; + } + } else if (fr.fileCategory === "data") { + if (fr.language === "sql" || /migration/i.test(filePath)) { + fileNode.type = "table"; + } else if (/\.proto$|\.graphql$|\.prisma$|openapi|swagger/i.test(filePath)) { + fileNode.type = "schema"; + } else if (/openapi|swagger/i.test(filePath)) { + fileNode.type = "endpoint"; + } + } + nodes.push(fileNode); + fileNodeByPath.set(filePath, fileId); + + // Function nodes + contains edges + for (const fn of fr.functions ?? []) { + const fnId = `function:${filePath}:${fn.name}`; + nodes.push({ + id: fnId, + type: "function", + name: fn.name, + filePath, + lineRange: [fn.startLine, fn.endLine], + summary: "", + tags: [], + complexity: "moderate", + }); + edges.push({ source: fileId, target: fnId, type: "contains", direction: "forward", weight: 1.0 }); + } + + // Class nodes + contains edges + for (const cls of fr.classes ?? []) { + const clsId = `class:${filePath}:${cls.name}`; + nodes.push({ + id: clsId, + type: "class", + name: cls.name, + filePath, + lineRange: [cls.startLine, cls.endLine], + summary: "", + tags: [], + complexity: "moderate", + }); + edges.push({ source: fileId, target: clsId, type: "contains", direction: "forward", weight: 1.0 }); + } + + // callGraph → calls edges + for (const cg of fr.callGraph ?? []) { + const callerId = `function:${filePath}:${cg.caller}`; + const calleeName = String(cg.callee).replace(/\(.*\)$/, "").trim(); + // Heuristic: same-file callee resolution. Cross-file is not reliable here + // without import-map stitching; merge-batch-graphs.py handles recovery. + if (nodes.some((n) => n.id === `function:${filePath}:${calleeName}`)) { + edges.push({ + source: callerId, + target: `function:${filePath}:${calleeName}`, + type: "calls", + direction: "forward", + weight: 0.7, + }); + } + } + } + + return { nodes, edges }; +} + +async function phase2Analyze(client) { + logPhase(2, "Analyzing files"); + const batches = await readJson(join(INTERMEDIATE, "batches.json")); + const totalBatches = batches.batches.length; + log(`Total batches: ${totalBatches}`); + + for (let i = 0; i < totalBatches; i++) { + const batch = batches.batches[i]; + log(`Batch ${i + 1}/${totalBatches} (${batch.files.length} files)`); + + const structInput = join(TMP, `ua-struct-input-${i}.json`); + const structOutput = join(TMP, `ua-struct-output-${i}.json`); + await writeJson(structInput, { + projectRoot: PROJECT_ROOT, + batchFiles: batch.files, + batchImportData: batches.importMap ?? {}, + }); + await spawnOk("node", [ + join(SKILL_DIR_UNDERSTAND, "extract-structure.mjs"), + structInput, structOutput, + ]); + const structResult = await readJson(structOutput); + const structResults = structResult.results ?? []; + + // Deterministic structural → { nodes, edges } + const { nodes, edges } = transformStructuralToGraph(structResults); + + // Per-file semantic enrichment via Ollama (only on file nodes). + const fileNodes = nodes.filter((n) => n.id.startsWith("file:")); + const queue = [...fileNodes]; + const concurrency = Math.max(1, args.concurrency); + const workers = Array.from({ length: concurrency }, () => ({ + next: async () => { + while (queue.length) { + const fn = queue.shift(); + const structEntry = structResults.find((s) => `file:${s.path}` === fn.id) ?? {}; + try { + const enriched = await enrichFileNode(client, fn, structEntry); + Object.assign(fn, enriched); + } catch (err) { + logWarn(`${fn.filePath}: ${err.message}; using structural fallback`); + Object.assign(fn, fallbackFileNode(fn, structEntry)); + } + } + }, + })); + await Promise.all(workers.map((w) => w.next())); + + await writeJson(join(INTERMEDIATE, `batch-${i}.json`), { nodes, edges }); + log(` wrote batch-${i}.json (${nodes.length} nodes, ${edges.length} edges)`); + } +} + +async function enrichFileNode(client, fileNode, structEntry) { + const content = await readFileSafe(join(PROJECT_ROOT, fileNode.filePath)); + const projectContext = `language=${structEntry.language ?? "unknown"} category=${structEntry.fileCategory ?? "code"} lines=${structEntry.totalLines ?? "?"}`; + const prompt = buildFileAnalysisPrompt(fileNode.filePath, content, projectContext); + const res = await client.chat({ + messages: [ + { role: "system", content: "You are a senior code analyst. Respond with valid JSON only — no prose, no markdown fences." }, + { role: "user", content: prompt }, + ], + format: "json", + }); + const parsed = parseFileAnalysisResponse(res.content); + if (!parsed) throw new Error("parseFileAnalysisResponse returned null"); + return { + summary: parsed.fileSummary ?? `Structural analysis of ${fileNode.name}`, + tags: parsed.tags ?? [], + complexity: parsed.complexity ?? "moderate", + languageNotes: parsed.languageNotes, + }; +} + +function fallbackFileNode(fileNode, structEntry) { + return { + summary: `${fileNode.type} file: ${fileNode.name} (${structEntry.totalLines ?? "?"} lines, structural-only)`, + tags: [fileNode.type, (structEntry.language ?? "unknown")], + complexity: structEntry.totalLines > 200 ? "complex" : structEntry.totalLines > 50 ? "moderate" : "simple", + }; +} + +async function readFileSafe(absPath) { + try { return await readFile(absPath, "utf8"); } catch { return ""; } +} + +async function phase3Assemble() { + logPhase(3, "Assembling batch graphs"); + await spawnOk("python3", [join(SKILL_DIR_UNDERSTAND, "merge-batch-graphs.py"), PROJECT_ROOT]); +} + +async function injectGraphMetadata(graph) { + const scan = await readJson(join(INTERMEDIATE, "scan-result.json")); + const commit = (() => { + try { return execSync("git rev-parse HEAD", { cwd: PROJECT_ROOT, encoding: "utf8" }).trim(); } + catch { return ""; } + })(); + graph.version = graph.version ?? "1.0.0"; + graph.kind = graph.kind ?? "codebase"; + graph.project = { + name: scan.name ?? basename(PROJECT_ROOT), + description: scan.description ?? "", + languages: scan.languages ?? [], + frameworks: scan.frameworks ?? [], + analyzedAt: scan.scannedAt ?? new Date().toISOString(), + gitCommitHash: commit, + }; + if (!Array.isArray(graph.tour)) graph.tour = []; + return graph; +} + +async function phase4Layers(client) { + logPhase(4, "Detecting layers"); + const assembledPath = join(INTERMEDIATE, "assembled-graph.json"); + const graph = await injectGraphMetadata(await readJson(assembledPath)); + + let usedLlm = false; + try { + const prompt = buildLayerDetectionPrompt(graph); + const res = await client.chat({ + messages: [ + { role: "system", content: "You are a software architect. Respond with valid JSON only." }, + { role: "user", content: prompt }, + ], + format: "json", + }); + const parsed = parseLayerDetectionResponse(res.content); + if (parsed && parsed.length > 0) { + graph.layers = applyLLMLayers(graph, parsed); + usedLlm = true; + } + } catch (err) { + logWarn(`Layer LLM call failed (${err.message}); using heuristic.`); + } + if (!usedLlm) { + graph.layers = detectLayers(graph); + } + await writeJson(assembledPath, graph); + log(`Layers: ${graph.layers.length} (${usedLlm ? "LLM" : "heuristic"}).`); +} + +async function phase5Tour(client) { + logPhase(5, "Building guided tour"); + const assembledPath = join(INTERMEDIATE, "assembled-graph.json"); + const graph = await injectGraphMetadata(await readJson(assembledPath)); + + try { + const prompt = buildTourGenerationPrompt(graph); + const res = await client.chat({ + messages: [ + { role: "system", content: "You are a software architecture educator. Respond with valid JSON only." }, + { role: "user", content: prompt }, + ], + format: "json", + }); + const parsed = parseTourGenerationResponse(res.content); + graph.tour = parsed ?? []; + } catch (err) { + logWarn(`Tour LLM call failed (${err.message}); leaving tour empty.`); + graph.tour = []; + } + await writeJson(assembledPath, graph); + log(`Tour steps: ${graph.tour.length}.`); +} + +async function phase6Review() { + logPhase(6, "Validating and finalizing"); + const assembledPath = join(INTERMEDIATE, "assembled-graph.json"); + const graph = await injectGraphMetadata(await readJson(assembledPath)); + + const schema = core.knowledgeGraphSchema ?? core.KnowledgeGraphSchema; + const result = schema?.safeParse?.(graph) ?? { success: true }; + if (!result.success) { + logWarn(`Schema validation issues (${result.error?.issues?.length ?? "?"} total, showing first 5):`); + for (const issue of (result.error?.issues ?? []).slice(0, 5)) { + logWarn(` ${issue.path?.join(".") ?? ""}: ${issue.message}`); + } + } + + // Write final knowledge graph + await writeJson(KNOWLEDGE_GRAPH, graph); + + // Fingerprints baseline + try { + const scan = await readJson(join(INTERMEDIATE, "scan-result.json")); + const commit = (() => { + try { return execSync("git rev-parse HEAD", { cwd: PROJECT_ROOT, encoding: "utf8" }).trim(); } + catch { return ""; } + })(); + const fpInput = join(TMP, "ua-fingerprint-input.json"); + await writeJson(fpInput, { + projectRoot: PROJECT_ROOT, + sourceFilePaths: (scan.files ?? []).map((f) => f.path).filter((p) => /\.[a-z0-9]+$/i.test(p)), + gitCommitHash: commit, + }); + await spawnOk("node", [ + join(SKILL_DIR_UNDERSTAND, "build-fingerprints.mjs"), + fpInput, + ]); + } catch (err) { + logWarn(`Fingerprint baseline failed (${err.message}); continuing.`); + } + + // meta.json + const commit = (() => { + try { return execSync("git rev-parse HEAD", { cwd: PROJECT_ROOT, encoding: "utf8" }).trim(); } + catch { return ""; } + })(); + saveMeta(PROJECT_ROOT, { + gitCommitHash: commit, + analyzedAt: new Date().toISOString(), + ollamaModel: args.model, + ollamaUrl: args.ollamaUrl, + }); + log(`Schema validation: ${result.success ? "passed" : "completed with warnings"}.`); +} + +async function phase7Clean() { + logPhase(7, "Done"); + log(`Wrote ${KNOWLEDGE_GRAPH}`); + log(`Open the dashboard with: /understand-dashboard`); +} + +// ---- Entry --------------------------------------------------------------- + +async function main() { + log(`Plugin root: ${PLUGIN_ROOT}`); + log(`Project root: ${PROJECT_ROOT}`); + const client = new OllamaClient({ baseUrl: args.ollamaUrl, model: args.model }); + await phase0Preflight(client); + await phase1Scan(); + await phase15Batch(); + await phase2Analyze(client); + await phase3Assemble(); + await phase4Layers(client); + await phase5Tour(client); + await phase6Review(); + await phase7Clean(); +} + +main().catch((err) => { + console.error("[understand-ollama] fatal:", err.stack ?? err.message); + process.exit(1); +}); diff --git a/understand-anything-plugin/skills/understand-ollama/smoke-client.mjs b/understand-anything-plugin/skills/understand-ollama/smoke-client.mjs new file mode 100644 index 000000000..bc5068632 --- /dev/null +++ b/understand-anything-plugin/skills/understand-ollama/smoke-client.mjs @@ -0,0 +1,54 @@ +// skills/understand-ollama/smoke-client.mjs +// Local smoke test for OllamaClient against a real running Ollama server. +// Run with: pnpm exec node skills/understand-ollama/smoke-client.mjs +import { OllamaClient } from "@understand-anything/core"; + +const model = process.env.OLLAMA_MODEL ?? "qwen2.5-coder:1.5b"; +const baseUrl = process.env.OLLAMA_URL ?? "http://127.0.0.1:11434"; +const client = new OllamaClient({ baseUrl, model }); + +console.log(`[smoke] baseUrl=${baseUrl} model=${model}`); +const health = await client.isHealthy(); +console.log("[smoke] isHealthy:", health); +if (!health.ok) { + console.error(`[smoke] FAIL: server not reachable at ${baseUrl}`); + process.exit(1); +} + +const models = await client.listModels(); +const haveModel = models.includes(model); +console.log(`[smoke] ${models.length} models available; ${model} present: ${haveModel}`); +if (!haveModel) { + console.error(`[smoke] FAIL: model not pulled. Run: ollama pull ${model}`); + process.exit(1); +} + +console.log("[smoke] chat..."); +const res = await client.chat({ + messages: [ + { role: "system", content: "Reply with the single word: ok" }, + { role: "user", content: "ping" }, + ], +}); +console.log(`[smoke] chat content=${JSON.stringify(res.content.slice(0, 120))}`); +if (!/ok/i.test(res.content)) { + console.error(`[smoke] FAIL: expected 'ok' in content`); + process.exit(1); +} + +console.log("[smoke] chat with format:json..."); +const jsonRes = await client.chat({ + messages: [ + { role: "system", content: "Reply with JSON only: {\"answer\":\"\"}" }, + { role: "user", content: "ping" }, + ], + format: "json", +}); +console.log(`[smoke] json content=${JSON.stringify(jsonRes.content.slice(0, 120))}`); +const parsed = JSON.parse(jsonRes.content); +if (typeof parsed.answer !== "string") { + console.error(`[smoke] FAIL: parsed JSON missing 'answer'`); + process.exit(1); +} + +console.log("[smoke] PASS"); diff --git a/understand-anything-plugin/skills/understand-ollama/validate-output.mjs b/understand-anything-plugin/skills/understand-ollama/validate-output.mjs new file mode 100644 index 000000000..8b35949e4 --- /dev/null +++ b/understand-anything-plugin/skills/understand-ollama/validate-output.mjs @@ -0,0 +1,21 @@ +// skills/understand-ollama/validate-output.mjs +// Smoke check: load the produced knowledge-graph.json and validate the schema. +import { readFileSync } from "node:fs"; +import * as core from "@understand-anything/core"; + +const root = process.argv[2]; +if (!root) { + console.error("Usage: validate-output.mjs "); + process.exit(2); +} +const kgPath = `${root}/.understand-anything/knowledge-graph.json`; +const kg = JSON.parse(readFileSync(kgPath, "utf8")); +const schema = core.knowledgeGraphSchema ?? core.KnowledgeGraphSchema; +const r = schema?.safeParse?.(kg); +if (!r) { console.error("no schema found"); process.exit(1); } +if (!r.success) { + console.error("FAIL: schema validation issues:"); + for (const i of r.error.issues.slice(0, 10)) console.error(` - ${i.path.join(".")}: ${i.message}`); + process.exit(1); +} +console.log(`OK: ${kg.nodes.length} nodes, ${kg.edges.length} edges, ${kg.layers.length} layers, ${(kg.tour ?? []).length} tour steps, project="${kg.project.name}"`); diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index 02a213545..2e3b2622f 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -1,7 +1,7 @@ --- name: understand description: Analyze a codebase to produce an interactive knowledge graph for understanding architecture, components, and relationships -argument-hint: ["[path] [--full|--auto-update|--no-auto-update|--review|--language ]"] +argument-hint: ["[path] [--full|--auto-update|--no-auto-update|--review|--language ] [--ollama]"] --- # /understand @@ -17,6 +17,7 @@ Analyze the current codebase and produce a `knowledge-graph.json` file in `.unde - `--review` — Run full LLM graph-reviewer instead of inline deterministic validation - `--language ` — Generate all textual content (summaries, descriptions, tags, titles, languageNotes, languageLesson) in the specified language. Accepts ISO 639-1 codes (`zh`, `ja`, `ko`, `en`, `es`, `fr`, `de`, etc.) or friendly names (`chinese`, `japanese`, `korean`, `english`, `spanish`, etc.). Locale variants supported: `zh-TW`, `zh-HK`, etc. Defaults to `en` (English). Stores preference in `.understand-anything/config.json` for consistency across incremental updates. - A directory path (e.g. `/path/to/repo` or `../other-project`) — Analyze the given directory instead of the current working directory + - `--ollama` — Drive the analysis through a local Ollama server instead of dispatching host-platform subagents. Pre-flight, scan, batch, assemble, review, and clean phases run identically to the cloud path; the per-file analysis (Phase 2), layer detection (Phase 4), and tour generation (Phase 5) are executed by `node /skills/understand-ollama/run-pipeline.mjs`. The Ollama URL and model come from `.understand-anything/config.json` (`ollama: { baseUrl, model, concurrency }`), or fall back to `http://127.0.0.1:11434` and `qwen2.5-coder:1.5b`. Pass `--ollama-url` / `--model` via `/understand-ollama` if you need to override. --- @@ -278,6 +279,24 @@ If the script exits non-zero, the failure is hard — relay the full stderr to t ## Phase 2 — ANALYZE +### Local Ollama path (`--ollama` flag) + +If `$ARGUMENTS` contains `--ollama`, skip the host-platform dispatch below entirely and run the bundled Ollama driver instead. The driver owns Phase 2 (analyze) AND Phases 4 (layers) and 5 (tour) — return from the pipeline here and do not run Phases 2/4/5 in the cloud path. + +```bash +node /skills/understand-ollama/run-pipeline.mjs \ + --project-root "$PROJECT_ROOT" \ + --plugin-root "$PLUGIN_ROOT" \ + ${OLLAMA_URL:+--ollama-url "$OLLAMA_URL"} \ + ${OLLAMA_MODEL:+--model "$OLLAMA_MODEL"} \ + --language "$OUTPUT_LANGUAGE" \ + ${OLLAMA_CONCURRENCY:+--concurrency "$OLLAMA_CONCURRENCY"} +``` + +Where `OLLAMA_URL`, `OLLAMA_MODEL`, and `OLLAMA_CONCURRENCY` come from the persisted `ollama: { baseUrl, model, concurrency }` block in `$PROJECT_ROOT/.understand-anything/config.json` (read with `loadConfig` from `@understand-anything/core`). If any value is absent, the driver's own defaults apply (`http://127.0.0.1:11434`, `qwen2.5-coder:1.5b`, `2`). + +If the driver exits non-zero, report its stderr and **STOP** — do not fall back to the host-platform dispatch on failure. The driver surfaces every warning or partial-result condition in its own progress output. + ### Full analysis path Load `.understand-anything/intermediate/batches.json` (produced by Phase 1.5). Iterate the `batches[]` array.