diff --git a/apps/server/src/services/__tests__/codecontext_client.test.ts b/apps/server/src/services/__tests__/codecontext_client.test.ts new file mode 100644 index 0000000..e0b26e5 --- /dev/null +++ b/apps/server/src/services/__tests__/codecontext_client.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { callCodecontext } from '../codecontext_client.js'; + +// ---- fixtures --------------------------------------------------------------- + +let workDir: string; +let projectDir: string; +let outsideDir: string; + +beforeEach(async () => { + // Shared workspace so projectDir and outsideDir are siblings but the + // realpath escape check still treats outsideDir as outside the project. + workDir = await mkdtemp(join(tmpdir(), 'codecontext-test-')); + projectDir = join(workDir, 'project'); + outsideDir = join(workDir, 'outside'); + await mkdir(projectDir); + await mkdir(outsideDir); +}); + +afterEach(async () => { + await rm(workDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +function mockJSONResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +// ---- tests ------------------------------------------------------------------ + +describe('callCodecontext — target_dir validation', () => { + it('rejects when target_dir does not exist', async () => { + const fetcher = vi.fn(); + await expect( + callCodecontext( + { + toolName: 'get_codebase_overview', + args: { target_dir: '/nonexistent/path/deliberately/missing' }, + projectPath: projectDir, + }, + fetcher as unknown as typeof fetch, + ), + ).rejects.toThrow(/target_dir does not exist/); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('rejects when target_dir is outside the project root', async () => { + const fetcher = vi.fn(); + await expect( + callCodecontext( + { + toolName: 'get_codebase_overview', + args: { target_dir: outsideDir }, + projectPath: projectDir, + }, + fetcher as unknown as typeof fetch, + ), + ).rejects.toThrow(/escapes project root/); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('injects projectPath as target_dir when args.target_dir is undefined', async () => { + const fetcher = vi.fn().mockResolvedValue( + mockJSONResponse({ result: 'overview text', error: null }), + ); + await callCodecontext( + { + toolName: 'get_codebase_overview', + args: { include_stats: true }, + projectPath: projectDir, + }, + fetcher as unknown as typeof fetch, + ); + expect(fetcher).toHaveBeenCalledTimes(1); + const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string); + expect(body.target_dir).toBe(projectDir); + expect(body.include_stats).toBe(true); + }); +}); + +describe('callCodecontext — HTTP request shape', () => { + it('POSTs to /v1/ with JSON content-type', async () => { + const fetcher = vi.fn().mockResolvedValue( + mockJSONResponse({ result: 'ok', error: null }), + ); + await callCodecontext( + { + toolName: 'search_symbols', + args: { query: 'User', limit: 5 }, + projectPath: projectDir, + }, + fetcher as unknown as typeof fetch, + ); + expect(fetcher).toHaveBeenCalledTimes(1); + const [url, init] = fetcher.mock.calls[0]!; + expect(url).toMatch(/\/v1\/search_symbols$/); + expect(init.method).toBe('POST'); + expect(init.headers['Content-Type']).toBe('application/json'); + const body = JSON.parse(init.body); + expect(body).toMatchObject({ query: 'User', limit: 5, target_dir: projectDir }); + }); +}); + +describe('callCodecontext — result handling', () => { + it('returns { result, truncated: false } when codecontext result is under the 32 kB limit', async () => { + const fetcher = vi.fn().mockResolvedValue( + mockJSONResponse({ result: 'a short markdown report', error: null }), + ); + const out = await callCodecontext( + { + toolName: 'get_codebase_overview', + args: {}, + projectPath: projectDir, + }, + fetcher as unknown as typeof fetch, + ); + expect(out.truncated).toBe(false); + expect(out.result).toBe('a short markdown report'); + }); + + it('truncates and marks truncated: true when result exceeds 32 kB', async () => { + const bigResult = 'x'.repeat(40_000); + const fetcher = vi.fn().mockResolvedValue( + mockJSONResponse({ result: bigResult, error: null }), + ); + const out = await callCodecontext( + { + toolName: 'get_codebase_overview', + args: {}, + projectPath: projectDir, + }, + fetcher as unknown as typeof fetch, + ); + expect(out.truncated).toBe(true); + expect(out.result).toMatch(/\[truncated, 8000 chars omitted; narrow with file_path/); + expect(out.result.length).toBeLessThan(bigResult.length); + }); +}); + +describe('callCodecontext — error paths', () => { + it('throws an actionable error when codecontext reports an empty-file parser failure', async () => { + const fetcher = vi.fn().mockResolvedValue( + mockJSONResponse({ + result: null, + error: + 'failed to refresh analysis: failed to analyze directory: ' + + 'failed to parse file /opt/boolab/.opencode/node_modules/foo/index.js: content is empty', + }), + ); + await expect( + callCodecontext( + { toolName: 'get_codebase_overview', args: {}, projectPath: projectDir }, + fetcher as unknown as typeof fetch, + ), + ).rejects.toThrow(/codecontext parse failure.*\.codecontextignore/); + }); + + it('throws a generic error when codecontext reports other errors', async () => { + const fetcher = vi.fn().mockResolvedValue( + mockJSONResponse({ result: null, error: 'symbol_name is required' }), + ); + await expect( + callCodecontext( + { toolName: 'get_symbol_info', args: {}, projectPath: projectDir }, + fetcher as unknown as typeof fetch, + ), + ).rejects.toThrow(/codecontext error: symbol_name is required/); + }); + + it('throws on HTTP non-2xx response', async () => { + const fetcher = vi.fn().mockResolvedValue( + new Response('upstream gateway boom', { status: 502 }), + ); + await expect( + callCodecontext( + { toolName: 'get_codebase_overview', args: {}, projectPath: projectDir }, + fetcher as unknown as typeof fetch, + ), + ).rejects.toThrow(/codecontext HTTP 502/); + }); + + it('translates a fetcher AbortError to a "timed out" error', async () => { + // The catch branch in callCodecontext maps any AbortError (whether it + // came from our internal 30s setTimeout or from the fetcher itself) to a + // "timed out" message. Exercising the catch directly is cleaner than + // wrangling vi.useFakeTimers with realpath's microtask scheduling. + const abortingFetcher = vi.fn().mockImplementation(() => { + const err = new Error('The user aborted a request.'); + err.name = 'AbortError'; + return Promise.reject(err); + }); + await expect( + callCodecontext( + { toolName: 'get_codebase_overview', args: {}, projectPath: projectDir }, + abortingFetcher as unknown as typeof fetch, + ), + ).rejects.toThrow(/timed out after 30000ms/); + }); +}); diff --git a/apps/server/src/services/__tests__/codecontext_tools.test.ts b/apps/server/src/services/__tests__/codecontext_tools.test.ts new file mode 100644 index 0000000..d0f70b6 --- /dev/null +++ b/apps/server/src/services/__tests__/codecontext_tools.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { executeGetCodebaseOverview } from '../tools/codecontext/get_codebase_overview.js'; +import { executeGetFileAnalysis } from '../tools/codecontext/get_file_analysis.js'; +import { executeGetSymbolInfo } from '../tools/codecontext/get_symbol_info.js'; +import { executeSearchSymbols } from '../tools/codecontext/search_symbols.js'; +import { executeGetDependencies } from '../tools/codecontext/get_dependencies.js'; +import { executeWatchChanges } from '../tools/codecontext/watch_changes.js'; +import { executeGetSemanticNeighborhoods } from '../tools/codecontext/get_semantic_neighborhoods.js'; +import { executeGetFrameworkAnalysis } from '../tools/codecontext/get_framework_analysis.js'; + +// ---- fixtures --------------------------------------------------------------- + +let projectDir: string; + +beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'codecontext-tools-test-')); +}); + +afterEach(async () => { + await rm(projectDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +function mockJSONResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +// Stub fetcher that records every call and returns a canned successful body. +// Each test inspects fetcher.mock.calls[0] to assert URL + body shape. +function makeStub() { + return vi.fn().mockResolvedValue( + mockJSONResponse({ result: 'wrapped ok', error: null }), + ); +} + +function parsePOST(fetcher: ReturnType): { + url: string; + body: Record; +} { + expect(fetcher).toHaveBeenCalledTimes(1); + const [url, init] = fetcher.mock.calls[0]! as [string, { body: string }]; + return { url, body: JSON.parse(init.body) }; +} + +// ---- per-wrapper smoke tests ----------------------------------------------- + +describe('codecontext wrappers — toolName + args forwarding', () => { + it('get_codebase_overview posts to /v1/get_codebase_overview with include_stats default true', async () => { + const fetcher = makeStub(); + await executeGetCodebaseOverview({}, projectDir, fetcher as unknown as typeof fetch); + const { url, body } = parsePOST(fetcher); + expect(url).toMatch(/\/v1\/get_codebase_overview$/); + expect(body).toMatchObject({ include_stats: true, target_dir: projectDir }); + }); + + it('get_file_analysis forwards file_path', async () => { + const fetcher = makeStub(); + await executeGetFileAnalysis( + { file_path: 'apps/server/src/index.ts' }, + projectDir, + fetcher as unknown as typeof fetch, + ); + const { url, body } = parsePOST(fetcher); + expect(url).toMatch(/\/v1\/get_file_analysis$/); + expect(body).toMatchObject({ + file_path: 'apps/server/src/index.ts', + target_dir: projectDir, + }); + }); + + it('get_symbol_info forwards symbol_name and omits optional fields when unset', async () => { + const fetcher = makeStub(); + await executeGetSymbolInfo( + { symbol_name: 'buildSystemPrompt' }, + projectDir, + fetcher as unknown as typeof fetch, + ); + const { url, body } = parsePOST(fetcher); + expect(url).toMatch(/\/v1\/get_symbol_info$/); + expect(body).toMatchObject({ symbol_name: 'buildSystemPrompt', target_dir: projectDir }); + expect(body).not.toHaveProperty('file_path'); + expect(body).not.toHaveProperty('framework_type'); + }); + + it('search_symbols defaults limit to 20 and forwards filters when set', async () => { + const fetcher = makeStub(); + await executeSearchSymbols( + { query: 'User', symbol_type: 'class' }, + projectDir, + fetcher as unknown as typeof fetch, + ); + const { url, body } = parsePOST(fetcher); + expect(url).toMatch(/\/v1\/search_symbols$/); + expect(body).toMatchObject({ + query: 'User', + symbol_type: 'class', + limit: 20, + target_dir: projectDir, + }); + }); + + it('get_dependencies defaults direction to "both"', async () => { + const fetcher = makeStub(); + await executeGetDependencies({}, projectDir, fetcher as unknown as typeof fetch); + const { url, body } = parsePOST(fetcher); + expect(url).toMatch(/\/v1\/get_dependencies$/); + expect(body).toMatchObject({ direction: 'both', target_dir: projectDir }); + expect(body).not.toHaveProperty('file_path'); + }); + + it('watch_changes forwards enable=false', async () => { + const fetcher = makeStub(); + await executeWatchChanges( + { enable: false }, + projectDir, + fetcher as unknown as typeof fetch, + ); + const { url, body } = parsePOST(fetcher); + expect(url).toMatch(/\/v1\/watch_changes$/); + expect(body).toMatchObject({ enable: false, target_dir: projectDir }); + }); + + it('get_semantic_neighborhoods defaults max_results to 10', async () => { + const fetcher = makeStub(); + await executeGetSemanticNeighborhoods( + {}, + projectDir, + fetcher as unknown as typeof fetch, + ); + const { url, body } = parsePOST(fetcher); + expect(url).toMatch(/\/v1\/get_semantic_neighborhoods$/); + expect(body).toMatchObject({ max_results: 10, target_dir: projectDir }); + }); + + it('get_framework_analysis sends only target_dir when no args are provided', async () => { + const fetcher = makeStub(); + await executeGetFrameworkAnalysis( + {}, + projectDir, + fetcher as unknown as typeof fetch, + ); + const { url, body } = parsePOST(fetcher); + expect(url).toMatch(/\/v1\/get_framework_analysis$/); + expect(body).toMatchObject({ target_dir: projectDir }); + expect(body).not.toHaveProperty('framework'); + expect(body).not.toHaveProperty('include_stats'); + }); +}); diff --git a/apps/server/src/services/codecontext_client.ts b/apps/server/src/services/codecontext_client.ts new file mode 100644 index 0000000..6772a56 --- /dev/null +++ b/apps/server/src/services/codecontext_client.ts @@ -0,0 +1,118 @@ +// v1.12 Track B.2: shared HTTP client for the codecontext sidecar. The 8 +// per-tool wrappers under tools/codecontext/ all funnel through callCodecontext +// — they're thin adapters that supply toolName + args + projectPath. The +// client owns: +// +// 1. target_dir validation. Codecontext's HTTP shim is naive and forwards +// any target_dir to codecontext, so without this layer a model that +// hallucinated a target_dir could read /opt/anything-on-disk. The +// project root is realpath'd and the requested target_dir is constrained +// to it (same invariant as path_guard.ts but for the codecontext path). +// 2. Inline truncation at 32 kB. Codecontext outputs are markdown reports +// that can balloon on large projects; the model can re-narrow via +// file_path / file_type / limit. Matches the "inline truncation, no +// opaque-id retrieval" decision locked in the 2026-05-21 recon. +// 3. Friendly mapping of codecontext's known failure modes — the empty- +// file parser bug (upstream issue #37) returns a generic error string, +// which we re-surface with a hint to add the file to .codecontextignore. + +import { realpath } from 'node:fs/promises'; + +export interface CodecontextRequest { + toolName: string; + args: Record; + projectPath: string; +} + +export interface CodecontextResponse { + result: string; + truncated: boolean; +} + +const CODECONTEXT_BASE_URL = process.env['CODECONTEXT_URL'] ?? 'http://codecontext:8080'; +const TRUNCATION_LIMIT = 32_000; +const REQUEST_TIMEOUT_MS = 30_000; + +export async function callCodecontext( + req: CodecontextRequest, + fetcher: typeof fetch = fetch, +): Promise { + // Step 1: realpath the project root, then realpath the requested target_dir + // (defaulting to projectPath when the caller didn't pass one — the 8 wrappers + // never pass target_dir; tests can override). A non-existent target_dir + // throws before we hit the network so the model gets a sharp error. + const resolvedProject = await realpath(req.projectPath); + const requestedTarget = req.args['target_dir']; + const targetDir = typeof requestedTarget === 'string' && requestedTarget.length > 0 + ? requestedTarget + : req.projectPath; + const resolvedTarget = await realpath(targetDir).catch(() => null); + if (resolvedTarget === null) { + throw new Error(`target_dir does not exist: ${targetDir}`); + } + if (resolvedTarget !== resolvedProject && !resolvedTarget.startsWith(resolvedProject + '/')) { + throw new Error(`target_dir ${targetDir} escapes project root ${resolvedProject}`); + } + + // Step 2: re-build args with the resolved target_dir so codecontext sees + // the real absolute path, not a symlink or relative form. + const argsToSend = { ...req.args, target_dir: resolvedTarget }; + + // Step 3: POST with a hard timeout. AbortController + setTimeout pattern + // matches web_fetch.ts; nothing fancier needed. + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + let response: Response; + try { + response = await fetcher(`${CODECONTEXT_BASE_URL}/v1/${req.toolName}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(argsToSend), + signal: controller.signal, + }); + } catch (err) { + clearTimeout(timer); + if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) { + throw new Error(`codecontext request timed out after ${REQUEST_TIMEOUT_MS}ms`); + } + throw new Error( + `codecontext network error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + clearTimeout(timer); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`codecontext HTTP ${response.status}: ${text.slice(0, 200)}`); + } + + const body = (await response.json()) as { result: string | null; error: string | null }; + if (body.error) { + // Upstream issue #37: empty source files crash codecontext's parser. The + // error message reliably contains "content is empty"; surface an + // actionable hint instead of the bare codecontext message. + if (body.error.includes('content is empty')) { + throw new Error( + `codecontext parse failure: ${body.error}. ` + + `Add the offending path to .codecontextignore in the project root and retry.`, + ); + } + throw new Error(`codecontext error: ${body.error}`); + } + if (body.result === null) { + return { result: '', truncated: false }; + } + + // Step 4: inline truncation. The model gets a clear hint about how to + // narrow the next call rather than a silent cut. Mirrors web_fetch.ts. + if (body.result.length > TRUNCATION_LIMIT) { + const truncated = body.result.slice(0, TRUNCATION_LIMIT); + const omitted = body.result.length - TRUNCATION_LIMIT; + return { + result: + `${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with file_path, file_type, or limit]`, + truncated: true, + }; + } + return { result: body.result, truncated: false }; +} diff --git a/apps/server/src/services/inference.ts b/apps/server/src/services/inference.ts index ec37b4f..e264f25 100644 --- a/apps/server/src/services/inference.ts +++ b/apps/server/src/services/inference.ts @@ -621,10 +621,26 @@ async function executeToolCall( } const parsed = tool.inputSchema.safeParse(toolCall.args); if (!parsed.success) { + // v1.12 Track B.2: enrich the zod-reject path so the model sees a + // one-line, tool-named hint ("tool 'search_symbols' rejected — query: + // Required") instead of a JSON blob of flatten output. Higher recovery + // rate on the next turn; doom-loop guard still bounds infinite retries. + // The cast is because tool.inputSchema is ZodType, so zod can't + // statically narrow flatten()'s fieldErrors key set — but the runtime + // shape is the standard { formErrors: string[]; fieldErrors: Record<...> }. + const flatten = parsed.error.flatten() as { + formErrors: string[]; + fieldErrors: Record; + }; + const fieldErrors = Object.entries(flatten.fieldErrors) + .map(([field, errs]) => `${field}: ${errs?.[0] ?? 'invalid'}`) + .join('; '); + const formError = flatten.formErrors[0]; + const hint = fieldErrors || formError || 'unknown validation error'; return { output: null, truncated: false, - error: `invalid input: ${JSON.stringify(parsed.error.flatten())}`, + error: `tool '${toolCall.name}' rejected — ${hint}`, }; } try { diff --git a/apps/server/src/services/tools.ts b/apps/server/src/services/tools.ts index d979277..725dfef 100644 --- a/apps/server/src/services/tools.ts +++ b/apps/server/src/services/tools.ts @@ -8,6 +8,19 @@ import { getGitMeta } from './git_meta.js'; import { findSkills, getSkillBody, getSkillResource } from './skills.js'; import { webSearch } from './web_search.js'; import { webFetch } from './web_fetch.js'; +// v1.12 Track B.2: codecontext tools. 8 wrappers re-exported from +// tools/codecontext/index.ts. Each calls into services/codecontext_client.ts +// which talks to the codecontext sidecar at http://codecontext:8080. +import { + getCodebaseOverview, + getFileAnalysis, + getSymbolInfo, + searchSymbols, + getDependencies, + watchChanges, + getSemanticNeighborhoods, + getFrameworkAnalysis, +} from './tools/codecontext/index.js'; const MAX_FILE_BYTES = 5 * 1024 * 1024; const DEFAULT_VIEW_LINES = 200; @@ -529,6 +542,17 @@ export const ALL_TOOLS: ReadonlyArray> = [ // services/inference.ts. webSearch as ToolDef, webFetch as ToolDef, + // v1.12 Track B.2: codecontext tools. Backed by the codecontext sidecar + // container. All read-only. target_dir is resolved server-side from the + // project root in codecontext_client.ts (the LLM never supplies it). + getCodebaseOverview as ToolDef, + getFileAnalysis as ToolDef, + getSymbolInfo as ToolDef, + searchSymbols as ToolDef, + getDependencies as ToolDef, + watchChanges as ToolDef, + getSemanticNeighborhoods as ToolDef, + getFrameworkAnalysis as ToolDef, ]; // v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is @@ -554,6 +578,16 @@ export const READ_ONLY_TOOL_NAMES = [ // toolset is fully contained in this list. 'web_search', 'web_fetch', + // v1.12 Track B.2: codecontext tools. Read-only — they call the + // codecontext sidecar which only analyzes files (never writes). + 'get_codebase_overview', + 'get_file_analysis', + 'get_symbol_info', + 'search_symbols', + 'get_dependencies', + 'watch_changes', + 'get_semantic_neighborhoods', + 'get_framework_analysis', ] as const; export const TOOLS_BY_NAME: Record> = Object.fromEntries( diff --git a/apps/server/src/services/tools/codecontext/get_codebase_overview.ts b/apps/server/src/services/tools/codecontext/get_codebase_overview.ts new file mode 100644 index 0000000..c624c09 --- /dev/null +++ b/apps/server/src/services/tools/codecontext/get_codebase_overview.ts @@ -0,0 +1,59 @@ +// v1.12 Track B.2: codecontext wrapper — get_codebase_overview. +// Pattern mirrors services/web_search.ts: pure executor + ToolDef wrapper. +// target_dir is supplied by callCodecontext from the resolved project root. + +import { z } from 'zod'; +import type { ToolDef } from '../../tools.js'; +import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; + +export const GetCodebaseOverviewInput = z.object({ + include_stats: z.boolean().optional(), +}); +export type GetCodebaseOverviewInputT = z.infer; + +const DESCRIPTION = + 'Returns a structured overview of the codebase: file count, symbol count, primary languages, and top-level architecture. ' + + 'Use this before deeper investigation to orient yourself in an unfamiliar codebase. ' + + 'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript symbols are approximate (uses JS grammar). ' + + 'PHP and SQL are not supported — fall back to view_file/grep for those.'; + +export async function executeGetCodebaseOverview( + input: GetCodebaseOverviewInputT, + projectPath: string, + fetcher: typeof fetch = fetch, +): Promise { + return callCodecontext( + { + toolName: 'get_codebase_overview', + args: { include_stats: input.include_stats ?? true }, + projectPath, + }, + fetcher, + ); +} + +export const getCodebaseOverview: ToolDef = { + name: 'get_codebase_overview', + description: DESCRIPTION, + inputSchema: GetCodebaseOverviewInput, + jsonSchema: { + type: 'function', + function: { + name: 'get_codebase_overview', + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + include_stats: { + type: 'boolean', + description: 'Include file count, symbol count, language stats. Defaults to true.', + }, + }, + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot) { + return await executeGetCodebaseOverview(input, projectRoot); + }, +}; diff --git a/apps/server/src/services/tools/codecontext/get_dependencies.ts b/apps/server/src/services/tools/codecontext/get_dependencies.ts new file mode 100644 index 0000000..e3b1c02 --- /dev/null +++ b/apps/server/src/services/tools/codecontext/get_dependencies.ts @@ -0,0 +1,60 @@ +// v1.12 Track B.2: codecontext wrapper — get_dependencies. + +import { z } from 'zod'; +import type { ToolDef } from '../../tools.js'; +import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; + +export const GetDependenciesInput = z.object({ + file_path: z.string().optional(), + direction: z.enum(['incoming', 'outgoing', 'both']).optional(), +}); +export type GetDependenciesInputT = z.infer; + +const DESCRIPTION = + 'Returns the import/dependency graph either for a single file (when file_path is set) or for the whole project. ' + + 'Direction "outgoing" = what this file imports; "incoming" = what imports this file; "both" = the union. ' + + 'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript dependencies are approximate. ' + + 'PHP and SQL are not supported.'; + +export async function executeGetDependencies( + input: GetDependenciesInputT, + projectPath: string, + fetcher: typeof fetch = fetch, +): Promise { + const args: Record = { + direction: input.direction ?? 'both', + }; + if (input.file_path) args['file_path'] = input.file_path; + return callCodecontext({ toolName: 'get_dependencies', args, projectPath }, fetcher); +} + +export const getDependencies: ToolDef = { + name: 'get_dependencies', + description: DESCRIPTION, + inputSchema: GetDependenciesInput, + jsonSchema: { + type: 'function', + function: { + name: 'get_dependencies', + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'Narrow to a single file. Omit for a project-wide graph.', + }, + direction: { + type: 'string', + enum: ['incoming', 'outgoing', 'both'], + description: 'Which edges to include. Defaults to "both".', + }, + }, + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot) { + return await executeGetDependencies(input, projectRoot); + }, +}; diff --git a/apps/server/src/services/tools/codecontext/get_file_analysis.ts b/apps/server/src/services/tools/codecontext/get_file_analysis.ts new file mode 100644 index 0000000..c21ae96 --- /dev/null +++ b/apps/server/src/services/tools/codecontext/get_file_analysis.ts @@ -0,0 +1,58 @@ +// v1.12 Track B.2: codecontext wrapper — get_file_analysis. + +import { z } from 'zod'; +import type { ToolDef } from '../../tools.js'; +import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; + +export const GetFileAnalysisInput = z.object({ + file_path: z.string().min(1), +}); +export type GetFileAnalysisInputT = z.infer; + +const DESCRIPTION = + 'Returns detailed analysis of a single file: symbols defined, imports, exports, and inferred role. ' + + 'Use when you have a specific file in mind and need its structure without view_file-ing the whole thing. ' + + 'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript symbols are approximate. ' + + 'PHP and SQL are not supported — fall back to view_file for those.'; + +export async function executeGetFileAnalysis( + input: GetFileAnalysisInputT, + projectPath: string, + fetcher: typeof fetch = fetch, +): Promise { + return callCodecontext( + { + toolName: 'get_file_analysis', + args: { file_path: input.file_path }, + projectPath, + }, + fetcher, + ); +} + +export const getFileAnalysis: ToolDef = { + name: 'get_file_analysis', + description: DESCRIPTION, + inputSchema: GetFileAnalysisInput, + jsonSchema: { + type: 'function', + function: { + name: 'get_file_analysis', + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'Absolute or project-relative path to the file.', + }, + }, + required: ['file_path'], + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot) { + return await executeGetFileAnalysis(input, projectRoot); + }, +}; diff --git a/apps/server/src/services/tools/codecontext/get_framework_analysis.ts b/apps/server/src/services/tools/codecontext/get_framework_analysis.ts new file mode 100644 index 0000000..8126e90 --- /dev/null +++ b/apps/server/src/services/tools/codecontext/get_framework_analysis.ts @@ -0,0 +1,58 @@ +// v1.12 Track B.2: codecontext wrapper — get_framework_analysis. + +import { z } from 'zod'; +import type { ToolDef } from '../../tools.js'; +import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; + +export const GetFrameworkAnalysisInput = z.object({ + framework: z.string().optional(), + include_stats: z.boolean().optional(), +}); +export type GetFrameworkAnalysisInputT = z.infer; + +const DESCRIPTION = + 'Returns framework-specific structural analysis: component relationships (React), hook usage patterns, store wiring (Vue/Pinia), service registration (Angular/Nest), etc. ' + + 'When framework is omitted, codecontext auto-detects from the project files. ' + + 'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript is approximate. ' + + 'PHP and SQL are not supported.'; + +export async function executeGetFrameworkAnalysis( + input: GetFrameworkAnalysisInputT, + projectPath: string, + fetcher: typeof fetch = fetch, +): Promise { + const args: Record = {}; + if (input.framework) args['framework'] = input.framework; + if (input.include_stats !== undefined) args['include_stats'] = input.include_stats; + return callCodecontext({ toolName: 'get_framework_analysis', args, projectPath }, fetcher); +} + +export const getFrameworkAnalysis: ToolDef = { + name: 'get_framework_analysis', + description: DESCRIPTION, + inputSchema: GetFrameworkAnalysisInput, + jsonSchema: { + type: 'function', + function: { + name: 'get_framework_analysis', + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + framework: { + type: 'string', + description: 'Framework name. Auto-detected if omitted.', + }, + include_stats: { + type: 'boolean', + description: 'Include component/hook/service counts.', + }, + }, + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot) { + return await executeGetFrameworkAnalysis(input, projectRoot); + }, +}; diff --git a/apps/server/src/services/tools/codecontext/get_semantic_neighborhoods.ts b/apps/server/src/services/tools/codecontext/get_semantic_neighborhoods.ts new file mode 100644 index 0000000..48e942e --- /dev/null +++ b/apps/server/src/services/tools/codecontext/get_semantic_neighborhoods.ts @@ -0,0 +1,73 @@ +// v1.12 Track B.2: codecontext wrapper — get_semantic_neighborhoods. + +import { z } from 'zod'; +import type { ToolDef } from '../../tools.js'; +import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; + +export const GetSemanticNeighborhoodsInput = z.object({ + file_path: z.string().optional(), + include_basic: z.boolean().optional(), + include_quality: z.boolean().optional(), + max_results: z.number().int().positive().optional(), +}); +export type GetSemanticNeighborhoodsInputT = z.infer; + +const DESCRIPTION = + 'Returns semantic neighborhoods — clusters of related files derived from git co-change patterns and import structure. ' + + 'Use when you want to find code that "belongs together" with a given file without enumerating imports manually. ' + + 'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript is approximate. ' + + 'PHP and SQL are not supported.'; + +const DEFAULT_MAX_RESULTS = 10; + +export async function executeGetSemanticNeighborhoods( + input: GetSemanticNeighborhoodsInputT, + projectPath: string, + fetcher: typeof fetch = fetch, +): Promise { + const args: Record = { + max_results: input.max_results ?? DEFAULT_MAX_RESULTS, + }; + if (input.file_path) args['file_path'] = input.file_path; + if (input.include_basic !== undefined) args['include_basic'] = input.include_basic; + if (input.include_quality !== undefined) args['include_quality'] = input.include_quality; + return callCodecontext({ toolName: 'get_semantic_neighborhoods', args, projectPath }, fetcher); +} + +export const getSemanticNeighborhoods: ToolDef = { + name: 'get_semantic_neighborhoods', + description: DESCRIPTION, + inputSchema: GetSemanticNeighborhoodsInput, + jsonSchema: { + type: 'function', + function: { + name: 'get_semantic_neighborhoods', + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'Anchor file for the neighborhood query. Omit for a project-wide view.', + }, + include_basic: { + type: 'boolean', + description: 'Include the basic (import-based) neighborhood. Default true.', + }, + include_quality: { + type: 'boolean', + description: 'Include code-quality metrics for the neighborhood. Default false.', + }, + max_results: { + type: 'integer', + description: `Cap on neighborhoods returned. Defaults to ${DEFAULT_MAX_RESULTS}.`, + }, + }, + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot) { + return await executeGetSemanticNeighborhoods(input, projectRoot); + }, +}; diff --git a/apps/server/src/services/tools/codecontext/get_symbol_info.ts b/apps/server/src/services/tools/codecontext/get_symbol_info.ts new file mode 100644 index 0000000..dc8522c --- /dev/null +++ b/apps/server/src/services/tools/codecontext/get_symbol_info.ts @@ -0,0 +1,63 @@ +// v1.12 Track B.2: codecontext wrapper — get_symbol_info. + +import { z } from 'zod'; +import type { ToolDef } from '../../tools.js'; +import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; + +export const GetSymbolInfoInput = z.object({ + symbol_name: z.string().min(1), + file_path: z.string().optional(), + framework_type: z.string().optional(), +}); +export type GetSymbolInfoInputT = z.infer; + +const DESCRIPTION = + 'Returns detailed information about a named symbol: definition location, kind (function/class/method/etc.), and (when known) framework-specific context (React component, Vue store, Angular service, …). ' + + 'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript symbols are approximate (uses JS grammar). ' + + 'PHP and SQL are not supported — fall back to grep for those.'; + +export async function executeGetSymbolInfo( + input: GetSymbolInfoInputT, + projectPath: string, + fetcher: typeof fetch = fetch, +): Promise { + const args: Record = { symbol_name: input.symbol_name }; + if (input.file_path) args['file_path'] = input.file_path; + if (input.framework_type) args['framework_type'] = input.framework_type; + return callCodecontext({ toolName: 'get_symbol_info', args, projectPath }, fetcher); +} + +export const getSymbolInfo: ToolDef = { + name: 'get_symbol_info', + description: DESCRIPTION, + inputSchema: GetSymbolInfoInput, + jsonSchema: { + type: 'function', + function: { + name: 'get_symbol_info', + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + symbol_name: { + type: 'string', + description: 'The symbol name to look up (case-sensitive).', + }, + file_path: { + type: 'string', + description: 'Narrow to a specific file when the symbol name is ambiguous.', + }, + framework_type: { + type: 'string', + description: 'Hint for framework-specific extraction (react|vue|svelte|django|fastapi|express|nest|…).', + }, + }, + required: ['symbol_name'], + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot) { + return await executeGetSymbolInfo(input, projectRoot); + }, +}; diff --git a/apps/server/src/services/tools/codecontext/index.ts b/apps/server/src/services/tools/codecontext/index.ts new file mode 100644 index 0000000..7abbcd7 --- /dev/null +++ b/apps/server/src/services/tools/codecontext/index.ts @@ -0,0 +1,11 @@ +// v1.12 Track B.2: codecontext tool registry. Re-exports the 8 ToolDefs so +// tools.ts can pull them in one line. + +export { getCodebaseOverview } from './get_codebase_overview.js'; +export { getFileAnalysis } from './get_file_analysis.js'; +export { getSymbolInfo } from './get_symbol_info.js'; +export { searchSymbols } from './search_symbols.js'; +export { getDependencies } from './get_dependencies.js'; +export { watchChanges } from './watch_changes.js'; +export { getSemanticNeighborhoods } from './get_semantic_neighborhoods.js'; +export { getFrameworkAnalysis } from './get_framework_analysis.js'; diff --git a/apps/server/src/services/tools/codecontext/search_symbols.ts b/apps/server/src/services/tools/codecontext/search_symbols.ts new file mode 100644 index 0000000..b5db808 --- /dev/null +++ b/apps/server/src/services/tools/codecontext/search_symbols.ts @@ -0,0 +1,77 @@ +// v1.12 Track B.2: codecontext wrapper — search_symbols. + +import { z } from 'zod'; +import type { ToolDef } from '../../tools.js'; +import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; + +export const SearchSymbolsInput = z.object({ + query: z.string().min(1), + file_type: z.string().optional(), + symbol_type: z.string().optional(), + framework_type: z.string().optional(), + limit: z.number().int().positive().optional(), +}); +export type SearchSymbolsInputT = z.infer; + +const DESCRIPTION = + 'Search for symbols (functions, classes, methods, types) across the codebase by name fragment. ' + + 'Filter by file_type, symbol_type, or framework_type to narrow. ' + + 'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript symbols are approximate. ' + + 'PHP and SQL are not supported — fall back to grep for those.'; + +const DEFAULT_LIMIT = 20; + +export async function executeSearchSymbols( + input: SearchSymbolsInputT, + projectPath: string, + fetcher: typeof fetch = fetch, +): Promise { + const args: Record = { + query: input.query, + limit: input.limit ?? DEFAULT_LIMIT, + }; + if (input.file_type) args['file_type'] = input.file_type; + if (input.symbol_type) args['symbol_type'] = input.symbol_type; + if (input.framework_type) args['framework_type'] = input.framework_type; + return callCodecontext({ toolName: 'search_symbols', args, projectPath }, fetcher); +} + +export const searchSymbols: ToolDef = { + name: 'search_symbols', + description: DESCRIPTION, + inputSchema: SearchSymbolsInput, + jsonSchema: { + type: 'function', + function: { + name: 'search_symbols', + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Substring or name fragment to match.' }, + file_type: { + type: 'string', + description: 'Filter by file extension or language (e.g. "ts", "py", "go").', + }, + symbol_type: { + type: 'string', + description: 'Filter by kind: function|class|method|variable|type|interface.', + }, + framework_type: { + type: 'string', + description: 'Filter by framework context (react|vue|svelte|…).', + }, + limit: { + type: 'integer', + description: `Max matches to return. Defaults to ${DEFAULT_LIMIT}.`, + }, + }, + required: ['query'], + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot) { + return await executeSearchSymbols(input, projectRoot); + }, +}; diff --git a/apps/server/src/services/tools/codecontext/watch_changes.ts b/apps/server/src/services/tools/codecontext/watch_changes.ts new file mode 100644 index 0000000..437f1c0 --- /dev/null +++ b/apps/server/src/services/tools/codecontext/watch_changes.ts @@ -0,0 +1,57 @@ +// v1.12 Track B.2: codecontext wrapper — watch_changes. + +import { z } from 'zod'; +import type { ToolDef } from '../../tools.js'; +import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; + +export const WatchChangesInput = z.object({ + enable: z.boolean(), +}); +export type WatchChangesInputT = z.infer; + +const DESCRIPTION = + 'Turn codecontext\'s file watcher on or off for this project. ' + + 'When on, codecontext re-analyzes files in the background as they change (debounced). Default is on. ' + + 'Disable temporarily if you\'re doing bulk edits and want to avoid analysis churn.'; + +export async function executeWatchChanges( + input: WatchChangesInputT, + projectPath: string, + fetcher: typeof fetch = fetch, +): Promise { + return callCodecontext( + { + toolName: 'watch_changes', + args: { enable: input.enable }, + projectPath, + }, + fetcher, + ); +} + +export const watchChanges: ToolDef = { + name: 'watch_changes', + description: DESCRIPTION, + inputSchema: WatchChangesInput, + jsonSchema: { + type: 'function', + function: { + name: 'watch_changes', + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + enable: { + type: 'boolean', + description: 'true = enable the watcher; false = disable.', + }, + }, + required: ['enable'], + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot) { + return await executeWatchChanges(input, projectRoot); + }, +}; diff --git a/apps/web/src/components/ToolCallLine.tsx b/apps/web/src/components/ToolCallLine.tsx index 78fc170..2c54013 100644 --- a/apps/web/src/components/ToolCallLine.tsx +++ b/apps/web/src/components/ToolCallLine.tsx @@ -49,6 +49,33 @@ export function formatToolArgs(name: string, args: Record): str if (name === 'git_status') { return ''; } + // v1.12 Track B.2: codecontext tool pills. Format is "most-identifying-arg", + // matching view_file/grep precedent — surface the path/symbol/query that + // makes the call meaningful at a glance. + if (name === 'get_codebase_overview') { + return ''; + } + if (name === 'get_file_analysis') { + return truncate(String(args.file_path ?? ''), ARG_SUMMARY_MAX); + } + if (name === 'get_symbol_info') { + return truncate(String(args.symbol_name ?? ''), ARG_SUMMARY_MAX); + } + if (name === 'search_symbols') { + return truncate(`"${String(args.query ?? '')}"`, ARG_SUMMARY_MAX); + } + if (name === 'get_dependencies') { + return truncate(String(args.file_path ?? '(project-wide)'), ARG_SUMMARY_MAX); + } + if (name === 'watch_changes') { + return args.enable ? 'enable' : 'disable'; + } + if (name === 'get_semantic_neighborhoods') { + return truncate(String(args.file_path ?? '(project-wide)'), ARG_SUMMARY_MAX); + } + if (name === 'get_framework_analysis') { + return truncate(String(args.framework ?? '(auto-detect)'), ARG_SUMMARY_MAX); + } // Unknown tool — surface first arg value or the literal {} so the user can // see something happened. Forward-compatible with future tools. const keys = Object.keys(args); diff --git a/docker-compose.yml b/docker-compose.yml index 073ccac..3fc4204 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: - "100.114.205.53:9500:3000" env_file: .env environment: + CODECONTEXT_URL: http://codecontext:8080 DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode volumes: - /opt:/opt