v1.12 track B.2: codecontext tool wrappers + tests
This commit is contained in:
205
apps/server/src/services/__tests__/codecontext_client.test.ts
Normal file
205
apps/server/src/services/__tests__/codecontext_client.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
155
apps/server/src/services/__tests__/codecontext_tools.test.ts
Normal file
155
apps/server/src/services/__tests__/codecontext_tools.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
118
apps/server/src/services/codecontext_client.ts
Normal file
118
apps/server/src/services/codecontext_client.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
11
apps/server/src/services/tools/codecontext/index.ts
Normal file
11
apps/server/src/services/tools/codecontext/index.ts
Normal 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';
|
||||||
77
apps/server/src/services/tools/codecontext/search_symbols.ts
Normal file
77
apps/server/src/services/tools/codecontext/search_symbols.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
57
apps/server/src/services/tools/codecontext/watch_changes.ts
Normal file
57
apps/server/src/services/tools/codecontext/watch_changes.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user