diff --git a/package-lock.json b/package-lock.json index 3de69ee..f9987a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -935,7 +935,6 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -1792,7 +1791,6 @@ "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2528,7 +2526,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -3118,7 +3115,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4329,7 +4325,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -6504,7 +6499,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7404,7 +7398,6 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -8305,7 +8298,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8403,7 +8395,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8550,7 +8541,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8644,7 +8634,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8880,7 +8869,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/backends/docsgpt-client.ts b/src/backends/docsgpt-client.ts new file mode 100644 index 0000000..1df3065 --- /dev/null +++ b/src/backends/docsgpt-client.ts @@ -0,0 +1,100 @@ +/** + * DocsGPT HTTP client for semantic search over Aztec documentation. + * + * Ported from the standalone aztec-docs MCP server. Talks to a DocsGPT + * instance that hosts a vector knowledge base of Aztec developer docs, + * framework source, example contracts, and more. + */ + +export interface SemanticSearchResult { + text: string; + title: string; + source: string; +} + +export class DocsGPTClientError extends Error { + constructor( + message: string, + public statusCode?: number + ) { + super(message); + this.name = "DocsGPTClientError"; + } +} + +export interface DocsGPTClientConfig { + apiUrl: string; + apiKey: string; + timeout?: number; +} + +export class DocsGPTClient { + private baseUrl: string; + private apiKey: string; + private timeout: number; + + constructor(config: DocsGPTClientConfig) { + this.baseUrl = config.apiUrl.replace(/\/+$/, ""); + this.apiKey = config.apiKey; + this.timeout = config.timeout ?? 60_000; + } + + async search( + query: string, + chunks: number = 5 + ): Promise { + const body = { + question: query, + api_key: this.apiKey, + chunks, + }; + + const url = `${this.baseUrl}/api/search`; + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(this.timeout), + }); + } catch (err) { + if (err instanceof DOMException && err.name === "TimeoutError") { + throw new DocsGPTClientError( + `Request timed out after ${this.timeout}ms` + ); + } + throw new DocsGPTClientError( + `Failed to connect to DocsGPT at ${this.baseUrl}: ${err instanceof Error ? err.message : String(err)}` + ); + } + + if (response.status === 401) { + throw new DocsGPTClientError( + "Invalid API key. Get a new key by running /mcp-key in the Noir Discord.", + 401 + ); + } + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new DocsGPTClientError( + `DocsGPT returned ${response.status}: ${text || response.statusText}`, + response.status + ); + } + + const data = await response.json(); + + if (!Array.isArray(data)) { + return []; + } + + return data.map((item: Record) => ({ + text: String(item.text || ""), + title: String(item.title || ""), + source: String(item.source || ""), + })); + } +} diff --git a/src/index.ts b/src/index.ts index f7553b9..43e2aab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,16 @@ #!/usr/bin/env node /** - * Aztec MCP Server + * Aztec MCP Server (unified) * - * An MCP server that provides local access to Aztec documentation, - * examples, and source code through cloned repositories. + * Provides local access to Aztec documentation, examples, source code, + * and semantic search through cloned repositories and DocsGPT. + * + * Tools: + * aztec_search — Semantic doc search via DocsGPT (requires API_KEY) + * aztec_search_code — Regex code search via ripgrep over cloned repos + * aztec_lookup_error — Error diagnosis with semantic fallback + * aztec_list_examples, aztec_read_example, aztec_read_file — Repo browsing + * aztec_sync_repos, aztec_status — Repo management */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; @@ -29,6 +36,7 @@ import { formatSyncResult, formatStatus, formatSearchResults, + formatSemanticSearchResults, formatExamplesList, formatExampleContent, formatFileContent, @@ -38,6 +46,23 @@ import { MCP_VERSION } from "./version.js"; import { getSyncState, writeAutoResyncAttempt } from "./utils/sync-metadata.js"; import { getRepoTag } from "./utils/git.js"; import type { Logger } from "./utils/git.js"; +import { DocsGPTClient } from "./backends/docsgpt-client.js"; + +// --------------------------------------------------------------------------- +// DocsGPT client — optional, enabled when API_KEY is set +// --------------------------------------------------------------------------- + +const docsgptClient = process.env.API_KEY + ? new DocsGPTClient({ + apiUrl: process.env.API_URL || "http://localhost:7091", + apiKey: process.env.API_KEY, + timeout: parseInt(process.env.REQUEST_TIMEOUT || "60000", 10), + }) + : null; + +// --------------------------------------------------------------------------- +// MCP server +// --------------------------------------------------------------------------- const server = new Server( { @@ -53,10 +78,55 @@ const server = new Server( ); /** - * Define available tools + * Define available tools. + * aztec_search_docs description changes based on whether DocsGPT is available. */ -server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ +server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools = [ + // Documentation search — semantic (DocsGPT) when API_KEY is set, ripgrep fallback otherwise + { + name: "aztec_search_docs", + description: docsgptClient + ? "Search Aztec documentation, guides, patterns, and API reference. " + + "Uses semantic search to find relevant content from developer docs, " + + "Aztec.nr framework docs, example contracts, and more." + : "Search Aztec documentation. Use for finding tutorials, guides, and API documentation.", + inputSchema: { + type: "object" as const, + properties: { + query: { + type: "string", + description: docsgptClient + ? "Natural language search query about Aztec development" + : "Documentation search query", + }, + section: { + type: "string", + description: docsgptClient + ? "Docs section filter (applies to local fallback search only). Examples: tutorials, concepts, developers, reference" + : "Docs section to search. Examples: tutorials, concepts, developers, reference", + }, + maxResults: { + type: "number", + description: docsgptClient + ? "Maximum results to return (default: 5 for semantic search, max: 20)" + : "Maximum results to return (default: 20)", + }, + ...(docsgptClient + ? { + chunks: { + type: "number", + description: + "Number of result chunks for semantic search (default: 5, max: 20). " + + "If omitted, maxResults is used.", + }, + } + : {}), + }, + required: ["query"], + }, + }, + // Repo sync { name: "aztec_sync_repos", description: @@ -64,7 +134,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ "Clones: aztec-packages (docs, aztec-nr, contracts), aztec-examples, aztec-starter. " + "Specify a version to clone a specific Aztec release tag.", inputSchema: { - type: "object", + type: "object" as const, properties: { version: { type: "string", @@ -84,22 +154,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ }, }, }, + // Status { name: "aztec_status", description: "Check the status of cloned Aztec repositories - shows which repos are available and their commit hashes.", inputSchema: { - type: "object", + type: "object" as const, properties: {}, }, }, + // Code search (ripgrep) { name: "aztec_search_code", description: "Search Aztec contract code and source files. Supports regex patterns. " + "Use for finding function implementations, patterns, and examples.", inputSchema: { - type: "object", + type: "object" as const, properties: { query: { type: "string", @@ -107,7 +179,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ }, filePattern: { type: "string", - description: "File glob pattern (default: *.nr). Examples: *.ts, *.{nr,ts}", + description: + "File glob pattern (default: *.nr). Examples: *.ts, *.{nr,ts}", }, repo: { type: "string", @@ -122,36 +195,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ required: ["query"], }, }, - { - name: "aztec_search_docs", - description: - "Search Aztec documentation. Use for finding tutorials, guides, and API documentation.", - inputSchema: { - type: "object", - properties: { - query: { - type: "string", - description: "Documentation search query", - }, - section: { - type: "string", - description: - "Docs section to search. Examples: tutorials, concepts, developers, reference", - }, - maxResults: { - type: "number", - description: "Maximum results to return (default: 20)", - }, - }, - required: ["query"], - }, - }, + // Examples { name: "aztec_list_examples", description: "List available Aztec contract examples. Returns contract names and paths.", inputSchema: { - type: "object", + type: "object" as const, properties: { category: { type: "string", @@ -166,7 +216,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ description: "Read the source code of an Aztec contract example. Use aztec_list_examples to find available examples.", inputSchema: { - type: "object", + type: "object" as const, properties: { name: { type: "string", @@ -176,12 +226,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ required: ["name"], }, }, + // File reading { name: "aztec_read_file", description: "Read any file from the cloned repositories by path. Path should be relative to the repos directory.", inputSchema: { - type: "object", + type: "object" as const, properties: { path: { type: "string", @@ -192,14 +243,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ required: ["path"], }, }, + // Error lookup (with semantic fallback) { name: "aztec_lookup_error", description: "Look up an Aztec error by message, error code, or hex signature. " + "Returns root cause and suggested fix. Searches Solidity errors, " + - "TX validation errors, circuit codes, AVM errors, and documentation.", + "TX validation errors, circuit codes, AVM errors, and documentation." + + (docsgptClient + ? " Falls back to semantic documentation search when no exact match is found." + : ""), inputSchema: { - type: "object", + type: "object" as const, properties: { query: { type: "string", @@ -219,41 +274,62 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ required: ["query"], }, }, - ], -})); + ]; + + return { tools }; +}); + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- -function validateToolRequest(name: string, args: Record | undefined): void { +function validateToolRequest( + name: string, + args: Record | undefined +): void { switch (name) { case "aztec_sync_repos": case "aztec_status": case "aztec_list_examples": break; - case "aztec_search_code": case "aztec_search_docs": + case "aztec_search_code": case "aztec_lookup_error": - if (!args?.query) throw new McpError(ErrorCode.InvalidParams, "query is required"); + if (!args?.query) + throw new McpError(ErrorCode.InvalidParams, "query is required"); break; case "aztec_read_example": - if (!args?.name) throw new McpError(ErrorCode.InvalidParams, "name is required"); + if (!args?.name) + throw new McpError(ErrorCode.InvalidParams, "name is required"); break; case "aztec_read_file": - if (!args?.path) throw new McpError(ErrorCode.InvalidParams, "path is required"); + if (!args?.path) + throw new McpError(ErrorCode.InvalidParams, "path is required"); break; default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } } +// --------------------------------------------------------------------------- +// Auto-resync +// --------------------------------------------------------------------------- + // Sync lock — prevents concurrent syncs from racing over filesystem paths let syncInFlight: Promise | null = null; function createSyncLog(): Logger { - return (message: string, level: "info" | "debug" | "warning" | "error" = "info") => { - server.sendLoggingMessage({ - level, - logger: "aztec-sync", - data: message, - }).catch(() => { }); + return ( + message: string, + level: "info" | "debug" | "warning" | "error" = "info" + ) => { + server + .sendLoggingMessage({ + level, + logger: "aztec-sync", + data: message, + }) + .catch(() => {}); }; } @@ -263,7 +339,10 @@ function ensureAutoResync(): void { if (syncInFlight) return; const syncState = getSyncState(); - if (syncState.kind !== "needsAutoResync" && syncState.kind !== "legacyUnknownVersion") { + if ( + syncState.kind !== "needsAutoResync" && + syncState.kind !== "legacyUnknownVersion" + ) { return; } @@ -279,10 +358,20 @@ function ensureAutoResync(): void { const detectedTag = await getRepoTag("aztec-packages"); if (detectedTag) { version = detectedTag; - log(`Auto-syncing repos (detected ${detectedTag} from existing checkout)...`, "info"); + log( + `Auto-syncing repos (detected ${detectedTag} from existing checkout)...`, + "info" + ); } else { - log("Install predates sync metadata. Run aztec_sync_repos to establish tracked state.", "warning"); - try { writeAutoResyncAttempt("deferred"); } catch { /* non-fatal */ } + log( + "Install predates sync metadata. Run aztec_sync_repos to establish tracked state.", + "warning" + ); + try { + writeAutoResyncAttempt("deferred"); + } catch { + /* non-fatal */ + } return; } } @@ -292,23 +381,33 @@ function ensureAutoResync(): void { log("Auto-sync complete", "info"); } else { // Sync failed or metadata could not be persisted — retry after backoff - try { writeAutoResyncAttempt("retryable"); } catch { /* non-fatal */ } + try { + writeAutoResyncAttempt("retryable"); + } catch { + /* non-fatal */ + } if (syncResult.success) { log(`Auto-resync partial: ${syncResult.message}`, "info"); } else { - log(`Auto-resync failed: ${syncResult.message}. Local tools will use existing checkouts.`, "warning"); + log( + `Auto-resync failed: ${syncResult.message}. Local tools will use existing checkouts.`, + "warning" + ); } } })(); // Fire and forget — auto-resync is best-effort background work. // Read-only tools proceed immediately with existing local checkouts. - syncInFlight = task.finally(() => { syncInFlight = null; }); + syncInFlight = task.finally(() => { + syncInFlight = null; + }); } -/** - * Handle tool calls - */ +// --------------------------------------------------------------------------- +// Tool dispatch +// --------------------------------------------------------------------------- + server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; @@ -316,21 +415,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { validateToolRequest(name, args); // Auto re-sync if MCP server version changed since last sync. - // ensureAutoResync() starts the sync (fire-and-forget) — we then wait for any - // in-flight sync to finish so read-only tools don't race against filesystem mutations. - if (name !== "aztec_sync_repos") { + // When using DocsGPT, aztec_search_docs doesn't need local repos — skip sync wait. + const isSemanticDocsSearch = name === "aztec_search_docs" && docsgptClient != null; + if (name !== "aztec_sync_repos" && !isSemanticDocsSearch) { ensureAutoResync(); - if (syncInFlight) await syncInFlight.catch(() => { }); + if (syncInFlight) await syncInFlight.catch(() => {}); } try { - // validateToolRequest() above guarantees name is a known tool let text!: string; switch (name) { case "aztec_sync_repos": { // Wait for any in-flight sync (auto or manual) before starting - while (syncInFlight) await syncInFlight.catch(() => { }); + while (syncInFlight) await syncInFlight.catch(() => {}); const log = createSyncLog(); const task = syncRepos({ version: args?.version as string | undefined, @@ -338,7 +436,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { repos: args?.repos as string[] | undefined, log, }); - syncInFlight = task.then(() => { }).finally(() => { syncInFlight = null; }); + syncInFlight = task + .then(() => {}) + .finally(() => { + syncInFlight = null; + }); const result = await task; text = formatSyncResult(result); break; @@ -350,6 +452,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { break; } + case "aztec_search_docs": { + const docsResult = await searchAztecDocs( + { + query: args!.query as string, + section: args?.section as string | undefined, + maxResults: args?.maxResults as number | undefined, + chunks: args?.chunks as number | undefined, + }, + docsgptClient + ); + text = + docsResult.kind === "semantic" + ? formatSemanticSearchResults(docsResult.result) + : formatSearchResults(docsResult.result); + break; + } + case "aztec_search_code": { const result = searchAztecCode({ query: args!.query as string, @@ -361,16 +480,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { break; } - case "aztec_search_docs": { - const result = searchAztecDocs({ - query: args!.query as string, - section: args?.section as string | undefined, - maxResults: args?.maxResults as number | undefined, - }); - text = formatSearchResults(result); - break; - } - case "aztec_list_examples": { const result = listAztecExamples({ category: args?.category as string | undefined, @@ -396,15 +505,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } case "aztec_lookup_error": { - const result = lookupAztecError({ - query: args!.query as string, - category: args?.category as string | undefined, - maxResults: args?.maxResults as number | undefined, - }); + const result = await lookupAztecError( + { + query: args!.query as string, + category: args?.category as string | undefined, + maxResults: args?.maxResults as number | undefined, + }, + docsgptClient + ); text = formatErrorLookupResult(result); break; } - } return { @@ -427,7 +538,8 @@ async function main() { await server.connect(transport); // Log to stderr (stdout is used for MCP communication) - console.error("Aztec MCP Server started"); + const mode = docsgptClient ? "semantic search enabled" : "code search only (set API_KEY for docs)"; + console.error(`Aztec MCP Server started (${mode})`); } main().catch((error) => { diff --git a/src/tools/error-lookup.ts b/src/tools/error-lookup.ts index e56ac31..72fcd1e 100644 --- a/src/tools/error-lookup.ts +++ b/src/tools/error-lookup.ts @@ -1,31 +1,78 @@ /** * Error lookup tool — diagnose any Aztec error by message, code, or hex signature. + * + * Enhanced: when the static catalog + dynamic parsers produce no matches, + * falls back to semantic search via DocsGPT for broader documentation context. */ import { lookupError } from "../utils/error-lookup.js"; import type { ErrorLookupResult } from "../utils/error-lookup.js"; +import type { DocsGPTClient } from "../backends/docsgpt-client.js"; +import type { SemanticSearchResult } from "../backends/docsgpt-client.js"; -export function lookupAztecError(options: { - query: string; - category?: string; - maxResults?: number; -}): { +export interface ErrorLookupToolResult { success: boolean; result: ErrorLookupResult; + semanticResults?: SemanticSearchResult[]; message: string; -} { +} + +export async function lookupAztecError( + options: { + query: string; + category?: string; + maxResults?: number; + }, + docsgptClient?: DocsGPTClient | null +): Promise { const { query, category, maxResults = 10 } = options; const result = lookupError(query, { category, maxResults }); const totalMatches = result.catalogMatches.length + result.codeMatches.length; + // If static lookup found results, return them directly + if (totalMatches > 0) { + return { + success: true, + result, + message: `Found ${result.catalogMatches.length} known error(s) and ${result.codeMatches.length} code reference(s) for "${query}"`, + }; + } + + // Semantic fallback: search docs for error context when catalog misses + if (docsgptClient) { + try { + const semanticResults = await docsgptClient.search( + `Aztec error: ${query}`, + 3 + ); + + if (semanticResults.length > 0) { + return { + success: true, + result, + semanticResults, + message: `No exact error match found for "${query}". Showing relevant documentation.`, + }; + } + } catch (err) { + // Surface the DocsGPT failure so callers can distinguish "no docs exist" + // from "the semantic backend is broken/misconfigured". + const detail = err instanceof Error ? err.message : String(err); + return { + success: true, + result, + message: + `No exact error match found for "${query}". ` + + `Semantic documentation search also failed: ${detail}`, + }; + } + } + return { success: true, result, - message: - totalMatches > 0 - ? `Found ${result.catalogMatches.length} known error(s) and ${result.codeMatches.length} code reference(s) for "${query}"` - : `No matches found for "${query}". Try a different error message, code, or hex signature.`, + message: `No matches found for "${query}". Try a different error message, code, or hex signature.`, }; } diff --git a/src/tools/index.ts b/src/tools/index.ts index 5b401fe..8200c0c 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,5 +9,7 @@ export { listAztecExamples, readAztecExample, readRepoFile, + type DocsSearchResult, + type SemanticSearchToolResult, } from "./search.js"; export { lookupAztecError } from "./error-lookup.js"; diff --git a/src/tools/search.ts b/src/tools/search.ts index dd328eb..68aba02 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -13,6 +13,8 @@ import { } from "../utils/search.js"; import { isRepoCloned } from "../utils/git.js"; import { getRepoNames } from "../repos/config.js"; +import { DocsGPTClient, DocsGPTClientError } from "../backends/docsgpt-client.js"; +import type { SemanticSearchResult } from "../backends/docsgpt-client.js"; /** * Search Aztec code (contracts, TypeScript, etc.) @@ -60,37 +62,89 @@ export function searchAztecCode(options: { } /** - * Search Aztec documentation + * Semantic search result shape returned by aztec_search_docs when using DocsGPT. */ -export function searchAztecDocs(options: { - query: string; - section?: string; - maxResults?: number; -}): { +export interface SemanticSearchToolResult { success: boolean; - results: SearchResult[]; + results: SemanticSearchResult[]; message: string; -} { +} + +/** + * Result type for aztec_search_docs — either semantic results (DocsGPT) + * or ripgrep code-search results (fallback when no API key). + */ +export type DocsSearchResult = + | { kind: "semantic"; result: SemanticSearchToolResult } + | { kind: "ripgrep"; result: { success: boolean; results: SearchResult[]; message: string } }; + +/** + * Search Aztec documentation. + * + * When a DocsGPT client is available (API_KEY set), uses semantic vector + * search for high-quality natural language results. Otherwise, falls back + * to the ripgrep-based search over cloned markdown files. + */ +export async function searchAztecDocs( + options: { + query: string; + section?: string; + maxResults?: number; + chunks?: number; + }, + client: DocsGPTClient | null +): Promise { + // Semantic path — preferred when DocsGPT is configured + if (client) { + const { query, chunks, maxResults } = options; + const numChunks = Math.min(chunks ?? maxResults ?? 5, 20); + + try { + const results = await client.search(query, numChunks); + + return { + kind: "semantic", + result: { + success: true, + results, + message: + results.length > 0 + ? `Found ${results.length} documentation matches` + : `No documentation matches found for "${query}".`, + }, + }; + } catch { + // DocsGPT unavailable — fall through to ripgrep if local docs exist + } + } + + // Ripgrep fallback — searches cloned markdown files const { query, section, maxResults = 20 } = options; if (!isRepoCloned("aztec-packages-docs")) { return { - success: false, - results: [], - message: - "aztec-packages-docs is not cloned. Run aztec_sync_repos first to get documentation.", + kind: "ripgrep", + result: { + success: false, + results: [], + message: + "aztec-packages-docs is not cloned. Run aztec_sync_repos first to get documentation.", + }, }; } const results = doSearchDocs(query, { section, maxResults }); return { - success: true, - results, - message: - results.length > 0 - ? `Found ${results.length} documentation matches` - : "No documentation matches found", + kind: "ripgrep", + result: { + success: true, + results, + message: + results.length > 0 + ? `Found ${results.length} documentation matches` + : "No documentation matches found", + }, }; } diff --git a/src/utils/format.ts b/src/utils/format.ts index 0ca83ea..89f2a92 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -6,6 +6,8 @@ import { isRepoError, type SyncResult } from "../tools/sync.js"; import type { SearchResult, FileInfo } from "./search.js"; import type { SyncMetadata } from "./sync-metadata.js"; import type { ErrorLookupResult } from "./error-lookup.js"; +import type { SemanticSearchToolResult } from "../tools/search.js"; +import type { ErrorLookupToolResult } from "../tools/error-lookup.js"; export function formatSyncResult(result: SyncResult): string { const lines = [ @@ -153,11 +155,7 @@ export function formatFileContent(result: { return result.content; } -export function formatErrorLookupResult(result: { - success: boolean; - result: ErrorLookupResult; - message: string; -}): string { +export function formatErrorLookupResult(result: ErrorLookupToolResult): string { const lines = [result.message, ""]; const { catalogMatches, codeMatches } = result.result; @@ -193,7 +191,31 @@ export function formatErrorLookupResult(result: { } } - if (catalogMatches.length === 0 && codeMatches.length === 0) { + // Semantic fallback results from DocsGPT + if (result.semanticResults && result.semanticResults.length > 0) { + lines.push("## Related Documentation"); + lines.push(""); + + for (const match of result.semanticResults) { + if (match.title) { + lines.push(`**${match.title}**`); + } + if (match.source) { + lines.push(`Source: ${match.source}`); + } + lines.push(""); + lines.push(match.text); + lines.push(""); + lines.push("---"); + lines.push(""); + } + } + + if ( + catalogMatches.length === 0 && + codeMatches.length === 0 && + (!result.semanticResults || result.semanticResults.length === 0) + ) { lines.push("No matching errors found. Try:"); lines.push("- A numeric error code (e.g., `2002`)"); lines.push("- A hex signature (e.g., `0xa5b2ba17`)"); @@ -202,3 +224,30 @@ export function formatErrorLookupResult(result: { return lines.join("\n"); } + +/** + * Format semantic search results from DocsGPT. + */ +export function formatSemanticSearchResults(result: SemanticSearchToolResult): string { + const lines = [result.message, ""]; + + if (!result.success || result.results.length === 0) { + return lines.join("\n"); + } + + for (const match of result.results) { + if (match.title) { + lines.push(`**${match.title}**`); + } + if (match.source) { + lines.push(`Source: ${match.source}`); + } + lines.push(""); + lines.push(match.text); + lines.push(""); + lines.push("---"); + lines.push(""); + } + + return lines.join("\n"); +} diff --git a/tests/tools/search.test.ts b/tests/tools/search.test.ts index 8895c4a..7689698 100644 --- a/tests/tools/search.test.ts +++ b/tests/tools/search.test.ts @@ -8,6 +8,11 @@ vi.mock("../../src/utils/search.js", () => ({ readFile: vi.fn(), })); +vi.mock("../../src/backends/docsgpt-client.js", () => ({ + DocsGPTClient: vi.fn(), + DocsGPTClientError: class extends Error { constructor(msg: string) { super(msg); this.name = "DocsGPTClientError"; } }, +})); + vi.mock("../../src/utils/git.js", () => ({ isRepoCloned: vi.fn(), })); @@ -18,7 +23,6 @@ vi.mock("../../src/repos/config.js", () => ({ import { searchCode, - searchDocs, listExamples, findExample, readFile, @@ -34,7 +38,6 @@ import { } from "../../src/tools/search.js"; const mockSearchCode = vi.mocked(searchCode); -const mockSearchDocs = vi.mocked(searchDocs); const mockListExamples = vi.mocked(listExamples); const mockFindExample = vi.mocked(findExample); const mockReadFile = vi.mocked(readFile); @@ -97,23 +100,96 @@ describe("searchAztecCode", () => { }); describe("searchAztecDocs", () => { - it("returns failure when aztec-packages-docs not cloned", () => { + it("falls back to ripgrep when no client configured", async () => { mockIsRepoCloned.mockReturnValue(false); - const result = searchAztecDocs({ query: "tutorial" }); - expect(result.success).toBe(false); - expect(result.message).toContain("aztec-packages-docs is not cloned"); + const result = await searchAztecDocs({ query: "tutorial" }, null); + expect(result.kind).toBe("ripgrep"); + expect(result.result.success).toBe(false); + expect(result.result.message).toContain("aztec-packages-docs is not cloned"); }); - it("delegates to searchDocs with correct options", () => { + it("uses ripgrep when no client and docs are cloned", async () => { mockIsRepoCloned.mockReturnValue(true); - mockSearchDocs.mockReturnValue([]); + const { searchDocs } = await import("../../src/utils/search.js"); + vi.mocked(searchDocs).mockReturnValue([]); - searchAztecDocs({ query: "tutorial", section: "concepts", maxResults: 5 }); + const result = await searchAztecDocs({ query: "tutorial", section: "concepts", maxResults: 5 }, null); + expect(result.kind).toBe("ripgrep"); + expect(result.result.success).toBe(true); + }); - expect(mockSearchDocs).toHaveBeenCalledWith("tutorial", { - section: "concepts", - maxResults: 5, - }); + it("returns semantic results from DocsGPT client", async () => { + const mockClient = { + search: vi.fn().mockResolvedValue([ + { text: "content", title: "Tutorial", source: "docs/tutorial.md" }, + ]), + } as any; + + const result = await searchAztecDocs({ query: "tutorial" }, mockClient); + expect(result.kind).toBe("semantic"); + if (result.kind === "semantic") { + expect(result.result.success).toBe(true); + expect(result.result.results).toHaveLength(1); + expect(result.result.results[0].title).toBe("Tutorial"); + } + expect(mockClient.search).toHaveBeenCalledWith("tutorial", 5); + }); + + it("respects chunks parameter", async () => { + const mockClient = { + search: vi.fn().mockResolvedValue([]), + } as any; + + await searchAztecDocs({ query: "test", chunks: 10 }, mockClient); + expect(mockClient.search).toHaveBeenCalledWith("test", 10); + }); + + it("uses maxResults as fallback for chunks in semantic mode", async () => { + const mockClient = { + search: vi.fn().mockResolvedValue([]), + } as any; + + await searchAztecDocs({ query: "test", maxResults: 8 }, mockClient); + expect(mockClient.search).toHaveBeenCalledWith("test", 8); + }); + + it("prefers chunks over maxResults when both provided", async () => { + const mockClient = { + search: vi.fn().mockResolvedValue([]), + } as any; + + await searchAztecDocs({ query: "test", chunks: 3, maxResults: 15 }, mockClient); + expect(mockClient.search).toHaveBeenCalledWith("test", 3); + }); + + it("falls back to ripgrep when client errors and local docs exist", async () => { + mockIsRepoCloned.mockReturnValue(true); + const { searchDocs } = await import("../../src/utils/search.js"); + vi.mocked(searchDocs).mockReturnValue([ + { file: "docs/tutorial.md", line: 1, content: "tutorial content", repo: "aztec-packages-docs" }, + ]); + + const mockClient = { + search: vi.fn().mockRejectedValue(new Error("network error")), + } as any; + + const result = await searchAztecDocs({ query: "test" }, mockClient); + expect(result.kind).toBe("ripgrep"); + expect(result.result.success).toBe(true); + expect(result.result.results).toHaveLength(1); + }); + + it("returns ripgrep not-cloned message when client errors and no local docs", async () => { + mockIsRepoCloned.mockReturnValue(false); + + const mockClient = { + search: vi.fn().mockRejectedValue(new Error("network error")), + } as any; + + const result = await searchAztecDocs({ query: "test" }, mockClient); + expect(result.kind).toBe("ripgrep"); + expect(result.result.success).toBe(false); + expect(result.result.message).toContain("aztec-packages-docs is not cloned"); }); });