From b6ab0f8b32c353e2b8fdc8c14bf8f0f29c8d1dab Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sat, 14 Mar 2026 19:00:54 +0800 Subject: [PATCH] feat(skill): scaffold skill package with /understand-chat command Add the @understand-anything/skill package with context-builder (search + 1-hop expansion + layer resolution), chat prompt builder, skill definition for Claude Code, and 14 passing tests. Co-Authored-By: Claude Opus 4.6 --- .../skill/.claude/skills/understand-chat.md | 18 ++ packages/skill/package.json | 19 ++ .../src/__tests__/context-builder.test.ts | 234 ++++++++++++++++++ packages/skill/src/context-builder.ts | 147 +++++++++++ packages/skill/src/index.ts | 6 + packages/skill/src/understand-chat.ts | 29 +++ packages/skill/tsconfig.json | 8 + pnpm-lock.yaml | 126 ++++++++++ 8 files changed, 587 insertions(+) create mode 100644 packages/skill/.claude/skills/understand-chat.md create mode 100644 packages/skill/package.json create mode 100644 packages/skill/src/__tests__/context-builder.test.ts create mode 100644 packages/skill/src/context-builder.ts create mode 100644 packages/skill/src/index.ts create mode 100644 packages/skill/src/understand-chat.ts create mode 100644 packages/skill/tsconfig.json diff --git a/packages/skill/.claude/skills/understand-chat.md b/packages/skill/.claude/skills/understand-chat.md new file mode 100644 index 0000000..e237759 --- /dev/null +++ b/packages/skill/.claude/skills/understand-chat.md @@ -0,0 +1,18 @@ +--- +name: understand-chat +description: Ask questions about the current codebase using the knowledge graph +arguments: query +--- + +# /understand-chat + +Answer questions about this codebase using the knowledge graph at `.understand-anything/knowledge-graph.json`. + +## Instructions + +1. Read the knowledge graph file at `.understand-anything/knowledge-graph.json` in the current project root +2. If the file doesn't exist, tell the user to run `/understand` first to analyze the project +3. Use the knowledge graph context to answer the user's query: "${ARGUMENTS}" +4. Reference specific files, functions, and relationships from the graph +5. If the project has layers defined, explain which layer(s) are relevant +6. Be concise but thorough -- link concepts to actual code locations diff --git a/packages/skill/package.json b/packages/skill/package.json new file mode 100644 index 0000000..c4df88f --- /dev/null +++ b/packages/skill/package.json @@ -0,0 +1,19 @@ +{ + "name": "@understand-anything/skill", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "dependencies": { + "@understand-anything/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "vitest": "^3.1.0" + } +} diff --git a/packages/skill/src/__tests__/context-builder.test.ts b/packages/skill/src/__tests__/context-builder.test.ts new file mode 100644 index 0000000..4f81c43 --- /dev/null +++ b/packages/skill/src/__tests__/context-builder.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect } from "vitest"; +import { buildChatContext, formatContextForPrompt } from "../context-builder.js"; +import type { KnowledgeGraph, GraphNode, GraphEdge, Layer } from "@understand-anything/core"; + +const makeNode = ( + overrides: Partial & { id: string; name: string }, +): GraphNode => ({ + type: "file", + summary: "", + tags: [], + complexity: "simple", + ...overrides, +}); + +const sampleNodes: GraphNode[] = [ + makeNode({ + id: "auth-ctrl", + name: "AuthenticationController", + type: "class", + filePath: "src/controllers/auth.ts", + summary: "Handles user login, logout, and session management", + tags: ["auth", "controller", "security"], + complexity: "complex", + languageNotes: "Uses Express middleware pattern", + }), + makeNode({ + id: "db-pool", + name: "DatabasePool", + type: "class", + filePath: "src/db/pool.ts", + summary: "Manages PostgreSQL connection pooling", + tags: ["database", "connection"], + complexity: "moderate", + }), + makeNode({ + id: "user-model", + name: "UserModel", + type: "class", + filePath: "src/models/user.ts", + summary: "ORM model for the users table", + tags: ["model", "database", "user"], + complexity: "moderate", + }), + makeNode({ + id: "auth-middleware", + name: "authMiddleware", + type: "function", + filePath: "src/middleware/auth.ts", + summary: "Express middleware that validates JWT tokens for authentication", + tags: ["auth", "middleware", "security"], + complexity: "simple", + }), + makeNode({ + id: "config", + name: "config.ts", + type: "file", + filePath: "src/config.ts", + summary: "Application configuration and environment variables", + tags: ["config", "env"], + complexity: "simple", + }), +]; + +const sampleEdges: GraphEdge[] = [ + { + source: "auth-ctrl", + target: "user-model", + type: "depends_on", + direction: "forward", + description: "AuthenticationController uses UserModel for user lookup", + weight: 0.9, + }, + { + source: "auth-ctrl", + target: "auth-middleware", + type: "calls", + direction: "forward", + description: "Controller registers auth middleware", + weight: 0.7, + }, + { + source: "user-model", + target: "db-pool", + type: "depends_on", + direction: "forward", + description: "UserModel uses DatabasePool for queries", + weight: 0.8, + }, +]; + +const sampleLayers: Layer[] = [ + { + id: "layer-api", + name: "API Layer", + description: "HTTP controllers and middleware", + nodeIds: ["auth-ctrl", "auth-middleware"], + }, + { + id: "layer-data", + name: "Data Layer", + description: "Database models and connections", + nodeIds: ["user-model", "db-pool"], + }, +]; + +const sampleGraph: KnowledgeGraph = { + version: "1.0.0", + project: { + name: "test-project", + languages: ["TypeScript"], + frameworks: ["Express"], + description: "A test project for unit tests", + analyzedAt: "2026-03-14T00:00:00Z", + gitCommitHash: "abc123", + }, + nodes: sampleNodes, + edges: sampleEdges, + layers: sampleLayers, + tour: [], +}; + +describe("buildChatContext", () => { + it("finds relevant nodes for a query", () => { + const ctx = buildChatContext(sampleGraph, "authentication"); + expect(ctx.relevantNodes.length).toBeGreaterThan(0); + const nodeNames = ctx.relevantNodes.map((n) => n.name); + expect(nodeNames).toContain("AuthenticationController"); + }); + + it("includes connected nodes via 1-hop expansion", () => { + // Searching for "authentication" should find auth-ctrl directly. + // auth-ctrl connects to user-model and auth-middleware via edges, + // so those should also appear in relevantNodes. + const ctx = buildChatContext(sampleGraph, "authentication"); + const nodeIds = ctx.relevantNodes.map((n) => n.id); + // auth-ctrl is a direct match + expect(nodeIds).toContain("auth-ctrl"); + // user-model and auth-middleware are 1-hop connected + expect(nodeIds).toContain("user-model"); + expect(nodeIds).toContain("auth-middleware"); + }); + + it("includes project metadata", () => { + const ctx = buildChatContext(sampleGraph, "database"); + expect(ctx.projectName).toBe("test-project"); + expect(ctx.projectDescription).toBe("A test project for unit tests"); + expect(ctx.languages).toEqual(["TypeScript"]); + expect(ctx.frameworks).toEqual(["Express"]); + }); + + it("includes relevant layers containing matched nodes", () => { + const ctx = buildChatContext(sampleGraph, "authentication"); + const layerNames = ctx.relevantLayers.map((l) => l.name); + // auth-ctrl is in API Layer + expect(layerNames).toContain("API Layer"); + }); + + it("includes relevant edges between relevant nodes", () => { + const ctx = buildChatContext(sampleGraph, "authentication"); + expect(ctx.relevantEdges.length).toBeGreaterThan(0); + // Should include the edge from auth-ctrl to user-model + const hasAuthToUser = ctx.relevantEdges.some( + (e) => e.source === "auth-ctrl" && e.target === "user-model", + ); + expect(hasAuthToUser).toBe(true); + }); + + it("stores the original query", () => { + const ctx = buildChatContext(sampleGraph, "database pool"); + expect(ctx.query).toBe("database pool"); + }); + + it("respects maxNodes parameter", () => { + const ctx = buildChatContext(sampleGraph, "auth", 1); + // With maxNodes=1, only 1 search result (before expansion) + // Expansion may add connected nodes, but initial search is limited + expect(ctx.relevantNodes.length).toBeGreaterThanOrEqual(1); + // Should still be bounded reasonably + expect(ctx.relevantNodes.length).toBeLessThanOrEqual(sampleNodes.length); + }); + + it("returns empty relevantNodes for a query with no matches", () => { + const ctx = buildChatContext(sampleGraph, "xyznonexistent"); + expect(ctx.relevantNodes.length).toBe(0); + expect(ctx.relevantEdges.length).toBe(0); + expect(ctx.relevantLayers.length).toBe(0); + }); +}); + +describe("formatContextForPrompt", () => { + it("produces a string containing node names and summaries", () => { + const ctx = buildChatContext(sampleGraph, "authentication"); + const formatted = formatContextForPrompt(ctx); + expect(formatted).toContain("AuthenticationController"); + expect(formatted).toContain("Handles user login, logout, and session management"); + }); + + it("includes project header information", () => { + const ctx = buildChatContext(sampleGraph, "authentication"); + const formatted = formatContextForPrompt(ctx); + expect(formatted).toContain("test-project"); + expect(formatted).toContain("TypeScript"); + expect(formatted).toContain("Express"); + }); + + it("includes edge/relationship descriptions", () => { + const ctx = buildChatContext(sampleGraph, "authentication"); + const formatted = formatContextForPrompt(ctx); + // Should reference the relationship between auth-ctrl and user-model + expect(formatted).toContain("AuthenticationController"); + expect(formatted).toContain("UserModel"); + // Edge type or description should appear + expect(formatted).toContain("depends_on"); + }); + + it("includes layer information when layers are present", () => { + const ctx = buildChatContext(sampleGraph, "authentication"); + const formatted = formatContextForPrompt(ctx); + expect(formatted).toContain("API Layer"); + }); + + it("includes file paths for nodes that have them", () => { + const ctx = buildChatContext(sampleGraph, "authentication"); + const formatted = formatContextForPrompt(ctx); + expect(formatted).toContain("src/controllers/auth.ts"); + }); + + it("includes complexity and type information", () => { + const ctx = buildChatContext(sampleGraph, "authentication"); + const formatted = formatContextForPrompt(ctx); + expect(formatted).toContain("complex"); + expect(formatted).toContain("class"); + }); +}); diff --git a/packages/skill/src/context-builder.ts b/packages/skill/src/context-builder.ts new file mode 100644 index 0000000..5717d2a --- /dev/null +++ b/packages/skill/src/context-builder.ts @@ -0,0 +1,147 @@ +import { SearchEngine } from "@understand-anything/core"; +import type { + KnowledgeGraph, + GraphNode, + GraphEdge, + Layer, +} from "@understand-anything/core"; + +export interface ChatContext { + projectName: string; + projectDescription: string; + languages: string[]; + frameworks: string[]; + relevantNodes: GraphNode[]; + relevantEdges: GraphEdge[]; + relevantLayers: Layer[]; + query: string; +} + +/** + * Build a ChatContext by searching the knowledge graph for nodes relevant + * to the user's query, expanding 1 hop via edges, and collecting the + * associated layers. + */ +export function buildChatContext( + graph: KnowledgeGraph, + query: string, + maxNodes?: number, +): ChatContext { + const limit = maxNodes ?? 15; + + // 1. Use SearchEngine to find relevant nodes + const engine = new SearchEngine(graph.nodes); + const searchResults = engine.search(query, { limit }); + + // Build a set of matched node IDs + const matchedIds = new Set(searchResults.map((r) => r.nodeId)); + + // 2. Expand to connected nodes (1 hop via edges) + const expandedIds = new Set(matchedIds); + for (const edge of graph.edges) { + if (matchedIds.has(edge.source)) { + expandedIds.add(edge.target); + } + if (matchedIds.has(edge.target)) { + expandedIds.add(edge.source); + } + } + + // Collect the actual node objects + const nodeMap = new Map(graph.nodes.map((n) => [n.id, n])); + const relevantNodes: GraphNode[] = []; + for (const id of expandedIds) { + const node = nodeMap.get(id); + if (node) { + relevantNodes.push(node); + } + } + + // 3. Collect edges where both endpoints are in the relevant set + const relevantEdges = graph.edges.filter( + (e) => expandedIds.has(e.source) && expandedIds.has(e.target), + ); + + // 4. Find layers containing any relevant node + const relevantLayers = graph.layers.filter((layer) => + layer.nodeIds.some((id) => expandedIds.has(id)), + ); + + return { + projectName: graph.project.name, + projectDescription: graph.project.description, + languages: graph.project.languages, + frameworks: graph.project.frameworks, + relevantNodes, + relevantEdges, + relevantLayers, + query, + }; +} + +/** + * Format the ChatContext as a readable markdown string for LLM consumption. + */ +export function formatContextForPrompt(context: ChatContext): string { + const lines: string[] = []; + + // Project header + lines.push(`# Project: ${context.projectName}`); + lines.push(""); + lines.push(context.projectDescription); + lines.push(""); + lines.push(`**Languages:** ${context.languages.join(", ")}`); + lines.push(`**Frameworks:** ${context.frameworks.join(", ")}`); + lines.push(""); + + // Layers section + if (context.relevantLayers.length > 0) { + lines.push("## Relevant Layers"); + lines.push(""); + for (const layer of context.relevantLayers) { + lines.push(`### ${layer.name}`); + lines.push(layer.description); + lines.push(""); + } + } + + // Nodes section + if (context.relevantNodes.length > 0) { + lines.push("## Code Components"); + lines.push(""); + for (const node of context.relevantNodes) { + lines.push(`### ${node.name} (${node.type})`); + if (node.filePath) { + lines.push(`- **File:** ${node.filePath}`); + } + lines.push(`- **Complexity:** ${node.complexity}`); + lines.push(`- **Summary:** ${node.summary}`); + if (node.tags.length > 0) { + lines.push(`- **Tags:** ${node.tags.join(", ")}`); + } + if (node.languageNotes) { + lines.push(`- **Language Notes:** ${node.languageNotes}`); + } + lines.push(""); + } + } + + // Edges/relationships section + if (context.relevantEdges.length > 0) { + const nodeMap = new Map(context.relevantNodes.map((n) => [n.id, n])); + lines.push("## Relationships"); + lines.push(""); + for (const edge of context.relevantEdges) { + const sourceName = nodeMap.get(edge.source)?.name ?? edge.source; + const targetName = nodeMap.get(edge.target)?.name ?? edge.target; + let line = `- ${sourceName} --[${edge.type}]--> ${targetName}`; + if (edge.description) { + line += `: ${edge.description}`; + } + lines.push(line); + } + lines.push(""); + } + + return lines.join("\n"); +} diff --git a/packages/skill/src/index.ts b/packages/skill/src/index.ts new file mode 100644 index 0000000..96c3335 --- /dev/null +++ b/packages/skill/src/index.ts @@ -0,0 +1,6 @@ +export { + buildChatContext, + formatContextForPrompt, + type ChatContext, +} from "./context-builder.js"; +export { buildChatPrompt } from "./understand-chat.js"; diff --git a/packages/skill/src/understand-chat.ts b/packages/skill/src/understand-chat.ts new file mode 100644 index 0000000..11e2aa0 --- /dev/null +++ b/packages/skill/src/understand-chat.ts @@ -0,0 +1,29 @@ +import type { KnowledgeGraph } from "@understand-anything/core"; +import { buildChatContext, formatContextForPrompt } from "./context-builder.js"; + +/** + * Build a complete chat prompt by combining knowledge graph context + * with a system instruction for answering codebase questions. + */ +export function buildChatPrompt( + graph: KnowledgeGraph, + query: string, +): string { + const context = buildChatContext(graph, query); + const formattedContext = formatContextForPrompt(context); + + return [ + "You are a knowledgeable assistant that answers questions about a software codebase.", + "Use the following knowledge graph context to inform your answer.", + "Reference specific files, functions, classes, and relationships from the graph.", + "If layers are present, explain which architectural layer(s) are relevant.", + "Be concise but thorough — link concepts to actual code locations.", + "", + "---", + "", + formattedContext, + "---", + "", + `**User question:** ${query}`, + ].join("\n"); +} diff --git a/packages/skill/tsconfig.json b/packages/skill/tsconfig.json new file mode 100644 index 0000000..a086b14 --- /dev/null +++ b/packages/skill/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cf90c8..84d6e77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,22 @@ importers: specifier: ^6.0.0 version: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1) + packages/skill: + dependencies: + '@understand-anything/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.15 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.1.0 + version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) + packages: '@babel/code-frame@7.29.0': @@ -775,6 +791,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} @@ -1231,6 +1250,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -1907,6 +1929,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + '@types/node@25.5.0': dependencies: undici-types: 7.18.2 @@ -1942,6 +1968,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1))': dependencies: '@vitest/spy': 3.2.4 @@ -2362,6 +2396,8 @@ snapshots: typescript@5.9.3: {} + undici-types@6.21.0: {} + undici-types@7.18.2: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -2374,6 +2410,27 @@ snapshots: dependencies: react: 19.2.4 + vite-node@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@3.2.4(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: cac: 6.7.14 @@ -2395,6 +2452,20 @@ snapshots: - tsx - yaml + vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: esbuild: 0.25.12 @@ -2409,6 +2480,20 @@ snapshots: jiti: 2.6.1 lightningcss: 1.31.1 + vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: esbuild: 0.27.4 @@ -2423,6 +2508,47 @@ snapshots: jiti: 2.6.1 lightningcss: 1.31.1 + vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) + vite-node: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.4(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: '@types/chai': 5.2.3