v1.12 track B.2: codecontext tool wrappers + tests

This commit is contained in:
2026-05-21 13:35:44 +00:00
parent 4fae77e526
commit 136e9538aa
16 changed files with 1073 additions and 1 deletions

View File

@@ -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/<toolName> 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/);
});
});

View File

@@ -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<typeof makeStub>): {
url: string;
body: Record<string, unknown>;
} {
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');
});
});

View File

@@ -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<string, unknown>;
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<CodecontextResponse> {
// 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 };
}

View File

@@ -621,10 +621,26 @@ async function executeToolCall(
} }
const parsed = tool.inputSchema.safeParse(toolCall.args); const parsed = tool.inputSchema.safeParse(toolCall.args);
if (!parsed.success) { 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<unknown>, 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<string, string[] | undefined>;
};
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 { return {
output: null, output: null,
truncated: false, truncated: false,
error: `invalid input: ${JSON.stringify(parsed.error.flatten())}`, error: `tool '${toolCall.name}' rejected — ${hint}`,
}; };
} }
try { try {

View File

@@ -8,6 +8,19 @@ import { getGitMeta } from './git_meta.js';
import { findSkills, getSkillBody, getSkillResource } from './skills.js'; import { findSkills, getSkillBody, getSkillResource } from './skills.js';
import { webSearch } from './web_search.js'; import { webSearch } from './web_search.js';
import { webFetch } from './web_fetch.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 MAX_FILE_BYTES = 5 * 1024 * 1024;
const DEFAULT_VIEW_LINES = 200; const DEFAULT_VIEW_LINES = 200;
@@ -529,6 +542,17 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
// services/inference.ts. // services/inference.ts.
webSearch as ToolDef<unknown>, webSearch as ToolDef<unknown>,
webFetch as ToolDef<unknown>, webFetch as ToolDef<unknown>,
// 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<unknown>,
getFileAnalysis as ToolDef<unknown>,
getSymbolInfo as ToolDef<unknown>,
searchSymbols as ToolDef<unknown>,
getDependencies as ToolDef<unknown>,
watchChanges as ToolDef<unknown>,
getSemanticNeighborhoods as ToolDef<unknown>,
getFrameworkAnalysis as ToolDef<unknown>,
]; ];
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is // 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. // toolset is fully contained in this list.
'web_search', 'web_search',
'web_fetch', '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; ] as const;
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries( export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(

View File

@@ -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<typeof GetCodebaseOverviewInput>;
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<CodecontextResponse> {
return callCodecontext(
{
toolName: 'get_codebase_overview',
args: { include_stats: input.include_stats ?? true },
projectPath,
},
fetcher,
);
}
export const getCodebaseOverview: ToolDef<GetCodebaseOverviewInputT> = {
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);
},
};

View File

@@ -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<typeof GetDependenciesInput>;
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<CodecontextResponse> {
const args: Record<string, unknown> = {
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<GetDependenciesInputT> = {
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);
},
};

View File

@@ -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<typeof GetFileAnalysisInput>;
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<CodecontextResponse> {
return callCodecontext(
{
toolName: 'get_file_analysis',
args: { file_path: input.file_path },
projectPath,
},
fetcher,
);
}
export const getFileAnalysis: ToolDef<GetFileAnalysisInputT> = {
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);
},
};

View File

@@ -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<typeof GetFrameworkAnalysisInput>;
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<CodecontextResponse> {
const args: Record<string, unknown> = {};
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<GetFrameworkAnalysisInputT> = {
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);
},
};

View File

@@ -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<typeof GetSemanticNeighborhoodsInput>;
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<CodecontextResponse> {
const args: Record<string, unknown> = {
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<GetSemanticNeighborhoodsInputT> = {
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);
},
};

View File

@@ -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<typeof GetSymbolInfoInput>;
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<CodecontextResponse> {
const args: Record<string, unknown> = { 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<GetSymbolInfoInputT> = {
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);
},
};

View File

@@ -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';

View File

@@ -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<typeof SearchSymbolsInput>;
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<CodecontextResponse> {
const args: Record<string, unknown> = {
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<SearchSymbolsInputT> = {
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);
},
};

View File

@@ -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<typeof WatchChangesInput>;
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<CodecontextResponse> {
return callCodecontext(
{
toolName: 'watch_changes',
args: { enable: input.enable },
projectPath,
},
fetcher,
);
}
export const watchChanges: ToolDef<WatchChangesInputT> = {
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);
},
};

View File

@@ -49,6 +49,33 @@ export function formatToolArgs(name: string, args: Record<string, unknown>): str
if (name === 'git_status') { if (name === 'git_status') {
return ''; 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 // Unknown tool — surface first arg value or the literal {} so the user can
// see something happened. Forward-compatible with future tools. // see something happened. Forward-compatible with future tools.
const keys = Object.keys(args); const keys = Object.keys(args);

View File

@@ -7,6 +7,7 @@ services:
- "100.114.205.53:9500:3000" - "100.114.205.53:9500:3000"
env_file: .env env_file: .env
environment: environment:
CODECONTEXT_URL: http://codecontext:8080
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
volumes: volumes:
- /opt:/opt - /opt:/opt