Merge v1.12 track B: codecontext sidecar
# Conflicts: # apps/web/src/components/ToolCallLine.tsx # docker-compose.yml
This commit is contained in:
191
AGENTS.md
191
AGENTS.md
@@ -1,191 +0,0 @@
|
|||||||
# Agents
|
|
||||||
|
|
||||||
## Code Reviewer
|
|
||||||
---
|
|
||||||
temperature: 0.3
|
|
||||||
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
|
||||||
---
|
|
||||||
You review code. Find real problems, not style nits.
|
|
||||||
|
|
||||||
Process:
|
|
||||||
1. Read the file(s) in question with view_file. If a diff is provided, read surrounding context too.
|
|
||||||
2. Use grep/find_files to check how changed symbols are used elsewhere.
|
|
||||||
3. Cite every finding as file:line.
|
|
||||||
|
|
||||||
Prioritize in order:
|
|
||||||
1. Bugs and logic errors
|
|
||||||
2. Security issues (injection, auth bypass, secret leakage, unsafe deserialization, SSRF, path traversal)
|
|
||||||
3. Race conditions, error handling, resource leaks
|
|
||||||
4. Performance issues with measurable impact
|
|
||||||
5. Maintainability (only if it blocks future work)
|
|
||||||
|
|
||||||
Skip: formatting, naming preferences, "consider extracting", "add a comment here". The user has a linter.
|
|
||||||
|
|
||||||
Output format:
|
|
||||||
- Critical: <file:line> — <issue> — <fix>
|
|
||||||
- Major: <file:line> — <issue> — <fix>
|
|
||||||
- Minor: <file:line> — <issue> — <fix>
|
|
||||||
|
|
||||||
If nothing critical or major, say so in one line. Do not pad.
|
|
||||||
|
|
||||||
|
|
||||||
## Debugger
|
|
||||||
---
|
|
||||||
temperature: 0.2
|
|
||||||
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
|
||||||
---
|
|
||||||
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
|
||||||
|
|
||||||
Process:
|
|
||||||
1. Restate the symptom in one line. Confirm you understand it.
|
|
||||||
2. Read the error/stacktrace. Identify the exact frame where things go wrong.
|
|
||||||
3. view_file on that frame. Read 50 lines around it.
|
|
||||||
4. grep for callers, related state, recent changes that could explain it.
|
|
||||||
5. State the root cause with file:line evidence.
|
|
||||||
6. Propose the minimal fix. Note any side effects.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Never guess. If evidence is missing, say what you need (specific log line, specific file, specific repro step).
|
|
||||||
- Distinguish symptom from cause. A null check fixes the symptom; missing init causes it.
|
|
||||||
- Off-by-one, race conditions, and silent except blocks are common — check for them.
|
|
||||||
- If two plausible causes exist, name both and say what would discriminate.
|
|
||||||
|
|
||||||
Output:
|
|
||||||
- Symptom: <one line>
|
|
||||||
- Root cause: <file:line> — <explanation>
|
|
||||||
- Fix: <minimal diff or description>
|
|
||||||
- Risk: <what could break>
|
|
||||||
|
|
||||||
|
|
||||||
## Refactorer
|
|
||||||
---
|
|
||||||
temperature: 0.3
|
|
||||||
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
|
||||||
---
|
|
||||||
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
|
||||||
|
|
||||||
Process:
|
|
||||||
1. Read the target file(s).
|
|
||||||
2. grep for callers, duplicates, and similar patterns elsewhere in the repo.
|
|
||||||
3. Identify the smallest refactor that delivers the goal.
|
|
||||||
|
|
||||||
Prioritize:
|
|
||||||
1. Deduplication where 3+ sites have near-identical logic
|
|
||||||
2. Extracting a function/module when one is doing two unrelated jobs
|
|
||||||
3. Decoupling when a change in A forces a change in B unnecessarily
|
|
||||||
4. Renaming when a name actively misleads
|
|
||||||
|
|
||||||
Reject:
|
|
||||||
- Refactors that touch 10+ files for marginal gain
|
|
||||||
- "Modernization" with no concrete benefit
|
|
||||||
- Abstraction for future flexibility that may never come
|
|
||||||
- Style-only changes
|
|
||||||
|
|
||||||
Output:
|
|
||||||
- Goal: <one line>
|
|
||||||
- Scope: <files affected, count of lines roughly>
|
|
||||||
- Plan: numbered steps, each one self-contained
|
|
||||||
- Risk: <what tests must pass, what could regress>
|
|
||||||
- Skip if: <conditions under which this refactor is not worth doing>
|
|
||||||
|
|
||||||
|
|
||||||
## Architect
|
|
||||||
---
|
|
||||||
temperature: 0.5
|
|
||||||
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
|
||||||
---
|
|
||||||
You design. You produce build plans, not code.
|
|
||||||
|
|
||||||
Process:
|
|
||||||
1. Restate the goal in your own words. Confirm constraints (perf, deploy, deps).
|
|
||||||
2. list_dir the relevant areas. Read existing patterns — match them unless there's a reason not to.
|
|
||||||
3. Decide: extend existing code or add new module. Justify.
|
|
||||||
4. Sketch the data flow: inputs → transforms → outputs → side effects.
|
|
||||||
5. Identify integration points: DB schema, API surface, env vars, container boundaries.
|
|
||||||
6. List failure modes and how the design handles them.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Reuse before inventing. If a service/lib in the repo already does this, say so.
|
|
||||||
- Prefer boring tech. New deps require justification.
|
|
||||||
- Tailscale IPs for internal routing. No 0.0.0.0 binds.
|
|
||||||
- Least privilege: separate read/write paths, explicit auth gates.
|
|
||||||
- State assumptions inline. Do not ask clarifying questions mid-design unless blocked.
|
|
||||||
|
|
||||||
Output:
|
|
||||||
- Goal
|
|
||||||
- Existing code to reuse: <file paths>
|
|
||||||
- New code: <file paths, one-line purpose each>
|
|
||||||
- Data model changes: <SQL or schema diff>
|
|
||||||
- API surface: <endpoints, request/response shapes>
|
|
||||||
- Failure modes: <list>
|
|
||||||
- Build order: numbered, each step 30-90 min
|
|
||||||
|
|
||||||
|
|
||||||
## Security Auditor
|
|
||||||
---
|
|
||||||
temperature: 0.2
|
|
||||||
description: Audits code for security vulnerabilities. Read-only.
|
|
||||||
---
|
|
||||||
You audit for security issues. Concrete findings only, no generic warnings.
|
|
||||||
|
|
||||||
Process:
|
|
||||||
1. Identify the trust boundary: where does untrusted input enter? Where does it leave?
|
|
||||||
2. Trace input flow with grep. Mark every transformation.
|
|
||||||
3. Check each finding against a real attack scenario.
|
|
||||||
|
|
||||||
Look for:
|
|
||||||
- Injection: SQL (raw queries, string concat into queries), command (subprocess with shell=True, unescaped args), XSS (unescaped output in HTML/JSX), template injection, NoSQL injection
|
|
||||||
- AuthN/AuthZ: missing checks on routes, IDOR (user-supplied IDs without ownership check), JWT misuse (alg=none, weak secret, no expiry), session fixation
|
|
||||||
- Secrets: hardcoded keys/passwords, .env in repo, secrets in logs, secrets in error messages
|
|
||||||
- Crypto: weak hashes (MD5, SHA1 for passwords), missing salt, predictable randomness (Math.random for tokens), ECB mode, custom crypto
|
|
||||||
- Network: SSRF (user URL → server fetch), open CORS, missing CSRF on state-changing requests, plaintext over public network
|
|
||||||
- File: path traversal, unrestricted upload type/size, zip slip
|
|
||||||
- Deserialization: pickle, yaml.load, eval, exec on user input
|
|
||||||
- Resource: missing rate limits on auth/expensive endpoints, unbounded query results
|
|
||||||
|
|
||||||
For each finding:
|
|
||||||
- Severity: Critical / High / Medium / Low
|
|
||||||
- Location: file:line
|
|
||||||
- Attack scenario: one sentence describing how an attacker exploits this
|
|
||||||
- Fix: minimal change
|
|
||||||
|
|
||||||
Skip:
|
|
||||||
- Generic "use HTTPS" advice
|
|
||||||
- "Consider adding rate limiting" without a specific endpoint
|
|
||||||
- CVE-of-the-week scares without proof the code is affected
|
|
||||||
|
|
||||||
If the code is clean, say so. Do not invent findings.
|
|
||||||
|
|
||||||
|
|
||||||
## Prompt Builder
|
|
||||||
---
|
|
||||||
temperature: 0.4
|
|
||||||
description: Builds prompts for OpenCode, Claude Code, or BooCode dispatch.
|
|
||||||
---
|
|
||||||
You write prompts that another coding agent will execute. Your output is the prompt, not the work.
|
|
||||||
|
|
||||||
Process:
|
|
||||||
1. Ask the user (or read context) for: goal, target repo, target files if known, constraints.
|
|
||||||
2. list_dir and view_file the target area. Confirm files exist and are roughly the shape you think.
|
|
||||||
3. Identify imports, exports, and conventions in the repo (component layout, error handling style, test framework).
|
|
||||||
4. Write the prompt.
|
|
||||||
|
|
||||||
Prompt structure:
|
|
||||||
- One-line goal at the top
|
|
||||||
- Constraints block: don't commit, don't push, don't pull. Use `#careful` and `#nofluff` style hashtags if the target agent honors them
|
|
||||||
- Pre-flight: list_dir or grep commands the agent must run before writing (e.g. "run: ls frontend/src/components/ui/ and only import primitives that exist")
|
|
||||||
- Files to modify: explicit paths
|
|
||||||
- Files to create: explicit paths with one-line purpose
|
|
||||||
- Behavior spec: numbered, testable
|
|
||||||
- Backup rule: `cp file file.bak-$(date +%Y%m%d)` before any destructive edit
|
|
||||||
- Verification: `py_compile`, `tsc --noEmit`, `docker compose up --build -d` — whichever applies
|
|
||||||
- Stop conditions: when to halt and report instead of pressing on
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Tailored to the target agent: OpenCode honors hashtag snippets and skills; Claude Code honors CLAUDE.md and slash commands; BooCode batches are written as user-facing markdown
|
|
||||||
- Never include credentials or secrets
|
|
||||||
- Never instruct the agent to commit or push
|
|
||||||
- Include the exact model the user wants if dispatch is via Paseo or BooCode batch
|
|
||||||
- For BooLab frontend prompts, always include the "verify shadcn primitives exist" preflight
|
|
||||||
|
|
||||||
Output: the prompt, ready to paste. Nothing else.
|
|
||||||
@@ -114,6 +114,8 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
|||||||
- A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` — it's accepted.
|
- A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` — it's accepted.
|
||||||
- `/opt/boolab` hosts a working sibling BooCode terminal at `boocode.indifferentketchup.com`. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (`@tailwind base`); boocode uses v4 — many subtle build differences. Don't assume parity.
|
- `/opt/boolab` hosts a working sibling BooCode terminal at `boocode.indifferentketchup.com`. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (`@tailwind base`); boocode uses v4 — many subtle build differences. Don't assume parity.
|
||||||
- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's `/etc/hosts` knows it. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if you ever move the shell to a different machine.
|
- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's `/etc/hosts` knows it. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if you ever move the shell to a different machine.
|
||||||
|
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore.template` documents recommended ignore patterns; users copy and adapt to project root manually.
|
||||||
|
- `os/exec` child supervisors must explicitly call `child.Wait()` in a goroutine and `os.Exit` on child death. `Signal(0)` returns nil on zombies and is NOT a liveness check. Without `Wait()`, docker's `restart: unless-stopped` policy never fires because the parent stays alive. The `codecontext/shim.go` implementation is the reference pattern.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
|
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
|
||||||
|
import { ALL_TOOLS } from './tools.js';
|
||||||
|
|
||||||
// v1.8.1: global agents live at /data/AGENTS.md inside the container
|
// v1.8.1: global agents live at /data/AGENTS.md inside the container
|
||||||
// (./data:/data:ro mount on the host). Per-project AGENTS.md at the project
|
// (./data:/data:ro mount on the host). Per-project AGENTS.md at the project
|
||||||
@@ -10,18 +11,12 @@ import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
|
|||||||
const GLOBAL_AGENTS_PATH = '/data/AGENTS.md';
|
const GLOBAL_AGENTS_PATH = '/data/AGENTS.md';
|
||||||
const CACHE_TTL_MS = 60_000;
|
const CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
// Tools whitelist universe matches services/tools.ts ALL_TOOLS. Keep in sync.
|
// v1.12 Track B.3: derive from services/tools.ts ALL_TOOLS so new tools are
|
||||||
// Batch 9.6: skill_find / skill_use / skill_resource added. Agents without an
|
// auto-recognized in agent frontmatter `tools:` arrays. The previous
|
||||||
// explicit `tools:` field inherit the full default set (which now includes
|
// hand-maintained list drifted (web_search/web_fetch from v1.11.8 + the 8
|
||||||
// the skill tools); agents with an explicit `tools:` array must list any
|
// codecontext tools were missing), silently filtering valid tool names out
|
||||||
// skill tool they want to use — strict opt-in.
|
// of agents that opted in. Single source of truth is tools.ts now.
|
||||||
// Batch 9.7: ask_user_input added — same opt-in semantics. Agents with an
|
const ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
|
||||||
// explicit tools list that omits it cannot trigger the interactive picker.
|
|
||||||
const ALL_TOOL_NAMES = [
|
|
||||||
'view_file', 'list_dir', 'grep', 'find_files', 'git_status',
|
|
||||||
'skill_find', 'skill_use', 'skill_resource',
|
|
||||||
'ask_user_input',
|
|
||||||
] as const;
|
|
||||||
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
||||||
const DEFAULT_TEMPERATURE = 0.7;
|
const DEFAULT_TEMPERATURE = 0.7;
|
||||||
|
|
||||||
|
|||||||
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 };
|
||||||
|
}
|
||||||
@@ -603,10 +603,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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -57,6 +57,33 @@ export function formatToolArgs(name: string, args: Record<string, unknown>): str
|
|||||||
ARG_SUMMARY_MAX,
|
ARG_SUMMARY_MAX,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// 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);
|
||||||
|
|||||||
33
codecontext/.codecontextignore.template
Normal file
33
codecontext/.codecontextignore.template
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# .codecontextignore — paths codecontext skips during analysis
|
||||||
|
# Copy to your project root and customize. Same syntax as .gitignore.
|
||||||
|
|
||||||
|
# Dependencies / vendored code
|
||||||
|
node_modules/
|
||||||
|
vendor/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
.next/
|
||||||
|
.nuxt/
|
||||||
|
.svelte-kit/
|
||||||
|
|
||||||
|
# IDE / tooling
|
||||||
|
.opencode/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Test artifacts / coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Lock files (rarely have meaningful symbols)
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
40
codecontext/Dockerfile
Normal file
40
codecontext/Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# v1.12 Track B — codecontext sidecar container.
|
||||||
|
#
|
||||||
|
# Multi-stage build: golang:1.24-alpine builder produces two binaries
|
||||||
|
# (codecontext from source + our HTTP shim), then a minimal alpine:3.20
|
||||||
|
# runtime holds both.
|
||||||
|
#
|
||||||
|
# No upstream Docker image exists for codecontext. We clone the repo
|
||||||
|
# directly because the module path declared in go.mod
|
||||||
|
# (github.com/nuthan-ms/codecontext) differs from the GitHub repo URL
|
||||||
|
# (github.com/nmakod/codecontext) — `go install` against the GitHub path
|
||||||
|
# wouldn't resolve. The tagged v3.2.1 source tree is the same either way.
|
||||||
|
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN apk add --no-cache git ca-certificates build-base
|
||||||
|
|
||||||
|
# Build codecontext from the v3.2.1 tag.
|
||||||
|
# CGO is required: codecontext binds tree-sitter via cgo.
|
||||||
|
RUN git clone --depth=1 --branch v3.2.1 https://github.com/nmakod/codecontext.git /build/codecontext
|
||||||
|
WORKDIR /build/codecontext
|
||||||
|
RUN CGO_ENABLED=1 GOOS=linux go build -o /build/codecontext-bin ./cmd/codecontext
|
||||||
|
|
||||||
|
# Build the shim. Stdlib-only — no go.sum needed.
|
||||||
|
WORKDIR /build/shim
|
||||||
|
COPY go.mod ./
|
||||||
|
COPY shim.go ./
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /build/shim-bin ./
|
||||||
|
|
||||||
|
# Runtime: alpine matches the build target so codecontext's cgo bindings
|
||||||
|
# resolve against the same musl libc.
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
COPY --from=builder /build/codecontext-bin /usr/local/bin/codecontext
|
||||||
|
COPY --from=builder /build/shim-bin /usr/local/bin/shim
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
|
||||||
|
CMD wget -qO- http://localhost:8080/health || exit 1
|
||||||
|
ENTRYPOINT ["/usr/local/bin/shim"]
|
||||||
3
codecontext/go.mod
Normal file
3
codecontext/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/indifferentketchup/boocode-codecontext-shim
|
||||||
|
|
||||||
|
go 1.24
|
||||||
442
codecontext/shim.go
Normal file
442
codecontext/shim.go
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
// boocode-codecontext-shim — wraps codecontext's stdio MCP server with an
|
||||||
|
// HTTP/JSON facade so the BooCode Node server can call codecontext over the
|
||||||
|
// container network instead of speaking MCP directly. One process per
|
||||||
|
// container, holds a single codecontext child via os/exec; concurrent HTTP
|
||||||
|
// requests are serialized onto the child because codecontext's internal
|
||||||
|
// CodeContextMCPServer.graph swaps per target_dir (see recon report
|
||||||
|
// 2026-05-21).
|
||||||
|
//
|
||||||
|
// MCP framing is newline-delimited JSON (NDJSON), not LSP-style
|
||||||
|
// Content-Length — per the MCP stdio transport spec:
|
||||||
|
// https://spec.modelcontextprotocol.io/specification/server/transports
|
||||||
|
//
|
||||||
|
// No third-party deps. Stdlib only.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- JSON-RPC types ----
|
||||||
|
|
||||||
|
// rpcMessage is shared by request, response, and notification. Notifications
|
||||||
|
// omit ID; requests omit Result/Error; responses omit Method/Params. omitempty
|
||||||
|
// + the zero int 0 sentinel works for ID because we never SEND id=0
|
||||||
|
// (nextID starts at 0 and atomic.AddInt32 returns 1 on the first call).
|
||||||
|
type rpcMessage struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int `json:"id,omitempty"`
|
||||||
|
Method string `json:"method,omitempty"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error *rpcError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// callToolResult is the MCP tools/call response shape. codecontext returns
|
||||||
|
// markdown wrapped in a TextContent entry.
|
||||||
|
type callToolResult struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
IsError bool `json:"isError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Globals ----
|
||||||
|
|
||||||
|
var (
|
||||||
|
child *exec.Cmd
|
||||||
|
childStdin io.WriteCloser
|
||||||
|
childStdout *bufio.Reader
|
||||||
|
|
||||||
|
// Serialize tools/call so codecontext's per-call graph rebuild doesn't
|
||||||
|
// race itself when concurrent HTTP requests target different projects.
|
||||||
|
// Initialize/notifications/initialized run before HTTP starts so they
|
||||||
|
// don't need this lock.
|
||||||
|
callMu sync.Mutex
|
||||||
|
|
||||||
|
pendingMu sync.Mutex
|
||||||
|
pending = make(map[int]chan *rpcMessage)
|
||||||
|
|
||||||
|
nextID int32
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- MCP framing (NDJSON) ----
|
||||||
|
|
||||||
|
func writeMessage(w io.Writer, msg *rpcMessage) error {
|
||||||
|
body, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Single write keeps the message atomic across concurrent writers.
|
||||||
|
// (We don't actually have concurrent writers here — callMu serializes —
|
||||||
|
// but the +'\n' append needs to be in one syscall regardless.)
|
||||||
|
_, err = w.Write(append(body, '\n'))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readerLoop(r *bufio.Reader) {
|
||||||
|
for {
|
||||||
|
line, err := r.ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
log.Printf("reader: EOF (child closed stdout)")
|
||||||
|
} else {
|
||||||
|
log.Printf("reader: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var msg rpcMessage
|
||||||
|
if err := json.Unmarshal(line, &msg); err != nil {
|
||||||
|
log.Printf("reader: malformed JSON: %v (line=%q)", err, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if msg.ID == 0 {
|
||||||
|
// Server-initiated notification or progress update; nothing to
|
||||||
|
// dispatch. codecontext doesn't currently send these but the
|
||||||
|
// MCP spec allows them.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pendingMu.Lock()
|
||||||
|
ch, ok := pending[msg.ID]
|
||||||
|
if ok {
|
||||||
|
delete(pending, msg.ID)
|
||||||
|
}
|
||||||
|
pendingMu.Unlock()
|
||||||
|
if ok {
|
||||||
|
ch <- &msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func call(ctx context.Context, method string, params any) (*rpcMessage, error) {
|
||||||
|
id := int(atomic.AddInt32(&nextID, 1))
|
||||||
|
ch := make(chan *rpcMessage, 1)
|
||||||
|
pendingMu.Lock()
|
||||||
|
pending[id] = ch
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
|
paramsJSON, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
pendingMu.Lock()
|
||||||
|
delete(pending, id)
|
||||||
|
pendingMu.Unlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &rpcMessage{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: id,
|
||||||
|
Method: method,
|
||||||
|
Params: paramsJSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeMessage(childStdin, msg); err != nil {
|
||||||
|
pendingMu.Lock()
|
||||||
|
delete(pending, id)
|
||||||
|
pendingMu.Unlock()
|
||||||
|
return nil, fmt.Errorf("write: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case resp := <-ch:
|
||||||
|
return resp, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
pendingMu.Lock()
|
||||||
|
delete(pending, id)
|
||||||
|
pendingMu.Unlock()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func notify(method string, params any) error {
|
||||||
|
paramsJSON, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg := &rpcMessage{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
Method: method,
|
||||||
|
Params: paramsJSON,
|
||||||
|
}
|
||||||
|
return writeMessage(childStdin, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Child lifecycle ----
|
||||||
|
|
||||||
|
func startChild() error {
|
||||||
|
// `codecontext mcp` with --watch=true (the default) keeps fsnotify
|
||||||
|
// running on the indexed directory; the per-call target_dir swap
|
||||||
|
// invalidates and re-indexes on demand. `--target=/opt/projects` is the
|
||||||
|
// initial scan target — codecontext rebuilds the graph against whatever
|
||||||
|
// target_dir each call carries, so this is just a valid bootstrap path
|
||||||
|
// (the default "." is the alpine root and trips on transient /proc fds).
|
||||||
|
child = exec.Command("codecontext", "mcp", "--target=/opt/projects", "--watch=true")
|
||||||
|
var err error
|
||||||
|
childStdin, err = child.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stdin pipe: %w", err)
|
||||||
|
}
|
||||||
|
stdout, err := child.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
childStdout = bufio.NewReader(stdout)
|
||||||
|
// codecontext's own log.SetOutput(os.Stderr) keeps its diagnostic noise
|
||||||
|
// off the JSON-RPC channel; we just pass-through to our own stderr.
|
||||||
|
child.Stderr = os.Stderr
|
||||||
|
|
||||||
|
if err := child.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("started codecontext pid=%d", child.Process.Pid)
|
||||||
|
|
||||||
|
go readerLoop(childStdout)
|
||||||
|
|
||||||
|
// Supervise the child. When codecontext exits (crash, OOM, externally
|
||||||
|
// pkill'd), child.Wait() returns and we tear the shim down so the
|
||||||
|
// container's `restart: unless-stopped` policy recreates us with a
|
||||||
|
// fresh child. Without this goroutine the dead child becomes a zombie
|
||||||
|
// (Signal(0) on a zombie returns nil, so the health endpoint would lie)
|
||||||
|
// and HTTP requests would queue forever waiting on responses that will
|
||||||
|
// never come. Discovered during B.1 kill-restart testing.
|
||||||
|
go func() {
|
||||||
|
err := child.Wait()
|
||||||
|
log.Printf("codecontext exited: %v — shim shutting down", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func killChild() {
|
||||||
|
if child == nil || child.Process == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("killing codecontext pid=%d", child.Process.Pid)
|
||||||
|
_ = child.Process.Signal(syscall.SIGTERM)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- child.Wait() }()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
log.Printf("codecontext exited")
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
log.Printf("codecontext did not exit on SIGTERM; sending SIGKILL")
|
||||||
|
_ = child.Process.Kill()
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP handshake: client sends initialize, server replies, client follows
|
||||||
|
// with the notifications/initialized notification. After that, tools/call
|
||||||
|
// is accepted.
|
||||||
|
func initializeMCP(ctx context.Context) error {
|
||||||
|
initParams := map[string]any{
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": map[string]any{},
|
||||||
|
"clientInfo": map[string]any{
|
||||||
|
"name": "boocode-codecontext-shim",
|
||||||
|
"version": "0.1.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := call(ctx, "initialize", initParams)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("initialize: %w", err)
|
||||||
|
}
|
||||||
|
if resp.Error != nil {
|
||||||
|
return fmt.Errorf("initialize error %d: %s", resp.Error.Code, resp.Error.Message)
|
||||||
|
}
|
||||||
|
if err := notify("notifications/initialized", map[string]any{}); err != nil {
|
||||||
|
return fmt.Errorf("notifications/initialized: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("MCP handshake complete (server result=%s)", string(resp.Result))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HTTP ----
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if child == nil || child.Process == nil {
|
||||||
|
http.Error(w, "no child", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Signal 0 doesn't actually deliver — it just returns an error if the
|
||||||
|
// process is gone. Cheaper than parsing /proc.
|
||||||
|
if err := child.Process.Signal(syscall.Signal(0)); err != nil {
|
||||||
|
http.Error(w, "child dead: "+err.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, "ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeToolHandler(toolName string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
targetDir := "-"
|
||||||
|
status := "ok"
|
||||||
|
defer func() {
|
||||||
|
log.Printf("%s target_dir=%q duration_ms=%d status=%s",
|
||||||
|
toolName, targetDir, time.Since(start).Milliseconds(), status)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var args json.RawMessage
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
|
||||||
|
status = "bad_request"
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
||||||
|
"result": nil,
|
||||||
|
"error": "invalid JSON body: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sniff target_dir purely for the access log; pass args through opaque.
|
||||||
|
var argsMap map[string]any
|
||||||
|
if json.Unmarshal(args, &argsMap) == nil {
|
||||||
|
if td, ok := argsMap["target_dir"].(string); ok {
|
||||||
|
targetDir = td
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
callMu.Lock()
|
||||||
|
resp, err := call(ctx, "tools/call", map[string]any{
|
||||||
|
"name": toolName,
|
||||||
|
"arguments": args,
|
||||||
|
})
|
||||||
|
callMu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
status = "rpc_error"
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]any{
|
||||||
|
"result": nil,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp.Error != nil {
|
||||||
|
status = "mcp_error"
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"result": nil,
|
||||||
|
"error": resp.Error.Message,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var ctr callToolResult
|
||||||
|
if err := json.Unmarshal(resp.Result, &ctr); err != nil {
|
||||||
|
status = "parse_error"
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"result": nil,
|
||||||
|
"error": "parse result: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// codecontext only emits text content. Concatenate (single-entry in
|
||||||
|
// practice, but the schema allows multiple).
|
||||||
|
var buf []byte
|
||||||
|
for _, c := range ctr.Content {
|
||||||
|
if c.Type == "text" {
|
||||||
|
buf = append(buf, c.Text...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text := string(buf)
|
||||||
|
|
||||||
|
if ctr.IsError {
|
||||||
|
status = "tool_error"
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"result": nil,
|
||||||
|
"error": text,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"result": text,
|
||||||
|
"error": nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- main ----
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetOutput(os.Stderr)
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||||||
|
log.Println("boocode-codecontext-shim starting")
|
||||||
|
|
||||||
|
if err := startChild(); err != nil {
|
||||||
|
log.Fatalf("startChild: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
initCtx, initCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
if err := initializeMCP(initCtx); err != nil {
|
||||||
|
initCancel()
|
||||||
|
killChild()
|
||||||
|
log.Fatalf("initializeMCP: %v", err)
|
||||||
|
}
|
||||||
|
initCancel()
|
||||||
|
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
// Go 1.22+ method-prefix routing. Any non-listed method → 405 automatically.
|
||||||
|
mux.HandleFunc("GET /health", handleHealth)
|
||||||
|
mux.HandleFunc("POST /v1/get_codebase_overview", makeToolHandler("get_codebase_overview"))
|
||||||
|
mux.HandleFunc("POST /v1/get_file_analysis", makeToolHandler("get_file_analysis"))
|
||||||
|
mux.HandleFunc("POST /v1/get_symbol_info", makeToolHandler("get_symbol_info"))
|
||||||
|
mux.HandleFunc("POST /v1/search_symbols", makeToolHandler("search_symbols"))
|
||||||
|
mux.HandleFunc("POST /v1/get_dependencies", makeToolHandler("get_dependencies"))
|
||||||
|
mux.HandleFunc("POST /v1/watch_changes", makeToolHandler("watch_changes"))
|
||||||
|
mux.HandleFunc("POST /v1/get_semantic_neighborhoods", makeToolHandler("get_semantic_neighborhoods"))
|
||||||
|
mux.HandleFunc("POST /v1/get_framework_analysis", makeToolHandler("get_framework_analysis"))
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: ":8080",
|
||||||
|
Handler: mux,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Println("listening on :8080")
|
||||||
|
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatalf("ListenAndServe: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-sigChan
|
||||||
|
log.Println("shutdown signal received")
|
||||||
|
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
_ = server.Shutdown(shutdownCtx)
|
||||||
|
shutdownCancel()
|
||||||
|
killChild()
|
||||||
|
log.Println("exit")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
||||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
||||||
volumes:
|
volumes:
|
||||||
@@ -60,6 +61,33 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- boocode_net
|
- boocode_net
|
||||||
|
|
||||||
|
# v1.12 Track B: codecontext sidecar. Stdio MCP server wrapped by a small
|
||||||
|
# HTTP shim (see ./codecontext/). No host port — reached from boocode at
|
||||||
|
# http://codecontext:8080 over the boocode_net bridge.
|
||||||
|
#
|
||||||
|
# Mounts /opt:/opt:ro (not just /opt/projects:ro): BooCode projects live
|
||||||
|
# at /opt/<slug> on the host, not exclusively under /opt/projects. The
|
||||||
|
# mount must cover anywhere a project.path could resolve to. Read-only
|
||||||
|
# because codecontext only analyzes — never writes. The model can't
|
||||||
|
# arbitrarily set target_dir to a sensitive subtree because the B.2
|
||||||
|
# wrappers validate target_dir against project.path before calling the
|
||||||
|
# shim, and the shim isn't reachable from outside boocode_net.
|
||||||
|
codecontext:
|
||||||
|
build:
|
||||||
|
context: ./codecontext
|
||||||
|
container_name: boocode_codecontext
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- boocode_net
|
||||||
|
volumes:
|
||||||
|
- /opt:/opt:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
boocode_pgdata:
|
boocode_pgdata:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user