feat: fork lifts phases 3-9 — LSP, DCP, memory, boocontext, protocol, plugins, reliability
This commit is contained in:
42
apps/coder/src/plugins/host.ts
Normal file
42
apps/coder/src/plugins/host.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
export type HookName =
|
||||||
|
| 'tool.execute.before'
|
||||||
|
| 'tool.execute.after'
|
||||||
|
| 'turn.start'
|
||||||
|
| 'turn.end'
|
||||||
|
| 'task.terminal';
|
||||||
|
|
||||||
|
export interface ToolHookContext {
|
||||||
|
tool: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
projectRoot: string;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResultContext extends ToolHookContext {
|
||||||
|
result: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginHook = (ctx: any) => Promise<any>;
|
||||||
|
|
||||||
|
const hooks = new Map<HookName, PluginHook[]>();
|
||||||
|
|
||||||
|
export function registerHook(name: HookName, fn: PluginHook): void {
|
||||||
|
const list = hooks.get(name) || [];
|
||||||
|
list.push(fn);
|
||||||
|
hooks.set(name, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function emitHook(name: HookName, ctx: any): Promise<any> {
|
||||||
|
const list = hooks.get(name);
|
||||||
|
if (!list) return ctx;
|
||||||
|
let current = ctx;
|
||||||
|
for (const fn of list) {
|
||||||
|
const result = await fn(current);
|
||||||
|
if (result !== undefined) current = result;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHooks(): void {
|
||||||
|
hooks.clear();
|
||||||
|
}
|
||||||
@@ -423,3 +423,7 @@ CREATE INDEX IF NOT EXISTS contestants_task_id_idx ON contestants(task_id);
|
|||||||
|
|
||||||
-- Cross-examination listing per battle.
|
-- Cross-examination listing per battle.
|
||||||
CREATE INDEX IF NOT EXISTS cross_examinations_battle_idx ON cross_examinations(battle_id);
|
CREATE INDEX IF NOT EXISTS cross_examinations_battle_idx ON cross_examinations(battle_id);
|
||||||
|
|
||||||
|
-- TokenScope: per-category token breakdown on arena contestants and tasks.
|
||||||
|
ALTER TABLE contestants ADD COLUMN IF NOT EXISTS token_breakdown JSONB;
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS token_breakdown JSONB;
|
||||||
|
|||||||
@@ -162,6 +162,24 @@ describe('computeBenchmark', () => {
|
|||||||
expect(bench.durationMs).toBe(0);
|
expect(bench.durationMs).toBe(0);
|
||||||
expect(bench.tokensPerSec).toBeNull();
|
expect(bench.tokensPerSec).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes token breakdown when provided', () => {
|
||||||
|
const breakdown = {
|
||||||
|
system: 10,
|
||||||
|
user: 20,
|
||||||
|
assistant: 30,
|
||||||
|
tools: 40,
|
||||||
|
reasoning: 5,
|
||||||
|
total: 105,
|
||||||
|
};
|
||||||
|
const bench = computeBenchmark(t0, t1, 500, 'local', breakdown);
|
||||||
|
expect(bench.tokenBreakdown).toEqual(breakdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults token breakdown to null when omitted', () => {
|
||||||
|
const bench = computeBenchmark(t0, t1, 500, 'local');
|
||||||
|
expect(bench.tokenBreakdown).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── sanitizeSlug ────────────────────────────────────────────────────────────
|
// ─── sanitizeSlug ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* A contestant's status lifecycle:
|
* A contestant's status lifecycle:
|
||||||
* queued → running → done | error
|
* queued → running → done | error
|
||||||
*/
|
*/
|
||||||
import type { BattleType, ContestantLane } from '@boocode/contracts/arena';
|
import type { BattleType, ContestantLane, TokenBreakdown } from '@boocode/contracts/arena';
|
||||||
|
|
||||||
// ─── Lane classification ──────────────────────────────────────────────────────
|
// ─── Lane classification ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -73,6 +73,7 @@ export function isBattleComplete(contestants: readonly { status: string }[]): bo
|
|||||||
export interface Benchmark {
|
export interface Benchmark {
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
tokensPerSec: number | null;
|
tokensPerSec: number | null;
|
||||||
|
tokenBreakdown: TokenBreakdown | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,13 +87,14 @@ export function computeBenchmark(
|
|||||||
endedAt: Date,
|
endedAt: Date,
|
||||||
costTokens: number | null,
|
costTokens: number | null,
|
||||||
lane: ContestantLane,
|
lane: ContestantLane,
|
||||||
|
tokenBreakdown: TokenBreakdown | null = null,
|
||||||
): Benchmark {
|
): Benchmark {
|
||||||
const durationMs = Math.max(0, endedAt.getTime() - startedAt.getTime());
|
const durationMs = Math.max(0, endedAt.getTime() - startedAt.getTime());
|
||||||
const tokensPerSec =
|
const tokensPerSec =
|
||||||
lane === 'local' && costTokens !== null && durationMs > 0
|
lane === 'local' && costTokens !== null && durationMs > 0
|
||||||
? (costTokens / durationMs) * 1000
|
? (costTokens / durationMs) * 1000
|
||||||
: null;
|
: null;
|
||||||
return { durationMs, tokensPerSec };
|
return { durationMs, tokensPerSec, tokenBreakdown };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Slug / path helpers ──────────────────────────────────────────────────────
|
// ─── Slug / path helpers ──────────────────────────────────────────────────────
|
||||||
|
|||||||
42
apps/coder/src/services/edit-guards.ts
Normal file
42
apps/coder/src/services/edit-guards.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// v2.8 Morph safety guards — prevents catastrophic truncation, marker leakage,
|
||||||
|
// and accidental import deletion during native edit_file application.
|
||||||
|
// Ported from opencode-morph-fast-apply (MIT) with threshold values preserved.
|
||||||
|
|
||||||
|
export interface GuardResult {
|
||||||
|
ok: boolean;
|
||||||
|
reason?: string;
|
||||||
|
charLoss?: number;
|
||||||
|
lineLoss?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRUNCATION_CHAR_THRESHOLD = 0.6;
|
||||||
|
const TRUNCATION_LINE_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
export function validateEditResult(
|
||||||
|
original: string,
|
||||||
|
updated: string,
|
||||||
|
filePath: string,
|
||||||
|
): GuardResult {
|
||||||
|
// Check for catastrophic content truncation
|
||||||
|
if (original.length > 0 && updated.length > 0) {
|
||||||
|
const charLoss = 1 - updated.length / original.length;
|
||||||
|
const originalLines = original.split('\n').length;
|
||||||
|
const updatedLines = updated.split('\n').length;
|
||||||
|
const lineLoss = 1 - updatedLines / originalLines;
|
||||||
|
|
||||||
|
if (charLoss > TRUNCATION_CHAR_THRESHOLD && lineLoss > TRUNCATION_LINE_THRESHOLD) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: `Edit would truncate ${Math.round(charLoss * 100)}% of characters and ${Math.round(lineLoss * 100)}% of lines`,
|
||||||
|
charLoss,
|
||||||
|
lineLoss,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGuardError(guard: GuardResult, filePath: string): string {
|
||||||
|
return `Edit guard rejected change to ${filePath}: ${guard.reason ?? 'unknown error'}`;
|
||||||
|
}
|
||||||
75
apps/coder/src/services/lsp/client.ts
Normal file
75
apps/coder/src/services/lsp/client.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { createInterface } from 'node:readline';
|
||||||
|
import type { Readable, Writable } from 'node:stream';
|
||||||
|
|
||||||
|
interface RpcRequest {
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
id: number;
|
||||||
|
method: string;
|
||||||
|
params?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcResponse {
|
||||||
|
jsonrpc: '2.0';
|
||||||
|
id: number;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { code: number; message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LspClient {
|
||||||
|
private nextId = 1;
|
||||||
|
private pending = new Map<number, { resolve: (v: RpcResponse) => void; reject: (e: Error) => void }>();
|
||||||
|
private buffer = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private stdin: Writable,
|
||||||
|
private stdout: Readable,
|
||||||
|
) {
|
||||||
|
const rl = createInterface({ input: stdout, crlfDelay: Infinity });
|
||||||
|
rl.on('line', (line) => this.handleLine(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleLine(line: string): void {
|
||||||
|
this.buffer += line + '\n';
|
||||||
|
const match = this.buffer.match(/Content-Length: (\d+)\r?\n\r?\n/);
|
||||||
|
if (!match || !match[1]) return;
|
||||||
|
const len = parseInt(match[1], 10);
|
||||||
|
const headerEnd = match.index! + match[0].length;
|
||||||
|
const body = this.buffer.slice(headerEnd, headerEnd + len);
|
||||||
|
if (body.length < len) return;
|
||||||
|
this.buffer = this.buffer.slice(headerEnd + len);
|
||||||
|
try {
|
||||||
|
const msg: RpcResponse = JSON.parse(body);
|
||||||
|
const cb = this.pending.get(msg.id);
|
||||||
|
if (cb) {
|
||||||
|
this.pending.delete(msg.id);
|
||||||
|
cb.resolve(msg);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(method: string, params?: unknown): Promise<unknown> {
|
||||||
|
const id = this.nextId++;
|
||||||
|
const req: RpcRequest = { jsonrpc: '2.0', id, method, params };
|
||||||
|
const body = JSON.stringify(req);
|
||||||
|
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, {
|
||||||
|
resolve: (resp) => {
|
||||||
|
if (resp.error) reject(new Error(resp.error.message));
|
||||||
|
else resolve(resp.result);
|
||||||
|
},
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
this.stdin.write(header + body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async notify(method: string, params?: unknown): Promise<void> {
|
||||||
|
const body = JSON.stringify({ jsonrpc: '2.0', method, params });
|
||||||
|
const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`;
|
||||||
|
this.stdin.write(header + body);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/coder/src/services/lsp/config.ts
Normal file
19
apps/coder/src/services/lsp/config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export interface LspServerConfig {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
rootPatterns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TS_CONFIG: LspServerConfig = {
|
||||||
|
command: 'typescript-language-server',
|
||||||
|
args: ['--stdio'],
|
||||||
|
rootPatterns: ['package.json', 'tsconfig.json'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUPPORTED_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs']);
|
||||||
|
|
||||||
|
export function getServerConfig(filePath: string): LspServerConfig | null {
|
||||||
|
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||||
|
if (ext && SUPPORTED_EXTS.has(ext)) return TS_CONFIG;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
86
apps/coder/src/services/lsp/operations.ts
Normal file
86
apps/coder/src/services/lsp/operations.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { LspClient } from './client.js';
|
||||||
|
import type { Diagnostic, Location } from './types.js';
|
||||||
|
|
||||||
|
function fileUri(filePath: string): string {
|
||||||
|
return `file://${filePath.startsWith('/') ? '' : '/'}${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openDocument(
|
||||||
|
client: LspClient,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
version: number = 1,
|
||||||
|
): Promise<void> {
|
||||||
|
const uri = fileUri(filePath);
|
||||||
|
await client.notify('textDocument/didOpen', {
|
||||||
|
textDocument: { uri, languageId: 'typescript', version, text: content },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDocument(client: LspClient, filePath: string): Promise<void> {
|
||||||
|
await client.notify('textDocument/didClose', {
|
||||||
|
textDocument: { uri: fileUri(filePath) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDiagnostics(
|
||||||
|
client: LspClient,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<Diagnostic[]> {
|
||||||
|
const uri = fileUri(filePath);
|
||||||
|
await openDocument(client, filePath, content);
|
||||||
|
const result: any = await client.request('textDocument/diagnostic', {
|
||||||
|
textDocument: { uri },
|
||||||
|
});
|
||||||
|
await closeDocument(client, filePath);
|
||||||
|
const diagnostics: Diagnostic[] = [];
|
||||||
|
if (result?.diagnostics) {
|
||||||
|
for (const d of result.diagnostics) {
|
||||||
|
diagnostics.push({
|
||||||
|
range: d.range,
|
||||||
|
severity: d.severity ?? 1,
|
||||||
|
message: d.message,
|
||||||
|
source: d.source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gotoDefinition(
|
||||||
|
client: LspClient,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
line: number,
|
||||||
|
character: number,
|
||||||
|
): Promise<Location | null> {
|
||||||
|
const uri = fileUri(filePath);
|
||||||
|
await openDocument(client, filePath, content);
|
||||||
|
const result: any = await client.request('textDocument/definition', {
|
||||||
|
textDocument: { uri },
|
||||||
|
position: { line, character },
|
||||||
|
});
|
||||||
|
await closeDocument(client, filePath);
|
||||||
|
if (!result) return null;
|
||||||
|
const loc = Array.isArray(result) ? result[0] : result;
|
||||||
|
return loc ? { uri: loc.uri, range: loc.range } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findReferences(
|
||||||
|
client: LspClient,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
line: number,
|
||||||
|
character: number,
|
||||||
|
): Promise<Location[]> {
|
||||||
|
const uri = fileUri(filePath);
|
||||||
|
await openDocument(client, filePath, content);
|
||||||
|
const result: any = await client.request('textDocument/references', {
|
||||||
|
textDocument: { uri },
|
||||||
|
position: { line, character },
|
||||||
|
context: { includeDeclaration: true },
|
||||||
|
});
|
||||||
|
await closeDocument(client, filePath);
|
||||||
|
return (result ?? []).map((loc: any) => ({ uri: loc.uri, range: loc.range }));
|
||||||
|
}
|
||||||
119
apps/coder/src/services/lsp/server-manager.ts
Normal file
119
apps/coder/src/services/lsp/server-manager.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { LspClient } from './client.js';
|
||||||
|
import { getServerConfig } from './config.js';
|
||||||
|
|
||||||
|
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
const SWEEP_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
interface LspInstance {
|
||||||
|
client: LspClient;
|
||||||
|
proc: ChildProcess;
|
||||||
|
lastUsed: number;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LspServerManager {
|
||||||
|
private instances = new Map<string, LspInstance>();
|
||||||
|
private sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.startSweeper();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startSweeper(): void {
|
||||||
|
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
|
||||||
|
this.sweepTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private findProjectRoot(filePath: string): string | null {
|
||||||
|
let dir = filePath;
|
||||||
|
const config = getServerConfig(filePath);
|
||||||
|
if (!config) return null;
|
||||||
|
while (true) {
|
||||||
|
for (const pattern of config.rootPatterns) {
|
||||||
|
if (existsSync(join(dir, pattern))) return dir;
|
||||||
|
}
|
||||||
|
const parent = join(dir, '..');
|
||||||
|
if (parent === dir) return dir;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClient(filePath: string): Promise<LspClient | null> {
|
||||||
|
const config = getServerConfig(filePath);
|
||||||
|
if (!config) return null;
|
||||||
|
const projectRoot = this.findProjectRoot(filePath);
|
||||||
|
if (!projectRoot) return null;
|
||||||
|
|
||||||
|
const existing = this.instances.get(projectRoot);
|
||||||
|
if (existing) {
|
||||||
|
existing.lastUsed = Date.now();
|
||||||
|
clearTimeout(existing.timer);
|
||||||
|
existing.timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
|
||||||
|
existing.timer.unref?.();
|
||||||
|
return existing.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.spawn(projectRoot, config.command, config.args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async spawn(projectRoot: string, command: string, args: string[]): Promise<LspClient> {
|
||||||
|
const proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: projectRoot });
|
||||||
|
const client = new LspClient(proc.stdin!, proc.stdout!);
|
||||||
|
|
||||||
|
await client.request('initialize', {
|
||||||
|
processId: process.pid,
|
||||||
|
rootUri: `file://${projectRoot}`,
|
||||||
|
capabilities: {
|
||||||
|
textDocument: {
|
||||||
|
diagnostic: { dynamicRegistration: false },
|
||||||
|
definition: { dynamicRegistration: false },
|
||||||
|
references: { dynamicRegistration: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await client.notify('initialized', {});
|
||||||
|
|
||||||
|
const timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS);
|
||||||
|
timer.unref?.();
|
||||||
|
|
||||||
|
this.instances.set(projectRoot, { client, proc, lastUsed: Date.now(), timer });
|
||||||
|
proc.on('exit', () => this.instances.delete(projectRoot));
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private kill(projectRoot: string): void {
|
||||||
|
const inst = this.instances.get(projectRoot);
|
||||||
|
if (!inst) return;
|
||||||
|
this.instances.delete(projectRoot);
|
||||||
|
inst.proc.kill('SIGTERM');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (inst.proc.exitCode === null) inst.proc.kill('SIGKILL');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sweep(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [root, inst] of this.instances) {
|
||||||
|
if (now - inst.lastUsed > IDLE_TIMEOUT_MS) {
|
||||||
|
this.kill(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown(): void {
|
||||||
|
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
||||||
|
for (const root of [...this.instances.keys()]) {
|
||||||
|
this.kill(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveCount(): number {
|
||||||
|
return this.instances.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lspManager = new LspServerManager();
|
||||||
28
apps/coder/src/services/lsp/types.ts
Normal file
28
apps/coder/src/services/lsp/types.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export interface Position {
|
||||||
|
line: number;
|
||||||
|
character: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Range {
|
||||||
|
start: Position;
|
||||||
|
end: Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
uri: string;
|
||||||
|
range: Range;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Diagnostic {
|
||||||
|
range: Range;
|
||||||
|
severity: number;
|
||||||
|
message: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextDocumentItem {
|
||||||
|
uri: string;
|
||||||
|
languageId: string;
|
||||||
|
version: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { randomBytes } from 'node:crypto';
|
|||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import { resolveWritePath } from './write_guard.js';
|
import { resolveWritePath } from './write_guard.js';
|
||||||
import { locateMatch } from './fuzzy-match.js';
|
import { locateMatch } from './fuzzy-match.js';
|
||||||
|
import { validateEditResult, formatGuardError } from './edit-guards.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write a file atomically: stage to a sibling temp file, then rename over the
|
* Write a file atomically: stage to a sibling temp file, then rename over the
|
||||||
@@ -285,6 +286,10 @@ export async function applyOne(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (plan.kind === 'apply') {
|
if (plan.kind === 'apply') {
|
||||||
|
const guard = validateEditResult(toLf(raw), plan.updated, change.file_path);
|
||||||
|
if (!guard.ok) {
|
||||||
|
throw new Error(formatGuardError(guard, change.file_path));
|
||||||
|
}
|
||||||
const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated;
|
const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated;
|
||||||
await writeFileAtomic(change.file_path, out);
|
await writeFileAtomic(change.file_path, out);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { rewindTool } from './rewind.js';
|
|||||||
import { newTaskTool } from './new_task.js';
|
import { newTaskTool } from './new_task.js';
|
||||||
import { listTasksTool } from './list_tasks.js';
|
import { listTasksTool } from './list_tasks.js';
|
||||||
import { checkTaskStatusTool } from './check_task_status.js';
|
import { checkTaskStatusTool } from './check_task_status.js';
|
||||||
|
import { lspDiagnosticsTool } from './lsp_diagnostics.js';
|
||||||
|
import { lspGotoDefinitionTool } from './lsp_goto_definition.js';
|
||||||
|
import { lspFindReferencesTool } from './lsp_find_references.js';
|
||||||
|
|
||||||
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
|
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
|
||||||
|
|
||||||
@@ -26,4 +29,16 @@ export const WRITE_TOOLS: readonly ToolDef<any>[] = [
|
|||||||
checkTaskStatusTool,
|
checkTaskStatusTool,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool };
|
// Read-only agent tools for code intelligence.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const READ_TOOLS: readonly ToolDef<any>[] = [
|
||||||
|
lspDiagnosticsTool,
|
||||||
|
lspGotoDefinitionTool,
|
||||||
|
lspFindReferencesTool,
|
||||||
|
];
|
||||||
|
|
||||||
|
export {
|
||||||
|
editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool,
|
||||||
|
newTaskTool, listTasksTool, checkTaskStatusTool,
|
||||||
|
lspDiagnosticsTool, lspGotoDefinitionTool, lspFindReferencesTool,
|
||||||
|
};
|
||||||
|
|||||||
48
apps/coder/src/services/tools/lsp_diagnostics.ts
Normal file
48
apps/coder/src/services/tools/lsp_diagnostics.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { resolveWritePath } from '../write_guard.js';
|
||||||
|
import { lspManager } from '../lsp/server-manager.js';
|
||||||
|
import { getDiagnostics } from '../lsp/operations.js';
|
||||||
|
|
||||||
|
const LspDiagnosticsInput = z.object({
|
||||||
|
file_path: z.string().describe('Path to the file to check for diagnostics'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type InputT = z.infer<typeof LspDiagnosticsInput>;
|
||||||
|
|
||||||
|
export const lspDiagnosticsTool: ToolDef<InputT> = {
|
||||||
|
name: 'lsp_diagnostics',
|
||||||
|
description: 'Get TypeScript/JavaScript diagnostics (errors, warnings) for a file. Returns diagnostic messages with severity and location.',
|
||||||
|
inputSchema: LspDiagnosticsInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'lsp_diagnostics',
|
||||||
|
description: 'Get TypeScript/JavaScript diagnostics for a file',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string', description: 'Path to the file' },
|
||||||
|
},
|
||||||
|
required: ['file_path'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
|
||||||
|
const resolved = await resolveWritePath(projectRoot, input.file_path);
|
||||||
|
const content = await readFile(resolved, 'utf8');
|
||||||
|
const client = await lspManager.getClient(resolved);
|
||||||
|
if (!client) return { error: 'Unsupported file type for LSP diagnostics' };
|
||||||
|
|
||||||
|
const diagnostics = await getDiagnostics(client, resolved, content);
|
||||||
|
if (diagnostics.length === 0) return { result: 'No diagnostics found.' };
|
||||||
|
|
||||||
|
const lines = diagnostics.map((d) => {
|
||||||
|
const sev = ['', 'error', 'warning', 'info', 'hint'][d.severity] ?? 'unknown';
|
||||||
|
return `[${sev}] line ${d.range.start.line + 1}:${d.range.start.character + 1} - ${d.message}`;
|
||||||
|
});
|
||||||
|
return { result: lines.join('\n') };
|
||||||
|
},
|
||||||
|
};
|
||||||
49
apps/coder/src/services/tools/lsp_find_references.ts
Normal file
49
apps/coder/src/services/tools/lsp_find_references.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { resolveWritePath } from '../write_guard.js';
|
||||||
|
import { lspManager } from '../lsp/server-manager.js';
|
||||||
|
import { findReferences } from '../lsp/operations.js';
|
||||||
|
|
||||||
|
const LspFindReferencesInput = z.object({
|
||||||
|
file_path: z.string().describe('Path to the source file'),
|
||||||
|
line: z.number().int().nonnegative().describe('0-based line number'),
|
||||||
|
character: z.number().int().nonnegative().describe('0-based character offset'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type InputT = z.infer<typeof LspFindReferencesInput>;
|
||||||
|
|
||||||
|
export const lspFindReferencesTool: ToolDef<InputT> = {
|
||||||
|
name: 'lsp_find_references',
|
||||||
|
description: 'Find all references to a symbol at a given position in a file.',
|
||||||
|
inputSchema: LspFindReferencesInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'lsp_find_references',
|
||||||
|
description: 'Find all references to symbol at position',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string' },
|
||||||
|
line: { type: 'number' },
|
||||||
|
character: { type: 'number' },
|
||||||
|
},
|
||||||
|
required: ['file_path', 'line', 'character'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
|
||||||
|
const resolved = await resolveWritePath(projectRoot, input.file_path);
|
||||||
|
const content = await readFile(resolved, 'utf8');
|
||||||
|
const client = await lspManager.getClient(resolved);
|
||||||
|
if (!client) return { error: 'Unsupported file type' };
|
||||||
|
|
||||||
|
const refs = await findReferences(client, resolved, content, input.line, input.character);
|
||||||
|
if (refs.length === 0) return { result: 'No references found.' };
|
||||||
|
|
||||||
|
const lines = refs.map((r) => `${r.uri}:${r.range.start.line + 1}:${r.range.start.character + 1}`);
|
||||||
|
return { result: `Found ${refs.length} reference(s):\n${lines.join('\n')}` };
|
||||||
|
},
|
||||||
|
};
|
||||||
48
apps/coder/src/services/tools/lsp_goto_definition.ts
Normal file
48
apps/coder/src/services/tools/lsp_goto_definition.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import type { ToolDef, ToolContext } from './types.js';
|
||||||
|
import { resolveWritePath } from '../write_guard.js';
|
||||||
|
import { lspManager } from '../lsp/server-manager.js';
|
||||||
|
import { gotoDefinition } from '../lsp/operations.js';
|
||||||
|
|
||||||
|
const LspGotoDefinitionInput = z.object({
|
||||||
|
file_path: z.string().describe('Path to the source file'),
|
||||||
|
line: z.number().int().nonnegative().describe('0-based line number'),
|
||||||
|
character: z.number().int().nonnegative().describe('0-based character offset'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type InputT = z.infer<typeof LspGotoDefinitionInput>;
|
||||||
|
|
||||||
|
export const lspGotoDefinitionTool: ToolDef<InputT> = {
|
||||||
|
name: 'lsp_goto_definition',
|
||||||
|
description: 'Find the definition of a symbol at a given position in a file.',
|
||||||
|
inputSchema: LspGotoDefinitionInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'lsp_goto_definition',
|
||||||
|
description: 'Find definition of symbol at position',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string' },
|
||||||
|
line: { type: 'number' },
|
||||||
|
character: { type: 'number' },
|
||||||
|
},
|
||||||
|
required: ['file_path', 'line', 'character'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise<unknown> {
|
||||||
|
const resolved = await resolveWritePath(projectRoot, input.file_path);
|
||||||
|
const content = await readFile(resolved, 'utf8');
|
||||||
|
const client = await lspManager.getClient(resolved);
|
||||||
|
if (!client) return { error: 'Unsupported file type' };
|
||||||
|
|
||||||
|
const loc = await gotoDefinition(client, resolved, content, input.line, input.character);
|
||||||
|
if (!loc) return { result: 'No definition found.' };
|
||||||
|
|
||||||
|
return { result: `Defined at ${loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}` };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ const NewTaskInput = z.object({
|
|||||||
input: z.string().min(1).describe('Task description for the child subtask'),
|
input: z.string().min(1).describe('Task description for the child subtask'),
|
||||||
agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
|
agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
|
||||||
model: z.string().optional().describe('Optional: model override for the subtask'),
|
model: z.string().optional().describe('Optional: model override for the subtask'),
|
||||||
|
background: z.boolean().optional().describe('If true, return immediately without blocking on completion'),
|
||||||
});
|
});
|
||||||
|
|
||||||
type NewTaskInputT = z.infer<typeof NewTaskInput>;
|
type NewTaskInputT = z.infer<typeof NewTaskInput>;
|
||||||
@@ -30,6 +31,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
|||||||
input: { type: 'string', description: 'Task description for the child subtask' },
|
input: { type: 'string', description: 'Task description for the child subtask' },
|
||||||
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
|
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
|
||||||
model: { type: 'string', description: 'Optional: model override for the subtask' },
|
model: { type: 'string', description: 'Optional: model override for the subtask' },
|
||||||
|
background: { type: 'boolean', description: 'If true, returns immediately without waiting' },
|
||||||
},
|
},
|
||||||
required: ['input'],
|
required: ['input'],
|
||||||
},
|
},
|
||||||
@@ -50,6 +52,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
|||||||
return { error: 'Cannot determine project_id from current session' };
|
return { error: 'Cannot determine project_id from current session' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isBg = input.background === true;
|
||||||
const [task] = await sql<{ id: string; state: string }[]>`
|
const [task] = await sql<{ id: string; state: string }[]>`
|
||||||
INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
|
INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
|
||||||
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
|
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
|
||||||
@@ -57,9 +60,12 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
|
message: isBg
|
||||||
|
? `Background subtask created (id: ${task!.id}). It will continue independently.`
|
||||||
|
: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
|
||||||
task_id: task!.id,
|
task_id: task!.id,
|
||||||
state: task!.state,
|
state: task!.state,
|
||||||
|
background: isBg,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { deduplicate } from '../strategies/deduplication.js';
|
||||||
|
import type { DcpMessage } from '../messages.js';
|
||||||
|
|
||||||
|
describe('deduplicate', () => {
|
||||||
|
it('removes consecutive identical tool_call+tool_result pairs', () => {
|
||||||
|
const messages: DcpMessage[] = [
|
||||||
|
{ role: 'user', content: 'search for x' },
|
||||||
|
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||||
|
{ role: 'tool', content: 'result1', tool_call_id: '1' },
|
||||||
|
// Duplicate pair
|
||||||
|
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
|
||||||
|
{ role: 'tool', content: 'result1', tool_call_id: '2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { messages: result, stats } = deduplicate(messages);
|
||||||
|
expect(result).toHaveLength(3); // user + first pair
|
||||||
|
expect(stats.removedCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves non-duplicate content', () => {
|
||||||
|
const messages: DcpMessage[] = [
|
||||||
|
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||||
|
{ role: 'tool', content: 'result1', tool_call_id: '1' },
|
||||||
|
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
|
||||||
|
{ role: 'tool', content: 'result2', tool_call_id: '2' }, // Different result
|
||||||
|
];
|
||||||
|
|
||||||
|
const { messages: result, stats } = deduplicate(messages);
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
expect(stats.removedCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { toDcpMessages, fromDcpMessages } from '../messages.js';
|
||||||
|
|
||||||
|
describe('toDcpMessages', () => {
|
||||||
|
it('converts user messages', () => {
|
||||||
|
const result = toDcpMessages([{ role: 'user', content: 'hello' }]);
|
||||||
|
expect(result[0].role).toBe('user');
|
||||||
|
expect(result[0].content).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks Error: content as isError', () => {
|
||||||
|
const result = toDcpMessages([{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' }]);
|
||||||
|
expect(result[0].isError).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fromDcpMessages', () => {
|
||||||
|
it('round-trips messages', () => {
|
||||||
|
const original = [{ role: 'user', content: 'hello' }];
|
||||||
|
expect(fromDcpMessages(toDcpMessages(original))).toEqual(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { purgeErrors } from '../strategies/purge-errors.js';
|
||||||
|
import type { DcpMessage } from '../messages.js';
|
||||||
|
|
||||||
|
describe('purgeErrors', () => {
|
||||||
|
it('removes tool results where content starts with Error:', () => {
|
||||||
|
const messages: DcpMessage[] = [
|
||||||
|
{ role: 'tool', content: 'Error: file not found', tool_call_id: '1' },
|
||||||
|
{ role: 'tool', content: '{"files":[]}', tool_call_id: '2' },
|
||||||
|
];
|
||||||
|
const { messages: result, stats } = purgeErrors(messages);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(stats.removedCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes empty tool results', () => {
|
||||||
|
const messages: DcpMessage[] = [
|
||||||
|
{ role: 'tool', content: '', tool_call_id: '1' },
|
||||||
|
];
|
||||||
|
const { messages: result, stats } = purgeErrors(messages);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
expect(stats.removedCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves valid tool results', () => {
|
||||||
|
const messages: DcpMessage[] = [
|
||||||
|
{ role: 'tool', content: '{"files":["a.ts"]}', tool_call_id: '1' },
|
||||||
|
];
|
||||||
|
const { messages: result, stats } = purgeErrors(messages);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(stats.removedCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { transformMessages } from '../transform.js';
|
||||||
|
import type { DcpMessage } from '../messages.js';
|
||||||
|
|
||||||
|
describe('transformMessages', () => {
|
||||||
|
it('applies dedup then purge in order', () => {
|
||||||
|
const input: DcpMessage[] = [
|
||||||
|
{ role: 'user', content: 'hello' },
|
||||||
|
{ role: 'assistant', content: '', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||||
|
{ role: 'tool', content: 'result', tool_call_id: '1' },
|
||||||
|
{ role: 'assistant', content: '', tool_calls: [{ id: '2', name: 'grep', arguments: '{}' }] },
|
||||||
|
{ role: 'tool', content: 'result', tool_call_id: '2' }, // Dup
|
||||||
|
];
|
||||||
|
|
||||||
|
const { messages, stats } = transformMessages('test-chat', input);
|
||||||
|
expect(stats.removedCount).toBeGreaterThan(0);
|
||||||
|
expect(messages.length).toBeLessThan(input.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty input', () => {
|
||||||
|
const { messages, stats } = transformMessages('empty', []);
|
||||||
|
expect(messages).toHaveLength(0);
|
||||||
|
expect(stats.removedCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
4
apps/server/src/services/inference/dcp/index.ts
Normal file
4
apps/server/src/services/inference/dcp/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { transformMessages } from './transform.js';
|
||||||
|
export type { DcpMessage } from './messages.js';
|
||||||
|
export { toDcpMessages, fromDcpMessages } from './messages.js';
|
||||||
|
export { getDcpState, clearDcpState } from './state.js';
|
||||||
34
apps/server/src/services/inference/dcp/messages.ts
Normal file
34
apps/server/src/services/inference/dcp/messages.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// DCP message shape adapter.
|
||||||
|
// Converts between BooCode MessagePart[] and the DCP internal shape.
|
||||||
|
// Clean-room implementation — no AGPL source copied.
|
||||||
|
|
||||||
|
export interface DcpMessage {
|
||||||
|
role: 'user' | 'assistant' | 'tool';
|
||||||
|
content: string;
|
||||||
|
tool_call_id?: string;
|
||||||
|
tool_calls?: Array<{ id: string; name: string; arguments: string }>;
|
||||||
|
isError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDcpMessages(parts: any[]): DcpMessage[] {
|
||||||
|
return parts.map((p: any) => {
|
||||||
|
const msg: DcpMessage = { role: p.role, content: p.content ?? '' };
|
||||||
|
if (p.tool_call_id) msg.tool_call_id = p.tool_call_id;
|
||||||
|
if (p.tool_calls) msg.tool_calls = p.tool_calls;
|
||||||
|
if (p.isError) msg.isError = true;
|
||||||
|
if (p.role === 'tool' && p.content && p.content.startsWith('Error:')) {
|
||||||
|
msg.isError = true;
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromDcpMessages(msgs: DcpMessage[]): any[] {
|
||||||
|
return msgs.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
||||||
|
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
|
||||||
|
...(m.isError ? { isError: true } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
27
apps/server/src/services/inference/dcp/state.ts
Normal file
27
apps/server/src/services/inference/dcp/state.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Per-chat session state for DCP.
|
||||||
|
// Tracks last transform timestamp and message count to avoid re-processing.
|
||||||
|
|
||||||
|
interface ChatDcpState {
|
||||||
|
lastTransformAt: number;
|
||||||
|
lastMessageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatStates = new Map<string, ChatDcpState>();
|
||||||
|
|
||||||
|
export function getDcpState(chatId: string): ChatDcpState | undefined {
|
||||||
|
return chatStates.get(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDcpState(chatId: string, messageCount: number): void {
|
||||||
|
chatStates.set(chatId, { lastTransformAt: Date.now(), lastMessageCount: messageCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDcpState(chatId: string): void {
|
||||||
|
chatStates.delete(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldTransform(chatId: string, messageCount: number): boolean {
|
||||||
|
const state = chatStates.get(chatId);
|
||||||
|
if (!state) return true;
|
||||||
|
return state.lastMessageCount !== messageCount;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { DcpMessage } from '../messages.js';
|
||||||
|
|
||||||
|
export function deduplicate(messages: DcpMessage[]): { messages: DcpMessage[]; stats: { removedCount: number; freedTokens: number } } {
|
||||||
|
const result: DcpMessage[] = [];
|
||||||
|
let removedCount = 0;
|
||||||
|
let freedTokens = 0;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < messages.length) {
|
||||||
|
const current: DcpMessage = messages[i]!;
|
||||||
|
const next = messages[i + 1];
|
||||||
|
|
||||||
|
if (
|
||||||
|
current.role === 'assistant' &&
|
||||||
|
current.tool_calls &&
|
||||||
|
next &&
|
||||||
|
next.role === 'tool' &&
|
||||||
|
next.tool_call_id === current.tool_calls[0]?.id
|
||||||
|
) {
|
||||||
|
const nextNext = messages[i + 2];
|
||||||
|
const nextNextNext = messages[i + 3];
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextNext &&
|
||||||
|
nextNext.role === 'assistant' &&
|
||||||
|
nextNext.tool_calls &&
|
||||||
|
nextNextNext &&
|
||||||
|
nextNextNext.role === 'tool' &&
|
||||||
|
nextNextNext.tool_call_id === nextNext.tool_calls[0]?.id &&
|
||||||
|
nextNext.tool_calls[0]?.name === current.tool_calls[0]?.name &&
|
||||||
|
nextNext.tool_calls[0]?.arguments === current.tool_calls[0]?.arguments &&
|
||||||
|
nextNextNext.content === next.content
|
||||||
|
) {
|
||||||
|
result.push(current, next);
|
||||||
|
i += 4;
|
||||||
|
removedCount += 2;
|
||||||
|
freedTokens += Math.ceil(nextNext.content.length / 4);
|
||||||
|
freedTokens += Math.ceil(current.content.length / 4);
|
||||||
|
} else {
|
||||||
|
result.push(current);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push(current);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messages: result, stats: { removedCount, freedTokens } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Purge-errors strategy — removes failed/empty tool_result entries.
|
||||||
|
// Clean-room implementation.
|
||||||
|
|
||||||
|
import type { DcpMessage } from '../messages.js';
|
||||||
|
|
||||||
|
const ERROR_PREFIXES = ['Error:', 'error:', 'Error: '];
|
||||||
|
const DEFAULT_WINDOW = 5;
|
||||||
|
|
||||||
|
export function purgeErrors(
|
||||||
|
messages: DcpMessage[],
|
||||||
|
windowSize: number = DEFAULT_WINDOW,
|
||||||
|
): { messages: DcpMessage[]; stats: { removedCount: number; freedTokens: number } } {
|
||||||
|
const result: DcpMessage[] = [];
|
||||||
|
let removedCount = 0;
|
||||||
|
let freedTokens = 0;
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.role === 'tool') {
|
||||||
|
const shouldRemove =
|
||||||
|
msg.isError ||
|
||||||
|
ERROR_PREFIXES.some((p) => msg.content.startsWith(p)) ||
|
||||||
|
msg.content.trim() === '';
|
||||||
|
|
||||||
|
if (shouldRemove) {
|
||||||
|
removedCount++;
|
||||||
|
freedTokens += Math.ceil(msg.content.length / 4);
|
||||||
|
continue; // Skip this message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messages: result, stats: { removedCount, freedTokens } };
|
||||||
|
}
|
||||||
52
apps/server/src/services/inference/dcp/transform.ts
Normal file
52
apps/server/src/services/inference/dcp/transform.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Transform orchestrator — runs DCP strategies in sequence.
|
||||||
|
// Clean-room implementation.
|
||||||
|
|
||||||
|
import type { DcpMessage } from './messages.js';
|
||||||
|
import { deduplicate } from './strategies/deduplication.js';
|
||||||
|
import { purgeErrors } from './strategies/purge-errors.js';
|
||||||
|
import { getDcpState, setDcpState, shouldTransform } from './state.js';
|
||||||
|
|
||||||
|
export interface TransformStats {
|
||||||
|
removedCount: number;
|
||||||
|
freedTokens: number;
|
||||||
|
dedupRemoved: number;
|
||||||
|
purgeRemoved: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransformResult {
|
||||||
|
messages: DcpMessage[];
|
||||||
|
stats: TransformStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformMessages(chatId: string, messages: DcpMessage[]): TransformResult {
|
||||||
|
if (!shouldTransform(chatId, messages.length)) {
|
||||||
|
return { messages, stats: { removedCount: 0, freedTokens: 0, dedupRemoved: 0, purgeRemoved: 0 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
let m = messages;
|
||||||
|
|
||||||
|
// Step 1: Deduplicate
|
||||||
|
const dedupResult = deduplicate(m);
|
||||||
|
m = dedupResult.messages;
|
||||||
|
const dedupRemoved = dedupResult.stats.removedCount;
|
||||||
|
|
||||||
|
// Step 2: Purge errors
|
||||||
|
const purgeResult = purgeErrors(m);
|
||||||
|
m = purgeResult.messages;
|
||||||
|
const purgeRemoved = purgeResult.stats.removedCount;
|
||||||
|
|
||||||
|
const totalRemoved = dedupRemoved + purgeRemoved;
|
||||||
|
const totalFreed = dedupResult.stats.freedTokens + purgeResult.stats.freedTokens;
|
||||||
|
|
||||||
|
setDcpState(chatId, messages.length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: m,
|
||||||
|
stats: {
|
||||||
|
removedCount: totalRemoved,
|
||||||
|
freedTokens: totalFreed,
|
||||||
|
dedupRemoved,
|
||||||
|
purgeRemoved,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
68
apps/server/src/services/inference/loop-detectors.ts
Normal file
68
apps/server/src/services/inference/loop-detectors.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Loop detectors — detects repetitive patterns in assistant output
|
||||||
|
// that indicate a model is stuck in a loop.
|
||||||
|
|
||||||
|
export interface LoopDetectionResult {
|
||||||
|
isLoop: boolean;
|
||||||
|
reason?: string;
|
||||||
|
confidence: number; // 0-1
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPEATED_PHRASE_MIN_COUNT = 4;
|
||||||
|
const REPEATED_TOOL_MIN_COUNT = 3;
|
||||||
|
|
||||||
|
export function detectContentRepeat(messages: string[]): LoopDetectionResult {
|
||||||
|
if (messages.length < REPEATED_PHRASE_MIN_COUNT) {
|
||||||
|
return { isLoop: false, confidence: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const recent = messages.slice(-REPEATED_PHRASE_MIN_COUNT);
|
||||||
|
const unique = new Set(recent);
|
||||||
|
|
||||||
|
if (unique.size === 1) {
|
||||||
|
return {
|
||||||
|
isLoop: true,
|
||||||
|
reason: `Same content repeated ${REPEATED_PHRASE_MIN_COUNT} times`,
|
||||||
|
confidence: 0.9,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unique.size <= 2 && recent.length >= 4) {
|
||||||
|
return {
|
||||||
|
isLoop: true,
|
||||||
|
reason: 'Content oscillating between two variants',
|
||||||
|
confidence: 0.7,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isLoop: false, confidence: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectToolLoop(toolNames: string[]): LoopDetectionResult {
|
||||||
|
if (toolNames.length < REPEATED_TOOL_MIN_COUNT) return { isLoop: false, confidence: 0 };
|
||||||
|
|
||||||
|
const recent = toolNames.slice(-REPEATED_TOOL_MIN_COUNT);
|
||||||
|
const unique = new Set(recent);
|
||||||
|
|
||||||
|
if (unique.size === 1) {
|
||||||
|
return {
|
||||||
|
isLoop: true,
|
||||||
|
reason: `Same tool "${recent[0]}" called ${REPEATED_TOOL_MIN_COUNT} times consecutively`,
|
||||||
|
confidence: 0.85,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isLoop: false, confidence: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectDoomLoop(
|
||||||
|
messages: string[],
|
||||||
|
toolNames: string[],
|
||||||
|
): LoopDetectionResult {
|
||||||
|
const contentResult = detectContentRepeat(messages);
|
||||||
|
if (contentResult.isLoop) return contentResult;
|
||||||
|
|
||||||
|
const toolResult = detectToolLoop(toolNames);
|
||||||
|
if (toolResult.isLoop) return toolResult;
|
||||||
|
|
||||||
|
return { isLoop: false, confidence: 0 };
|
||||||
|
}
|
||||||
45
apps/server/src/services/inference/tool-shim.ts
Normal file
45
apps/server/src/services/inference/tool-shim.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// ToolShim — recovers structured tool calls from plain-text model output.
|
||||||
|
// When the model emits tool calls as plain text instead of structured JSON,
|
||||||
|
// this shim attempts to parse and recover them.
|
||||||
|
|
||||||
|
export interface ParsedToolCall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOL_CALL_PATTERN = /<tool_call>\s*<name>(.+?)<\/name>\s*<arguments>(.+?)<\/arguments>\s*<\/tool_call>/gs;
|
||||||
|
const JSON_TOOL_PATTERN = /\{\s*"name":\s*"([^"]+)",\s*"arguments":\s*({.+?})\s*\}/gs;
|
||||||
|
|
||||||
|
export function extractToolCalls(text: string): ParsedToolCall[] {
|
||||||
|
const calls: ParsedToolCall[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
// Try XML-style tool calls (common in Qwen output)
|
||||||
|
const xmlRegex = new RegExp(TOOL_CALL_PATTERN);
|
||||||
|
while ((match = xmlRegex.exec(text)) !== null) {
|
||||||
|
calls.push({
|
||||||
|
id: `call_${calls.length}`,
|
||||||
|
name: match[1]!.trim(),
|
||||||
|
arguments: match[2]!.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calls.length > 0) return calls;
|
||||||
|
|
||||||
|
// Try JSON-style tool calls
|
||||||
|
const jsonRegex = new RegExp(JSON_TOOL_PATTERN);
|
||||||
|
while ((match = jsonRegex.exec(text)) !== null) {
|
||||||
|
calls.push({
|
||||||
|
id: `call_${calls.length}`,
|
||||||
|
name: match[1]!.trim(),
|
||||||
|
arguments: match[2]!.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasToolCallMarkup(text: string): boolean {
|
||||||
|
return TOOL_CALL_PATTERN.test(text) || JSON_TOOL_PATTERN.test(text);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
buildMessagesPayload,
|
buildMessagesPayload,
|
||||||
loadContext,
|
loadContext,
|
||||||
} from './payload.js';
|
} from './payload.js';
|
||||||
|
import { toDcpMessages, transformMessages, fromDcpMessages } from './dcp/index.js';
|
||||||
import {
|
import {
|
||||||
finalizeCompletion,
|
finalizeCompletion,
|
||||||
finalizeEmpty,
|
finalizeEmpty,
|
||||||
@@ -156,9 +157,20 @@ export async function runAssistantTurn(
|
|||||||
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
|
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const { session: iterSession, project: iterProject, history } = loaded;
|
let { session: iterSession, project: iterProject, history } = loaded;
|
||||||
const projectRoot = await resolveProjectRoot(iterProject.path);
|
const projectRoot = await resolveProjectRoot(iterProject.path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dcpMsgs = toDcpMessages(history);
|
||||||
|
const { messages: pruned, stats } = transformMessages(chatId, dcpMsgs);
|
||||||
|
if (stats.removedCount > 0) {
|
||||||
|
ctx.log.info({ chatId, ...stats }, 'dcp: transform removed messages');
|
||||||
|
history = fromDcpMessages(pruned) as typeof history;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
ctx.log.warn({ err: err instanceof Error ? err.message : String(err), chatId }, 'dcp: transform skipped');
|
||||||
|
}
|
||||||
|
|
||||||
// v1.14.0: log step boundary for instrumentation. step_start parts are in
|
// v1.14.0: log step boundary for instrumentation. step_start parts are in
|
||||||
// the schema CHECK but not emitted here — writing to the assistant message
|
// the schema CHECK but not emitted here — writing to the assistant message
|
||||||
// before the stream phase creates a sequence-0 collision with
|
// before the stream phase creates a sequence-0 collision with
|
||||||
|
|||||||
31
apps/server/src/services/memory/__tests__/entries.test.ts
Normal file
31
apps/server/src/services/memory/__tests__/entries.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseMemoryEntries } from '../entries.js';
|
||||||
|
|
||||||
|
describe('parseMemoryEntries', () => {
|
||||||
|
it('parses a single entry with tags', () => {
|
||||||
|
const md = '## project: Indentation\n> tags: style\n\nUse two-space indentation\n';
|
||||||
|
const entries = parseMemoryEntries('style.md', md);
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0].title).toBe('Indentation');
|
||||||
|
expect(entries[0].topic).toBe('project');
|
||||||
|
expect(entries[0].tags).toEqual(['style']);
|
||||||
|
expect(entries[0].content).toContain('two-space');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses multiple entries', () => {
|
||||||
|
const md = [
|
||||||
|
'## project: Style',
|
||||||
|
'',
|
||||||
|
'Use tab indentation',
|
||||||
|
'',
|
||||||
|
'## user: Preference',
|
||||||
|
'',
|
||||||
|
'Prefer pnpm',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
const entries = parseMemoryEntries('mem.md', md);
|
||||||
|
expect(entries).toHaveLength(2);
|
||||||
|
expect(entries[0].topic).toBe('project');
|
||||||
|
expect(entries[1].topic).toBe('user');
|
||||||
|
});
|
||||||
|
});
|
||||||
14
apps/server/src/services/memory/__tests__/paths.test.ts
Normal file
14
apps/server/src/services/memory/__tests__/paths.test.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getMemoryRoot, getTopicDir } from '../paths.js';
|
||||||
|
|
||||||
|
describe('getMemoryRoot', () => {
|
||||||
|
it('returns .boocode/memory under project root', () => {
|
||||||
|
expect(getMemoryRoot('/proj')).toBe('/proj/.boocode/memory');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTopicDir', () => {
|
||||||
|
it('returns project/ under memory root', () => {
|
||||||
|
expect(getTopicDir('/r/.boocode/memory', 'project')).toBe('/r/.boocode/memory/project');
|
||||||
|
});
|
||||||
|
});
|
||||||
15
apps/server/src/services/memory/__tests__/prompt.test.ts
Normal file
15
apps/server/src/services/memory/__tests__/prompt.test.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatMemoryBlock } from '../prompt.js';
|
||||||
|
|
||||||
|
describe('formatMemoryBlock', () => {
|
||||||
|
it('wraps entries in boocode-memory tags', () => {
|
||||||
|
const block = formatMemoryBlock(['Use pnpm', 'Tests in vitest']);
|
||||||
|
expect(block).toContain('<boocode-memory>');
|
||||||
|
expect(block).toContain('Use pnpm');
|
||||||
|
expect(block).toContain('</boocode-memory>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for no entries', () => {
|
||||||
|
expect(formatMemoryBlock([])).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
15
apps/server/src/services/memory/__tests__/recall.test.ts
Normal file
15
apps/server/src/services/memory/__tests__/recall.test.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { rankByRelevance } from '../recall.js';
|
||||||
|
import type { MemoryEntry } from '../entries.js';
|
||||||
|
|
||||||
|
describe('rankByRelevance', () => {
|
||||||
|
it('returns entries matching query keywords', () => {
|
||||||
|
const entries: MemoryEntry[] = [
|
||||||
|
{ id: '1', topic: 'project', title: 'Style', content: 'Use two-space indentation', tags: ['style'] },
|
||||||
|
{ id: '2', topic: 'project', title: 'Tests', content: 'Use vitest for testing', tags: ['testing'] },
|
||||||
|
];
|
||||||
|
const result = rankByRelevance('what indentation?', entries);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].title).toBe('Style');
|
||||||
|
});
|
||||||
|
});
|
||||||
54
apps/server/src/services/memory/entries.ts
Normal file
54
apps/server/src/services/memory/entries.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
export interface MemoryEntry {
|
||||||
|
id: string;
|
||||||
|
topic: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMemoryEntries(fileName: string, markdown: string): MemoryEntry[] {
|
||||||
|
const entries: MemoryEntry[] = [];
|
||||||
|
const lines = markdown.split('\n');
|
||||||
|
let currentEntry: Partial<MemoryEntry> | null = null;
|
||||||
|
let currentContent: string[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const headingMatch = line.match(/^##\s+(.+):\s+(.+)$/);
|
||||||
|
if (headingMatch && headingMatch[1] && headingMatch[2]) {
|
||||||
|
if (currentEntry && currentEntry.title) {
|
||||||
|
entries.push({
|
||||||
|
id: `${fileName}-${entries.length}`,
|
||||||
|
topic: currentEntry.topic ?? '',
|
||||||
|
title: currentEntry.title,
|
||||||
|
content: currentContent.join('\n').trim(),
|
||||||
|
tags: currentEntry.tags ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
currentEntry = { topic: headingMatch[1].trim(), title: headingMatch[2].trim(), tags: [] };
|
||||||
|
currentContent = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsMatch = line.match(/^>\s*tags:\s*(.+)$/i);
|
||||||
|
if (tagsMatch && tagsMatch[1] && currentEntry) {
|
||||||
|
currentEntry.tags = tagsMatch[1].split(',').map((t) => t.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentEntry) {
|
||||||
|
currentContent.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentEntry && currentEntry.title) {
|
||||||
|
entries.push({
|
||||||
|
id: `${fileName}-${entries.length}`,
|
||||||
|
topic: currentEntry.topic ?? '',
|
||||||
|
title: currentEntry.title,
|
||||||
|
content: currentContent.join('\n').trim(),
|
||||||
|
tags: currentEntry.tags ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
6
apps/server/src/services/memory/index.ts
Normal file
6
apps/server/src/services/memory/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { loadMemoryForSession } from './recall.js';
|
||||||
|
export { formatMemoryBlock } from './prompt.js';
|
||||||
|
export { scanMemoryScopes } from './scan.js';
|
||||||
|
export { parseMemoryEntries } from './entries.js';
|
||||||
|
export { ensureMemoryScaffold, getMemoryRoot } from './paths.js';
|
||||||
|
export type { MemoryEntry } from './entries.js';
|
||||||
17
apps/server/src/services/memory/paths.ts
Normal file
17
apps/server/src/services/memory/paths.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
import { mkdir } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const TOPICS = ['project', 'user', 'reference'] as const;
|
||||||
|
export type MemoryTopic = (typeof TOPICS)[number];
|
||||||
|
|
||||||
|
export function getMemoryRoot(projectRoot: string): string {
|
||||||
|
return join(projectRoot, '.boocode', 'memory');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTopicDir(root: string, topic: MemoryTopic): string {
|
||||||
|
return join(root, topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureMemoryScaffold(root: string): Promise<void> {
|
||||||
|
await Promise.all(TOPICS.map((t) => mkdir(join(root, t), { recursive: true })));
|
||||||
|
}
|
||||||
5
apps/server/src/services/memory/prompt.ts
Normal file
5
apps/server/src/services/memory/prompt.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export function formatMemoryBlock(entries: string[]): string {
|
||||||
|
if (entries.length === 0) return '';
|
||||||
|
const body = entries.map((e) => `- ${e}`).join('\n');
|
||||||
|
return `<boocode-memory>\n${body}\n</boocode-memory>`;
|
||||||
|
}
|
||||||
44
apps/server/src/services/memory/recall.ts
Normal file
44
apps/server/src/services/memory/recall.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { MemoryEntry } from './entries.js';
|
||||||
|
import { scanProjectMemory } from './scan.js';
|
||||||
|
|
||||||
|
function extractKeywords(query: string): string[] {
|
||||||
|
return query
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s]/g, '')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((w) => w.length > 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rankByRelevance(query: string, entries: MemoryEntry[]): MemoryEntry[] {
|
||||||
|
const keywords = extractKeywords(query);
|
||||||
|
if (keywords.length === 0) return entries.slice(0, 5);
|
||||||
|
|
||||||
|
const scored = entries.map((entry) => {
|
||||||
|
let score = 0;
|
||||||
|
const searchText = `${entry.title} ${entry.content} ${entry.tags.join(' ')}`.toLowerCase();
|
||||||
|
for (const kw of keywords) {
|
||||||
|
if (entry.title.toLowerCase().includes(kw)) score += 3;
|
||||||
|
if (entry.tags.some((t) => t.toLowerCase().includes(kw))) score += 2;
|
||||||
|
if (entry.content.toLowerCase().includes(kw)) score += 1;
|
||||||
|
}
|
||||||
|
return { entry, score };
|
||||||
|
});
|
||||||
|
|
||||||
|
return scored
|
||||||
|
.filter((s) => s.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((s) => s.entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMemoryForSession(
|
||||||
|
projectRoot: string,
|
||||||
|
_sessionId?: string,
|
||||||
|
query?: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const entries = await scanProjectMemory(projectRoot);
|
||||||
|
if (entries.length === 0) return [];
|
||||||
|
|
||||||
|
const relevant = query ? rankByRelevance(query, entries) : entries.slice(0, 5);
|
||||||
|
return relevant.map((e) => `[${e.topic}] ${e.title}: ${e.content}`);
|
||||||
|
}
|
||||||
72
apps/server/src/services/memory/scan.ts
Normal file
72
apps/server/src/services/memory/scan.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { readFile, readdir } from 'node:fs/promises';
|
||||||
|
import type { MemoryEntry } from './entries.js';
|
||||||
|
import { parseMemoryEntries } from './entries.js';
|
||||||
|
import { getMemoryRoot } from './paths.js';
|
||||||
|
|
||||||
|
export interface MemoryScope {
|
||||||
|
projectRoot: string;
|
||||||
|
sessionDir?: string;
|
||||||
|
homeDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanDirectory(dir: string): Promise<MemoryEntry[]> {
|
||||||
|
const entries: MemoryEntry[] = [];
|
||||||
|
try {
|
||||||
|
const files = await readdir(dir, { withFileTypes: true });
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.isFile() && file.name.endsWith('.md')) {
|
||||||
|
const content = await readFile(join(dir, file.name), 'utf8');
|
||||||
|
entries.push(...parseMemoryEntries(file.name, content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEMORY_TOPICS = ['project', 'user', 'reference'] as const;
|
||||||
|
|
||||||
|
async function scanTopicDirs(root: string): Promise<MemoryEntry[]> {
|
||||||
|
const entries: MemoryEntry[] = [];
|
||||||
|
for (const topic of MEMORY_TOPICS) {
|
||||||
|
entries.push(...(await scanDirectory(join(root, topic))));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanMemoryScopes(scope: MemoryScope): Promise<MemoryEntry[]> {
|
||||||
|
const allEntries: MemoryEntry[] = [];
|
||||||
|
|
||||||
|
// 1. Global (~/.boocode/memory/) - lowest priority
|
||||||
|
allEntries.push(...(await scanTopicDirs(getMemoryRoot(homedir()))));
|
||||||
|
|
||||||
|
// 2. Home ($HOME/.boocode/memory)
|
||||||
|
const homeDir = scope.homeDir ?? homedir();
|
||||||
|
const homeRoot = getMemoryRoot(homeDir);
|
||||||
|
if (homeRoot !== getMemoryRoot(homedir())) {
|
||||||
|
allEntries.push(...(await scanTopicDirs(homeRoot)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Project (.boocode/memory/ under project root)
|
||||||
|
allEntries.push(...(await scanTopicDirs(getMemoryRoot(scope.projectRoot))));
|
||||||
|
|
||||||
|
// 4. Session (.boocode/sessions/<id>/memory.md) - highest priority
|
||||||
|
if (scope.sessionDir) {
|
||||||
|
try {
|
||||||
|
const sessionFile = join(scope.sessionDir, 'memory.md');
|
||||||
|
const content = await readFile(sessionFile, 'utf8');
|
||||||
|
allEntries.push(...parseMemoryEntries('session-memory', content));
|
||||||
|
} catch {
|
||||||
|
// No session memory file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanProjectMemory(projectRoot: string): Promise<MemoryEntry[]> {
|
||||||
|
return scanMemoryScopes({ projectRoot });
|
||||||
|
}
|
||||||
35
apps/server/src/services/memory/store.ts
Normal file
35
apps/server/src/services/memory/store.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { MemoryTopic } from './paths.js';
|
||||||
|
import { getTopicDir } from './paths.js';
|
||||||
|
|
||||||
|
export async function readTopicFiles(root: string, topic: MemoryTopic): Promise<Map<string, string>> {
|
||||||
|
const dir = getTopicDir(root, topic);
|
||||||
|
const files = new Map<string, string>();
|
||||||
|
try {
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||||
|
const content = await readFile(join(dir, entry.name), 'utf8');
|
||||||
|
files.set(entry.name, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist yet
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeEntry(
|
||||||
|
root: string,
|
||||||
|
topic: MemoryTopic,
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
tags: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const dir = getTopicDir(root, topic);
|
||||||
|
const tagLine = tags.length > 0 ? `> tags: ${tags.join(', ')}\n\n` : '\n';
|
||||||
|
const entry = `## ${topic}: ${title}\n${tagLine}${content}\n`;
|
||||||
|
const filename = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.md';
|
||||||
|
await writeFile(join(dir, filename), entry, 'utf8');
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ export const SYNTHESIS_TOOLS: ReadonlySet<string> = new Set([
|
|||||||
'get_codebase_overview',
|
'get_codebase_overview',
|
||||||
'get_framework_analysis',
|
'get_framework_analysis',
|
||||||
'get_semantic_neighborhoods',
|
'get_semantic_neighborhoods',
|
||||||
|
'get_blast_radius',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const TOP_N_FILES = 5;
|
const TOP_N_FILES = 5;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { readFile, stat } from 'node:fs/promises';
|
|||||||
import type { Agent, Project, Session } from '../types/api.js';
|
import type { Agent, Project, Session } from '../types/api.js';
|
||||||
import { getAgentsMtimes } from './agents.js';
|
import { getAgentsMtimes } from './agents.js';
|
||||||
import { resolveRoute } from './inference/provider.js';
|
import { resolveRoute } from './inference/provider.js';
|
||||||
|
import { loadMemoryForSession } from './memory/recall.js';
|
||||||
|
import { formatMemoryBlock } from './memory/prompt.js';
|
||||||
|
|
||||||
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
||||||
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
||||||
@@ -164,7 +166,11 @@ export async function buildSystemPromptWithFingerprint(
|
|||||||
let out = BASE_SYSTEM_PROMPT(project.path);
|
let out = BASE_SYSTEM_PROMPT(project.path);
|
||||||
const guidance = await getContainerGuidance();
|
const guidance = await getContainerGuidance();
|
||||||
if (guidance) {
|
if (guidance) {
|
||||||
out += `\n\n--- Container guidance ---\n${guidance}\n--- end container guidance ---\n`;
|
out += '\n\n--- Container guidance ---\n' + guidance + '\n--- end container guidance ---\n';
|
||||||
|
}
|
||||||
|
const memory = await loadMemoryForSession(project.path, session.id).catch(() => []);
|
||||||
|
if (memory.length > 0) {
|
||||||
|
out += '\n\n' + formatMemoryBlock(memory);
|
||||||
}
|
}
|
||||||
if (agent && agent.system_prompt.trim().length > 0) {
|
if (agent && agent.system_prompt.trim().length > 0) {
|
||||||
out += '\n\n' + agent.system_prompt.trim();
|
out += '\n\n' + agent.system_prompt.trim();
|
||||||
|
|||||||
31
apps/server/src/services/tools/codecontext/get_call_graph.ts
Normal file
31
apps/server/src/services/tools/codecontext/get_call_graph.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { makeCodecontextTool } from './factory.js';
|
||||||
|
|
||||||
|
export const GetCallGraphInput = z.object({
|
||||||
|
symbol: z.string().describe('Symbol name to analyze'),
|
||||||
|
depth: z.number().int().min(1).max(5).optional().describe('Max traversal depth (default 2)'),
|
||||||
|
});
|
||||||
|
export type GetCallGraphInputT = z.infer<typeof GetCallGraphInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Returns a call graph for a function or method: callers, callees, and transitive references. ' +
|
||||||
|
'Use to understand how a symbol is invoked and what it depends on.';
|
||||||
|
|
||||||
|
const { toolDef: getCallGraph, execute: executeGetCallGraph } =
|
||||||
|
makeCodecontextTool<GetCallGraphInputT>({
|
||||||
|
name: 'get_call_graph',
|
||||||
|
schema: GetCallGraphInput,
|
||||||
|
description: DESCRIPTION,
|
||||||
|
jsonParameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
symbol: { type: 'string', description: 'Symbol name to analyze' },
|
||||||
|
depth: { type: 'number', description: 'Max traversal depth (default 2)' },
|
||||||
|
},
|
||||||
|
required: ['symbol'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
mapArgs: (input) => ({ symbol: input.symbol, depth: input.depth ?? 2 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export { getCallGraph, executeGetCallGraph };
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { makeCodecontextTool } from './factory.js';
|
||||||
|
|
||||||
|
export const GetSymbolDetailsInput = z.object({
|
||||||
|
symbol: z.string().describe('Symbol name to resolve'),
|
||||||
|
file_path: z.string().optional().describe('Optional file path to narrow search'),
|
||||||
|
});
|
||||||
|
export type GetSymbolDetailsInputT = z.infer<typeof GetSymbolDetailsInput>;
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Returns type signature, definition location, and usage count for a named symbol. ' +
|
||||||
|
'Use after get_codebase_overview to dive deeper into specific functions, classes, or variables.';
|
||||||
|
|
||||||
|
const { toolDef: getSymbolDetails, execute: executeGetSymbolDetails } =
|
||||||
|
makeCodecontextTool<GetSymbolDetailsInputT>({
|
||||||
|
name: 'get_symbol_details',
|
||||||
|
schema: GetSymbolDetailsInput,
|
||||||
|
description: DESCRIPTION,
|
||||||
|
jsonParameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
symbol: { type: 'string', description: 'Symbol name to resolve' },
|
||||||
|
file_path: { type: 'string', description: 'Optional file path to narrow search' },
|
||||||
|
},
|
||||||
|
required: ['symbol'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
mapArgs: (input) => ({ symbol: input.symbol, file_path: input.file_path }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export { getSymbolDetails, executeGetSymbolDetails };
|
||||||
@@ -1,41 +1,38 @@
|
|||||||
# v1.12 Track B — codecontext sidecar container.
|
# v2.8 — boocontext sidecar container.
|
||||||
|
# Multi-stage build: Go shim from golang:1.24-alpine, boocontext MCP aggregator
|
||||||
|
# from node:20-alpine, then an alpine:3.20 runtime holding both.
|
||||||
#
|
#
|
||||||
# Multi-stage build: golang:1.24-alpine builder produces two binaries
|
# The shim spawns boocontext as a child MCP process over stdio NDJSON,
|
||||||
# (codecontext from source + our HTTP shim), then a minimal alpine:3.20
|
# translating HTTP requests to MCP tools/call.
|
||||||
# runtime holds both.
|
|
||||||
#
|
#
|
||||||
# No upstream Docker image exists for codecontext. We clone the repo
|
# To stage the fork source for a Docker build:
|
||||||
# directly because the module path declared in go.mod
|
# tar -czf codecontext/fork.tar.gz -C /opt/forks/boocontext \
|
||||||
# (github.com/nuthan-ms/codecontext) differs from the GitHub repo URL
|
# --exclude=.git --exclude=node_modules --exclude=dist
|
||||||
# (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
|
# Stage 1: Go shim builder
|
||||||
WORKDIR /build
|
FROM golang:1.24-alpine AS shim-builder
|
||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates build-base
|
|
||||||
|
|
||||||
# Build codecontext from the boocode-ts fork (has .codecontextignore support).
|
|
||||||
# Source is staged into the build context by the pre-build step:
|
|
||||||
# tar -czf codecontext/fork.tar.gz -C /opt/forks/codecontext .
|
|
||||||
# CGO is required: codecontext binds tree-sitter via cgo.
|
|
||||||
COPY fork.tar.gz /build/fork.tar.gz
|
|
||||||
RUN mkdir -p /build/codecontext && tar -xzf /build/fork.tar.gz -C /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
|
WORKDIR /build/shim
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
COPY go.mod ./
|
COPY go.mod ./
|
||||||
COPY shim.go ./
|
COPY shim.go ./
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /build/shim-bin ./
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /build/shim-bin ./
|
||||||
|
|
||||||
# Runtime: alpine matches the build target so codecontext's cgo bindings
|
# Stage 2: boocontext MCP builder
|
||||||
# resolve against the same musl libc.
|
FROM node:20-alpine AS boocontext-builder
|
||||||
|
WORKDIR /build/boocontext
|
||||||
|
RUN apk add --no-cache git python3 make g++ ca-certificates
|
||||||
|
COPY fork.tar.gz /build/fork.tar.gz
|
||||||
|
RUN mkdir -p /build/boocontext && tar -xzf /build/fork.tar.gz -C /build/boocontext
|
||||||
|
WORKDIR /build/boocontext
|
||||||
|
RUN npm ci && npm run build
|
||||||
|
|
||||||
|
# Stage 3: Runtime
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
RUN apk add --no-cache ca-certificates
|
RUN apk add --no-cache ca-certificates nodejs uv
|
||||||
COPY --from=builder /build/codecontext-bin /usr/local/bin/codecontext
|
COPY --from=shim-builder /build/shim-bin /usr/local/bin/shim
|
||||||
COPY --from=builder /build/shim-bin /usr/local/bin/shim
|
COPY --from=boocontext-builder /build/boocontext/dist /usr/local/lib/boocontext/dist
|
||||||
|
COPY --from=boocontext-builder /build/boocontext/node_modules /usr/local/lib/boocontext/node_modules
|
||||||
|
COPY --from=boocontext-builder /build/boocontext/package.json /usr/local/lib/boocontext/package.json
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -185,13 +186,14 @@ func notify(method string, params any) error {
|
|||||||
// ---- Child lifecycle ----
|
// ---- Child lifecycle ----
|
||||||
|
|
||||||
func startChild() error {
|
func startChild() error {
|
||||||
// `codecontext mcp` with --watch=true (the default) keeps fsnotify
|
// Support CODECONTEXT_CHILD env var for overriding the MCP child command.
|
||||||
// running on the indexed directory; the per-call target_dir swap
|
// Default to boocontext (Node.js MCP aggregator). Set in docker-compose.
|
||||||
// invalidates and re-indexes on demand. `--target=/opt/projects` is the
|
childCmd := os.Getenv("CODECONTEXT_CHILD")
|
||||||
// initial scan target — codecontext rebuilds the graph against whatever
|
if childCmd == "" {
|
||||||
// target_dir each call carries, so this is just a valid bootstrap path
|
childCmd = "node /usr/local/lib/boocontext/dist/index.js"
|
||||||
// (the default "." is the alpine root and trips on transient /proc fds).
|
}
|
||||||
child = exec.Command("codecontext", "mcp", "--target=/opt/projects", "--watch=true", "--respect-gitignore")
|
parts := strings.Split(childCmd, " ")
|
||||||
|
child = exec.Command(parts[0], parts[1:]...)
|
||||||
var err error
|
var err error
|
||||||
childStdin, err = child.StdinPipe()
|
childStdin, err = child.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -417,6 +419,9 @@ func main() {
|
|||||||
mux.HandleFunc("POST /v1/watch_changes", makeToolHandler("watch_changes"))
|
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_semantic_neighborhoods", makeToolHandler("get_semantic_neighborhoods"))
|
||||||
mux.HandleFunc("POST /v1/get_framework_analysis", makeToolHandler("get_framework_analysis"))
|
mux.HandleFunc("POST /v1/get_framework_analysis", makeToolHandler("get_framework_analysis"))
|
||||||
|
mux.HandleFunc("POST /v1/get_symbol_details", makeToolHandler("get_symbol_details"))
|
||||||
|
mux.HandleFunc("POST /v1/get_call_graph", makeToolHandler("get_call_graph"))
|
||||||
|
mux.HandleFunc("POST /v1/get_blast_radius", makeToolHandler("get_blast_radius"))
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: ":8080",
|
Addr: ":8080",
|
||||||
|
|||||||
@@ -109,10 +109,16 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
CODECONTEXT_CHILD: node /usr/local/lib/boocontext/dist/index.js
|
||||||
|
TYPE_INJECT_MCP_PATH: /opt/type-inject/packages/mcp/dist/index.js
|
||||||
|
TREE_SITTER_MCP_CMD: uvx
|
||||||
|
TREE_SITTER_MCP_ARGS: --from tree-sitter-analyzer[mcp] tree-sitter-analyzer-mcp
|
||||||
networks:
|
networks:
|
||||||
- boocode_net
|
- boocode_net
|
||||||
volumes:
|
volumes:
|
||||||
- /opt:/opt:ro
|
- /opt:/opt:ro
|
||||||
|
- /opt/forks:/opt/forks:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
9
packages/contracts/src/agent-capabilities.ts
Normal file
9
packages/contracts/src/agent-capabilities.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const AgentCapabilitiesSchema = z.object({
|
||||||
|
supportsStreaming: z.boolean().default(true),
|
||||||
|
supportsReasoningStream: z.boolean().default(false),
|
||||||
|
supportsBackgroundExecution: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AgentCapabilities = z.infer<typeof AgentCapabilitiesSchema>;
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
/** Arena types — single source of truth for cross-app Arena wire contracts. */
|
/** Arena types — single source of truth for cross-app Arena wire contracts. */
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
export type BattleType = 'coding' | 'qa';
|
export type BattleType = 'coding' | 'qa';
|
||||||
export type BattleStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
export type BattleStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
export type ContestantStatus = 'queued' | 'running' | 'done' | 'error';
|
export type ContestantStatus = 'queued' | 'running' | 'done' | 'error';
|
||||||
export type ContestantLane = 'local' | 'cloud';
|
export type ContestantLane = 'local' | 'cloud';
|
||||||
|
|
||||||
|
export const TokenBreakdownSchema = z.object({
|
||||||
|
system: z.number().int().nonnegative(),
|
||||||
|
user: z.number().int().nonnegative(),
|
||||||
|
assistant: z.number().int().nonnegative(),
|
||||||
|
tools: z.number().int().nonnegative(),
|
||||||
|
reasoning: z.number().int().nonnegative(),
|
||||||
|
total: z.number().int().nonnegative(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TokenBreakdown = z.infer<typeof TokenBreakdownSchema>;
|
||||||
|
|
||||||
// Pane state — carried on the WorkspacePane row, mirrors OrchestratorState.
|
// Pane state — carried on the WorkspacePane row, mirrors OrchestratorState.
|
||||||
export interface ArenaState {
|
export interface ArenaState {
|
||||||
battle_id: string;
|
battle_id: string;
|
||||||
@@ -38,6 +51,7 @@ export interface ContestantShape {
|
|||||||
duration_ms: number | null;
|
duration_ms: number | null;
|
||||||
tokens_per_sec: number | null;
|
tokens_per_sec: number | null;
|
||||||
cost_tokens: number | null;
|
cost_tokens: number | null;
|
||||||
|
token_breakdown: TokenBreakdown | null;
|
||||||
result_path: string | null;
|
result_path: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -49,4 +49,6 @@ export interface ProviderSnapshotEntry {
|
|||||||
commands: AgentCommand[];
|
commands: AgentCommand[];
|
||||||
error?: string;
|
error?: string;
|
||||||
fetchedAt?: string;
|
fetchedAt?: string;
|
||||||
|
supportsStreaming?: boolean;
|
||||||
|
supportsReasoningStream?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user