diff --git a/.openclaw-plugin/README.md b/.openclaw-plugin/README.md new file mode 100644 index 000000000..e85a382e8 --- /dev/null +++ b/.openclaw-plugin/README.md @@ -0,0 +1,209 @@ +# Understand-Anything — OpenClaw Gateway Plugin + +A native [OpenClaw](https://github.com/openclaw/openclaw) gateway plugin that runs the +Understand-Anything knowledge-graph pipeline **inside the gateway process** — no +Claude Code, no skill/Task-tool dispatch, no per-platform symlinks. + +This is a deeper integration than the `install.sh openclaw` skill symlink (which +still works and is unaffected): the gateway itself gains analysis tools that any +connected agent can call mid-conversation, plus a dashboard route. + +## What it registers + +**Agent tools** (available to every agent connected to the gateway): + +| Tool | Purpose | +| --- | --- | +| `understand_list_projects` | List configured projects + analyzed status and graph size | +| `understand_analyze_project` | Start background analysis: tree-sitter structural pass + one LLM call per file, persisted to the project's `.ua/` dir | +| `understand_status` | Poll a running analysis job / inspect the persisted graph's metadata | +| `understand_search` | Fuzzy-search graph nodes (names, tags, summaries) via `@understand-anything/core`'s SearchEngine | +| `understand_get_node` | Full node detail + incoming/outgoing edges with neighbor names | +| `understand_analyze_pr` | Resolve a PR's (or branch diff's) changed files, compute the blast radius against the persisted graph, and generate an LLM-narrated PR walkthrough tour | + +**HTTP routes** (mounted on the gateway): + +- `GET /understand-anything` — project picker +- `GET /understand-anything/open?project=` — starts (or reuses) a viewer + instance for that project and redirects to its token-protected dashboard + URL. Binds to 127.0.0.1 only. + + With an Anthropic API key configured, this serves **interactive-server.ts** + instead of the plain upstream viewer: the identical dashboard + read-only + JSON API, plus a floating "Ask" chat panel backed by a live LLM + (`POST /ask.json`) — grounded in the persisted knowledge graph via + `SearchEngine`, the same idea as upstream's `/understand-chat` skill, just + reachable from the browser instead of a CLI. With no key configured, it + falls back to the plain zero-LLM `understand-anything-viewer` unchanged. + This split is deliberate: the standalone viewer's whole reason to exist is + staying LLM-free for team-sharing, so that path is never modified — the + interactive server is an additive, separate script that happens to reuse + its static assets. + + Unanalyzed projects get an **"Understand this project" button** right on + this page — no need to go find the tool call. Already-analyzed ones get a + **"Re-analyze"** button (analysis is always a full, fresh re-run — never + incremental — so this is also how you reset a project's tours/summaries to + whatever the pipeline currently produces as it improves). With + `allowAddProject: true` (see Install below), the picker also gets an + **"Add a project"** form: paste a `https://github.com/owner/repo` URL + (shallow-cloned into `~/.local/share/understand-anything-plugin/clones`) or + an existing local path, and it's added to the list and analysis starts + immediately. + + With an API key configured, the dashboard also gets a **"Tours" panel** + (🧭 button): every analyzed project gets two tours automatically — + a free, deterministic **module walkthrough** (dependency order, also + synced into the standard `graph.tour` field so upstream's own Learn + persona/LearnPanel plays it with zero changes) and an LLM-narrated + **code-review walkthrough** ranking the highest-risk files by complexity + and how central they are in the dependency graph. You can also select node(s) + in the graph canvas and type a prompt to generate a **custom tour** scoped + to just what you picked (e.g. "walk me through the auth flow"), or enter a + PR number or base branch to generate a **PR walkthrough** + (`POST /generate-pr-tour.json`, also callable directly as the + `understand_analyze_pr` tool): the changed files are resolved via `gh pr diff` + or `git diff ...HEAD` (falling back to uncommitted working-tree changes), + mapped onto the already-analyzed graph, and the LLM narrates the change plus + its 1-hop blast radius, grounded only in nodes actually in the diff or + directly touching it. + +## How it works + +The pipeline (`src/pipeline.ts`) is built entirely on `@understand-anything/core`'s +public API: `createIgnoreFilter` + `LanguageRegistry` for the walk, +`TreeSitterPlugin.analyzeFile` for deterministic structure, +`buildFileAnalysisPrompt`/`parseFileAnalysisResponse` + +`buildProjectSummaryPrompt`/`parseProjectSummaryResponse` for LLM enrichment, +`GraphBuilder` → `validateGraph` → `saveGraph`/`saveMeta` for persistence. The +LLM step is a single-turn structured-JSON call per file against the Anthropic +Messages API (`src/llm.ts`) — bounded by `concurrency` and `maxFiles`. + +Output is byte-compatible with the rest of the ecosystem: the same +`.ua/knowledge-graph.json` the skills produce, and the same dashboard renders. + +The Ask panel (`src/ask.ts`) works the same way at query time: `SearchEngine` +finds the graph nodes most relevant to the question, a bounded number of +their source files are read for grounding, and a single LLM call (the same +`llm.ts` caller used for analysis) answers using only that context — no +re-scanning the project per question. `src/interactive-server.ts` is a +distinct server from `understand-anything-viewer` (same static dashboard +build + JSON API, reused read-only) that adds the `/ask.json` endpoint and +injects a small vanilla-JS chat widget (`src/ask-widget.js`, no build step) +into the served `index.html`. + +Both the Ask and Tours widgets also ground on whatever node(s) are currently +selected in the graph canvas — read once by a shared discovery script +(`src/selection.js`, injected before either widget) via a `MutationObserver` +on `.react-flow__node.selected`, no changes to the dashboard's React source. +Ask always includes the current selection as primary context ahead of +whatever `SearchEngine` matches from the question text, so asking a question +while looking at a specific file answers about that file even if the question +itself doesn't share vocabulary with it (previously Ask had no notion of +selection at all, unlike custom-tour generation, which already scoped to it — +this unifies both widgets on one selection source instead of each keeping its +own). + +Tours (`src/tour-generation.ts`, `src/custom-tour.ts`, `src/tour-store.ts`) are +persisted to a plugin-owned `.ua/tours.json` sidecar, not the graph itself — +upstream's schema only has room for one tour (`graph.tour`), so module +walkthroughs go there for compatibility while code-review and custom tours +live alongside in `tours.json` and are played by `src/tours-widget.js`, the +same vanilla-JS/no-build-step pattern as the Ask widget. Selecting nodes for +a custom tour reads the live React Flow selection via a `MutationObserver` on +`.react-flow__node.selected` — zero patches to the dashboard's React source, +the same non-invasive approach the whole interactive layer uses throughout. + +PR walkthroughs (`src/pr-diff.ts`) build on `@understand-anything/core`'s pure, +deterministic `computeDiffOverlay` — no LLM involved in the blast-radius +computation itself: changed files map onto file/function/class nodes sharing +the same `filePath`, then a 1-hop edge walk (both directions) finds everything +else the change touches. Only the resulting changed + affected node lists (not +the whole graph) get passed to the LLM to narrate, and large diffs are capped +(40 changed / 20 affected nodes shown per prompt) so the required output stays +bounded regardless of how many files a diff touches — the model is told how +many nodes were omitted and asked to summarize rather than enumerate. The +result — a `DiffOverlay` plus generated tour steps — is persisted the same way +as a custom tour (`kind: "prWalkthrough"` in `.ua/tours.json`, one entry per +generation, unlike the singleton module/code-review kinds) and the overlay +itself is saved to `.ua/diff-overlay.json`, formalizing the on-disk contract +the `understand-diff` skill already documents. + +Project registration (`src/project-store.ts`) keeps config-declared projects +(fixed, from `projects` below) and dynamically-added ones (persisted to +`dynamic-projects.json` in the same state dir, so they survive a restart) in +one combined, index-stable list — new entries only ever append. Adding a +GitHub URL only recognizes an actual `https://github.com/...` or +`git@github.com:...` URL (never a bare `owner/repo` shorthand, `file://`, or +non-GitHub git URL — those could otherwise be used to smuggle an arbitrary +local path or host into a "clone", defeating the projects allowlist this +whole feature sits inside) and shells out to `git clone --depth 1` with a +3-minute timeout. + +## Install + +From the repo root: + +```bash +pnpm install +pnpm --filter @understand-anything/openclaw-plugin build +pnpm --filter understand-anything-viewer build # embeds the dashboard for the /understand-anything route +``` + +Then in your `~/.openclaw/openclaw.json`: + +```jsonc +{ + "plugins": { + "load": { "paths": ["/path/to/Understand-Anything/.openclaw-plugin"] }, + "entries": { + "understand-anything": { + "enabled": true, + "config": { + "projects": ["/abs/path/to/project-a", "/abs/path/to/project-b"], + "model": "claude-sonnet-5", // optional + "concurrency": 5, // optional + "maxFiles": 400, // optional + "anthropicApiKey": "sk-ant-...", // optional; falls back to ANTHROPIC_API_KEY on the gateway process + "allowAddProject": true // optional, default false — lets the dashboard register new projects at runtime + } + } + } + } +} +``` + +Restart the gateway. Then, from any agent session: + +``` +understand_analyze_project { "project": "0" } +understand_status +understand_search { "query": "session management" } +``` + +Open `/understand-anything` in a browser to view the dashboard. With an API +key configured (as above), you'll see a floating chat button — ask it +anything about the analyzed codebase and it answers grounded in the graph. + +## Security notes + +- Analysis and serving are restricted to the configured `projects` allowlist. +- The dashboard viewer inherits upstream's security model: 127.0.0.1 bind, + per-instance random access token, graph-derived file allowlist, 1 MB/no-binary + caps on source preview. +- The API key is only read from plugin config or the gateway process env; it is + never written to disk by the plugin. It's passed to the interactive server + subprocess via an environment variable, never a CLI arg (CLI args are + visible in `ps`; env vars of a process you own are not). +- `/ask.json` requires the same per-instance access token as the graph/file + endpoints (sent as `X-Ask-Token` instead of a query param, since it's a + POST body, not a GET link) and only ever reads files already listed in the + persisted knowledge graph, same as `/file-content.json`. +- `allowAddProject` is **off by default** and worth understanding before + turning on: the `projects` allowlist is otherwise the *only* way to expand + what this plugin can read, run LLM calls against, or serve — a fixed list + the operator controls via config. Enabling it means anyone who can reach + the dashboard route can trigger analysis (file reads + LLM API cost) of any + local path on the host, or have it clone and analyze any public GitHub + repo. Reasonable for a personal/trusted-operator gateway; think twice + before enabling it anywhere the dashboard route isn't equally trusted. diff --git a/.openclaw-plugin/openclaw.plugin.json b/.openclaw-plugin/openclaw.plugin.json new file mode 100644 index 000000000..7e82fd250 --- /dev/null +++ b/.openclaw-plugin/openclaw.plugin.json @@ -0,0 +1,80 @@ +{ + "id": "understand-anything", + "name": "Understand Anything", + "version": "0.1.0", + "description": "Native OpenClaw gateway plugin for Understand-Anything: tree-sitter + LLM knowledge-graph pipeline running inside the gateway process, with a gateway-mounted dashboard and query tools — no Claude Code / Task-tool dispatch required.", + "author": "Egonex (https://github.com/Egonex-AI/Understand-Anything), OpenClaw port by Tank", + "main": "dist/index.js", + "activation": { + "onStartup": true + }, + "contracts": { + "tools": [ + "understand_list_projects", + "understand_analyze_project", + "understand_status", + "understand_search", + "understand_get_node", + "understand_analyze_pr" + ] + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "anthropicApiKey": { + "type": "string", + "description": "Anthropic API key for the LLM enrichment phase. Falls back to the gateway's default anthropic:default auth profile when unset." + }, + "model": { + "type": "string", + "description": "Anthropic model id used for file/project/layer/tour analysis calls.", + "default": "claude-sonnet-5" + }, + "concurrency": { + "type": "number", + "description": "Max concurrent per-file LLM analysis calls during a project scan.", + "default": 5, + "minimum": 1, + "maximum": 20 + }, + "maxFiles": { + "type": "number", + "description": "Safety cap on how many recognized source files a single analysis run will send to the LLM (cost/runtime bound on large repos).", + "default": 400, + "minimum": 1 + }, + "projects": { + "type": "array", + "items": { "type": "string" }, + "description": "Absolute paths to projects this plugin is allowed to analyze/serve. Analysis and dashboard routes only operate on directories listed here; when empty or unset the plugin activates but every tool reports an empty project list." + }, + "allowAddProject": { + "type": "boolean", + "description": "Let the dashboard's 'Add a project' form register new projects at runtime — by GitHub URL (shallow-cloned into ~/.local/share/understand-anything-plugin/clones) or an existing local path — without editing config or restarting the gateway. Off by default: the projects allowlist above is otherwise the only way to expand what this plugin can read/analyze/serve, and this setting relaxes that for anyone who can reach the dashboard route.", + "default": false + } + } + }, + "uiHints": { + "anthropicApiKey": { + "label": "Anthropic API Key", + "secret": true + }, + "model": { + "label": "Analysis Model" + }, + "concurrency": { + "label": "Analysis Concurrency" + }, + "maxFiles": { + "label": "Max Files Per Analysis Run" + }, + "projects": { + "label": "Allowed Project Paths" + }, + "allowAddProject": { + "label": "Allow Adding Projects From the Dashboard" + } + } +} diff --git a/.openclaw-plugin/package.json b/.openclaw-plugin/package.json new file mode 100644 index 000000000..2375f4d45 --- /dev/null +++ b/.openclaw-plugin/package.json @@ -0,0 +1,34 @@ +{ + "name": "@understand-anything/openclaw-plugin", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "OpenClaw gateway plugin: native tree-sitter + LLM knowledge-graph pipeline and dashboard for Understand-Anything.", + "main": "dist/index.js", + "openclaw": { + "extensions": [ + "./dist/index.js" + ] + }, + "scripts": { + "build": "tsc && cp src/selection.js dist/selection.js && cp src/ask-widget.js dist/ask-widget.js && cp src/tours-widget.js dist/tours-widget.js", + "dev": "tsc --watch", + "clean": "rm -rf dist", + "test": "vitest run" + }, + "dependencies": { + "@sinclair/typebox": "^0.34.0", + "@understand-anything/core": "workspace:*", + "understand-anything-viewer": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.2.1", + "typescript": "^5.7.0", + "vitest": "^3.1.0" + }, + "files": [ + "dist", + "openclaw.plugin.json", + "README.md" + ] +} diff --git a/.openclaw-plugin/src/__tests__/ask.test.ts b/.openclaw-plugin/src/__tests__/ask.test.ts new file mode 100644 index 000000000..f0a97fe0a --- /dev/null +++ b/.openclaw-plugin/src/__tests__/ask.test.ts @@ -0,0 +1,151 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { saveGraph, type KnowledgeGraph } from "@understand-anything/core"; +import { askAboutProject } from "../ask.js"; +import type { LlmCaller } from "../llm.js"; + +const dirsToClean: string[] = []; + +function makeTmpProject(): string { + const dir = mkdtempSync(join(tmpdir(), "ua-ask-")); + dirsToClean.push(dir); + return dir; +} + +afterEach(() => { + while (dirsToClean.length) rmSync(dirsToClean.pop()!, { recursive: true, force: true }); +}); + +function makeGraph(root: string): KnowledgeGraph { + return { + version: "1.0.0", + project: { + name: "test-project", + languages: ["typescript"], + frameworks: ["Node.js"], + description: "A test project for ask.ts coverage.", + analyzedAt: new Date(0).toISOString(), + gitCommitHash: "deadbeef", + }, + nodes: [ + { + id: "file:src/auth.ts", + type: "file", + name: "auth.ts", + filePath: "src/auth.ts", + summary: "Handles user authentication and session tokens.", + tags: ["auth", "security"], + complexity: "moderate", + }, + { + id: "file:src/util.ts", + type: "file", + name: "util.ts", + filePath: "src/util.ts", + summary: "Miscellaneous string/date helpers.", + tags: ["utility"], + complexity: "simple", + }, + ], + edges: [], + layers: [], + tour: [], + }; +} + +describe("askAboutProject", () => { + it("reports the project hasn't been analyzed yet when there's no graph", async () => { + const root = makeTmpProject(); + const calls: string[] = []; + const stubLlm: LlmCaller = async (_sys, _user) => { + calls.push("called"); + return "should not be called"; + }; + + const result = await askAboutProject(root, "how does auth work?", stubLlm); + + expect(result.answer).toMatch(/hasn't been analyzed/i); + expect(result.citedNodes).toEqual([]); + expect(calls).toEqual([]); // never calls the LLM without a graph + }); + + it("grounds the prompt in matched nodes and returns their ids as citations", async () => { + const root = makeTmpProject(); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src", "auth.ts"), "export function login() {}\n"); + saveGraph(root, makeGraph(root)); + + let capturedPrompt = ""; + const stubLlm: LlmCaller = async (_sys, userContent) => { + capturedPrompt = userContent; + return "Auth is handled in src/auth.ts."; + }; + + const result = await askAboutProject(root, "how does authentication work?", stubLlm); + + expect(result.answer).toBe("Auth is handled in src/auth.ts."); + expect(result.citedNodes.map((n) => n.id)).toContain("file:src/auth.ts"); + // The auth-relevant node's summary should have made it into the grounding prompt. + expect(capturedPrompt).toContain("Handles user authentication"); + expect(capturedPrompt).toContain("how does authentication work?"); + }); + + it("includes a source snippet for a strongly matched file when readable", async () => { + const root = makeTmpProject(); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src", "auth.ts"), "export function login(user: string) { return user; }\n"); + saveGraph(root, makeGraph(root)); + + let capturedPrompt = ""; + const stubLlm: LlmCaller = async (_sys, userContent) => { + capturedPrompt = userContent; + return "ok"; + }; + + await askAboutProject(root, "authentication login", stubLlm); + + expect(capturedPrompt).toContain("export function login"); + }); + + it("grounds the prompt in a selected node even when the question text doesn't match it", async () => { + const root = makeTmpProject(); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src", "util.ts"), "export function pad(s: string) { return s; }\n"); + saveGraph(root, makeGraph(root)); + + let capturedPrompt = ""; + const stubLlm: LlmCaller = async (_sys, userContent) => { + capturedPrompt = userContent; + return "ok"; + }; + + const result = await askAboutProject(root, "what does this do?", stubLlm, ["file:src/util.ts"]); + + expect(capturedPrompt).toContain("currently has these node(s) selected/focused"); + expect(capturedPrompt).toContain("Miscellaneous string/date helpers"); + expect(capturedPrompt).toContain("export function pad"); + expect(result.citedNodes.map((n) => n.id)).toContain("file:src/util.ts"); + }); + + it("doesn't duplicate a node that's both selected and search-matched", async () => { + const root = makeTmpProject(); + saveGraph(root, makeGraph(root)); + + const stubLlm: LlmCaller = async () => "ok"; + const result = await askAboutProject(root, "authentication", stubLlm, ["file:src/auth.ts"]); + + expect(result.citedNodes.filter((n) => n.id === "file:src/auth.ts")).toHaveLength(1); + }); + + it("ignores a selected node id that isn't in the graph", async () => { + const root = makeTmpProject(); + saveGraph(root, makeGraph(root)); + + const stubLlm: LlmCaller = async () => "ok"; + const result = await askAboutProject(root, "how does auth work?", stubLlm, ["file:does-not-exist.ts"]); + + expect(result.citedNodes.map((n) => n.id)).not.toContain("file:does-not-exist.ts"); + }); +}); diff --git a/.openclaw-plugin/src/__tests__/custom-tour.test.ts b/.openclaw-plugin/src/__tests__/custom-tour.test.ts new file mode 100644 index 000000000..c79d9b8c1 --- /dev/null +++ b/.openclaw-plugin/src/__tests__/custom-tour.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { KnowledgeGraph } from "@understand-anything/core"; +import { generateCustomTour } from "../custom-tour.js"; +import type { LlmCaller } from "../llm.js"; + +function makeGraph(): KnowledgeGraph { + return { + version: "1.0.0", + project: { name: "p", languages: ["typescript"], frameworks: [], description: "d", analyzedAt: new Date(0).toISOString(), gitCommitHash: "x" }, + nodes: [ + { id: "file:auth.ts", type: "file", name: "auth.ts", filePath: "auth.ts", summary: "Handles login.", tags: [], complexity: "moderate" }, + { id: "file:session.ts", type: "file", name: "session.ts", filePath: "session.ts", summary: "Session tokens.", tags: [], complexity: "simple" }, + ], + edges: [], + layers: [], + tour: [], + }; +} + +describe("generateCustomTour", () => { + it("rejects an empty node selection without calling the LLM", async () => { + const stubLlm: LlmCaller = async () => { + throw new Error("should not be called"); + }; + const result = await generateCustomTour(makeGraph(), [], "explain auth", stubLlm); + expect(result.steps).toEqual([]); + expect(result.error).toMatch(/no nodes selected/i); + }); + + it("rejects an empty prompt without calling the LLM", async () => { + const stubLlm: LlmCaller = async () => { + throw new Error("should not be called"); + }; + const result = await generateCustomTour(makeGraph(), ["file:auth.ts"], " ", stubLlm); + expect(result.error).toMatch(/prompt is empty/i); + }); + + it("rejects node ids that don't exist in the graph", async () => { + const stubLlm: LlmCaller = async () => { + throw new Error("should not be called"); + }; + const result = await generateCustomTour(makeGraph(), ["file:does-not-exist.ts"], "explain this", stubLlm); + expect(result.error).toMatch(/none.*found/i); + }); + + it("grounds the prompt in only the selected nodes and returns parsed steps", async () => { + let capturedPrompt = ""; + const stubLlm: LlmCaller = async (_sys, userContent) => { + capturedPrompt = userContent; + return JSON.stringify({ + steps: [{ order: 1, title: "Auth flow", description: "Login then session.", nodeIds: ["file:auth.ts", "file:session.ts"] }], + }); + }; + + const result = await generateCustomTour(makeGraph(), ["file:auth.ts", "file:session.ts"], "walk me through the auth flow", stubLlm); + + expect(capturedPrompt).toContain("walk me through the auth flow"); + expect(capturedPrompt).toContain("file:auth.ts"); + expect(result.error).toBeUndefined(); + expect(result.steps).toHaveLength(1); + expect(result.steps[0].nodeIds).toEqual(["file:auth.ts", "file:session.ts"]); + }); +}); diff --git a/.openclaw-plugin/src/__tests__/glob-match.test.ts b/.openclaw-plugin/src/__tests__/glob-match.test.ts new file mode 100644 index 000000000..40a0e92be --- /dev/null +++ b/.openclaw-plugin/src/__tests__/glob-match.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { globToRegExp, matchesAnyPattern } from "../glob-match.js"; + +describe("globToRegExp", () => { + it("matches ** across path segments", () => { + expect(globToRegExp("src/api/**").test("src/api/v1/users.ts")).toBe(true); + expect(globToRegExp("src/api/**").test("src/web/users.ts")).toBe(false); + }); + + it("keeps * within a single segment", () => { + expect(globToRegExp("src/*.ts").test("src/main.ts")).toBe(true); + expect(globToRegExp("src/*.ts").test("src/nested/main.ts")).toBe(false); + }); + + it("matches ? as exactly one non-separator character", () => { + expect(globToRegExp("file?.ts").test("file1.ts")).toBe(true); + expect(globToRegExp("file?.ts").test("file12.ts")).toBe(false); + expect(globToRegExp("file?.ts").test("file/.ts")).toBe(false); + }); + + it("escapes regex metacharacters in literal parts", () => { + expect(globToRegExp("src/a+b.ts").test("src/a+b.ts")).toBe(true); + expect(globToRegExp("src/a+b.ts").test("src/aab.ts")).toBe(false); + expect(globToRegExp("*.test.ts").test("foo.test.ts")).toBe(true); + expect(globToRegExp("*.test.ts").test("foo.testxts")).toBe(false); + }); +}); + +describe("matchesAnyPattern", () => { + it("returns true when any pattern matches", () => { + expect(matchesAnyPattern("src/api/users.ts", ["docs/**", "src/api/**"])).toBe(true); + }); + it("returns false when no pattern matches", () => { + expect(matchesAnyPattern("src/web/index.ts", ["docs/**", "src/api/**"])).toBe(false); + }); +}); diff --git a/.openclaw-plugin/src/__tests__/llm.test.ts b/.openclaw-plugin/src/__tests__/llm.test.ts new file mode 100644 index 000000000..38b67c92d --- /dev/null +++ b/.openclaw-plugin/src/__tests__/llm.test.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; + +const requestCalls: Array<{ options: unknown; response: FakeResponse }> = []; + +class FakeResponse extends EventEmitter { + respond(body: string): void { + // setImmediate (a macrotask), not queueMicrotask — the response callback + // itself is scheduled via queueMicrotask below, and its "data"/"end" + // listeners are only attached once that callback runs. Emitting on a + // microtask here could still race ahead of it; a macrotask guarantees + // the callback (and its listener registration) has already run. + setImmediate(() => { + this.emit("data", Buffer.from(body)); + this.emit("end"); + }); + } +} + +class FakeRequest extends EventEmitter { + write(): void {} + end(): void {} + destroy(): void {} + setTimeout(): void {} +} + +vi.mock("node:https", () => ({ + default: { + request: (options: unknown, callback: (res: FakeResponse) => void) => { + const res = new FakeResponse(); + const req = new FakeRequest(); + requestCalls.push({ options, response: res }); + // Defer so the caller can attach its own listeners first, matching real https.request timing. + queueMicrotask(() => callback(res)); + return req; + }, + }, +})); + +const { createLlmCaller } = await import("../llm.js"); + +describe("createLlmCaller", () => { + it("extracts the text block when it's the first content block (the common case)", async () => { + const call = createLlmCaller("fake-key", "claude-sonnet-5"); + const promise = call("system", "user content"); + + requestCalls[requestCalls.length - 1].response.respond( + JSON.stringify({ content: [{ type: "text", text: "the answer" }] }), + ); + + expect(await promise).toBe("the answer"); + }); + + it("finds the text block even when a thinking block comes first (regression: extended thinking puts content[0] as type=thinking, not text)", async () => { + const call = createLlmCaller("fake-key", "claude-sonnet-5"); + const promise = call("system", "user content"); + + requestCalls[requestCalls.length - 1].response.respond( + JSON.stringify({ + content: [ + { type: "thinking", thinking: "internal reasoning...", signature: "abc" }, + { type: "text", text: "the real answer" }, + ], + }), + ); + + expect(await promise).toBe("the real answer"); + }); + + it("rejects when no content block is actually type text", async () => { + const call = createLlmCaller("fake-key", "claude-sonnet-5"); + const promise = call("system", "user content"); + + requestCalls[requestCalls.length - 1].response.respond( + JSON.stringify({ content: [{ type: "thinking", thinking: "only thinking, no answer" }] }), + ); + + await expect(promise).rejects.toThrow(/no text block found/i); + }); + + it("rejects with the Anthropic API error message when the response is an error", async () => { + const call = createLlmCaller("fake-key", "claude-sonnet-5"); + const promise = call("system", "user content"); + + requestCalls[requestCalls.length - 1].response.respond( + JSON.stringify({ error: { message: "overloaded_error: try again" } }), + ); + + await expect(promise).rejects.toThrow(/overloaded_error/); + }); +}); diff --git a/.openclaw-plugin/src/__tests__/pr-diff.test.ts b/.openclaw-plugin/src/__tests__/pr-diff.test.ts new file mode 100644 index 000000000..b97596523 --- /dev/null +++ b/.openclaw-plugin/src/__tests__/pr-diff.test.ts @@ -0,0 +1,103 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { KnowledgeGraph } from "@understand-anything/core"; +import { getChangedFiles, generatePrWalkthrough } from "../pr-diff.js"; +import type { LlmCaller } from "../llm.js"; + +const dirsToClean: string[] = []; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf-8" }); +} + +function makeGitRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "ua-pr-diff-")); + dirsToClean.push(dir); + git(dir, ["init", "-q", "-b", "main"]); + git(dir, ["config", "user.email", "test@example.com"]); + git(dir, ["config", "user.name", "Test"]); + writeFileSync(join(dir, "a.ts"), "export const a = 1;\n"); + git(dir, ["add", "."]); + git(dir, ["commit", "-q", "-m", "init"]); + return dir; +} + +afterEach(() => { + while (dirsToClean.length) rmSync(dirsToClean.pop()!, { recursive: true, force: true }); +}); + +describe("getChangedFiles", () => { + it("diffs a feature branch against the base branch", async () => { + const repo = makeGitRepo(); + git(repo, ["checkout", "-q", "-b", "feature"]); + writeFileSync(join(repo, "b.ts"), "export const b = 2;\n"); + git(repo, ["add", "."]); + git(repo, ["commit", "-q", "-m", "add b"]); + + const result = await getChangedFiles(repo, { baseBranch: "main" }); + expect(result.changedFiles).toEqual(["b.ts"]); + expect(result.baseBranch).toBe("main"); + }); + + it("falls back to uncommitted working-tree changes when there's nothing committed against the base", async () => { + const repo = makeGitRepo(); + writeFileSync(join(repo, "a.ts"), "export const a = 999;\n"); // uncommitted edit + const result = await getChangedFiles(repo, { baseBranch: "main" }); + expect(result.changedFiles).toEqual(["a.ts"]); + expect(result.baseBranch).toBe("working tree"); + }); + + it("returns no changed files when nothing changed at all", async () => { + const repo = makeGitRepo(); + const result = await getChangedFiles(repo, { baseBranch: "main" }); + expect(result.changedFiles).toEqual([]); + }); +}); + +function makeGraph(): KnowledgeGraph { + return { + version: "1.0.0", + project: { name: "p", languages: ["typescript"], frameworks: [], description: "d", analyzedAt: "2026-01-01T00:00:00.000Z", gitCommitHash: "x" }, + nodes: [ + { id: "file:a.ts", type: "file", name: "a.ts", filePath: "a.ts", summary: "File a.", tags: [], complexity: "simple" }, + { id: "file:b.ts", type: "file", name: "b.ts", filePath: "b.ts", summary: "Imports a.", tags: [], complexity: "simple" }, + ], + edges: [{ source: "file:b.ts", target: "file:a.ts", type: "imports", direction: "forward", weight: 0.7 }], + layers: [], + tour: [], + }; +} + +describe("generatePrWalkthrough", () => { + it("reports an error without calling the LLM when no changed file matches the graph", async () => { + const stubLlm: LlmCaller = async () => { + throw new Error("should not be called"); + }; + const result = await generatePrWalkthrough(makeGraph(), ["does-not-exist.ts"], "main", stubLlm); + expect(result.steps).toEqual([]); + expect(result.error).toMatch(/none of the changed files matched/i); + expect(result.overlay.changedNodeIds).toEqual([]); + }); + + it("grounds the prompt in changed + affected nodes and returns parsed steps", async () => { + let capturedPrompt = ""; + const stubLlm: LlmCaller = async (_sys, userContent) => { + capturedPrompt = userContent; + return JSON.stringify({ + steps: [{ order: 1, title: "a.ts changed", description: "b.ts depends on it.", nodeIds: ["file:a.ts", "file:b.ts"] }], + }); + }; + + const result = await generatePrWalkthrough(makeGraph(), ["a.ts"], "main", stubLlm); + + expect(capturedPrompt).toContain("file:a.ts"); + expect(capturedPrompt).toContain("file:b.ts"); // affected node should be in the prompt too + expect(result.error).toBeUndefined(); + expect(result.overlay.changedNodeIds).toEqual(["file:a.ts"]); + expect(result.overlay.affectedNodeIds).toEqual(["file:b.ts"]); + expect(result.steps).toHaveLength(1); + }); +}); diff --git a/.openclaw-plugin/src/__tests__/project-store.test.ts b/.openclaw-plugin/src/__tests__/project-store.test.ts new file mode 100644 index 000000000..99ea8e191 --- /dev/null +++ b/.openclaw-plugin/src/__tests__/project-store.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ProjectStore } from "../project-store.js"; + +const dirsToClean: string[] = []; + +function makeTmpDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + dirsToClean.push(dir); + return dir; +} + +afterEach(() => { + while (dirsToClean.length) rmSync(dirsToClean.pop()!, { recursive: true, force: true }); +}); + +describe("ProjectStore", () => { + it("lists config-declared projects and rejects adding when allowAddProject is false", async () => { + const configRoot = makeTmpDir("ua-store-config-"); + const stateDir = makeTmpDir("ua-store-state-"); + const store = new ProjectStore({ configProjects: [configRoot], allowAddProject: false, stateDir }); + + expect(store.list()).toEqual([configRoot]); + expect(store.canAddProject()).toBe(false); + + const result = await store.addProject("/some/other/path"); + expect("error" in result).toBe(true); + expect(store.list()).toEqual([configRoot]); // unchanged + }); + + it("adds a local path project and persists it across a fresh store instance", async () => { + const configRoot = makeTmpDir("ua-store-config-"); + const stateDir = makeTmpDir("ua-store-state-"); + const newProject = makeTmpDir("ua-store-newproj-"); + + const store = new ProjectStore({ configProjects: [configRoot], allowAddProject: true, stateDir }); + const result = await store.addProject(newProject); + + expect(result).toEqual({ root: newProject }); + expect(store.list()).toEqual([configRoot, newProject]); + + // A fresh store reading the same state dir should pick up the persisted addition. + const reloaded = new ProjectStore({ configProjects: [configRoot], allowAddProject: true, stateDir }); + expect(reloaded.list()).toEqual([configRoot, newProject]); + }); + + it("rejects a local path that doesn't exist", async () => { + const stateDir = makeTmpDir("ua-store-state-"); + const store = new ProjectStore({ configProjects: [], allowAddProject: true, stateDir }); + + const result = await store.addProject("/definitely/not/a/real/path/xyz123"); + expect("error" in result).toBe(true); + expect(store.list()).toEqual([]); + }); + + it("does not add a duplicate when the same path is added twice", async () => { + const stateDir = makeTmpDir("ua-store-state-"); + const project = makeTmpDir("ua-store-dup-"); + const store = new ProjectStore({ configProjects: [], allowAddProject: true, stateDir }); + + await store.addProject(project); + await store.addProject(project); + + expect(store.list()).toEqual([project]); + }); + + it("does not treat a github.com-shaped local directory name as a clone target", async () => { + // Regression guard: only a real https://github.com/... or git@github.com:... + // URL should trigger a clone. A bare "owner/repo"-looking string must be + // rejected as a local path (not silently attempted as a clone) since it's + // ambiguous with a genuine relative path and we don't want surprise network calls. + const stateDir = makeTmpDir("ua-store-state-"); + const store = new ProjectStore({ configProjects: [], allowAddProject: true, stateDir }); + + const result = await store.addProject("owner/repo"); + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toMatch(/not an existing local directory/); + } + }); + + it("rejects non-github git URLs and file:// URLs (would otherwise let git clone read arbitrary paths)", async () => { + const stateDir = makeTmpDir("ua-store-state-"); + const store = new ProjectStore({ configProjects: [], allowAddProject: true, stateDir }); + + for (const bad of ["file:///etc/passwd", "https://gitlab.com/owner/repo", "git://example.com/repo.git"]) { + const result = await store.addProject(bad); + expect("error" in result).toBe(true); + } + }); +}); diff --git a/.openclaw-plugin/src/__tests__/resolve-import.test.ts b/.openclaw-plugin/src/__tests__/resolve-import.test.ts new file mode 100644 index 000000000..4ca55787e --- /dev/null +++ b/.openclaw-plugin/src/__tests__/resolve-import.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { resolveImportTarget } from "../pipeline.js"; + +describe("resolveImportTarget", () => { + const known = new Set([ + "src/main.ts", + "src/util.ts", + "src/components/Button.tsx", + "src/lib/index.ts", + "scripts/run.mjs", + "pkg/mod.py", + ]); + + it("resolves a TS-ESM .js specifier to the .ts file on disk (regression: util.js.ts)", () => { + expect(resolveImportTarget("src/main.ts", "./util.js", known)).toBe("src/util.ts"); + }); + + it("resolves an extensionless specifier by trying known extensions", () => { + expect(resolveImportTarget("src/main.ts", "./util", known)).toBe("src/util.ts"); + expect(resolveImportTarget("src/main.ts", "./components/Button", known)).toBe("src/components/Button.tsx"); + }); + + it("resolves a directory import to its index file", () => { + expect(resolveImportTarget("src/main.ts", "./lib", known)).toBe("src/lib/index.ts"); + }); + + it("resolves an exact-match specifier as written", () => { + expect(resolveImportTarget("scripts/other.mjs", "./run.mjs", known)).toBe("scripts/run.mjs"); + }); + + it("resolves ../ traversal", () => { + expect(resolveImportTarget("src/components/Button.tsx", "../util.js", known)).toBe("src/util.ts"); + }); + + it("skips bare package imports", () => { + expect(resolveImportTarget("src/main.ts", "react", known)).toBeNull(); + expect(resolveImportTarget("src/main.ts", "@scope/pkg", known)).toBeNull(); + }); + + it("returns null for unresolvable relative imports", () => { + expect(resolveImportTarget("src/main.ts", "./missing.js", known)).toBeNull(); + }); +}); diff --git a/.openclaw-plugin/src/__tests__/tour-generation.test.ts b/.openclaw-plugin/src/__tests__/tour-generation.test.ts new file mode 100644 index 000000000..ea1a01fea --- /dev/null +++ b/.openclaw-plugin/src/__tests__/tour-generation.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import type { KnowledgeGraph } from "@understand-anything/core"; +import { generateModuleTour, rankNodesForReview, generateCodeReviewTour } from "../tour-generation.js"; +import type { LlmCaller } from "../llm.js"; + +function makeGraph(): KnowledgeGraph { + return { + version: "1.0.0", + project: { name: "p", languages: ["typescript"], frameworks: [], description: "d", analyzedAt: new Date(0).toISOString(), gitCommitHash: "x" }, + nodes: [ + { id: "file:a.ts", type: "file", name: "a.ts", filePath: "a.ts", summary: "Simple entry point.", tags: [], complexity: "simple" }, + { id: "file:core.ts", type: "file", name: "core.ts", filePath: "core.ts", summary: "Complex core logic everything depends on.", tags: [], complexity: "complex" }, + { id: "file:leaf.ts", type: "file", name: "leaf.ts", filePath: "leaf.ts", summary: "Isolated leaf utility.", tags: [], complexity: "simple" }, + ], + edges: [ + { source: "file:a.ts", target: "file:core.ts", type: "imports", direction: "forward", weight: 0.7 }, + { source: "file:leaf.ts", target: "file:core.ts", type: "imports", direction: "forward", weight: 0.7 }, + ], + layers: [], + tour: [], + }; +} + +describe("generateModuleTour", () => { + it("produces a non-empty, ordered tour for a graph with edges", () => { + const tour = generateModuleTour(makeGraph()); + expect(tour.length).toBeGreaterThan(0); + expect(tour[0].order).toBe(1); + }); +}); + +describe("rankNodesForReview", () => { + it("ranks the complex, highly-connected node above simple/isolated ones", () => { + const ranked = rankNodesForReview(makeGraph()); + expect(ranked[0].id).toBe("file:core.ts"); + }); + + it("respects the limit parameter", () => { + const ranked = rankNodesForReview(makeGraph(), 1); + expect(ranked).toHaveLength(1); + }); +}); + +describe("generateCodeReviewTour", () => { + it("returns an empty array when the graph has no code nodes", async () => { + const emptyGraph: KnowledgeGraph = { ...makeGraph(), nodes: [] }; + const stubLlm: LlmCaller = async () => { + throw new Error("should not be called"); + }; + const tour = await generateCodeReviewTour(emptyGraph, stubLlm); + expect(tour).toEqual([]); + }); + + it("grounds the prompt in the ranked nodes and parses the LLM's step response", async () => { + let capturedPrompt = ""; + const stubLlm: LlmCaller = async (_sys, userContent) => { + capturedPrompt = userContent; + return JSON.stringify({ + steps: [{ order: 1, title: "Start with core.ts", description: "Central and complex.", nodeIds: ["file:core.ts"] }], + }); + }; + + const tour = await generateCodeReviewTour(makeGraph(), stubLlm); + + expect(capturedPrompt).toContain("file:core.ts"); + expect(tour).toHaveLength(1); + expect(tour[0].title).toBe("Start with core.ts"); + expect(tour[0].nodeIds).toEqual(["file:core.ts"]); + }); +}); diff --git a/.openclaw-plugin/src/__tests__/tour-store.test.ts b/.openclaw-plugin/src/__tests__/tour-store.test.ts new file mode 100644 index 000000000..59c22b341 --- /dev/null +++ b/.openclaw-plugin/src/__tests__/tour-store.test.ts @@ -0,0 +1,66 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadTours, upsertTour, makeTourId, type StoredTour } from "../tour-store.js"; + +const dirsToClean: string[] = []; + +function makeTmpProject(): string { + const dir = mkdtempSync(join(tmpdir(), "ua-tours-")); + dirsToClean.push(dir); + return dir; +} + +afterEach(() => { + while (dirsToClean.length) rmSync(dirsToClean.pop()!, { recursive: true, force: true }); +}); + +function makeTour(kind: StoredTour["kind"], title: string): StoredTour { + return { id: makeTourId(kind), kind, title, description: "d", createdAt: new Date(0).toISOString(), steps: [] }; +} + +describe("tour-store", () => { + it("returns an empty list when no tours.json exists yet", () => { + const root = makeTmpProject(); + expect(loadTours(root)).toEqual([]); + }); + + it("persists a tour and reloads it", () => { + const root = makeTmpProject(); + const tour = makeTour("module", "Module walkthrough"); + upsertTour(root, tour); + expect(loadTours(root)).toEqual([tour]); + }); + + it("replaces the existing tour of the same singleton kind (module/codeReview)", () => { + const root = makeTmpProject(); + upsertTour(root, makeTour("module", "First")); + const second = makeTour("module", "Second"); + upsertTour(root, second); + + const stored = loadTours(root); + expect(stored).toHaveLength(1); + expect(stored[0].title).toBe("Second"); + }); + + it("accumulates custom tours instead of replacing them", () => { + const root = makeTmpProject(); + upsertTour(root, makeTour("custom", "Custom A")); + upsertTour(root, makeTour("custom", "Custom B")); + + const stored = loadTours(root); + expect(stored).toHaveLength(2); + expect(stored.map((t) => t.title)).toEqual(["Custom A", "Custom B"]); + }); + + it("keeps module and codeReview tours independent of each other", () => { + const root = makeTmpProject(); + upsertTour(root, makeTour("module", "Module")); + upsertTour(root, makeTour("codeReview", "Review")); + + const stored = loadTours(root); + expect(stored).toHaveLength(2); + expect(stored.map((t) => t.kind).sort()).toEqual(["codeReview", "module"]); + }); +}); diff --git a/.openclaw-plugin/src/__tests__/walk.test.ts b/.openclaw-plugin/src/__tests__/walk.test.ts new file mode 100644 index 000000000..0a10a1b84 --- /dev/null +++ b/.openclaw-plugin/src/__tests__/walk.test.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { walkProject } from "../walk.js"; + +const noopIgnoreFilter = { isIgnored: () => false }; + +const dirsToClean: string[] = []; + +function makeTmpDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + dirsToClean.push(dir); + return dir; +} + +afterEach(() => { + while (dirsToClean.length) { + const dir = dirsToClean.pop()!; + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("walkProject", () => { + it("lists ordinary files under root", () => { + const root = makeTmpDir("ua-walk-basic-"); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src", "a.ts"), "// a"); + writeFileSync(join(root, "README.md"), "# readme"); + + const results = walkProject(root, noopIgnoreFilter).sort(); + expect(results).toEqual(["README.md", "src/a.ts"]); + }); + + it("does not follow a symlink that escapes the project root (security: allowlist bypass)", () => { + const root = makeTmpDir("ua-walk-root-"); + const outside = makeTmpDir("ua-walk-outside-"); + writeFileSync(join(outside, "secret.ts"), "// should never be read"); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src", "a.ts"), "// a"); + symlinkSync(outside, join(root, "src", "escape"), "dir"); + + const results = walkProject(root, noopIgnoreFilter).sort(); + expect(results).toEqual(["src/a.ts"]); + expect(results.some((r) => r.includes("secret"))).toBe(false); + }); + + it("does not infinite-loop on a symlink cycle (crash risk: unbounded recursion)", () => { + const root = makeTmpDir("ua-walk-cycle-"); + mkdirSync(join(root, "a")); + writeFileSync(join(root, "a", "file.ts"), "// a"); + // a/loop -> root (cycle back to an ancestor) + symlinkSync(root, join(root, "a", "loop"), "dir"); + + const results = walkProject(root, noopIgnoreFilter).sort(); + expect(results).toEqual(["a/file.ts"]); + }); + + it("does not follow a symlink cycle pointing at itself", () => { + const root = makeTmpDir("ua-walk-self-cycle-"); + writeFileSync(join(root, "real.ts"), "// real"); + symlinkSync(root, join(root, "self"), "dir"); + + const results = walkProject(root, noopIgnoreFilter).sort(); + expect(results).toEqual(["real.ts"]); + }); +}); diff --git a/.openclaw-plugin/src/ask-widget.js b/.openclaw-plugin/src/ask-widget.js new file mode 100644 index 000000000..902b04f61 --- /dev/null +++ b/.openclaw-plugin/src/ask-widget.js @@ -0,0 +1,179 @@ +// Floating "Ask" chat widget — injected into the dashboard by +// interactive-server.ts. Talks to POST /ask.json using the same access token +// the page itself was loaded with (?token= in the URL). Vanilla JS/CSS, no +// build step, so it stays independent of the dashboard's own React app and +// its build pipeline. +// +// Grounds each question in whatever node(s) are currently selected in the +// graph canvas, via the shared discovery in selection.js (window.uaSelection) +// — the same mechanism the Tours widget uses for custom-tour scoping. +(function () { + "use strict"; + + var TOKEN = new URLSearchParams(window.location.search).get("token") || ""; + + var STYLE = "\n" + + "#ua-ask-fab{position:fixed;bottom:24px;right:24px;z-index:9999;width:56px;height:56px;border-radius:50%;" + + "background:#1f6feb;color:#fff;border:none;box-shadow:0 4px 14px rgba(0,0,0,.3);cursor:pointer;" + + "font-size:24px;display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif;}\n" + + "#ua-ask-fab:hover{background:#3b82f6;}\n" + + "#ua-ask-panel{position:fixed;bottom:92px;right:24px;z-index:9999;width:380px;max-width:calc(100vw - 48px);" + + "height:520px;max-height:calc(100vh - 140px);background:#0d1117;color:#e6edf3;border:1px solid #30363d;" + + "border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.5);display:none;flex-direction:column;" + + "font-family:system-ui,-apple-system,sans-serif;overflow:hidden;}\n" + + "#ua-ask-panel.open{display:flex;}\n" + + "#ua-ask-header{padding:12px 14px;border-bottom:1px solid #30363d;font-weight:600;font-size:14px;" + + "display:flex;justify-content:space-between;align-items:center;}\n" + + "#ua-ask-header button{background:none;border:none;color:#8b949e;cursor:pointer;font-size:18px;}\n" + + "#ua-ask-messages{flex:1;overflow-y:auto;padding:12px 14px;display:flex;flex-direction:column;gap:10px;}\n" + + ".ua-ask-msg{font-size:13px;line-height:1.5;white-space:pre-wrap;word-wrap:break-word;}\n" + + ".ua-ask-msg.user{align-self:flex-end;background:#1f6feb;color:#fff;padding:8px 12px;border-radius:12px 12px 2px 12px;max-width:85%;}\n" + + ".ua-ask-msg.assistant{align-self:flex-start;background:#161b22;padding:8px 12px;border-radius:12px 12px 12px 2px;max-width:90%;border:1px solid #30363d;}\n" + + ".ua-ask-msg.error{align-self:flex-start;color:#f85149;font-style:italic;}\n" + + ".ua-ask-cites{margin-top:6px;font-size:11px;color:#8b949e;}\n" + + ".ua-ask-cites code{background:#21262d;padding:1px 4px;border-radius:4px;}\n" + + "#ua-ask-form{border-top:1px solid #30363d;padding:10px;display:flex;gap:8px;}\n" + + "#ua-ask-input{flex:1;background:#161b22;border:1px solid #30363d;border-radius:8px;color:#e6edf3;" + + "padding:8px 10px;font-size:13px;resize:none;font-family:inherit;}\n" + + "#ua-ask-send{background:#1f6feb;color:#fff;border:none;border-radius:8px;padding:0 14px;cursor:pointer;font-size:13px;}\n" + + "#ua-ask-send:disabled{opacity:.5;cursor:default;}\n" + + "#ua-ask-empty{color:#8b949e;font-size:13px;padding:8px 0;}\n" + + "#ua-ask-selection{font-size:11px;color:#8b949e;padding:6px 14px;border-top:1px solid #30363d;}\n" + + "#ua-ask-selection code{background:#21262d;padding:1px 4px;border-radius:4px;}\n"; + + function el(tag, attrs, children) { + var e = document.createElement(tag); + if (attrs) for (var k in attrs) e.setAttribute(k, attrs[k]); + (children || []).forEach(function (c) { + e.appendChild(typeof c === "string" ? document.createTextNode(c) : c); + }); + return e; + } + + function addMessage(container, role, text, citedNodes) { + var msg = el("div", { class: "ua-ask-msg " + role }); + msg.textContent = text; + if (citedNodes && citedNodes.length) { + var cites = el("div", { class: "ua-ask-cites" }); + cites.textContent = "Referenced: "; + citedNodes.slice(0, 6).forEach(function (n, i) { + if (i > 0) cites.appendChild(document.createTextNode(", ")); + cites.appendChild(el("code", {}, [n.filePath || n.name])); + }); + msg.appendChild(cites); + } + container.appendChild(msg); + container.scrollTop = container.scrollHeight; + return msg; + } + + function init() { + if (!TOKEN) return; // no token in URL — not a live session, don't offer Ask + + var style = document.createElement("style"); + style.textContent = STYLE; + document.head.appendChild(style); + + var fab = el("button", { id: "ua-ask-fab", title: "Ask about this codebase" }, ["💬"]); + var panel = el("div", { id: "ua-ask-panel" }); + var header = el("div", { id: "ua-ask-header" }, [ + "Ask about this codebase", + el("button", { id: "ua-ask-close" }, ["×"]), + ]); + var messages = el("div", { id: "ua-ask-messages" }, [ + el("div", { id: "ua-ask-empty" }, ["Ask a question about how this codebase works — grounded in the analyzed knowledge graph."]), + ]); + var selectionBar = el("div", { id: "ua-ask-selection" }, ["Nothing selected in the graph — asking generally."]); + var input = el("textarea", { id: "ua-ask-input", rows: "1", placeholder: "e.g. how does the request flow work?" }); + var sendBtn = el("button", { id: "ua-ask-send" }, ["Ask"]); + var form = el("div", { id: "ua-ask-form" }, [input, sendBtn]); + + panel.appendChild(header); + panel.appendChild(messages); + panel.appendChild(selectionBar); + panel.appendChild(form); + document.body.appendChild(fab); + document.body.appendChild(panel); + + fab.addEventListener("click", function () { + panel.classList.toggle("open"); + if (panel.classList.contains("open")) input.focus(); + }); + header.querySelector("#ua-ask-close").addEventListener("click", function () { + panel.classList.remove("open"); + }); + + function renderSelectionBar(ids) { + selectionBar.textContent = ""; + if (!ids.length) { + selectionBar.textContent = "Nothing selected in the graph — asking generally."; + return; + } + selectionBar.appendChild(document.createTextNode("Grounded in: ")); + ids.slice(0, 4).forEach(function (id, i) { + if (i > 0) selectionBar.appendChild(document.createTextNode(", ")); + selectionBar.appendChild(el("code", {}, [id])); + }); + if (ids.length > 4) selectionBar.appendChild(document.createTextNode(" +" + (ids.length - 4) + " more")); + } + + if (window.uaSelection) { + renderSelectionBar(window.uaSelection.get()); + window.uaSelection.subscribe(renderSelectionBar); + } + + var asking = false; + function ask() { + var question = input.value.trim(); + if (!question || asking) return; + var empty = document.getElementById("ua-ask-empty"); + if (empty) empty.remove(); + addMessage(messages, "user", question); + input.value = ""; + asking = true; + sendBtn.disabled = true; + var thinking = addMessage(messages, "assistant", "Thinking…"); + + fetch("/ask.json", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Ask-Token": TOKEN }, + body: JSON.stringify({ + question: question, + selectedNodeIds: window.uaSelection ? window.uaSelection.get() : [], + }), + }) + .then(function (res) { + return res.json().then(function (data) { + if (!res.ok) throw new Error(data.error || "Request failed"); + return data; + }); + }) + .then(function (data) { + thinking.remove(); + addMessage(messages, "assistant", data.answer, data.citedNodes); + }) + .catch(function (err) { + thinking.remove(); + addMessage(messages, "error", "Error: " + err.message); + }) + .finally(function () { + asking = false; + sendBtn.disabled = false; + }); + } + + sendBtn.addEventListener("click", ask); + input.addEventListener("keydown", function (e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + ask(); + } + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/.openclaw-plugin/src/ask.ts b/.openclaw-plugin/src/ask.ts new file mode 100644 index 000000000..5e4165bc0 --- /dev/null +++ b/.openclaw-plugin/src/ask.ts @@ -0,0 +1,114 @@ +import { readFileSync, statSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { loadGraph, SearchEngine, type GraphNode, type KnowledgeGraph } from "@understand-anything/core"; +import type { LlmCaller } from "./llm.js"; + +const MAX_MATCHES = 8; +const MAX_SNIPPET_FILES = 3; +const MAX_SNIPPET_CHARS = 4000; +const MAX_SOURCE_FILE_BYTES = 1024 * 1024; + +const ASK_SYSTEM_PROMPT = + "You are answering a developer's question about a specific codebase. Use ONLY the context provided " + + "below — project description, matched knowledge-graph nodes, and source snippets. If the context doesn't " + + "contain enough information to answer confidently, say so plainly rather than guessing. Cite the relevant " + + "file paths in your answer where helpful. Respond in plain text (light markdown is fine), not JSON."; + +export interface AskResult { + answer: string; + citedNodes: Array<{ id: string; name: string; type: string; filePath?: string }>; +} + +function safeReadSnippet(projectRoot: string, filePath: string): string | null { + try { + const abs = resolve(projectRoot, filePath); + const rel = relative(projectRoot, abs); + if (rel.startsWith("..") || rel === "") return null; // stay inside the project + const stat = statSync(abs); + if (!stat.isFile() || stat.size > MAX_SOURCE_FILE_BYTES) return null; + const buffer = readFileSync(abs); + if (buffer.includes(0)) return null; // binary + return buffer.toString("utf8").slice(0, MAX_SNIPPET_CHARS); + } catch { + return null; + } +} + +function nodeContext(n: GraphNode): string { + const parts = [`[${n.type}] ${n.name}`]; + if (n.filePath) parts.push(`(${n.filePath})`); + if (n.summary) parts.push(`— ${n.summary}`); + if (n.tags?.length) parts.push(`tags: ${n.tags.join(", ")}`); + return parts.join(" "); +} + +/** + * Answers a free-form question about a project's already-analyzed codebase, + * grounded in the persisted knowledge graph rather than the raw source tree — + * this is what lets the dashboard's Ask panel work without re-scanning the + * project on every question. Mirrors upstream's /understand-chat skill, just + * reachable from the browser instead of a CLI. + * + * `selectedNodeIds` (from the dashboard's shared node-selection discovery — + * see selection.js) are always included as grounding context, ahead of and + * independent from whatever the free-text question happens to match via + * SearchEngine — otherwise a question asked while looking at a specific file + * silently ignores that the user is looking at it (see custom-tour's + * nodeIds-scoped generation, which already treats an explicit selection as + * authoritative; Ask previously had no equivalent). + */ +export async function askAboutProject( + projectRoot: string, + question: string, + llmCall: LlmCaller, + selectedNodeIds: string[] = [], +): Promise { + const graph: KnowledgeGraph | null = loadGraph(projectRoot, { validate: false }); + if (!graph) { + return { + answer: "This project hasn't been analyzed yet — run understand_analyze_project first.", + citedNodes: [], + }; + } + + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + const selectedNodes = selectedNodeIds + .map((id) => byId.get(id)) + .filter((n): n is GraphNode => n !== undefined); + const selectedIdSet = new Set(selectedNodes.map((n) => n.id)); + + const engine = new SearchEngine(graph.nodes); + const searchMatches = engine + .search(question, { limit: MAX_MATCHES }) + .map((r) => byId.get(r.nodeId)) + .filter((n): n is GraphNode => n !== undefined && !selectedIdSet.has(n.id)); + + const snippets: string[] = []; + let snippetCount = 0; + for (const n of [...selectedNodes, ...searchMatches]) { + if (snippetCount >= MAX_SNIPPET_FILES || !n.filePath) continue; + const content = safeReadSnippet(projectRoot, n.filePath); + if (content) { + snippets.push(`--- ${n.filePath} ---\n${content}`); + snippetCount++; + } + } + + const prompt = `Project: ${graph.project.name} +Description: ${graph.project.description} +Languages: ${graph.project.languages.join(", ")} +Frameworks: ${graph.project.frameworks.join(", ")} + +${selectedNodes.length ? `The developer currently has these node(s) selected/focused in the dashboard — treat them as the primary subject of the question unless the question clearly points elsewhere:\n${selectedNodes.map(nodeContext).join("\n")}\n\n` : ""}Matched knowledge-graph nodes for this question: +${searchMatches.map(nodeContext).join("\n") || (selectedNodes.length ? "(no additional strong matches beyond the selection above)" : "(no strong matches — answer from project description alone if possible)")} + +${snippets.length ? `Source snippets:\n${snippets.join("\n\n")}\n\n` : ""}Question: ${question}`; + + const answer = await llmCall(ASK_SYSTEM_PROMPT, prompt, 1500); + + const citedNodes = [...selectedNodes, ...searchMatches]; + return { + answer, + citedNodes: citedNodes.map((n) => ({ id: n.id, name: n.name, type: n.type, ...(n.filePath ? { filePath: n.filePath } : {}) })), + }; +} diff --git a/.openclaw-plugin/src/custom-tour.ts b/.openclaw-plugin/src/custom-tour.ts new file mode 100644 index 000000000..5081a2e1c --- /dev/null +++ b/.openclaw-plugin/src/custom-tour.ts @@ -0,0 +1,65 @@ +import { parseTourGenerationResponse, type GraphNode, type KnowledgeGraph, type TourStep } from "@understand-anything/core"; +import type { LlmCaller } from "./llm.js"; + +const CUSTOM_TOUR_SYSTEM_PROMPT = + "You are building a custom guided-tour walkthrough of a codebase for a developer, based on their specific " + + 'request and a set of nodes they selected in the knowledge graph. Always respond with a single valid JSON ' + + 'object shaped { "steps": [{ "order": number, "title": string, "description": string, "nodeIds": string[] }] } ' + + "and nothing else."; + +const MAX_CUSTOM_TOUR_NODES = 40; + +function buildCustomTourPrompt(graph: KnowledgeGraph, nodes: GraphNode[], userPrompt: string): string { + const nodeList = nodes + .map((n) => ` - ${n.id} [${n.type}] ${n.name}${n.filePath ? ` (${n.filePath})` : ""}: ${n.summary}`) + .join("\n"); + + return `Project: ${graph.project.name} +Description: ${graph.project.description} + +The developer selected these nodes and asked for a custom tour: +${nodeList} + +Developer's request: "${userPrompt}" + +Create an ordered walkthrough (steps) that satisfies the request, using ONLY the nodes listed above. Every nodeId +you reference must come from that list — do not invent new ones. Return a JSON object with a "steps" array; each +step has "order" (starting at 1), "title", "description" (2-3 sentences addressing the request), and "nodeIds" +(a subset of the list above relevant to that step).`; +} + +export interface CustomTourResult { + steps: TourStep[]; + error?: string; +} + +/** + * Generates a tour scoped to a user-selected set of nodes plus a free-text + * prompt — e.g. "explain how these fit together" or "walk me through the + * auth flow" over whatever the user selected in the graph. Unlike the + * automatic module/code-review tours, this is entirely driven by what the + * developer picked and asked for. + */ +export async function generateCustomTour( + graph: KnowledgeGraph, + nodeIds: string[], + userPrompt: string, + llmCall: LlmCaller, +): Promise { + if (nodeIds.length === 0) return { steps: [], error: "No nodes selected." }; + if (!userPrompt.trim()) return { steps: [], error: "Prompt is empty." }; + + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + const nodes = nodeIds + .map((id) => byId.get(id)) + .filter((n): n is GraphNode => n !== undefined) + .slice(0, MAX_CUSTOM_TOUR_NODES); + + if (nodes.length === 0) return { steps: [], error: "None of the selected node ids were found in the graph." }; + + const prompt = buildCustomTourPrompt(graph, nodes, userPrompt); + const response = await llmCall(CUSTOM_TOUR_SYSTEM_PROMPT, prompt, 2000); + const steps = parseTourGenerationResponse(response); + if (steps.length === 0) return { steps: [], error: "Could not generate a tour from the model's response." }; + return { steps }; +} diff --git a/.openclaw-plugin/src/dashboard-route.ts b/.openclaw-plugin/src/dashboard-route.ts new file mode 100644 index 000000000..5128d19db --- /dev/null +++ b/.openclaw-plugin/src/dashboard-route.ts @@ -0,0 +1,401 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { createRequire } from "node:module"; +import { spawn, type ChildProcessByStdio } from "node:child_process"; +import type { Readable } from "node:stream"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +interface Logger { + info(...a: unknown[]): void; + warn(...a: unknown[]): void; + error(...a: unknown[]): void; +} + +/** When set, the dashboard serves the interactive (LLM-backed Ask panel) server instead of the plain zero-LLM viewer. */ +export interface InteractiveLlmOptions { + apiKey: string; + model: string; +} + +interface ViewerInstance { + proc: ChildProcessByStdio; + port: number; + token: string; + lastUsedAtMs: number; +} + +const DASHBOARD_URL_RE = /Dashboard URL: http:\/\/127\.0\.0\.1:(\d+)\/\?token=([a-f0-9]+)/; +const START_TIMEOUT_MS = 15_000; +const IDLE_TIMEOUT_MS = 30 * 60_000; +const IDLE_SWEEP_INTERVAL_MS = 5 * 60_000; + +// Keyed by project root. Holds the in-flight *promise*, not just the settled +// instance — set synchronously before spawn() returns, so two requests for +// the same project racing during the (up to START_TIMEOUT_MS) startup window +// both await the same spawn instead of each starting their own orphaned +// viewer process. +const viewers = new Map>(); + +let idleSweepTimer: ReturnType | null = null; + +function resolveViewerBinPath(): string { + const require = createRequire(import.meta.url); + const pkgJsonPath = require.resolve("understand-anything-viewer/package.json"); + return join(dirname(pkgJsonPath), "bin", "viewer.mjs"); +} + +function hasGraph(projectRoot: string): boolean { + return ( + existsSync(join(projectRoot, ".ua", "knowledge-graph.json")) || + existsSync(join(projectRoot, ".understand-anything", "knowledge-graph.json")) + ); +} + +function startViewer(projectRoot: string, log: Logger, llmOptions: InteractiveLlmOptions | null): Promise { + const viewerBin = resolveViewerBinPath(); + const viewerDist = join(dirname(viewerBin), "..", "dist"); + if (!existsSync(join(viewerDist, "index.html"))) { + return Promise.reject( + new Error( + `understand-anything-viewer has not been built. Run: pnpm --filter understand-anything-viewer build (from the Understand-Anything repo root).`, + ), + ); + } + + // With an API key configured, serve the interactive server (adds a live + // Ask panel) instead of the plain zero-LLM viewer. Same static dashboard + // build and JSON API underneath — see interactive-server.ts's module doc. + const interactiveServerPath = join(HERE, "interactive-server.js"); + const useInteractive = llmOptions !== null && existsSync(interactiveServerPath); + const command = useInteractive ? interactiveServerPath : viewerBin; + const commandArgs = useInteractive ? [projectRoot, "--port", "0"] : [projectRoot, "--port", "0", "--no-open"]; + const label = useInteractive ? "interactive server" : "viewer"; + + return new Promise((resolve, reject) => { + const proc = spawn(process.execPath, [command, ...commandArgs], { + stdio: ["ignore", "pipe", "pipe"], + env: useInteractive + ? { ...process.env, UNDERSTAND_ANTHROPIC_API_KEY: llmOptions!.apiKey, UNDERSTAND_MODEL: llmOptions!.model } + : process.env, + }); + + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + proc.kill(); + viewers.delete(projectRoot); + reject(new Error(`Timed out waiting for the ${label} to start for ${projectRoot}`)); + }, START_TIMEOUT_MS); + + let buffer = ""; + proc.stdout.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + const match = buffer.match(DASHBOARD_URL_RE); + if (match && !settled) { + settled = true; + clearTimeout(timeout); + const instance: ViewerInstance = { + proc, + port: Number(match[1]), + token: match[2], + lastUsedAtMs: Date.now(), + }; + log.info(`[understand-anything] ${label} started for ${projectRoot} on port ${instance.port}`); + resolve(instance); + } + }); + proc.stderr.on("data", (chunk: Buffer) => { + log.warn(`[understand-anything] ${label} stderr (${projectRoot}): ${chunk.toString().trim()}`); + }); + proc.on("exit", (code) => { + // Only clear the map if this process is still the tracked one — an + // idle-evicted/superseded viewer's belated exit must not clobber a + // newer entry that has since replaced it. + viewers.get(projectRoot)?.then( + (instance) => { + if (instance.proc === proc) viewers.delete(projectRoot); + }, + () => viewers.delete(projectRoot), + ); + if (!settled) { + settled = true; + clearTimeout(timeout); + viewers.delete(projectRoot); + reject(new Error(`${label} exited (code ${code}) before starting for ${projectRoot}`)); + } + }); + proc.on("error", (err) => { + if (!settled) { + settled = true; + clearTimeout(timeout); + viewers.delete(projectRoot); + reject(err); + } + }); + }); +} + +async function getOrStartViewer(projectRoot: string, log: Logger, llmOptions: InteractiveLlmOptions | null): Promise { + const pending = viewers.get(projectRoot); + if (pending) { + try { + const instance = await pending; + instance.lastUsedAtMs = Date.now(); + return instance; + } catch { + // Fall through and start a fresh one — the failed attempt already + // removed itself from the map (see startViewer's reject paths). + } + } + + const startPromise = startViewer(projectRoot, log, llmOptions); + viewers.set(projectRoot, startPromise); + return startPromise; +} + +/** Kills every tracked viewer process. Call on gateway shutdown to avoid leaking child processes across restarts. */ +export function shutdownAllViewers(log: Logger): void { + if (idleSweepTimer) { + clearInterval(idleSweepTimer); + idleSweepTimer = null; + } + for (const [projectRoot, pending] of viewers) { + pending + .then((instance) => instance.proc.kill()) + .catch(() => { + /* already dead / never started */ + }); + viewers.delete(projectRoot); + } + log.info("[understand-anything] shut down all tracked viewer processes"); +} + +/** Starts the idle-eviction sweep (kills viewers unused for IDLE_TIMEOUT_MS). Idempotent. */ +export function startIdleViewerSweep(log: Logger): void { + if (idleSweepTimer) return; + idleSweepTimer = setInterval(() => { + const now = Date.now(); + for (const [projectRoot, pending] of viewers) { + pending + .then((instance) => { + if (now - instance.lastUsedAtMs > IDLE_TIMEOUT_MS) { + log.info(`[understand-anything] evicting idle viewer for ${projectRoot} (port ${instance.port})`); + instance.proc.kill(); + viewers.delete(projectRoot); + } + }) + .catch(() => viewers.delete(projectRoot)); + } + }, IDLE_SWEEP_INTERVAL_MS); + idleSweepTimer.unref?.(); +} + +function sendHtml(res: ServerResponse, status: number, html: string): void { + res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", "Content-Length": Buffer.byteLength(html) }); + res.end(html); +} + +function redirectToPicker(res: ServerResponse, error?: string): void { + const location = error ? `/understand-anything?error=${encodeURIComponent(error)}` : "/understand-anything"; + res.writeHead(302, { Location: location }); + res.end(); +} + +export interface PickerOptions { + getJobState: (root: string) => "running" | "done" | "error" | null; + canAddProject: boolean; + error?: string; +} + +function pickerHtml(projects: string[], opts: PickerOptions): string { + let anyRunning = false; + const items = projects + .map((p, i) => { + const analyzed = hasGraph(p); + const jobState = opts.getJobState(p); + if (jobState === "running") anyRunning = true; + + if (analyzed) { + if (jobState === "running") { + return `
  • ${escapeHtml(p)} (re-analyzing…)
  • `; + } + return `
  • ${escapeHtml(p)} +
    + +
  • `; + } + if (jobState === "running") { + return `
  • ${escapeHtml(p)} — analyzing… (this page refreshes automatically)
  • `; + } + if (jobState === "error") { + return `
  • ${escapeHtml(p)} — analysis failed +
    + +
  • `; + } + return `
  • ${escapeHtml(p)} +
    + +
  • `; + }) + .join("\n"); + + const addForm = opts.canAddProject + ? `

    Add a project

    +
    + + +
    +

    GitHub URLs are shallow-cloned locally. Local paths must already exist on this machine.

    ` + : ""; + + const errorBanner = opts.error + ? `

    ${escapeHtml(opts.error)}

    ` + : ""; + + return `Understand Anything${ + anyRunning ? `` : "" + } + +

    Understand Anything

    +${errorBanner} +

    Configured projects:

    +
      ${items || "
    • No projects configured — set plugins.entries.understand-anything.config.projects
    • "}
    +${addForm} +`; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function pathFromUrl(url: string | undefined): { pathname: string; query: URLSearchParams } { + const u = new URL(url ?? "/", "http://localhost"); + return { pathname: u.pathname, query: u.searchParams }; +} + +const MAX_FORM_BODY_BYTES = 8192; + +function readFormBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ""; + let bytes = 0; + req.on("data", (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > MAX_FORM_BODY_BYTES) { + reject(new Error("Request body too large")); + req.destroy(); + return; + } + body += chunk.toString("utf8"); + }); + req.on("end", () => resolve(new URLSearchParams(body))); + req.on("error", reject); + }); +} + +export interface DashboardRouteOptions { + getProjects: () => string[]; + getLlmOptions: () => InteractiveLlmOptions | null; + getJobState: (root: string) => "running" | "done" | "error" | null; + startAnalysis: (root: string) => { started: true } | { error: string }; + /** null when runtime project addition is disabled (allowAddProject config flag is off). */ + addProject: ((input: string) => Promise<{ root: string } | { error: string }>) | null; +} + +export function registerDashboardRoutes( + registerHttpRoute: (params: { + path: string; + handler: (req: IncomingMessage, res: ServerResponse) => Promise | boolean | void; + auth: "gateway" | "plugin"; + match?: "exact" | "prefix"; + }) => void, + log: Logger, + opts: DashboardRouteOptions, +): void { + startIdleViewerSweep(log); + + const servePicker = async (req: IncomingMessage, res: ServerResponse) => { + const { query } = pathFromUrl(req.url); + sendHtml( + res, + 200, + pickerHtml(opts.getProjects(), { + getJobState: opts.getJobState, + canAddProject: opts.addProject !== null, + error: query.get("error") ?? undefined, + }), + ); + }; + + registerHttpRoute({ path: "/understand-anything", auth: "plugin", match: "exact", handler: servePicker }); + registerHttpRoute({ + path: "/understand-anything/", + auth: "plugin", + match: "prefix", + handler: async (req, res) => { + const { pathname, query } = pathFromUrl(req.url); + const method = (req.method ?? "GET").toUpperCase(); + + if (pathname === "/understand-anything/analyze" && method === "POST") { + const projects = opts.getProjects(); + const idx = Number(query.get("project")); + if (!Number.isInteger(idx) || idx < 0 || idx >= projects.length) { + return redirectToPicker(res, "Unknown project index."); + } + const result = opts.startAnalysis(projects[idx]); + return redirectToPicker(res, "error" in result ? result.error : undefined); + } + + if (pathname === "/understand-anything/add-project" && method === "POST") { + if (!opts.addProject) return redirectToPicker(res, "Adding projects is disabled."); + try { + const form = await readFormBody(req); + const input = form.get("input") ?? ""; + const result = await opts.addProject(input); + if ("error" in result) return redirectToPicker(res, result.error); + opts.startAnalysis(result.root); // add & understand in one step + return redirectToPicker(res); + } catch (err) { + return redirectToPicker(res, err instanceof Error ? err.message : String(err)); + } + } + + if (pathname === "/understand-anything/open") { + const projects = opts.getProjects(); + if (!query.has("project")) { + return sendHtml(res, 400, "

    Missing required project query param. Back

    "); + } + const idx = Number(query.get("project")); + if (!Number.isInteger(idx) || idx < 0 || idx >= projects.length) { + return sendHtml(res, 400, "

    Unknown project index. Back

    "); + } + const projectRoot = projects[idx]; + if (!hasGraph(projectRoot)) { + return sendHtml( + res, + 404, + `

    No knowledge graph found for ${escapeHtml(projectRoot)} yet. Call the understand_analyze_project tool first.

    `, + ); + } + try { + const viewer = await getOrStartViewer(projectRoot, log, opts.getLlmOptions()); + res.writeHead(302, { Location: `http://127.0.0.1:${viewer.port}/?token=${viewer.token}` }); + res.end(); + } catch (err) { + log.error(`[understand-anything] failed to start viewer for ${projectRoot}:`, err); + sendHtml(res, 500, `

    Failed to start dashboard viewer: ${escapeHtml(err instanceof Error ? err.message : String(err))}

    `); + } + return; + } + + return servePicker(req, res); + }, + }); + + log.info("[understand-anything] dashboard routes registered: /understand-anything"); +} diff --git a/.openclaw-plugin/src/glob-match.ts b/.openclaw-plugin/src/glob-match.ts new file mode 100644 index 000000000..481603e82 --- /dev/null +++ b/.openclaw-plugin/src/glob-match.ts @@ -0,0 +1,32 @@ +/** + * Minimal glob matcher for layer file patterns (e.g. "src/api/**", "*.test.ts"). + * Supports `**` (any depth), `*` (any run within a segment), and `?` (one char). + * Not a full glob implementation — sufficient for the coarse layer-detection + * patterns an LLM produces, not for user-facing ignore rules (those go through + * @understand-anything/core's createIgnoreFilter, which uses the real `ignore` package). + */ +export function globToRegExp(pattern: string): RegExp { + let re = ""; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === "*") { + if (pattern[i + 1] === "*") { + re += ".*"; + i++; + } else { + re += "[^/]*"; + } + } else if (c === "?") { + re += "[^/]"; + } else if (".+^${}()|[]\\".includes(c)) { + re += `\\${c}`; + } else { + re += c; + } + } + return new RegExp(`^${re}$`); +} + +export function matchesAnyPattern(relPath: string, patterns: string[]): boolean { + return patterns.some((p) => globToRegExp(p).test(relPath)); +} diff --git a/.openclaw-plugin/src/index.ts b/.openclaw-plugin/src/index.ts new file mode 100644 index 000000000..61ad65462 --- /dev/null +++ b/.openclaw-plugin/src/index.ts @@ -0,0 +1,517 @@ +/** + * Understand-Anything — native OpenClaw gateway plugin. + * + * Registers: + * - Agent tools: understand_list_projects, understand_analyze_project, + * understand_status, understand_search, understand_get_node + * - Gateway HTTP routes: /understand-anything (project picker + dashboard) + * + * The analysis pipeline (src/pipeline.ts) runs entirely inside the gateway + * process: tree-sitter structural analysis + single-turn LLM enrichment via + * @understand-anything/core, persisted to the analyzed project's `.ua/` + * directory — the same on-disk contract the upstream skills, viewer, and + * dashboard already speak. No Claude Code / Task-tool dispatch involved. + */ + +import { existsSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { Type } from "@sinclair/typebox"; +import { + SearchEngine, + loadGraph, + loadMeta, + saveDiffOverlay, + type KnowledgeGraph, + type GraphNode, +} from "@understand-anything/core"; +import { analyzeProject, type AnalyzeProjectResult } from "./pipeline.js"; +import { createLlmCaller, resolveAnthropicApiKey, type LlmCaller } from "./llm.js"; +import { registerDashboardRoutes, shutdownAllViewers, type InteractiveLlmOptions } from "./dashboard-route.js"; +import { ProjectStore, expandHome } from "./project-store.js"; +import { getChangedFiles, generatePrWalkthrough } from "./pr-diff.js"; +import { upsertTour, makeTourId } from "./tour-store.js"; + +interface PluginApi { + registerTool(def: { + name: string; + description: string; + parameters: unknown; + execute: ( + id: string, + params: Record, + ) => Promise<{ content: Array<{ type: string; text: string }> }>; + }): void; + registerHttpRoute(params: { + path: string; + handler: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => Promise | boolean | void; + auth: "gateway" | "plugin"; + match?: "exact" | "prefix"; + }): void; + on?: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown, opts?: { priority?: number }) => void; + pluginConfig?: Record; + logger: { info(...a: unknown[]): void; warn(...a: unknown[]): void; error(...a: unknown[]): void }; +} + +interface UnderstandConfig { + projects?: string[]; + anthropicApiKey?: string; + model?: string; + concurrency?: number; + maxFiles?: number; + allowAddProject?: boolean; +} + +// ── Analysis job registry ─────────────────────────────────────────────────── +// Analysis of a real project takes minutes (one LLM call per file). Tool calls +// must return promptly, so understand_analyze_project starts a background job +// inside the gateway process and understand_status polls it. + +interface AnalysisJob { + state: "running" | "done" | "error"; + startedAt: string; + finishedAt?: string; + progress: string[]; + result?: AnalyzeProjectResult; + error?: string; +} + +const jobs = new Map(); + +const MAX_PROGRESS_LINES = 50; + +function textResult(text: string): { content: Array<{ type: string; text: string }> } { + return { content: [{ type: "text", text }] }; +} + +function jsonResult(value: unknown): { content: Array<{ type: string; text: string }> } { + return textResult(JSON.stringify(value, null, 2)); +} + +// ── Graph cache (mtime-invalidated) ───────────────────────────────────────── + +interface CachedGraph { + graph: KnowledgeGraph; + mtimeMs: number; + search: SearchEngine; +} + +const graphCache = new Map(); + +function graphFilePath(projectRoot: string): string | null { + for (const dir of [".understand-anything", ".ua"]) { + const p = join(projectRoot, dir, "knowledge-graph.json"); + if (existsSync(p)) return p; + } + return null; +} + +function getGraph(projectRoot: string): CachedGraph | null { + const file = graphFilePath(projectRoot); + if (!file) { + graphCache.delete(projectRoot); + return null; + } + const mtimeMs = statSync(file).mtimeMs; + const cached = graphCache.get(projectRoot); + if (cached && cached.mtimeMs === mtimeMs) return cached; + + // validate:false — serve whatever is on disk; the pipeline validated at save + // time and upstream tooling (viewer) is similarly tolerant on read. + // + // saveGraph (in @understand-anything/core) writes with a plain writeFileSync, + // not a temp-file+rename, and a background analyzeProject() job can call it + // at any moment while this route keeps serving reads. A read landing mid-write + // sees truncated JSON and throws — fall back to the previous cached graph + // (if any) rather than letting that propagate as an uncaught exception. + let graph: KnowledgeGraph | null; + try { + graph = loadGraph(projectRoot, { validate: false }); + } catch { + return cached ?? null; + } + if (!graph) return null; + const entry: CachedGraph = { graph, mtimeMs, search: new SearchEngine(graph.nodes) }; + graphCache.set(projectRoot, entry); + return entry; +} + +// ── Node formatting helpers ───────────────────────────────────────────────── + +// Graphs are loaded with { validate: false } (see getGraph), so a hand-edited +// or legacy-schema knowledge-graph.json could be missing/renaming any field — +// every access here is defensive rather than assuming the GraphNode shape. +function nodeBrief(n: GraphNode): Record { + return { + id: n.id, + type: n.type, + name: n.name, + ...(n.filePath ? { filePath: n.filePath } : {}), + summary: n.summary, + ...(Array.isArray(n.tags) && n.tags.length ? { tags: n.tags } : {}), + complexity: n.complexity, + }; +} + +function edgesFor(graph: KnowledgeGraph, nodeId: string) { + const outgoing = graph.edges + .filter((e) => e.source === nodeId) + .map((e) => ({ type: e.type, target: e.target, ...(e.description ? { description: e.description } : {}) })); + const incoming = graph.edges + .filter((e) => e.target === nodeId) + .map((e) => ({ type: e.type, source: e.source, ...(e.description ? { description: e.description } : {}) })); + return { outgoing, incoming }; +} + +// ── Activation ────────────────────────────────────────────────────────────── + +export function activate(api: PluginApi): void { + const log = api.logger; + const cfg = (api.pluginConfig ?? {}) as UnderstandConfig; + + const configProjects: string[] = []; + for (const raw of cfg.projects ?? []) { + const p = resolve(expandHome(raw)); + if (!existsSync(p) || !statSync(p).isDirectory()) { + log.warn(`[understand-anything] configured project is not an existing directory, skipping: ${raw}`); + continue; + } + configProjects.push(p); + } + + const store = new ProjectStore({ configProjects, allowAddProject: cfg.allowAddProject === true }); + if (store.list().length === 0) { + log.warn( + "[understand-anything] no valid projects configured (plugins.entries.understand-anything.config.projects) — tools will report an empty project list.", + ); + } + + const model = cfg.model ?? "claude-sonnet-5"; + const defaultConcurrency = cfg.concurrency ?? 5; + const defaultMaxFiles = cfg.maxFiles ?? 400; + + /** Resolve a `project` tool param (absolute path, or index into the current project list). */ + function resolveProject(param: unknown): { root: string } | { error: string } { + const projects = store.list(); + if (projects.length === 0) return { error: "No projects configured for the understand-anything plugin." }; + if (param === undefined || param === null || param === "") { + if (projects.length === 1) return { root: projects[0] }; + return { error: `Multiple projects configured — specify one of:\n${projects.map((p, i) => `${i}: ${p}`).join("\n")}` }; + } + if (typeof param === "number" || /^\d+$/.test(String(param))) { + const idx = Number(param); + if (idx >= 0 && idx < projects.length) return { root: projects[idx] }; + return { error: `Project index ${idx} out of range (0..${projects.length - 1}).` }; + } + const asPath = resolve(expandHome(String(param))); + const match = projects.find((p) => p === asPath); + if (match) return { root: match }; + return { + error: `"${param}" is not a configured project. Configured projects:\n${projects.map((p, i) => `${i}: ${p}`).join("\n")}`, + }; + } + + function makeLlmCaller(): LlmCaller | { error: string } { + const key = resolveAnthropicApiKey(cfg.anthropicApiKey); + if (!key) { + return { + error: + "No Anthropic API key available. Set plugins.entries.understand-anything.config.anthropicApiKey or the ANTHROPIC_API_KEY environment variable on the gateway process.", + }; + } + return createLlmCaller(key, model); + } + + /** + * Starts (or reports already-running) analysis for a project. Shared by the + * understand_analyze_project tool and the dashboard's "Understand this + * project" button — one job-tracking path, not two. + */ + function startAnalysis(root: string, opts: { concurrency?: number; maxFiles?: number } = {}): { started: true } | { error: string } { + const existing = jobs.get(root); + if (existing?.state === "running") { + return { error: `Analysis already running for ${root} (started ${existing.startedAt}).` }; + } + + const llm = makeLlmCaller(); + if (typeof llm !== "function") return { error: llm.error }; + + const job: AnalysisJob = { state: "running", startedAt: new Date().toISOString(), progress: [] }; + jobs.set(root, job); + + const pushProgress = (message: string) => { + job.progress.push(`${new Date().toISOString()} ${message}`); + if (job.progress.length > MAX_PROGRESS_LINES) job.progress.splice(0, job.progress.length - MAX_PROGRESS_LINES); + }; + + void analyzeProject(root, llm, { + concurrency: opts.concurrency ?? defaultConcurrency, + maxFiles: opts.maxFiles ?? defaultMaxFiles, + onProgress: pushProgress, + }) + .then((result) => { + job.state = "done"; + job.finishedAt = new Date().toISOString(); + job.result = result; + graphCache.delete(root); // force reload of the freshly-persisted graph + log.info( + `[understand-anything] analysis complete for ${root}: ${result.graph.nodes.length} nodes, ${result.graph.edges.length} edges (${result.filesAnalyzed} files).`, + ); + }) + .catch((err: unknown) => { + job.state = "error"; + job.finishedAt = new Date().toISOString(); + job.error = err instanceof Error ? err.message : String(err); + log.error(`[understand-anything] analysis failed for ${root}: ${job.error}`); + }); + + return { started: true }; + } + + // ── Tools ──────────────────────────────────────────────────────────────── + + api.registerTool({ + name: "understand_list_projects", + description: + "List the projects configured for Understand-Anything, whether each has been analyzed (has a knowledge graph), and graph size if so.", + parameters: Type.Object({}), + async execute() { + const rows = store.list().map((root, index) => { + const cached = graphFilePath(root) ? getGraph(root) : null; + const meta = loadMeta(root); + return { + index, + root, + analyzed: cached !== null, + ...(cached + ? { + nodes: cached.graph.nodes.length, + edges: cached.graph.edges.length, + ...(meta?.lastAnalyzedAt ? { lastAnalyzedAt: meta.lastAnalyzedAt } : {}), + } + : {}), + ...(jobs.get(root) ? { analysisJob: jobs.get(root)!.state } : {}), + }; + }); + return jsonResult({ projects: rows, dashboard: "/understand-anything" }); + }, + }); + + api.registerTool({ + name: "understand_analyze_project", + description: + "Start (or restart) knowledge-graph analysis of a configured project. Runs in the background inside the gateway — tree-sitter structural analysis plus one LLM call per source file — and persists .ua/knowledge-graph.json when done. Poll progress with understand_status.", + parameters: Type.Object({ + project: Type.Optional( + Type.String({ description: "Configured project path or index. May be omitted when exactly one project is configured." }), + ), + maxFiles: Type.Optional(Type.Integer({ minimum: 1, description: `Cap on files analyzed (default ${defaultMaxFiles}).` })), + concurrency: Type.Optional( + Type.Integer({ minimum: 1, maximum: 20, description: `Concurrent LLM calls (default ${defaultConcurrency}).` }), + ), + }), + async execute(_id, params) { + const resolved = resolveProject(params.project); + if ("error" in resolved) return textResult(resolved.error); + + const result = startAnalysis(resolved.root, { + concurrency: params.concurrency as number | undefined, + maxFiles: params.maxFiles as number | undefined, + }); + if ("error" in result) return textResult(`${result.error} Poll with understand_status.`); + + return textResult( + `Analysis started for ${resolved.root} (model ${model}). This runs one LLM call per source file and may take a few minutes — poll with understand_status.`, + ); + }, + }); + + api.registerTool({ + name: "understand_status", + description: + "Status of a project's knowledge-graph analysis: running job progress, last completed result, or the persisted graph's metadata.", + parameters: Type.Object({ + project: Type.Optional(Type.String({ description: "Configured project path or index." })), + }), + async execute(_id, params) { + const resolved = resolveProject(params.project); + if ("error" in resolved) return textResult(resolved.error); + const root = resolved.root; + + const job = jobs.get(root); + const cached = getGraph(root); + const meta = loadMeta(root); + return jsonResult({ + project: root, + graphOnDisk: cached ? { nodes: cached.graph.nodes.length, edges: cached.graph.edges.length } : null, + ...(meta ? { meta } : {}), + job: job + ? { + state: job.state, + startedAt: job.startedAt, + ...(job.finishedAt ? { finishedAt: job.finishedAt } : {}), + ...(job.error ? { error: job.error } : {}), + ...(job.result + ? { + filesScanned: job.result.filesScanned, + filesAnalyzed: job.result.filesAnalyzed, + warnings: job.result.warnings, + } + : {}), + recentProgress: job.progress.slice(-10), + } + : null, + }); + }, + }); + + api.registerTool({ + name: "understand_search", + description: + "Fuzzy-search a project's knowledge graph (names, tags, summaries). Returns matching nodes ranked by relevance. Use understand_get_node for full detail on a hit.", + parameters: Type.Object({ + query: Type.String({ description: "Search query — names, concepts, tags (e.g. 'auth', 'graph builder')." }), + project: Type.Optional(Type.String({ description: "Configured project path or index." })), + types: Type.Optional( + Type.Array(Type.String(), { description: "Filter to node types, e.g. [\"file\", \"function\", \"class\"]." }), + ), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: "Max results (default 10)." })), + }), + async execute(_id, params) { + const resolved = resolveProject(params.project); + if ("error" in resolved) return textResult(resolved.error); + const cached = getGraph(resolved.root); + if (!cached) return textResult(`No knowledge graph for ${resolved.root} yet — run understand_analyze_project first.`); + + const results = cached.search.search(String(params.query), { + types: params.types as GraphNode["type"][] | undefined, + limit: (params.limit as number | undefined) ?? 10, + }); + const byId = new Map(cached.graph.nodes.map((n) => [n.id, n])); + return jsonResult({ + query: params.query, + results: results.map((r) => ({ + score: Number(r.score.toFixed(3)), + ...nodeBrief(byId.get(r.nodeId)!), + })), + }); + }, + }); + + api.registerTool({ + name: "understand_get_node", + description: + "Full detail for one knowledge-graph node: summary, tags, file/line range, language notes, and all incoming/outgoing edges with neighbor names.", + parameters: Type.Object({ + nodeId: Type.String({ description: "Node id (from understand_search results), e.g. 'file:src/auth.ts'." }), + project: Type.Optional(Type.String({ description: "Configured project path or index." })), + }), + async execute(_id, params) { + const resolved = resolveProject(params.project); + if ("error" in resolved) return textResult(resolved.error); + const cached = getGraph(resolved.root); + if (!cached) return textResult(`No knowledge graph for ${resolved.root} yet — run understand_analyze_project first.`); + + const node = cached.graph.nodes.find((n) => n.id === params.nodeId); + if (!node) return textResult(`Node not found: ${params.nodeId}. Find ids with understand_search.`); + + const { outgoing, incoming } = edgesFor(cached.graph, node.id); + const nameOf = (id: string) => cached.graph.nodes.find((n) => n.id === id)?.name ?? id; + return jsonResult({ + ...node, + edges: { + outgoing: outgoing.map((e) => ({ ...e, targetName: nameOf(e.target as string) })), + incoming: incoming.map((e) => ({ ...e, sourceName: nameOf(e.source as string) })), + }, + }); + }, + }); + + api.registerTool({ + name: "understand_analyze_pr", + description: + "Understand what a pull request (or branch diff) changed against an already-analyzed project: maps changed files onto the " + + "knowledge graph, computes the 1-hop blast radius (what imports/calls the changed code, and what it imports/calls), and " + + "generates an LLM-narrated walkthrough of the change. Persists a diff-overlay for the dashboard's visual diff mode too.", + parameters: Type.Object({ + project: Type.Optional(Type.String({ description: "Configured project path or index." })), + prNumber: Type.Optional(Type.Integer({ description: "A GitHub PR number to diff via `gh pr diff` (requires gh CLI + auth on the gateway host)." })), + baseBranch: Type.Optional(Type.String({ description: "Base branch to diff HEAD against (default: repo's detected default branch). Ignored if prNumber is set." })), + }), + async execute(_id, params) { + const resolved = resolveProject(params.project); + if ("error" in resolved) return textResult(resolved.error); + const root = resolved.root; + + const cached = getGraph(root); + if (!cached) return textResult(`No knowledge graph for ${root} yet — run understand_analyze_project first.`); + + const llm = makeLlmCaller(); + if (typeof llm !== "function") return textResult(llm.error); + + try { + const { changedFiles, baseBranch: resolvedSource } = await getChangedFiles(root, { + prNumber: params.prNumber as number | undefined, + baseBranch: params.baseBranch as string | undefined, + }); + if (changedFiles.length === 0) { + return textResult(`No changed files found (source: ${resolvedSource}).`); + } + + const result = await generatePrWalkthrough(cached.graph, changedFiles, resolvedSource, llm); + saveDiffOverlay(root, result.overlay); + if (result.error) { + return jsonResult({ error: result.error, overlay: result.overlay }); + } + + upsertTour(root, { + id: makeTourId("prWalkthrough"), + kind: "prWalkthrough", + title: `PR walkthrough: ${resolvedSource}`, + description: `Changed: ${changedFiles.slice(0, 5).join(", ")}${changedFiles.length > 5 ? "…" : ""}`, + createdAt: new Date().toISOString(), + steps: result.steps, + diffSource: resolvedSource, + }); + + return jsonResult({ + diffSource: resolvedSource, + changedFiles, + changedNodeIds: result.overlay.changedNodeIds, + affectedNodeIds: result.overlay.affectedNodeIds, + tourSteps: result.steps, + dashboard: "/understand-anything", + }); + } catch (err) { + return textResult(`Failed to analyze PR/diff: ${err instanceof Error ? err.message : String(err)}`); + } + }, + }); + + // ── Dashboard routes ───────────────────────────────────────────────────── + + // The dashboard serves a live Ask panel (interactive-server.ts) instead of + // the plain zero-LLM viewer whenever a key is actually resolvable — same + // resolution order the analysis pipeline uses, so "configure once, both + // features work" holds. + const getLlmOptions = (): InteractiveLlmOptions | null => { + const key = resolveAnthropicApiKey(cfg.anthropicApiKey); + return key ? { apiKey: key, model } : null; + }; + + registerDashboardRoutes(api.registerHttpRoute.bind(api), log, { + getProjects: () => store.list(), + getLlmOptions, + getJobState: (root) => jobs.get(root)?.state ?? null, + startAnalysis: (root) => startAnalysis(root), + addProject: store.canAddProject() ? (input: string) => store.addProject(input) : null, + }); + + // Spawned understand-anything-viewer child processes otherwise accumulate + // forever and become fully orphaned (unreachable, un-killable from this + // process) on the next plugin/gateway reload, since module state resets. + api.on?.("gateway_stop", () => shutdownAllViewers(log)); + + log.info( + `[understand-anything] activated: ${store.list().length} project(s), model ${model}, tools understand_list_projects/analyze_project/status/search/get_node, dashboard at /understand-anything.`, + ); +} diff --git a/.openclaw-plugin/src/interactive-server.ts b/.openclaw-plugin/src/interactive-server.ts new file mode 100644 index 000000000..61fe68a20 --- /dev/null +++ b/.openclaw-plugin/src/interactive-server.ts @@ -0,0 +1,452 @@ +#!/usr/bin/env node +/** + * Interactive dashboard server — a superset of understand-anything-viewer + * (same static dashboard build, same read-only JSON API, same token/security + * model) that additionally serves a floating "Ask" chat widget backed by a + * live LLM. Spawned as its own subprocess by dashboard-route.ts, exactly like + * the plain viewer, but only when the plugin has an Anthropic API key + * configured — with no key, dashboard-route.ts falls back to the upstream + * zero-LLM viewer instead. + * + * Deliberately NOT a fork of understand-anything-viewer: that package's whole + * reason to exist is staying LLM-free for team-sharing without an API key. + * This is a distinct, additive server that happens to reuse its static + * assets and API surface. + * + * Usage: node interactive-server.js --port + * Env: UNDERSTAND_ANTHROPIC_API_KEY, UNDERSTAND_MODEL (never passed as CLI + * args — those would be visible in `ps aux`). + */ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { createRequire } from "node:module"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadGraph, saveDiffOverlay } from "@understand-anything/core"; +import { askAboutProject } from "./ask.js"; +import { createLlmCaller } from "./llm.js"; +import { generateCustomTour } from "./custom-tour.js"; +import { getChangedFiles, generatePrWalkthrough } from "./pr-diff.js"; +import { loadTours, upsertTour, makeTourId } from "./tour-store.js"; + +const require = createRequire(import.meta.url); +const viewerPkgJson = require.resolve("understand-anything-viewer/package.json"); +const DIST_DIR = path.join(path.dirname(viewerPkgJson), "dist"); +const MAX_SOURCE_FILE_BYTES = 1024 * 1024; +const UA_DIR_CANDIDATES = [".understand-anything", ".ua"]; +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +const args = process.argv.slice(2); +let projectRoot = process.cwd(); +let port = 0; +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--port") { + port = Number(args[++i]); + } else if (!a.startsWith("-")) { + projectRoot = path.resolve(a); + } +} + +const apiKey = process.env.UNDERSTAND_ANTHROPIC_API_KEY; +const model = process.env.UNDERSTAND_MODEL || "claude-sonnet-5"; +if (!apiKey) { + console.error("Error: UNDERSTAND_ANTHROPIC_API_KEY env var is required for the interactive server."); + process.exit(1); +} +const llmCall = createLlmCaller(apiKey, model); + +if (!fs.existsSync(DIST_DIR)) { + console.error( + "Error: embedded dashboard build not found. Run `pnpm --filter understand-anything-viewer build` first.", + ); + process.exit(1); +} + +const graphDir = UA_DIR_CANDIDATES + .map((d) => path.join(projectRoot, d)) + .find((d) => fs.existsSync(path.join(d, "knowledge-graph.json"))); + +if (!graphDir) { + console.error(`Error: no knowledge graph found under ${projectRoot}. Run understand_analyze_project first.`); + process.exit(1); +} + +const ACCESS_TOKEN = crypto.randomBytes(16).toString("hex"); + +function sendJson(res: ServerResponse, status: number, payload: unknown): void { + const body = JSON.stringify(payload); + res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }); + res.end(body); +} + +function normalizeGraphPath(filePath: string): string | null { + const rawPath = path.isAbsolute(filePath) + ? filePath.startsWith(projectRoot) + ? path.relative(projectRoot, filePath) + : null + : filePath; + if (rawPath === null) return null; + const normalized = path.normalize(rawPath); + if ( + !normalized || + normalized === "." || + normalized.includes("\0") || + normalized === ".." || + normalized.startsWith(`..${path.sep}`) || + path.isAbsolute(normalized) + ) { + return null; + } + return normalized.split(path.sep).join("/"); +} + +function graphFilePathSet(): Set { + const allowed = new Set(); + try { + const raw = JSON.parse(fs.readFileSync(path.join(graphDir!, "knowledge-graph.json"), "utf-8")); + for (const node of raw.nodes ?? []) { + if (typeof node.filePath !== "string") continue; + const normalized = normalizeGraphPath(node.filePath); + if (normalized) allowed.add(normalized); + } + } catch { + return allowed; + } + return allowed; +} + +const LANGUAGE_BY_EXT: Record = { + bash: "bash", c: "c", cc: "cpp", cpp: "cpp", cs: "csharp", css: "css", + go: "go", h: "c", hpp: "cpp", html: "markup", java: "java", + js: "javascript", jsx: "jsx", json: "json", md: "markdown", + mjs: "javascript", py: "python", rb: "ruby", rs: "rust", sh: "bash", + ts: "typescript", tsx: "tsx", txt: "text", yaml: "yaml", yml: "yaml", +}; + +function detectLanguage(filePath: string): string { + const ext = path.extname(filePath).slice(1).toLowerCase(); + return LANGUAGE_BY_EXT[ext] ?? "text"; +} + +function readSourceFile(url: URL): { statusCode: number; payload: unknown } { + const reject = (message: string, statusCode = 400) => ({ statusCode, payload: { error: message } }); + const requestedPath = url.searchParams.get("path") ?? ""; + if (!requestedPath) return reject("Missing path"); + if (requestedPath.includes("\0")) return reject("Invalid path"); + if (path.isAbsolute(requestedPath)) return reject("Absolute paths are not allowed"); + + const normalizedPath = path.normalize(requestedPath); + if (normalizedPath === "." || normalizedPath.startsWith(`..${path.sep}`) || normalizedPath === "..") { + return reject("Path must stay inside the project"); + } + + const absoluteFile = path.resolve(projectRoot, normalizedPath); + const relativeToRoot = path.relative(projectRoot, absoluteFile); + if (!relativeToRoot || relativeToRoot.startsWith(`..${path.sep}`) || relativeToRoot === "..") { + return reject("Path must stay inside the project"); + } + const safeRelativePath = relativeToRoot.split(path.sep).join("/"); + if (!graphFilePathSet().has(safeRelativePath)) { + return reject("File is not in the knowledge graph", 404); + } + + let stat; + try { + stat = fs.statSync(absoluteFile); + } catch { + return reject("File not found", 404); + } + if (!stat.isFile()) return reject("Path is not a file"); + if (stat.size > MAX_SOURCE_FILE_BYTES) return reject("File is too large to preview", 413); + + const buffer = fs.readFileSync(absoluteFile); + if (buffer.includes(0)) return reject("Binary files cannot be previewed", 415); + + const content = buffer.toString("utf8"); + return { + statusCode: 200, + payload: { + path: safeRelativePath, + language: detectLanguage(relativeToRoot), + content, + sizeBytes: buffer.byteLength, + lineCount: content.length === 0 ? 0 : content.split(/\r\n|\n|\r/).length, + }, + }; +} + +function serveGraphJson(res: ServerResponse, fileName: string): void { + const candidate = path.join(graphDir!, fileName); + if (fs.existsSync(candidate)) { + try { + const raw = JSON.parse(fs.readFileSync(candidate, "utf-8")); + if (Array.isArray(raw.nodes)) { + raw.nodes = raw.nodes.map((node: { filePath?: unknown; [k: string]: unknown }) => { + if (typeof node.filePath !== "string") return node; + const abs = node.filePath; + const rel = abs.startsWith(projectRoot) + ? abs.slice(projectRoot.length).replace(/^[\\/]/, "") + : path.isAbsolute(abs) + ? path.basename(abs) + : abs; + return { ...node, filePath: rel }; + }); + } + sendJson(res, 200, raw); + } catch { + sendJson(res, 500, { error: "Failed to read graph file" }); + } + return; + } + if (fileName === "knowledge-graph.json") { + sendJson(res, 404, { error: "No knowledge graph found. Run understand_analyze_project first." }); + } else { + res.statusCode = 404; + res.end(); + } +} + +const CONTENT_TYPES: Record = { + ".css": "text/css", ".html": "text/html", ".ico": "image/x-icon", + ".js": "text/javascript", ".json": "application/json", ".map": "application/json", + ".png": "image/png", ".svg": "image/svg+xml", ".txt": "text/plain", + ".wasm": "application/wasm", ".woff": "font/woff", ".woff2": "font/woff2", +}; + +const WIDGET_SCRIPT_TAGS = + `` + + ``; + +function serveIndexHtmlWithWidget(res: ServerResponse): void { + const absolute = path.join(DIST_DIR, "index.html"); + let html = fs.readFileSync(absolute, "utf8"); + html = html.includes("") ? html.replace("", `${WIDGET_SCRIPT_TAGS}`) : html + WIDGET_SCRIPT_TAGS; + res.setHeader("Content-Type", "text/html"); + res.end(html); +} + +function serveLocalScript(res: ServerResponse, filename: string): void { + const absolute = path.join(HERE, filename); + res.setHeader("Content-Type", "text/javascript"); + res.end(fs.readFileSync(absolute, "utf8")); +} + +function serveStatic(res: ServerResponse, pathname: string): void { + if (pathname === "/selection.js") return serveLocalScript(res, "selection.js"); + if (pathname === "/ask-widget.js") return serveLocalScript(res, "ask-widget.js"); + if (pathname === "/tours-widget.js") return serveLocalScript(res, "tours-widget.js"); + + const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, ""); + const absolute = path.resolve(DIST_DIR, relative); + if (absolute !== DIST_DIR && !absolute.startsWith(DIST_DIR + path.sep)) { + res.statusCode = 403; + res.end("Forbidden"); + return; + } + if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) { + res.statusCode = 404; + res.end("Not found"); + return; + } + if (relative === "index.html") return serveIndexHtmlWithWidget(res); + res.setHeader("Content-Type", CONTENT_TYPES[path.extname(absolute).toLowerCase()] ?? "application/octet-stream"); + res.end(fs.readFileSync(absolute)); +} + +function readBody(req: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + let body = ""; + let bytes = 0; + req.on("data", (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > maxBytes) { + reject(new Error("Request body too large")); + req.destroy(); + return; + } + body += chunk.toString("utf8"); + }); + req.on("end", () => resolve(body)); + req.on("error", reject); + }); +} + +const TOKEN_VIA_HEADER = new Set(["/ask.json", "/generate-tour.json", "/generate-pr-tour.json"]); +const PROTECTED = new Set([ + "/knowledge-graph.json", + "/domain-graph.json", + "/diff-overlay.json", + "/meta.json", + "/config.json", + "/file-content.json", + "/ask.json", + "/tours.json", + "/generate-tour.json", + "/generate-pr-tour.json", +]); + +const server = createServer((req, res) => { + void (async () => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + const pathname = url.pathname; + + if (!PROTECTED.has(pathname)) { + serveStatic(res, pathname); + return; + } + + const token = TOKEN_VIA_HEADER.has(pathname) ? req.headers["x-ask-token"] : url.searchParams.get("token"); + if (token !== ACCESS_TOKEN) { + sendJson(res, 403, { error: "Forbidden: missing or invalid token" }); + return; + } + + if (pathname === "/ask.json") { + if ((req.method ?? "GET").toUpperCase() !== "POST") { + sendJson(res, 405, { error: "POST required" }); + return; + } + try { + const body = await readBody(req, 8192); + const parsed = JSON.parse(body || "{}") as { question?: unknown; selectedNodeIds?: unknown }; + const question = typeof parsed.question === "string" ? parsed.question.trim() : ""; + if (!question) { + sendJson(res, 400, { error: "Missing question" }); + return; + } + const selectedNodeIds = Array.isArray(parsed.selectedNodeIds) + ? parsed.selectedNodeIds.filter((n): n is string => typeof n === "string") + : []; + const result = await askAboutProject(projectRoot, question, llmCall, selectedNodeIds); + sendJson(res, 200, result); + } catch (err) { + sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }); + } + return; + } + + if (pathname === "/tours.json") { + sendJson(res, 200, { tours: loadTours(projectRoot) }); + return; + } + + if (pathname === "/generate-tour.json") { + if ((req.method ?? "GET").toUpperCase() !== "POST") { + sendJson(res, 405, { error: "POST required" }); + return; + } + try { + const body = await readBody(req, 16384); + const parsed = JSON.parse(body || "{}") as { nodeIds?: unknown; prompt?: unknown }; + const nodeIds = Array.isArray(parsed.nodeIds) ? parsed.nodeIds.filter((n): n is string => typeof n === "string") : []; + const userPrompt = typeof parsed.prompt === "string" ? parsed.prompt : ""; + + const graph = loadGraph(projectRoot, { validate: false }); + if (!graph) { + sendJson(res, 404, { error: "No knowledge graph found for this project." }); + return; + } + + const result = await generateCustomTour(graph, nodeIds, userPrompt, llmCall); + if (result.error) { + sendJson(res, 400, { error: result.error }); + return; + } + + const tour = { + id: makeTourId("custom" as const), + kind: "custom" as const, + title: userPrompt.slice(0, 80), + description: userPrompt, + createdAt: new Date().toISOString(), + steps: result.steps, + prompt: userPrompt, + }; + upsertTour(projectRoot, tour); + sendJson(res, 200, { tour }); + } catch (err) { + sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }); + } + return; + } + + if (pathname === "/generate-pr-tour.json") { + if ((req.method ?? "GET").toUpperCase() !== "POST") { + sendJson(res, 405, { error: "POST required" }); + return; + } + try { + const body = await readBody(req, 4096); + const parsed = JSON.parse(body || "{}") as { prNumber?: unknown; baseBranch?: unknown }; + const prNumber = typeof parsed.prNumber === "number" ? parsed.prNumber : undefined; + const baseBranch = typeof parsed.baseBranch === "string" && parsed.baseBranch.trim() ? parsed.baseBranch.trim() : undefined; + + const graph = loadGraph(projectRoot, { validate: false }); + if (!graph) { + sendJson(res, 404, { error: "No knowledge graph found for this project." }); + return; + } + + const { changedFiles, baseBranch: resolvedSource } = await getChangedFiles(projectRoot, { prNumber, baseBranch }); + if (changedFiles.length === 0) { + sendJson(res, 400, { error: `No changed files found (source: ${resolvedSource}).` }); + return; + } + + const result = await generatePrWalkthrough(graph, changedFiles, resolvedSource, llmCall); + saveDiffOverlay(projectRoot, result.overlay); + if (result.error) { + sendJson(res, 400, { error: result.error, overlay: result.overlay }); + return; + } + + const tour = { + id: makeTourId("prWalkthrough" as const), + kind: "prWalkthrough" as const, + title: `PR walkthrough: ${resolvedSource}`, + description: `Changed: ${changedFiles.slice(0, 5).join(", ")}${changedFiles.length > 5 ? "…" : ""}`, + createdAt: new Date().toISOString(), + steps: result.steps, + diffSource: resolvedSource, + }; + upsertTour(projectRoot, tour); + sendJson(res, 200, { tour, overlay: result.overlay }); + } catch (err) { + sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }); + } + return; + } + + if (pathname === "/file-content.json") { + const result = readSourceFile(url); + sendJson(res, result.statusCode, result.payload); + return; + } + + if (pathname === "/config.json") { + const candidate = path.join(graphDir!, "config.json"); + if (fs.existsSync(candidate)) { + try { + sendJson(res, 200, JSON.parse(fs.readFileSync(candidate, "utf-8"))); + } catch { + sendJson(res, 500, { error: "Failed to read config file" }); + } + return; + } + sendJson(res, 200, { autoUpdate: false, outputLanguage: "en" }); + return; + } + + serveGraphJson(res, pathname.slice(1)); + })(); +}); + +server.listen(port, "127.0.0.1", () => { + const address = server.address(); + const boundPort = typeof address === "object" && address ? address.port : port; + console.log(`Serving graph from ${graphDir}`); + console.log(`Dashboard URL: http://127.0.0.1:${boundPort}/?token=${ACCESS_TOKEN}`); + console.log(`Ask token: ${ACCESS_TOKEN}`); +}); diff --git a/.openclaw-plugin/src/llm.ts b/.openclaw-plugin/src/llm.ts new file mode 100644 index 000000000..8edde1401 --- /dev/null +++ b/.openclaw-plugin/src/llm.ts @@ -0,0 +1,148 @@ +import https from "node:https"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface LlmCaller { + (systemPrompt: string, userContent: string, maxTokens?: number): Promise; +} + +/** + * Resolves an Anthropic API key the same way as other OpenClaw plugins that + * need a one-off completion outside the chat harness (see + * openclaw-cortex/hooks/reflection/handler.ts's callClaude): explicit plugin + * config first, then the calling agent's own auth profile, then the + * environment. + * + * Deliberately does NOT default OPENCLAW_AGENT_DIR to any particular agent + * (e.g. "main") — a gateway can host multiple agents with separate homes, and + * silently reading a different agent's auth profile would mean billing/using + * the wrong account's key. The auth-profile fallback only applies when the + * host has actually told us which agent this is via OPENCLAW_AGENT_DIR. + */ +export function resolveAnthropicApiKey(configuredKey?: string): string | null { + if (configuredKey?.trim()) return configuredKey.trim(); + + const agentDir = process.env.OPENCLAW_AGENT_DIR; + if (agentDir) { + try { + const profiles = JSON.parse(readFileSync(join(agentDir, "auth-profiles.json"), "utf-8")); + const key = profiles?.profiles?.["anthropic:default"]?.key; + if (typeof key === "string" && key.trim()) return key.trim(); + } catch { + // fall through + } + } + + if (process.env.ANTHROPIC_API_KEY?.trim()) return process.env.ANTHROPIC_API_KEY.trim(); + return null; +} + +/** Hard deadline on a single Anthropic call — without this, one stalled TCP connection (network + * partition, proxy hang) never settles, permanently consuming a mapWithConcurrency worker slot + * and wedging the analysis job in "running" state with no recovery short of a gateway restart. */ +const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; + +/** + * Single-turn structured-JSON completion against the Anthropic Messages API. + * Used for the deterministic-per-call phases of the pipeline (file analysis, + * project summary, layer detection, tour generation) — each call is + * independent, so a raw HTTPS request is simpler and cheaper than pulling the + * full Claude Agent SDK into the gateway process for what is not a chat + * session. + */ +export function createLlmCaller(apiKey: string, model: string, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS): LlmCaller { + return function callModel(systemPrompt: string, userContent: string, maxTokens = 2000): Promise { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ + model, + max_tokens: maxTokens, + system: systemPrompt, + messages: [{ role: "user", content: userContent }], + }); + + let settled = false; + const settleReject = (err: Error) => { + if (settled) return; + settled = true; + reject(err); + }; + const settleResolve = (text: string) => { + if (settled) return; + settled = true; + resolve(text); + }; + + const req = https.request( + { + hostname: "api.anthropic.com", + path: "/v1/messages", + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + "Content-Length": Buffer.byteLength(body), + }, + timeout: requestTimeoutMs, + }, + (res) => { + let data = ""; + res.on("data", (chunk: Buffer) => (data += chunk)); + res.on("end", () => { + try { + const parsed = JSON.parse(data); + if (parsed?.error) { + settleReject(new Error(`Anthropic API error: ${parsed.error.message ?? JSON.stringify(parsed.error)}`)); + return; + } + // content[0] is not reliably the text block — extended thinking + // (enabled by default on some models/accounts) puts a "thinking" + // block first, with the actual response in a later block. Find + // the first block that's actually type "text" instead of + // blindly indexing [0]. + const blocks = Array.isArray(parsed?.content) ? parsed.content : []; + const textBlock = blocks.find((b: { type?: string; text?: unknown }) => b?.type === "text" && typeof b.text === "string"); + if (!textBlock) { + settleReject(new Error(`Unexpected Anthropic response shape (no text block found): ${data.slice(0, 300)}`)); + return; + } + settleResolve(textBlock.text as string); + } catch (err) { + settleReject(err instanceof Error ? err : new Error(String(err))); + } + }); + res.on("error", (err) => settleReject(err)); + }, + ); + req.on("error", (err) => settleReject(err)); + req.on("timeout", () => { + req.destroy(); + settleReject(new Error(`Anthropic request timed out after ${requestTimeoutMs}ms`)); + }); + req.write(body); + req.end(); + }); + }; +} + +/** Bounded concurrency map — avoids hammering the API with hundreds of files at once. */ +export async function mapWithConcurrency( + items: T[], + concurrency: number, + fn: (item: T, index: number) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let next = 0; + + async function worker(): Promise { + while (true) { + const i = next++; + if (i >= items.length) return; + results[i] = await fn(items[i], i); + } + } + + const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, () => worker()); + await Promise.all(workers); + return results; +} diff --git a/.openclaw-plugin/src/pipeline.ts b/.openclaw-plugin/src/pipeline.ts new file mode 100644 index 000000000..1fe9e3e8f --- /dev/null +++ b/.openclaw-plugin/src/pipeline.ts @@ -0,0 +1,285 @@ +import { execSync } from "node:child_process"; +import { readFileSync, statSync } from "node:fs"; +import { basename, dirname, join, relative } from "node:path"; +import { + GraphBuilder, + LanguageRegistry, + TreeSitterPlugin, + createIgnoreFilter, + buildFileAnalysisPrompt, + parseFileAnalysisResponse, + buildProjectSummaryPrompt, + parseProjectSummaryResponse, + validateGraph, + saveGraph, + saveMeta, + type KnowledgeGraph, + type Layer, + type StructuralAnalysis, +} from "@understand-anything/core"; +import { walkProject } from "./walk.js"; +import { matchesAnyPattern } from "./glob-match.js"; +import { mapWithConcurrency, type LlmCaller } from "./llm.js"; +import { generateModuleTour, generateCodeReviewTour } from "./tour-generation.js"; +import { upsertTour, makeTourId } from "./tour-store.js"; + +const MAX_FILE_BYTES = 1024 * 1024; // skip anything over 1MB — matches the viewer's own cap +const MAX_SOURCE_CHARS_FOR_PROMPT = 12_000; // bound per-file prompt size/cost +const MAX_SAMPLE_FILES_FOR_SUMMARY = 8; +const RESOLVABLE_EXTENSIONS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rb", ".java", ".rs"]; +const INDEX_BASENAMES = ["index.ts", "index.tsx", "index.js", "index.jsx", "index.mjs", "index.py"]; + +const FILE_ANALYSIS_SYSTEM_PROMPT = + "You are a precise code analysis assistant embedded in an automated pipeline. Always respond with a single valid JSON object and nothing else."; + +export interface AnalyzeProjectOptions { + concurrency: number; + maxFiles: number; + onProgress?: (message: string) => void; +} + +export interface AnalyzeProjectResult { + graph: KnowledgeGraph; + filesScanned: number; + filesAnalyzed: number; + filesSkipped: number; + warnings: string[]; +} + +function resolveGitHash(projectRoot: string): string { + try { + return execSync("git rev-parse HEAD", { cwd: projectRoot, stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + } catch { + return "unknown"; + } +} + +/** Resolves a relative/bare import specifier against the set of scanned files. Returns null if unresolvable. */ +export function resolveImportTarget(fromFile: string, source: string, knownFiles: Set): string | null { + if (!source.startsWith("./") && !source.startsWith("../")) return null; // skip package imports + + const fromDir = dirname(fromFile); + const rawTarget = join(fromDir, source); + + // TS ESM convention: `import "./util.js"` refers to util.ts/util.tsx on disk. + // Try the specifier as written first, then with its JS extension swapped. + const stems = [rawTarget]; + const jsExtMatch = rawTarget.match(/\.(js|mjs|cjs|jsx)$/); + if (jsExtMatch) stems.push(rawTarget.slice(0, -jsExtMatch[0].length)); + + for (const stem of stems) { + for (const ext of RESOLVABLE_EXTENSIONS) { + const candidate = (stem + ext).split("\\").join("/"); + if (knownFiles.has(candidate)) return candidate; + } + } + for (const indexName of INDEX_BASENAMES) { + const candidate = join(rawTarget, indexName).split("\\").join("/"); + if (knownFiles.has(candidate)) return candidate; + } + return null; +} + +export async function analyzeProject( + projectRoot: string, + llmCall: LlmCaller, + opts: AnalyzeProjectOptions, +): Promise { + const warnings: string[] = []; + const log = opts.onProgress ?? (() => {}); + + const gitHash = resolveGitHash(projectRoot); + const ignoreFilter = createIgnoreFilter(projectRoot); + const languageRegistry = LanguageRegistry.createDefault(); + + log("Walking project tree..."); + const allRelFiles = walkProject(projectRoot, ignoreFilter); + const codeFiles = allRelFiles.filter((f) => languageRegistry.getForFile(f) !== null); + + const filesToAnalyze = codeFiles.slice(0, opts.maxFiles); + const filesSkipped = codeFiles.length - filesToAnalyze.length; + if (filesSkipped > 0) { + warnings.push( + `Project has ${codeFiles.length} recognized source files; capped analysis at ${opts.maxFiles} (maxFiles config). ${filesSkipped} files were not analyzed.`, + ); + } + log(`Found ${allRelFiles.length} files total, ${codeFiles.length} recognized as source (analyzing ${filesToAnalyze.length}).`); + + const treeSitter = new TreeSitterPlugin(languageRegistry.getAllLanguages()); + await treeSitter.init(); + + const knownFiles = new Set(filesToAnalyze); + const projectName = basename(projectRoot); + + interface FileResult { + rel: string; + content: string; + structure: StructuralAnalysis; + fileSummary: string; + tags: string[]; + complexity: "simple" | "moderate" | "complex"; + summaries: Record; + languageNotes?: string; + } + + let completed = 0; + const fileResults = await mapWithConcurrency(filesToAnalyze, opts.concurrency, async (rel): Promise => { + const abs = join(projectRoot, rel); + let content: string; + try { + const stat = statSync(abs); + if (stat.size > MAX_FILE_BYTES) return null; + content = readFileSync(abs, "utf-8"); + if (content.includes("\0")) return null; // binary + } catch { + return null; + } + + const structure = treeSitter.analyzeFile(rel, content); + + const prompt = buildFileAnalysisPrompt(rel, content.slice(0, MAX_SOURCE_CHARS_FOR_PROMPT), projectName); + let fileSummary = ""; + let tags: string[] = []; + let complexity: "simple" | "moderate" | "complex" = "moderate"; + let summaries: Record = {}; + let languageNotes: string | undefined; + + try { + const response = await llmCall(FILE_ANALYSIS_SYSTEM_PROMPT, prompt, 1200); + const parsed = parseFileAnalysisResponse(response); + if (parsed) { + fileSummary = parsed.fileSummary; + tags = parsed.tags; + complexity = parsed.complexity; + summaries = { ...parsed.functionSummaries, ...parsed.classSummaries }; + languageNotes = parsed.languageNotes; + } else { + warnings.push(`Could not parse LLM analysis for ${rel}; kept structural facts only.`); + } + } catch (err) { + warnings.push(`LLM analysis failed for ${rel}: ${err instanceof Error ? err.message : String(err)}`); + } + + completed++; + if (completed % 10 === 0 || completed === filesToAnalyze.length) { + log(`Analyzed ${completed}/${filesToAnalyze.length} files...`); + } + + return { rel, content, structure, fileSummary, tags, complexity, summaries, languageNotes }; + }); + + const builder = new GraphBuilder(projectName, gitHash, languageRegistry); + let filesAnalyzed = 0; + + for (const result of fileResults) { + if (!result) continue; + filesAnalyzed++; + builder.addFileWithAnalysis(result.rel, result.structure, { + summary: result.fileSummary, + fileSummary: result.fileSummary, + tags: result.tags, + complexity: result.complexity, + summaries: result.summaries, + }); + + for (const imp of result.structure.imports) { + const target = resolveImportTarget(result.rel, imp.source, knownFiles); + if (target && target !== result.rel) { + builder.addImportEdge(result.rel, target); + } + } + } + + log("Generating project summary..."); + const sampleFiles = fileResults + .filter((r): r is FileResult => r !== null) + .slice(0, MAX_SAMPLE_FILES_FOR_SUMMARY) + .map((r) => ({ path: r.rel, content: r.content.slice(0, 2000) })); + + // Built once — GraphBuilder.build() is a pure, deterministic snapshot (stable + // string node IDs, no internal counters), but nodes/edges can run into the + // thousands, so building it twice was a needless double array-copy. + const graph = builder.build(); + + try { + const summaryPrompt = buildProjectSummaryPrompt(filesToAnalyze, sampleFiles); + const summaryResponse = await llmCall(FILE_ANALYSIS_SYSTEM_PROMPT, summaryPrompt, 1500); + const parsedSummary = parseProjectSummaryResponse(summaryResponse); + if (parsedSummary) { + graph.project.description = parsedSummary.description; + graph.project.frameworks = parsedSummary.frameworks; + graph.layers = parsedSummary.layers.map((l, idx): Layer => ({ + id: `layer:${idx}:${l.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`, + name: l.name, + description: l.description, + nodeIds: graph.nodes + .filter((n) => n.filePath && matchesAnyPattern(n.filePath, l.filePatterns)) + .map((n) => n.id), + })); + } else { + warnings.push("Could not parse LLM project summary response."); + } + } catch (err) { + warnings.push(`Project summary generation failed: ${err instanceof Error ? err.message : String(err)}`); + } + + // Module walkthrough is free (no LLM) and goes straight into graph.tour — + // the only tour field upstream's dashboard/Learn persona knows how to play, + // so this keeps that working with zero changes. Populated before + // validation so the schema check covers it too. + graph.tour = generateModuleTour(graph); + + const validation = validateGraph(graph); + if (!validation.success) { + warnings.push(`Graph failed schema validation: ${validation.fatal ?? "unknown error"}`); + throw new Error(`Generated knowledge graph failed validation: ${validation.fatal ?? "unknown error"}`); + } + const finalGraph = validation.data as KnowledgeGraph; + + log("Persisting knowledge graph..."); + saveGraph(projectRoot, finalGraph); + saveMeta(projectRoot, { + lastAnalyzedAt: new Date().toISOString(), + gitCommitHash: gitHash, + version: "0.1.0", + analyzedFiles: filesAnalyzed, + }); + + upsertTour(projectRoot, { + id: makeTourId("module"), + kind: "module", + title: "Module walkthrough", + description: "Dependency-ordered tour through the codebase's modules, generated automatically at analysis time.", + createdAt: new Date().toISOString(), + steps: finalGraph.tour, + }); + + log("Generating code-review tour..."); + try { + const reviewSteps = await generateCodeReviewTour(finalGraph, llmCall); + if (reviewSteps.length > 0) { + upsertTour(projectRoot, { + id: makeTourId("codeReview"), + kind: "codeReview", + title: "Code review walkthrough", + description: "Highest-risk files ranked by complexity and how central they are in the dependency graph.", + createdAt: new Date().toISOString(), + steps: reviewSteps, + }); + } else { + warnings.push("Code-review tour skipped: no code nodes to rank."); + } + } catch (err) { + warnings.push(`Code-review tour generation failed: ${err instanceof Error ? err.message : String(err)}`); + } + + return { + graph: finalGraph, + filesScanned: allRelFiles.length, + filesAnalyzed, + filesSkipped, + warnings, + }; +} diff --git a/.openclaw-plugin/src/pr-diff.ts b/.openclaw-plugin/src/pr-diff.ts new file mode 100644 index 000000000..58398cebc --- /dev/null +++ b/.openclaw-plugin/src/pr-diff.ts @@ -0,0 +1,148 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { computeDiffOverlay, parseTourGenerationResponse, type DiffOverlay, type GraphNode, type KnowledgeGraph, type TourStep } from "@understand-anything/core"; +import type { LlmCaller } from "./llm.js"; + +const execFileAsync = promisify(execFile); +const DIFF_TIMEOUT_MS = 30_000; + +export interface ChangedFilesResult { + changedFiles: string[]; + baseBranch: string; +} + +/** + * Resolves the changed-files list the same way understand-diff (the skill) + * does: a specific PR number takes priority (via `gh pr diff`, since the + * gateway host is assumed to already be `gh`-authenticated the same way it + * is for the rest of this project's PR workflow), otherwise a base branch + * comparison (`git diff ...HEAD`), falling back to uncommitted + * working-tree changes if the repo isn't currently ahead of the base. + */ +export async function getChangedFiles( + projectRoot: string, + opts: { prNumber?: number; baseBranch?: string } = {}, +): Promise { + if (opts.prNumber !== undefined) { + const { stdout } = await execFileAsync("gh", ["pr", "diff", String(opts.prNumber), "--name-only"], { + cwd: projectRoot, + timeout: DIFF_TIMEOUT_MS, + }); + return { changedFiles: splitLines(stdout), baseBranch: `PR #${opts.prNumber}` }; + } + + const baseBranch = opts.baseBranch ?? (await detectDefaultBranch(projectRoot)); + + try { + const { stdout } = await execFileAsync("git", ["diff", `${baseBranch}...HEAD`, "--name-only"], { + cwd: projectRoot, + timeout: DIFF_TIMEOUT_MS, + }); + const changedFiles = splitLines(stdout); + if (changedFiles.length > 0) return { changedFiles, baseBranch }; + } catch { + // Falls through to uncommitted-changes diff below — e.g. baseBranch doesn't exist locally. + } + + const { stdout } = await execFileAsync("git", ["diff", "--name-only"], { cwd: projectRoot, timeout: DIFF_TIMEOUT_MS }); + return { changedFiles: splitLines(stdout), baseBranch: "working tree" }; +} + +async function detectDefaultBranch(projectRoot: string): Promise { + try { + const { stdout } = await execFileAsync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { + cwd: projectRoot, + timeout: DIFF_TIMEOUT_MS, + }); + return stdout.trim().replace(/^refs\/remotes\/origin\//, ""); + } catch { + return "main"; + } +} + +function splitLines(s: string): string[] { + return s.split("\n").map((l) => l.trim()).filter(Boolean); +} + +const PR_TOUR_SYSTEM_PROMPT = + "You are helping a developer understand what a pull request changed and why it matters. Always respond with a " + + 'single valid JSON object shaped { "steps": [{ "order": number, "title": string, "description": string, ' + + '"nodeIds": string[] }] } and nothing else.'; + +// Bounds on how many nodes get listed in the prompt, independent of max_tokens: a diff touching +// dozens of nodes would otherwise blow the output budget just describing the input, since every +// listed node is fair game for the model to narrate. Large diffs get a truncated, still-grounded +// list rather than an unbounded one — the model is told explicitly how many were omitted. +const MAX_CHANGED_NODES_IN_PROMPT = 40; +const MAX_AFFECTED_NODES_IN_PROMPT = 20; +const PR_TOUR_MAX_TOKENS = 8000; + +function buildPrTourPrompt(graph: KnowledgeGraph, overlay: DiffOverlay, changed: GraphNode[], affected: GraphNode[]): string { + const changedShown = changed.slice(0, MAX_CHANGED_NODES_IN_PROMPT); + const affectedShown = affected.slice(0, MAX_AFFECTED_NODES_IN_PROMPT); + const changedOmitted = changed.length - changedShown.length; + const affectedOmitted = affected.length - affectedShown.length; + + const changedList = changedShown.map((n) => ` - ${n.id} [${n.type}] ${n.name}${n.filePath ? ` (${n.filePath})` : ""}: ${n.summary}`).join("\n"); + const affectedList = affectedShown.map((n) => ` - ${n.id} [${n.type}] ${n.name}${n.filePath ? ` (${n.filePath})` : ""}: ${n.summary}`).join("\n"); + + return `Project: ${graph.project.name} +Description: ${graph.project.description} + +Diff source: ${overlay.baseBranch ?? "unknown"} +Changed files: ${overlay.changedFiles.join(", ")} + +Directly changed nodes: +${changedList || " (none matched in the graph)"} +${changedOmitted > 0 ? ` ...and ${changedOmitted} more changed node(s), omitted for brevity — summarize the overall pattern rather than every node.\n` : ""} +Downstream/upstream affected nodes (1-hop blast radius, not directly changed): +${affectedList || " (none)"} +${affectedOmitted > 0 ? ` ...and ${affectedOmitted} more affected node(s), omitted for brevity.\n` : ""} +Create a walkthrough (3-8 steps) explaining this change to a reviewer: what changed, why it likely matters, and what +the blast radius means for the rest of the system. Every nodeId you reference must come from the two lists above — +do not invent new ones, and clearly note which step's nodes are "changed" vs. "affected" in the description text. +${changedOmitted + affectedOmitted > 0 ? "Since some nodes were omitted above, favor grouping/summarizing over listing every individual node.\n" : ""}Return a JSON object with a "steps" array; each step has "order" (starting at 1), "title", "description", and +"nodeIds" (a subset of the nodes listed above).`; +} + +export interface PrWalkthroughResult { + overlay: DiffOverlay; + steps: TourStep[]; + error?: string; +} + +/** + * Full PR/diff understanding pipeline: resolve changed files, compute the + * blast-radius overlay against the already-analyzed graph (core's + * computeDiffOverlay — pure, no LLM), then ask the model to narrate a + * walkthrough grounded in only the changed + affected nodes. + */ +export async function generatePrWalkthrough( + graph: KnowledgeGraph, + changedFiles: string[], + baseBranch: string, + llmCall: LlmCaller, +): Promise { + const overlay = computeDiffOverlay(graph, changedFiles, baseBranch); + + if (overlay.changedNodeIds.length === 0) { + return { + overlay, + steps: [], + error: "None of the changed files matched any node in the analyzed graph — the project may need re-analysis to pick up these files.", + }; + } + + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + const changed = overlay.changedNodeIds.map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined); + const affected = overlay.affectedNodeIds.map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined); + + const prompt = buildPrTourPrompt(graph, overlay, changed, affected); + const response = await llmCall(PR_TOUR_SYSTEM_PROMPT, prompt, PR_TOUR_MAX_TOKENS); + const steps = parseTourGenerationResponse(response); + + if (steps.length === 0) { + return { overlay, steps: [], error: "Could not generate a walkthrough from the model's response." }; + } + return { overlay, steps }; +} diff --git a/.openclaw-plugin/src/project-store.ts b/.openclaw-plugin/src/project-store.ts new file mode 100644 index 000000000..573b7efbc --- /dev/null +++ b/.openclaw-plugin/src/project-store.ts @@ -0,0 +1,167 @@ +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; + +const CLONE_TIMEOUT_MS = 3 * 60_000; + +export function expandHome(p: string): string { + return p === "~" || p.startsWith("~/") ? join(homedir(), p.slice(1)) : p; +} + +function defaultStateDir(): string { + return join(homedir(), ".local", "share", "understand-anything-plugin"); +} + +export interface ProjectStoreOptions { + /** Absolute paths from plugin config — always present, never persisted (config is the source of truth for these). */ + configProjects: string[]; + /** Whether runtime project addition (GitHub clone or local path) is enabled at all. */ + allowAddProject: boolean; + /** Where dynamically-added projects persist across restarts and where GitHub repos get cloned. Defaults under ~/.local/share. */ + stateDir?: string; +} + +/** + * Combined project list: config-declared projects (fixed, always first) plus + * dynamically-added ones (GitHub clone or local path, persisted to disk so + * they survive a gateway restart). Indices only ever grow — existing entries + * never move — so tool/dashboard references by index stay stable. + */ +export class ProjectStore { + private readonly allowAddProject: boolean; + private readonly stateDir: string; + private readonly stateFile: string; + private readonly cloneDir: string; + private projects: string[]; + private dynamicProjects: string[]; + + constructor(opts: ProjectStoreOptions) { + this.allowAddProject = opts.allowAddProject; + this.stateDir = opts.stateDir ?? defaultStateDir(); + this.stateFile = join(this.stateDir, "dynamic-projects.json"); + this.cloneDir = join(this.stateDir, "clones"); + this.dynamicProjects = this.loadPersisted(); + this.projects = [...opts.configProjects, ...this.dynamicProjects]; + } + + private loadPersisted(): string[] { + if (!this.allowAddProject || !existsSync(this.stateFile)) return []; + try { + const raw = JSON.parse(readFileSync(this.stateFile, "utf-8")); + return Array.isArray(raw) + ? raw.filter((p): p is string => typeof p === "string" && existsSync(p) && statSync(p).isDirectory()) + : []; + } catch { + return []; + } + } + + private persist(): void { + mkdirSync(this.stateDir, { recursive: true }); + writeFileSync(this.stateFile, JSON.stringify(this.dynamicProjects, null, 2), "utf-8"); + } + + list(): string[] { + return this.projects; + } + + canAddProject(): boolean { + return this.allowAddProject; + } + + /** + * Adds a project by GitHub URL (shallow-cloned into a dedicated directory) + * or an existing local path. Idempotent for GitHub URLs already cloned, and + * for a path/clone already in the list. + */ + async addProject(input: string): Promise<{ root: string } | { error: string }> { + if (!this.allowAddProject) return { error: "Adding projects at runtime is disabled (set allowAddProject: true in plugin config)." }; + + const trimmed = input.trim(); + if (!trimmed) return { error: "Input is empty." }; + + const githubRef = parseGithubRef(trimmed); + const resolved = githubRef ? await this.resolveGithubProject(githubRef) : this.resolveLocalPath(trimmed); + if ("error" in resolved) return resolved; + + if (!this.projects.includes(resolved.root)) { + this.projects.push(resolved.root); + this.dynamicProjects.push(resolved.root); + this.persist(); + } + return resolved; + } + + private resolveLocalPath(input: string): { root: string } | { error: string } { + const abs = resolve(expandHome(input)); + if (!isAbsolute(abs) || !existsSync(abs) || !statSync(abs).isDirectory()) { + return { error: `"${input}" is not an existing local directory.` }; + } + return { root: abs }; + } + + private async resolveGithubProject(ref: GithubRef): Promise<{ root: string } | { error: string }> { + const destDir = join(this.cloneDir, `${sanitize(ref.owner)}-${sanitize(ref.repo)}`); + if (existsSync(join(destDir, ".git"))) { + return { root: destDir }; // already cloned — reuse + } + mkdirSync(this.cloneDir, { recursive: true }); + try { + await cloneRepo(ref.url, destDir); + } catch (err) { + return { error: `Failed to clone ${ref.url}: ${err instanceof Error ? err.message : String(err)}` }; + } + return { root: destDir }; + } +} + +interface GithubRef { + url: string; + owner: string; + repo: string; +} + +/** + * Only recognizes an actual github.com https/ssh URL — not a bare + * "owner/repo" shorthand (ambiguous with a genuine relative local path), and + * not arbitrary git/file:// URLs (a file:// or local-path "clone" could be + * abused to copy arbitrary host paths into the clone dir, defeating the + * projects allowlist this whole feature sits inside). + */ +function parseGithubRef(input: string): GithubRef | null { + const httpsMatch = input.match(/^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/); + if (httpsMatch) return { url: `https://github.com/${httpsMatch[1]}/${httpsMatch[2]}.git`, owner: httpsMatch[1], repo: httpsMatch[2] }; + + const sshMatch = input.match(/^git@github\.com:([\w.-]+)\/([\w.-]+?)(?:\.git)?$/); + if (sshMatch) return { url: `git@github.com:${sshMatch[1]}/${sshMatch[2]}.git`, owner: sshMatch[1], repo: sshMatch[2] }; + + return null; +} + +function sanitize(s: string): string { + return s.replace(/[^\w.-]/g, "_"); +} + +function cloneRepo(url: string, destDir: string): Promise { + return new Promise((resolve, reject) => { + const proc = spawn("git", ["clone", "--depth", "1", url, destDir], { stdio: ["ignore", "ignore", "pipe"] }); + let stderr = ""; + proc.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString())); + + const timeout = setTimeout(() => { + proc.kill(); + reject(new Error(`clone timed out after ${CLONE_TIMEOUT_MS}ms`)); + }, CLONE_TIMEOUT_MS); + + proc.on("exit", (code) => { + clearTimeout(timeout); + if (code === 0) resolve(); + else reject(new Error(stderr.trim() || `git clone exited with code ${code}`)); + }); + proc.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} diff --git a/.openclaw-plugin/src/selection.js b/.openclaw-plugin/src/selection.js new file mode 100644 index 000000000..71bad3e95 --- /dev/null +++ b/.openclaw-plugin/src/selection.js @@ -0,0 +1,53 @@ +// Shared node-selection discovery — the single source of truth for "what +// node(s) is the user currently looking at in the graph canvas", read by both +// ask-widget.js and tours-widget.js (previously each widget that cared kept +// its own private MutationObserver; now there's exactly one, watching once). +// +// Must be loaded before ask-widget.js/tours-widget.js — see WIDGET_SCRIPT_TAGS +// in interactive-server.ts. +(function () { + "use strict"; + + var selectedNodeIds = []; + var subscribers = []; + + function sameIds(a, b) { + if (a.length !== b.length) return false; + for (var i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; + } + + function refresh() { + var nodes = document.querySelectorAll(".react-flow__node.selected"); + var next = Array.prototype.map.call(nodes, function (n) { return n.getAttribute("data-id"); }).filter(Boolean); + if (sameIds(next, selectedNodeIds)) return; + selectedNodeIds = next; + subscribers.forEach(function (cb) { cb(selectedNodeIds.slice()); }); + } + + window.uaSelection = { + // Current selection snapshot — a plain array of graph node ids. + get: function () { return selectedNodeIds.slice(); }, + // Called with the new selection (array of ids) whenever it changes. + // Returns an unsubscribe function. + subscribe: function (cb) { + subscribers.push(cb); + return function () { subscribers = subscribers.filter(function (s) { return s !== cb; }); }; + }, + }; + + function start() { + // React Flow toggles the "selected" class on node elements; watching for + // class-attribute changes across the document catches every selection + // change without touching the dashboard's own React source. + var observer = new MutationObserver(refresh); + observer.observe(document.body, { attributes: true, attributeFilter: ["class"], subtree: true }); + refresh(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start); + } else { + start(); + } +})(); diff --git a/.openclaw-plugin/src/tour-generation.ts b/.openclaw-plugin/src/tour-generation.ts new file mode 100644 index 000000000..8ab5dc602 --- /dev/null +++ b/.openclaw-plugin/src/tour-generation.ts @@ -0,0 +1,77 @@ +import { + generateHeuristicTour, + parseTourGenerationResponse, + type GraphNode, + type KnowledgeGraph, + type TourStep, +} from "@understand-anything/core"; +import type { LlmCaller } from "./llm.js"; + +const REVIEW_TOUR_SYSTEM_PROMPT = + "You are a senior engineer preparing a code-review walkthrough for a teammate. Always respond with a single " + + 'valid JSON object shaped { "steps": [{ "order": number, "title": string, "description": string, "nodeIds": string[] }] } and nothing else.'; + +const COMPLEXITY_WEIGHT: Record = { simple: 0, moderate: 1, complex: 2 }; +const DEFAULT_REVIEW_LIMIT = 12; + +/** + * Free, deterministic module-walkthrough tour — no LLM call. Populates the + * standard `graph.tour` field, so upstream's existing Learn persona / + * LearnPanel UI works with zero changes; this is the only tour kind the + * stock dashboard knows how to play. + */ +export function generateModuleTour(graph: KnowledgeGraph): TourStep[] { + return generateHeuristicTour(graph); +} + +/** + * Ranks code nodes by review risk: complexity plus how central the node is + * in the dependency graph (total in+out edge degree) — files that are both + * complex and heavily depended-on/depending-on are exactly what a reviewer + * should look at first, in the absence of test-coverage or churn data. + */ +export function rankNodesForReview(graph: KnowledgeGraph, limit = DEFAULT_REVIEW_LIMIT): GraphNode[] { + const degree = new Map(); + for (const e of graph.edges) { + degree.set(e.source, (degree.get(e.source) ?? 0) + 1); + degree.set(e.target, (degree.get(e.target) ?? 0) + 1); + } + + return graph.nodes + .filter((n) => n.type !== "concept") + .map((n) => ({ node: n, score: COMPLEXITY_WEIGHT[n.complexity] * 2 + (degree.get(n.id) ?? 0) })) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map((x) => x.node); +} + +function buildReviewTourPrompt(graph: KnowledgeGraph, ranked: GraphNode[]): string { + const nodeList = ranked + .map((n) => ` - ${n.id} [${n.type}] ${n.name}${n.filePath ? ` (${n.filePath})` : ""}: ${n.summary} (complexity: ${n.complexity})`) + .join("\n"); + + return `Project: ${graph.project.name} +Description: ${graph.project.description} + +The following nodes were flagged as highest review priority (complexity + how central they are in the dependency graph): +${nodeList} + +Create a short, ordered code-review walkthrough (3-8 steps) that groups these nodes sensibly and explains, for each +step, WHY a reviewer should care (risk, blast radius, tricky logic) — not just what the code does. Every nodeId you +reference must come from the list above. Return a JSON object with a "steps" array; each step has "order" (starting +at 1), "title", "description" (2-3 sentences focused on review risk), and "nodeIds" (a subset of the list above).`; +} + +/** + * LLM-narrated code-review tour: one call, grounded only in the top-ranked + * risk nodes (not the whole graph) to keep prompt size and cost bounded. + * Returns an empty array (no tour) if the project has no code nodes at all. + */ +export async function generateCodeReviewTour(graph: KnowledgeGraph, llmCall: LlmCaller): Promise { + const ranked = rankNodesForReview(graph); + if (ranked.length === 0) return []; + + const prompt = buildReviewTourPrompt(graph, ranked); + const response = await llmCall(REVIEW_TOUR_SYSTEM_PROMPT, prompt, 2000); + return parseTourGenerationResponse(response); +} diff --git a/.openclaw-plugin/src/tour-store.ts b/.openclaw-plugin/src/tour-store.ts new file mode 100644 index 000000000..f5f5360a7 --- /dev/null +++ b/.openclaw-plugin/src/tour-store.ts @@ -0,0 +1,69 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { TourStep } from "@understand-anything/core"; + +const TOURS_FILE = "tours.json"; +const UA_DIR_CANDIDATES = [".understand-anything", ".ua"]; + +export type TourKind = "module" | "codeReview" | "custom" | "prWalkthrough"; + +/** Kinds that accumulate (one project can have many) rather than replace the previous tour of that kind. */ +const ACCUMULATING_KINDS: ReadonlySet = new Set(["custom", "prWalkthrough"]); + +export interface StoredTour { + id: string; + kind: TourKind; + title: string; + description: string; + createdAt: string; + steps: TourStep[]; + /** The user's free-text request, for kind "custom" tours only. */ + prompt?: string; + /** Which base branch / PR this was generated from, for kind "prWalkthrough" only. */ + diffSource?: string; +} + +/** + * Resolves the project's data directory the same way @understand-anything/core's + * persistence module does (legacy `.understand-anything/` first, else `.ua/`), + * without a fragile deep-import into core's internals for one directory-name check. + */ +function resolveUaDir(projectRoot: string): string { + for (const dir of UA_DIR_CANDIDATES) { + const candidate = join(projectRoot, dir); + if (existsSync(candidate)) return candidate; + } + return join(projectRoot, ".ua"); +} + +function toursFilePath(projectRoot: string): string { + return join(resolveUaDir(projectRoot), TOURS_FILE); +} + +export function loadTours(projectRoot: string): StoredTour[] { + const file = toursFilePath(projectRoot); + if (!existsSync(file)) return []; + try { + const raw = JSON.parse(readFileSync(file, "utf-8")); + return Array.isArray(raw?.tours) ? raw.tours : []; + } catch { + return []; + } +} + +function saveTours(projectRoot: string, tours: StoredTour[]): void { + const dir = resolveUaDir(projectRoot); + mkdirSync(dir, { recursive: true }); + writeFileSync(toursFilePath(projectRoot), JSON.stringify({ tours }, null, 2), "utf-8"); +} + +/** Replaces any existing tour of the same kind (module/codeReview are singletons) or appends (custom/prWalkthrough tours accumulate). */ +export function upsertTour(projectRoot: string, tour: StoredTour): void { + const existing = loadTours(projectRoot); + const next = ACCUMULATING_KINDS.has(tour.kind) ? [...existing, tour] : [...existing.filter((t) => t.kind !== tour.kind), tour]; + saveTours(projectRoot, next); +} + +export function makeTourId(kind: TourKind): string { + return `${kind}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} diff --git a/.openclaw-plugin/src/tours-widget.js b/.openclaw-plugin/src/tours-widget.js new file mode 100644 index 000000000..ce067e965 --- /dev/null +++ b/.openclaw-plugin/src/tours-widget.js @@ -0,0 +1,299 @@ +// Floating "Tours" widget — injected into the dashboard by interactive-server.ts, +// alongside ask-widget.js. Lists/plays the auto-generated (module, code review), +// custom, and PR-walkthrough tours from GET /tours.json, and lets the user select +// node(s) in the live React Flow canvas — selection is read via the shared +// discovery in selection.js (window.uaSelection), not a private observer here — +// plus a free-text prompt to generate a new custom tour via POST /generate-tour.json, +// or a PR number/base branch to generate a diff walkthrough via POST /generate-pr-tour.json. +(function () { + "use strict"; + + var TOKEN = new URLSearchParams(window.location.search).get("token") || ""; + + var STYLE = "\n" + + "#ua-tours-fab{position:fixed;bottom:24px;right:92px;z-index:9999;width:56px;height:56px;border-radius:50%;" + + "background:#8957e5;color:#fff;border:none;box-shadow:0 4px 14px rgba(0,0,0,.3);cursor:pointer;" + + "font-size:22px;display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif;}\n" + + "#ua-tours-fab:hover{background:#a371f7;}\n" + + "#ua-tours-panel{position:fixed;bottom:92px;right:92px;z-index:9999;width:380px;max-width:calc(100vw - 48px);" + + "height:520px;max-height:calc(100vh - 140px);background:#0d1117;color:#e6edf3;border:1px solid #30363d;" + + "border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.5);display:none;flex-direction:column;" + + "font-family:system-ui,-apple-system,sans-serif;overflow:hidden;}\n" + + "#ua-tours-panel.open{display:flex;}\n" + + "#ua-tours-header{padding:12px 14px;border-bottom:1px solid #30363d;font-weight:600;font-size:14px;" + + "display:flex;justify-content:space-between;align-items:center;}\n" + + "#ua-tours-header button{background:none;border:none;color:#8b949e;cursor:pointer;font-size:18px;}\n" + + "#ua-tours-body{flex:1;overflow-y:auto;padding:12px 14px;display:flex;flex-direction:column;gap:10px;}\n" + + ".ua-tour-item{border:1px solid #30363d;border-radius:8px;padding:10px;}\n" + + ".ua-tour-item h4{margin:0 0 4px;font-size:13px;}\n" + + ".ua-tour-item p{margin:0 0 8px;font-size:11px;color:#8b949e;}\n" + + ".ua-tour-item button{background:#1f6feb;color:#fff;border:none;border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;}\n" + + "#ua-tour-generate{border-top:1px solid #30363d;padding:10px 14px;}\n" + + "#ua-tour-generate h4{margin:0 0 6px;font-size:13px;}\n" + + "#ua-selection-count{font-size:11px;color:#8b949e;margin-bottom:6px;}\n" + + "#ua-tour-prompt{width:100%;box-sizing:border-box;background:#161b22;border:1px solid #30363d;border-radius:8px;" + + "color:#e6edf3;padding:8px 10px;font-size:13px;resize:none;font-family:inherit;margin-bottom:6px;}\n" + + "#ua-tour-generate-btn{width:100%;background:#8957e5;color:#fff;border:none;border-radius:8px;padding:8px;cursor:pointer;font-size:13px;}\n" + + "#ua-tour-generate-btn:disabled{opacity:.5;cursor:default;}\n" + + "#ua-pr-generate{border-top:1px solid #30363d;padding:10px 14px;}\n" + + "#ua-pr-generate h4{margin:0 0 6px;font-size:13px;}\n" + + "#ua-pr-generate p{margin:0 0 6px;font-size:11px;color:#8b949e;}\n" + + "#ua-pr-input{width:100%;box-sizing:border-box;background:#161b22;border:1px solid #30363d;border-radius:8px;" + + "color:#e6edf3;padding:8px 10px;font-size:13px;font-family:inherit;margin-bottom:6px;}\n" + + "#ua-pr-generate-btn{width:100%;background:#1f6feb;color:#fff;border:none;border-radius:8px;padding:8px;cursor:pointer;font-size:13px;}\n" + + "#ua-pr-generate-btn:disabled{opacity:.5;cursor:default;}\n" + + "#ua-tour-player{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:9999;" + + "background:#0d1117;color:#e6edf3;border:1px solid #30363d;border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.5);" + + "padding:14px 18px;width:460px;max-width:calc(100vw - 48px);display:none;font-family:system-ui,-apple-system,sans-serif;}\n" + + "#ua-tour-player.open{display:block;}\n" + + "#ua-tour-player h4{margin:0 0 4px;font-size:14px;}\n" + + "#ua-tour-player p{margin:0 0 10px;font-size:12px;color:#8b949e;line-height:1.4;}\n" + + "#ua-tour-player-controls{display:flex;justify-content:space-between;align-items:center;}\n" + + "#ua-tour-player-controls button{background:#21262d;color:#e6edf3;border:1px solid #30363d;border-radius:6px;" + + "padding:4px 12px;cursor:pointer;font-size:12px;}\n" + + "#ua-tour-player-controls button:disabled{opacity:.4;cursor:default;}\n" + + "#ua-tour-step-count{font-size:11px;color:#8b949e;}\n" + + ".ua-highlighted-node{outline:3px solid #f0883e !important;outline-offset:2px;}\n"; + + function el(tag, attrs, children) { + var e = document.createElement(tag); + if (attrs) for (var k in attrs) e.setAttribute(k, attrs[k]); + (children || []).forEach(function (c) { + e.appendChild(typeof c === "string" ? document.createTextNode(c) : c); + }); + return e; + } + + function authedFetch(url, options) { + options = options || {}; + options.headers = Object.assign({}, options.headers, { "X-Ask-Token": TOKEN }); + return fetch(url, options); + } + + // --- Live selection tracking — reads the shared discovery in selection.js + // (window.uaSelection) instead of running its own MutationObserver; Ask uses + // the same source, so both widgets always agree on "what's selected". + function getSelectedNodeIds() { + return window.uaSelection ? window.uaSelection.get() : []; + } + function renderSelectionCount(ids) { + var countEl = document.getElementById("ua-selection-count"); + if (!countEl) return; + countEl.textContent = ids.length === 0 + ? "No nodes selected in the graph — click one or more to scope a custom tour." + : ids.length + " node(s) selected: " + ids.slice(0, 3).join(", ") + (ids.length > 3 ? "…" : ""); + } + + function highlightNodes(nodeIds) { + document.querySelectorAll(".ua-highlighted-node").forEach(function (n) { n.classList.remove("ua-highlighted-node"); }); + (nodeIds || []).forEach(function (id) { + var el = document.querySelector('.react-flow__node[data-id="' + CSS.escape(id) + '"]'); + if (el) el.classList.add("ua-highlighted-node"); + }); + } + + // --- Tour player --- + var currentTour = null; + var currentStep = 0; + + function renderPlayerStep() { + if (!currentTour) return; + var step = currentTour.steps[currentStep]; + document.getElementById("ua-tour-player-title").textContent = step.title; + document.getElementById("ua-tour-player-desc").textContent = step.description; + document.getElementById("ua-tour-step-count").textContent = (currentStep + 1) + " / " + currentTour.steps.length; + document.getElementById("ua-tour-prev").disabled = currentStep === 0; + document.getElementById("ua-tour-next").disabled = currentStep === currentTour.steps.length - 1; + highlightNodes(step.nodeIds); + } + + function playTour(tour) { + if (!tour.steps || tour.steps.length === 0) return; + currentTour = tour; + currentStep = 0; + document.getElementById("ua-tours-panel").classList.remove("open"); + document.getElementById("ua-tour-player").classList.add("open"); + renderPlayerStep(); + } + + function closePlayer() { + document.getElementById("ua-tour-player").classList.remove("open"); + highlightNodes([]); + currentTour = null; + } + + // --- Tour list --- + function tourItemEl(tour) { + var item = el("div", { class: "ua-tour-item" }); + item.appendChild(el("h4", {}, [tour.title + " (" + tour.steps.length + " steps)"])); + item.appendChild(el("p", {}, [tour.description || ""])); + var playBtn = el("button", {}, ["Play"]); + playBtn.addEventListener("click", function () { playTour(tour); }); + item.appendChild(playBtn); + return item; + } + + function loadTours() { + return authedFetch("/tours.json").then(function (res) { return res.json(); }).then(function (data) { return data.tours || []; }); + } + + function renderTourList() { + var body = document.getElementById("ua-tours-body"); + body.innerHTML = ""; + loadTours().then(function (tours) { + if (tours.length === 0) { + body.appendChild(el("p", { style: "color:#8b949e;font-size:13px;" }, ["No tours yet."])); + return; + } + tours.forEach(function (tour) { body.appendChild(tourItemEl(tour)); }); + }); + } + + // --- Generate custom tour --- + function generateTour() { + var input = document.getElementById("ua-tour-prompt"); + var btn = document.getElementById("ua-tour-generate-btn"); + var prompt = input.value.trim(); + if (!prompt) return; + btn.disabled = true; + btn.textContent = "Generating…"; + + authedFetch("/generate-tour.json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nodeIds: getSelectedNodeIds(), prompt: prompt }), + }) + .then(function (res) { + return res.json().then(function (data) { + if (!res.ok) throw new Error(data.error || "Request failed"); + return data; + }); + }) + .then(function (data) { + input.value = ""; + renderTourList(); + if (data.tour) playTour(data.tour); + }) + .catch(function (err) { + alert("Could not generate tour: " + err.message); + }) + .finally(function () { + btn.disabled = false; + btn.textContent = "Generate custom tour"; + }); + } + + // --- Generate PR/diff walkthrough --- + function generatePrTour() { + var input = document.getElementById("ua-pr-input"); + var btn = document.getElementById("ua-pr-generate-btn"); + var raw = input.value.trim(); + if (!raw) return; + var isPrNumber = /^\d+$/.test(raw); + var body = isPrNumber ? { prNumber: Number(raw) } : { baseBranch: raw }; + + btn.disabled = true; + btn.textContent = "Generating…"; + + authedFetch("/generate-pr-tour.json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + .then(function (res) { + return res.json().then(function (data) { + if (!res.ok) throw new Error(data.error || "Request failed"); + return data; + }); + }) + .then(function (data) { + input.value = ""; + renderTourList(); + if (data.tour) playTour(data.tour); + }) + .catch(function (err) { + alert("Could not generate PR walkthrough: " + err.message); + }) + .finally(function () { + btn.disabled = false; + btn.textContent = "Generate PR walkthrough"; + }); + } + + function init() { + if (!TOKEN) return; // no token in URL — not a live session + + var style = document.createElement("style"); + style.textContent = STYLE; + document.head.appendChild(style); + + var fab = el("button", { id: "ua-tours-fab", title: "Tours" }, ["🧭"]); + var panel = el("div", { id: "ua-tours-panel" }); + var header = el("div", { id: "ua-tours-header" }, [ + "Tours", + el("button", { id: "ua-tours-close" }, ["×"]), + ]); + var body = el("div", { id: "ua-tours-body" }); + var generate = el("div", { id: "ua-tour-generate" }, [ + el("h4", {}, ["Generate a custom tour"]), + el("div", { id: "ua-selection-count" }, ["No nodes selected in the graph — click one or more to scope a custom tour."]), + el("textarea", { id: "ua-tour-prompt", rows: "2", placeholder: "e.g. walk me through the request lifecycle" }), + el("button", { id: "ua-tour-generate-btn" }, ["Generate custom tour"]), + ]); + + var prGenerate = el("div", { id: "ua-pr-generate" }, [ + el("h4", {}, ["Generate a PR walkthrough"]), + el("p", {}, ["Enter a PR number, or a base branch to diff against HEAD (e.g. main)."]), + el("input", { id: "ua-pr-input", type: "text", placeholder: "42 or main" }), + el("button", { id: "ua-pr-generate-btn" }, ["Generate PR walkthrough"]), + ]); + + panel.appendChild(header); + panel.appendChild(body); + panel.appendChild(generate); + panel.appendChild(prGenerate); + document.body.appendChild(fab); + document.body.appendChild(panel); + + var player = el("div", { id: "ua-tour-player" }, [ + el("div", { id: "ua-tour-player-controls" }), + ]); + player.insertBefore(el("h4", { id: "ua-tour-player-title" }, [""]), player.firstChild); + player.insertBefore(el("p", { id: "ua-tour-player-desc" }, [""]), player.querySelector("#ua-tour-player-controls")); + var controls = player.querySelector("#ua-tour-player-controls"); + var prevBtn = el("button", { id: "ua-tour-prev" }, ["◀ Prev"]); + var stepCount = el("span", { id: "ua-tour-step-count" }, [""]); + var nextBtn = el("button", { id: "ua-tour-next" }, ["Next ▶"]); + var closeBtn = el("button", { id: "ua-tour-player-close" }, ["Close"]); + controls.appendChild(prevBtn); + controls.appendChild(stepCount); + controls.appendChild(nextBtn); + controls.appendChild(closeBtn); + document.body.appendChild(player); + + fab.addEventListener("click", function () { + panel.classList.toggle("open"); + if (panel.classList.contains("open")) renderTourList(); + }); + header.querySelector("#ua-tours-close").addEventListener("click", function () { panel.classList.remove("open"); }); + document.getElementById("ua-tour-generate-btn").addEventListener("click", generateTour); + document.getElementById("ua-pr-generate-btn").addEventListener("click", generatePrTour); + + prevBtn.addEventListener("click", function () { if (currentStep > 0) { currentStep--; renderPlayerStep(); } }); + nextBtn.addEventListener("click", function () { if (currentTour && currentStep < currentTour.steps.length - 1) { currentStep++; renderPlayerStep(); } }); + closeBtn.addEventListener("click", closePlayer); + + if (window.uaSelection) { + renderSelectionCount(window.uaSelection.get()); + window.uaSelection.subscribe(renderSelectionCount); + } else { + renderSelectionCount([]); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/.openclaw-plugin/src/walk.ts b/.openclaw-plugin/src/walk.ts new file mode 100644 index 000000000..e788eb112 --- /dev/null +++ b/.openclaw-plugin/src/walk.ts @@ -0,0 +1,73 @@ +import { readdirSync, realpathSync, statSync } from "node:fs"; +import { join, relative, sep } from "node:path"; +import type { IgnoreFilter } from "@understand-anything/core"; + +/** + * Recursively lists files under root that pass the ignore filter, as paths + * relative to root. + * + * `statSync` follows symlinks, so a symlink anywhere under root that isn't + * covered by the ignore patterns (e.g. a tooling symlink pointing outside the + * repo) could otherwise read files outside the configured project — the + * entire point of the `projects` allowlist this feeds into. Every resolved + * entry is therefore checked to still live under root's real path, and + * visited real directories are tracked to break symlink cycles that would + * otherwise recurse forever and crash the whole gateway process (this runs + * in-process, not in a subprocess). + */ +export function walkProject(root: string, ignoreFilter: IgnoreFilter): string[] { + const results: string[] = []; + const visitedRealDirs = new Set(); + + let realRoot: string; + try { + realRoot = realpathSync(root); + } catch { + return results; + } + const realRootPrefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep; + // Seed with root itself so a top-level self-referential symlink (`ln -s . self`) + // is caught by the same cycle guard as any deeper one, rather than walking + // root's own contents an extra time before the guard has anything to match. + visitedRealDirs.add(realRoot); + + function isContained(realPath: string): boolean { + return realPath === realRoot || realPath.startsWith(realRootPrefix); + } + + function walk(dir: string): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + + for (const entry of entries) { + const abs = join(dir, entry); + const rel = relative(root, abs); + if (ignoreFilter.isIgnored(rel)) continue; + + let stat; + let realAbs: string; + try { + realAbs = realpathSync(abs); + stat = statSync(abs); + } catch { + continue; + } + if (!isContained(realAbs)) continue; // symlink escapes the project root + + if (stat.isDirectory()) { + if (visitedRealDirs.has(realAbs)) continue; // symlink cycle guard + visitedRealDirs.add(realAbs); + walk(abs); + } else if (stat.isFile()) { + results.push(rel); + } + } + } + + walk(root); + return results; +} diff --git a/.openclaw-plugin/tsconfig.json b/.openclaw-plugin/tsconfig.json new file mode 100644 index 000000000..959e2d542 --- /dev/null +++ b/.openclaw-plugin/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "sourceMap": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/.openclaw-plugin/vitest.config.ts b/.openclaw-plugin/vitest.config.ts new file mode 100644 index 000000000..869608407 --- /dev/null +++ b/.openclaw-plugin/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/__tests__/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3eac16a8..d12461076 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,28 @@ importers: specifier: ^3.1.0 version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + .openclaw-plugin: + dependencies: + '@sinclair/typebox': + specifier: ^0.34.0 + version: 0.34.52 + '@understand-anything/core': + specifier: workspace:* + version: link:../understand-anything-plugin/packages/core + understand-anything-viewer: + specifier: workspace:* + version: link:../understand-anything-plugin/packages/viewer + devDependencies: + '@types/node': + specifier: ^25.2.1 + version: 25.5.0 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.1.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + homepage: dependencies: astro: @@ -1051,6 +1073,9 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sinclair/typebox@0.34.52': + resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} + '@tailwindcss/node@4.2.1': resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} @@ -4022,6 +4047,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sinclair/typebox@0.34.52': {} + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 36da254fc..d70c8cc4a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - 'understand-anything-plugin/packages/*' - 'understand-anything-plugin' - 'homepage' + - '.openclaw-plugin' allowBuilds: '@tree-sitter-grammars/tree-sitter-kotlin': true esbuild: true diff --git a/understand-anything-plugin/packages/core/src/__tests__/diff-overlay-persistence.test.ts b/understand-anything-plugin/packages/core/src/__tests__/diff-overlay-persistence.test.ts new file mode 100644 index 000000000..6f828cf27 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/__tests__/diff-overlay-persistence.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, rmSync, existsSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { saveDiffOverlay, loadDiffOverlay } from "../persistence/index.js"; +import type { DiffOverlay } from "../types.js"; + +const testRoot = join(tmpdir(), "ua-diff-overlay-persist-test"); + +const overlay: DiffOverlay = { + version: "1.0.0", + baseBranch: "main", + generatedAt: "2026-04-01T00:00:00.000Z", + changedFiles: ["src/auth.ts"], + changedNodeIds: ["file:src/auth.ts"], + affectedNodeIds: ["file:src/session.ts"], +}; + +describe("diff overlay persistence", () => { + beforeEach(() => { + if (existsSync(testRoot)) rmSync(testRoot, { recursive: true }); + mkdirSync(testRoot, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testRoot)) rmSync(testRoot, { recursive: true }); + }); + + it("saves and loads a diff overlay", () => { + saveDiffOverlay(testRoot, overlay); + const loaded = loadDiffOverlay(testRoot); + expect(loaded).not.toBeNull(); + expect(loaded!.changedNodeIds).toEqual(["file:src/auth.ts"]); + expect(loaded!.affectedNodeIds).toEqual(["file:src/session.ts"]); + expect(loaded!.baseBranch).toBe("main"); + }); + + it("returns null when no diff overlay exists", () => { + expect(loadDiffOverlay(testRoot)).toBeNull(); + }); + + it("saves to diff-overlay.json, alongside but distinct from knowledge-graph.json", () => { + saveDiffOverlay(testRoot, overlay); + expect(existsSync(join(testRoot, ".ua", "diff-overlay.json"))).toBe(true); + expect(existsSync(join(testRoot, ".ua", "knowledge-graph.json"))).toBe(false); + }); + + it("sanitizes absolute changedFiles paths to stay relative to projectRoot", () => { + const absoluteOverlay: DiffOverlay = { + ...overlay, + changedFiles: [join(testRoot, "src", "auth.ts")], + }; + saveDiffOverlay(testRoot, absoluteOverlay); + const loaded = loadDiffOverlay(testRoot); + expect(loaded!.changedFiles).toEqual(["src/auth.ts"]); + }); + + it("returns null (not throw) on a corrupt diff-overlay.json", () => { + mkdirSync(join(testRoot, ".ua"), { recursive: true }); + writeFileSync(join(testRoot, ".ua", "diff-overlay.json"), "not json", "utf-8"); + expect(loadDiffOverlay(testRoot)).toBeNull(); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/__tests__/diff-overlay.test.ts b/understand-anything-plugin/packages/core/src/__tests__/diff-overlay.test.ts new file mode 100644 index 000000000..86916c8b7 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/__tests__/diff-overlay.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { computeDiffOverlay } from "../diff-overlay.js"; +import type { KnowledgeGraph } from "../types.js"; + +function makeGraph(): KnowledgeGraph { + return { + version: "1.0.0", + project: { name: "p", languages: ["typescript"], frameworks: [], description: "d", analyzedAt: "2026-01-01T00:00:00.000Z", gitCommitHash: "x" }, + nodes: [ + { id: "file:auth.ts", type: "file", name: "auth.ts", filePath: "auth.ts", summary: "Auth.", tags: [], complexity: "moderate" }, + { id: "function:auth.ts:login", type: "function", name: "login", filePath: "auth.ts", summary: "Login fn.", tags: [], complexity: "moderate" }, + { id: "file:session.ts", type: "file", name: "session.ts", filePath: "session.ts", summary: "Sessions, imports auth.", tags: [], complexity: "simple" }, + { id: "file:unrelated.ts", type: "file", name: "unrelated.ts", filePath: "unrelated.ts", summary: "Nothing to do with auth.", tags: [], complexity: "simple" }, + ], + edges: [ + // session.ts imports auth.ts -> session.ts should be "affected" when auth.ts changes + { source: "file:session.ts", target: "file:auth.ts", type: "imports", direction: "forward", weight: 0.7 }, + ], + layers: [], + tour: [], + }; +} + +describe("computeDiffOverlay", () => { + it("maps changed files to both file and function/class nodes defined in them", () => { + const overlay = computeDiffOverlay(makeGraph(), ["auth.ts"]); + expect(overlay.changedNodeIds.sort()).toEqual(["file:auth.ts", "function:auth.ts:login"]); + }); + + it("finds affected nodes via 1-hop edges in either direction, excluding changed nodes themselves", () => { + const overlay = computeDiffOverlay(makeGraph(), ["auth.ts"]); + expect(overlay.affectedNodeIds).toEqual(["file:session.ts"]); + expect(overlay.affectedNodeIds).not.toContain("file:auth.ts"); + }); + + it("does not flag unrelated files as affected", () => { + const overlay = computeDiffOverlay(makeGraph(), ["auth.ts"]); + expect(overlay.affectedNodeIds).not.toContain("file:unrelated.ts"); + }); + + it("returns empty changed/affected sets for a file not present in the graph", () => { + const overlay = computeDiffOverlay(makeGraph(), ["does-not-exist.ts"]); + expect(overlay.changedNodeIds).toEqual([]); + expect(overlay.affectedNodeIds).toEqual([]); + }); + + it("includes baseBranch when provided, omits it when not", () => { + const withBranch = computeDiffOverlay(makeGraph(), ["auth.ts"], "main"); + expect(withBranch.baseBranch).toBe("main"); + + const withoutBranch = computeDiffOverlay(makeGraph(), ["auth.ts"]); + expect(withoutBranch.baseBranch).toBeUndefined(); + }); + + it("preserves the changedFiles list verbatim and stamps a generatedAt timestamp", () => { + const overlay = computeDiffOverlay(makeGraph(), ["auth.ts", "does-not-exist.ts"]); + expect(overlay.changedFiles).toEqual(["auth.ts", "does-not-exist.ts"]); + expect(typeof overlay.generatedAt).toBe("string"); + expect(new Date(overlay.generatedAt).toString()).not.toBe("Invalid Date"); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/diff-overlay.ts b/understand-anything-plugin/packages/core/src/diff-overlay.ts new file mode 100644 index 000000000..eba659fed --- /dev/null +++ b/understand-anything-plugin/packages/core/src/diff-overlay.ts @@ -0,0 +1,44 @@ +import type { DiffOverlay, KnowledgeGraph } from "./types.js"; + +/** + * Maps a set of changed file paths onto an already-analyzed knowledge graph: + * finds the nodes defined in those files (file nodes plus any function/class + * nodes whose `filePath` matches, since graph-builder stamps the same + * filePath on both), then walks one hop of edges in both directions to find + * the blast radius — what imports/calls the changed nodes, and what they in + * turn import/call. Pure and deterministic; no LLM involved. + * + * `changedFiles` should be paths relative to the project root, matching how + * node.filePath is stored (see persistence's sanitiseFilePath). + */ +export function computeDiffOverlay( + graph: KnowledgeGraph, + changedFiles: string[], + baseBranch?: string, +): DiffOverlay { + const changedFileSet = new Set(changedFiles); + + const changedNodeIds = graph.nodes + .filter((n) => typeof n.filePath === "string" && changedFileSet.has(n.filePath)) + .map((n) => n.id); + const changedNodeIdSet = new Set(changedNodeIds); + + const affectedNodeIdSet = new Set(); + for (const edge of graph.edges) { + if (changedNodeIdSet.has(edge.source) && !changedNodeIdSet.has(edge.target)) { + affectedNodeIdSet.add(edge.target); + } + if (changedNodeIdSet.has(edge.target) && !changedNodeIdSet.has(edge.source)) { + affectedNodeIdSet.add(edge.source); + } + } + + return { + version: "1.0.0", + ...(baseBranch ? { baseBranch } : {}), + generatedAt: new Date().toISOString(), + changedFiles, + changedNodeIds, + affectedNodeIds: [...affectedNodeIdSet], + }; +} diff --git a/understand-anything-plugin/packages/core/src/index.ts b/understand-anything-plugin/packages/core/src/index.ts index c5f629aa4..e1d091c6e 100644 --- a/understand-anything-plugin/packages/core/src/index.ts +++ b/understand-anything-plugin/packages/core/src/index.ts @@ -122,3 +122,4 @@ export { type IgnoreFilter, } from "./ignore-filter.js"; export { generateStarterIgnoreFile } from "./ignore-generator.js"; +export { computeDiffOverlay } from "./diff-overlay.js"; diff --git a/understand-anything-plugin/packages/core/src/persistence/index.ts b/understand-anything-plugin/packages/core/src/persistence/index.ts index d3b86fd9b..bbf9011d8 100644 --- a/understand-anything-plugin/packages/core/src/persistence/index.ts +++ b/understand-anything-plugin/packages/core/src/persistence/index.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join, isAbsolute, relative, basename } from "node:path"; -import type { KnowledgeGraph, AnalysisMeta, ProjectConfig } from "../types.js"; +import type { KnowledgeGraph, AnalysisMeta, ProjectConfig, DiffOverlay } from "../types.js"; import type { FingerprintStore } from "../fingerprint.js"; import { validateGraph } from "../schema.js"; @@ -50,32 +50,31 @@ function ensureDir(projectRoot: string): string { * This means the developer's home directory, username, and company * directory layout are never written to knowledge-graph.json. */ +function sanitiseFilePath(fp: string, projectRoot: string): string { + const normalRoot = projectRoot.endsWith("/") ? projectRoot : projectRoot + "/"; + + if (!isAbsolute(fp)) { + // Already relative — nothing to do. + return fp; + } + + if (fp.startsWith(normalRoot) || fp.startsWith(projectRoot)) { + // Inside the project root — make it relative. + return relative(projectRoot, fp); + } + + // Absolute but outside the project root — use only the filename + // so we leak as little as possible. + return basename(fp); +} + function sanitiseFilePaths( graph: KnowledgeGraph, projectRoot: string, ): KnowledgeGraph { - const normalRoot = projectRoot.endsWith("/") - ? projectRoot - : projectRoot + "/"; - const sanitisedNodes = graph.nodes.map((node) => { if (typeof node.filePath !== "string") return node; - - const fp = node.filePath; - - if (!isAbsolute(fp)) { - // Already relative — nothing to do. - return node; - } - - if (fp.startsWith(normalRoot) || fp.startsWith(projectRoot)) { - // Inside the project root — make it relative. - return { ...node, filePath: relative(projectRoot, fp) }; - } - - // Absolute but outside the project root — use only the filename - // so we leak as little as possible. - return { ...node, filePath: basename(fp) }; + return { ...node, filePath: sanitiseFilePath(node.filePath, projectRoot) }; }); return { ...graph, nodes: sanitisedNodes }; @@ -195,3 +194,24 @@ export function loadDomainGraph( return data as KnowledgeGraph; } + +const DIFF_OVERLAY_FILE = "diff-overlay.json"; + +export function saveDiffOverlay(projectRoot: string, overlay: DiffOverlay): void { + const dir = ensureDir(projectRoot); + const sanitised: DiffOverlay = { + ...overlay, + changedFiles: overlay.changedFiles.map((fp) => sanitiseFilePath(fp, projectRoot)), + }; + writeFileSync(join(dir, DIFF_OVERLAY_FILE), JSON.stringify(sanitised, null, 2), "utf-8"); +} + +export function loadDiffOverlay(projectRoot: string): DiffOverlay | null { + const filePath = join(resolveUaDir(projectRoot), DIFF_OVERLAY_FILE); + if (!existsSync(filePath)) return null; + try { + return JSON.parse(readFileSync(filePath, "utf-8")) as DiffOverlay; + } catch { + return null; + } +} diff --git a/understand-anything-plugin/packages/core/src/types.ts b/understand-anything-plugin/packages/core/src/types.ts index 28cadca0f..820a70c1a 100644 --- a/understand-anything-plugin/packages/core/src/types.ts +++ b/understand-anything-plugin/packages/core/src/types.ts @@ -114,6 +114,20 @@ export interface KnowledgeGraph { tour: TourStep[]; } +// DiffOverlay — maps a set of changed files onto the knowledge graph so the +// dashboard can highlight changed vs. affected (blast-radius) nodes. Matches +// the shape the understand-diff skill already writes/reads at +// /diff-overlay.json (see skills/understand-diff/SKILL.md step 8) — +// this type/persistence pair formalizes that existing on-disk contract. +export interface DiffOverlay { + version: string; + baseBranch?: string; + generatedAt: string; + changedFiles: string[]; + changedNodeIds: string[]; + affectedNodeIds: string[]; +} + // Theme configuration (for dashboard customization) export interface ThemeConfig { presetId: string;