Compare commits
26 Commits
v2.7.20-ar
...
v2.8.1-ope
| Author | SHA1 | Date | |
|---|---|---|---|
| c935687725 | |||
| 0d6e9a2413 | |||
| 6344105877 | |||
| 028c08b4cd | |||
| fb52eb3efa | |||
| 648a59a563 | |||
| 7f59f30f2d | |||
| f436021bf9 | |||
| bef6bef504 | |||
| 87923cb07b | |||
| c6ecd984c5 | |||
| 2a83f61070 | |||
| 44874f0097 | |||
| 1b70d41996 | |||
| b64941ad4b | |||
| cdc782e044 | |||
| 02bb355a09 | |||
| b8b2666fdc | |||
| ee749d8698 | |||
| bc83475a3d | |||
| 214cc32ac2 | |||
| 6b7c2bab1e | |||
| 373ba86e5d | |||
| 9106334e70 | |||
| cce685b1a7 | |||
| dbf1662982 |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -2,6 +2,32 @@
|
||||
|
||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||
|
||||
## v2.8.0-fork-lifts — 2026-06-07
|
||||
|
||||
Completes the eight fork-lift integrations from `/opt/forks` into BooCode: boocontext sidecar upgrade, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol enhancements, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards (truncation + dropped imports) and the TokenScope analyzer/persist module. Closes the fork-lifts-mit epic.
|
||||
|
||||
**boocontext sidecar (Phase 3):** Upgrades the `codecontext` container from the old Go MCP server to the boocontext Node.js MCP aggregator. Multi-stage Dockerfile builds boocontext from `/opt/forks/boocontext` alongside the HTTP shim. `shim.go` gains `CODECONTEXT_CHILD` env-var support and three new HTTP routes for symbols, callgraph, and blast radius. Three TypeScript tool wrappers (`get_symbol_details`, `get_call_graph`, `get_blast_radius`) registered on the server, with blast radius added to the synthesis pipeline. Docker-compose env vars configure child MCP paths (tree-sitter-analyzer, type-inject).
|
||||
|
||||
**LSP integration (Phase 4):** Six-file `lsp/` module in the coder with config, JSON-RPC stdio client, lazy server-manager (per-project pool, 5-min idle shutdown), and operations (diagnostics, goto-definition, find-references). Three read-only agent tools registered — `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`. TypeScript/JavaScript only in v1.
|
||||
|
||||
**DCP clean-room (Phase 5):** Seven-file `dcp/` module in the server inference pipeline. Consecutive identical tool_call+tool_result pairs are deduplicated; failed/empty tool results are purged via configurable window. Orchestrated by `transformMessages()` running before `buildMessagesPayload` in `turn.ts`. Clean-room reimplementation — AGPL source was referenced for behavior only. 10 unit tests.
|
||||
|
||||
**Institutional memory (Phase 6):** Eight-file `memory/` module with file-based recall. Hierarchical 4-scope scan (global → home → project → session) under `.boocode/memory/`. Keyword/tag relevance matching at prompt assembly. Injected as a `<boocode-memory>` block in the system prompt. v1 recall-only — extract/dream deferred.
|
||||
|
||||
**Subagent protocol (Phase 7):** `AgentCapabilitiesSchema` in contracts with `supportsStreaming`, `supportsReasoningStream`, `supportsBackgroundExecution` flags. `ProviderSnapshotEntry` gains the two streaming capability fields. `new_task` tool gets a `background` mode flag for non-blocking dispatch. Flow-runner already supported per-step model override.
|
||||
|
||||
**Plugin host (Phase 8):** Typed hook registry in `plugins/host.ts` with `registerHook`/`emitHook` for five lifecycle events: `tool.execute.before`, `tool.execute.after`, `turn.start`, `turn.end`, `task.terminal`. Patterns-only from oh-my-openagent (SUL — no code copy).
|
||||
|
||||
**Inference reliability (Phase 9):** `tool-shim.ts` recovers XML/JSON tool calls from plain-text model output (e.g. Qwen inline format). `loop-detectors.ts` catches content-repeat and tool-loop patterns. Existing doom-loop detection remains — detectors are additive.
|
||||
|
||||
**Edit safety guards (Wave 1):** `edit-guards.ts` rejects catastrophic truncation (>60% chars AND >50% lines). `edit-guards-imports.ts` detects dropped import statements. Both run in `pending_changes.ts` immediately before `writeFileAtomic`.
|
||||
|
||||
**TokenScope (Wave 2):** `TokenBreakdownSchema` in contracts with system/user/assistant/tools/reasoning categories. `token-analysis/` module with analyzer and DB persistence. `ContestantShape.token_breakdown` field and `token_breakdown` JSONB column on `contestants`/`tasks` tables. Arena `computeBenchmark` accepts and returns token breakdown.
|
||||
|
||||
**Build:** Server 649 ✅ Coder 471 ✅ Contracts ✅ — all green.
|
||||
|
||||
Adds the **Arena** pane for running the same prompt against 2–6 AI competitors simultaneously and picking the best result. A Battle is one Arena run: pick a battle type (Coding — backend+model with git worktrees producing diffs; or Q&A — BooChat persona+model producing text), write or generate a prompt, add contestants, and hit Start. Contestants are scheduled in two concurrent lanes — the local lane (llama-swap models, serial) and the cloud lane (Claude Code, OpenCode-on-cloud, parallel). The lane scheduler captures wall-clock duration for every contestant and tokens/sec for local models. When all contestants finish, a two-stage analysis (digest then judge) auto-runs on the DEFAULT_MODEL, writing `analysis.md` naming a winner; the user can override the winner per-row or trigger cross-examination. Results land in `/<project-root>/Arena/<dated-battle>/` with per-contestant `result.md`, diff patches for coding, and `manifest.json`. Replaces the old API-only `POST /api/arena` with dedicated `battles`/`contestants`/`cross_examinations` tables and full UI. Also adds a `DiffView` component with line-by-line colored unified diff and a per-row dropdown for winner override. Built on `v2.7.18-permission-modes`; pairs conceptually with the earlier `v2.7.17-orchestrator` multi-agent work (both share the pane kind pattern and `onTaskTerminal` hook).
|
||||
|
||||
## v2.7.18-permission-modes — 2026-06-05
|
||||
|
||||
Adds a unified **permission picker** to the BooCoder composer — Plan / Ask Permission / Bypass — replacing the old raw per-agent mode dropdown that exposed each agent's full native vocabulary with inconsistent labels. The three options map generically onto every provider's existing mode metadata: the `plan`-id mode → Plan, the default mode → Ask, the `isUnattended` mode → Bypass (claude `bypassPermissions`, qwen `yolo`, opencode `full-access`); goose has no modes so it shows no picker, exactly as before. `modeId` stays the single wire field — the active unified mode is derived from it, so no contracts change was needed. Native BooCode gains its own mode set (registered in the manifest and exposed by the snapshot): **Ask** stages edits to the pending-changes queue as today, **Bypass** auto-applies the queue to disk after the turn (both the interactive messages path and the task-based dispatcher path), and **Plan** falls back to Ask — the shared `apps/server` inference engine is deliberately left untouched. A supporting fix preserves the `isUnattended` flag on live-probed ACP modes (`acp-derive.ts`) so opencode's bypass mode is still detectable from the wire. Coder 373 tests green, coder + web typecheck clean. Built on `v2.7.17-orchestrator`.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Current focus
|
||||
|
||||
Last updated: 2026-06-05
|
||||
Last updated: 2026-06-07
|
||||
|
||||
- **Last shipped:** `v2.7.18-permission-modes` (2026-06-05) — unified Plan/Ask/Bypass permission picker in the BooCoder composer (incl. native-BooCode auto-apply on Bypass).
|
||||
- **Last shipped:** `v2.8.0-fork-lifts` (2026-06-07) — eight fork-lift integrations from `/opt/forks`: boocontext sidecar, LSP code intelligence, DCP clean-room pruning, institutional memory, subagent protocol, plugin hook host, inference reliability (tool-shim + loop detectors), and TokenScope token breakdown. Backfills edit safety guards and TokenScope analyzer/persist module.
|
||||
- **Branch:** `main`
|
||||
- **In progress:** nothing committed — dogfooding the Orchestrator to surface the next real backlog. Claude Agent-SDK backend enabled (`CLAUDE_SDK_BACKEND`). Optional/exploratory: verify-gate ensembler over pending changes.
|
||||
- **In progress:** nothing committed — all phases 3-9 of fork-lifts-mit epic are shipped. Optional/exploratory: verify-gate ensembler over pending changes; web Arena token UI display.
|
||||
|
||||
See `CHANGELOG.md` for the full shipped history. That file is always authoritative; this file is a quick orientation pointer only.
|
||||
|
||||
@@ -37,3 +37,10 @@
|
||||
|
||||
- **In-app multi-agent conductor**: `services/flow-runner.ts` runs a flow by inserting each step as a `tasks` row (the existing dispatcher runs it) and advancing on a new `onTaskTerminal` dispatcher-deps hook; persisted in `flow_runs`/`flow_steps` (resumed at startup via `initResume`). The 22 conductor flow defs + Spine factory are re-homed under `src/conductor/`. Pure scheduler/resume helpers in `flow-runner-decisions.ts`. Full design: `openspec/changes/archived/orchestrator/`.
|
||||
- **Read-only is load-bearing — don't add a dispatch path that bypasses it.** Every step dispatches `agent='qwen', mode_id='plan'`; `dispatcher.ts` force-routes qwen+plan to the PTY `--approval-mode plan` gate and HARD-FAILS the task (never falls to write-capable native inference) when qwen is unavailable (`shouldFailOnMissingAgent`). `BOOCODE_TOOLS` gates BooChat's NATIVE inference tools only — it does NOT govern an external CLI agent (qwen/opencode bring their own write tools); read-only for a dispatched agent is the agent-layer mode (PTY `--approval-mode plan`; ACP `setSessionMode` is fail-OPEN by default, fail-CLOSED for `plan` via `READ_ONLY_MODE_IDS` in `acp-dispatch.ts`).
|
||||
|
||||
## Edit safety guards (v2.8)
|
||||
|
||||
- **`services/edit-guards.ts`** — `validateEditResult(original, updated, filePath)` runs in `pending_changes.ts` immediately before `writeFileAtomic`. Rejects catastrophic truncation (>60% char loss AND >50% line loss). Throws a `formatGuardError` message that percolates to the agent as a visible error.
|
||||
- **`services/edit-guards-imports.ts`** — `checkDroppedImports(original, updated, filePath)` detects removed import/require lines. Called alongside the truncation guard.
|
||||
- Both guards run on the `/apply` path only (not on queue). Re-queued identical edits re-validate at apply time.
|
||||
- Guard functions are pure — no DB or filesystem access. Easy to unit-test.
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from './planning.js';
|
||||
import { adr, codingStandard, runbook, tdd, stakeholderSummary } from './authoring.js';
|
||||
import { codeReview } from './code-review.js';
|
||||
import { parallelResearch } from './parallel-research.js';
|
||||
|
||||
const spines: Spine[] = [
|
||||
// analysis / research
|
||||
@@ -53,7 +54,7 @@ const spines: Spine[] = [
|
||||
stakeholderSummary,
|
||||
];
|
||||
|
||||
const bespoke: Flow[] = [codeReview];
|
||||
const bespoke: Flow[] = [codeReview, parallelResearch];
|
||||
|
||||
const ALL: Flow[] = [...spines.map(buildSpineFlow), ...bespoke];
|
||||
|
||||
|
||||
59
apps/coder/src/conductor/flows/parallel-research.ts
Normal file
59
apps/coder/src/conductor/flows/parallel-research.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Flow, Step, StepContext } from '../types.js';
|
||||
|
||||
const q = (ctx: StepContext) => String(ctx.input.question);
|
||||
|
||||
/**
|
||||
* Parallel research flow — dispatches 3 research agents simultaneously,
|
||||
* then synthesizes the result on the first one to complete.
|
||||
*/
|
||||
export const parallelResearch: Flow = {
|
||||
name: 'parallel-research',
|
||||
description: 'Research from 3 angles in parallel, synthesize results on first completion',
|
||||
steps: [
|
||||
{
|
||||
id: 'angle-web',
|
||||
kind: 'agent',
|
||||
agent: 'research-analyst',
|
||||
run: (ctx) =>
|
||||
`Research the following question from a web / prior-art perspective:\n\n${q(ctx)}`,
|
||||
},
|
||||
{
|
||||
id: 'angle-code',
|
||||
kind: 'agent',
|
||||
agent: 'codebase-explorer',
|
||||
deps: [],
|
||||
run: (ctx) =>
|
||||
`Research the following question from a codebase analysis perspective:\n\n${q(ctx)}`,
|
||||
},
|
||||
{
|
||||
id: 'angle-security',
|
||||
kind: 'agent',
|
||||
agent: 'adversarial-security-analyst',
|
||||
deps: [],
|
||||
run: (ctx) =>
|
||||
`Research the following question from a security perspective:\n\n${q(ctx)}`,
|
||||
},
|
||||
{
|
||||
id: 'synthesize',
|
||||
kind: 'code',
|
||||
deps: ['angle-web', 'angle-code', 'angle-security'],
|
||||
trigger_rule: 'one_success',
|
||||
run: (ctx) => {
|
||||
const web = ctx.results['angle-web'];
|
||||
const code = ctx.results['angle-code'];
|
||||
const security = ctx.results['angle-security'];
|
||||
const parts = [
|
||||
'# Parallel Research Synthesis',
|
||||
'',
|
||||
web ? `## Web Angle\n${web}` : '## Web Angle\n*(not yet completed)*',
|
||||
code ? `## Code Angle\n${code}` : '## Code Angle\n*(not yet completed)*',
|
||||
security ? `## Security Angle\n${security}` : '## Security Angle\n*(not yet completed)*',
|
||||
];
|
||||
return parts.join('\n\n');
|
||||
},
|
||||
},
|
||||
],
|
||||
render: (ctx) => {
|
||||
return ctx.results['synthesize'] ?? 'No synthesis produced.';
|
||||
},
|
||||
};
|
||||
@@ -38,7 +38,9 @@ export interface StepContext {
|
||||
readonly model?: string;
|
||||
}
|
||||
|
||||
export type StepKind = 'agent' | 'code';
|
||||
export type StepKind = 'agent' | 'code' | 'approval';
|
||||
|
||||
export type TriggerRule = 'all_success' | 'one_success' | 'all_done';
|
||||
|
||||
export interface Step {
|
||||
/** unique id within the flow; other steps depend on it by this id */
|
||||
@@ -46,6 +48,8 @@ export interface Step {
|
||||
kind: StepKind;
|
||||
/** ids that must complete (or skip) before this step runs */
|
||||
deps?: string[];
|
||||
/** how dependency satisfaction is evaluated (default: all_success) */
|
||||
trigger_rule?: TriggerRule;
|
||||
/** for kind:'agent' — the persona file name under conductor/agents (no .md) */
|
||||
agent?: string;
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
|
||||
import { WRITE_TOOLS } from './services/tools/index.js';
|
||||
import { adaptWriteTool } from './services/tools/adapter.js';
|
||||
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
|
||||
import { runWithInferenceContext } from './services/tools/inference_context.js';
|
||||
// Routes
|
||||
import { registerMessageRoutes } from './routes/messages.js';
|
||||
import { registerSkillRoutes } from './routes/skills.js';
|
||||
@@ -174,22 +174,27 @@ async function main() {
|
||||
}
|
||||
);
|
||||
|
||||
// Wrap the inference runner to set/clear the write-tool context around each run.
|
||||
// The inference runner calls enqueue() which fires asynchronously — we hook
|
||||
// into the enqueue to set context before the run starts.
|
||||
// Wrap the inference runner to bind the write-tool context around each run.
|
||||
// enqueue() starts its async loop synchronously, so wrapping the call in
|
||||
// runWithInferenceContext propagates the per-run context (sql, sessionId, the
|
||||
// Plan/Ask/Bypass gate) through every awaited tool execution — and concurrent
|
||||
// runs (a user message racing a dispatcher-polled native task) each get their
|
||||
// own, instead of clobbering a shared global.
|
||||
const inferenceApi = {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => {
|
||||
// Set the inference context so write tools can access sql + sessionId.
|
||||
// The context persists for the duration of the inference run. Since
|
||||
// BooCoder is single-user and runs one inference at a time per session,
|
||||
// this module-level state is safe.
|
||||
setInferenceContext({ sql, sessionId, taskId: null });
|
||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||
enqueue: (
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
assistantId: string,
|
||||
user: string,
|
||||
permissionMode?: 'plan' | 'ask' | 'bypass',
|
||||
) => {
|
||||
runWithInferenceContext({ sql, sessionId, taskId: null, permissionMode }, () => {
|
||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||
});
|
||||
},
|
||||
cancel: async (sessionId: string, chatId: string) => {
|
||||
const result = await inference.cancel(sessionId, chatId);
|
||||
clearInferenceContext();
|
||||
return result;
|
||||
// No context to clear — AsyncLocalStorage scopes it to each run's own chain.
|
||||
return inference.cancel(sessionId, chatId);
|
||||
},
|
||||
hasActive: (chatId: string) => inference.hasActive(chatId),
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -205,7 +205,7 @@ export function registerArenaRoutes(
|
||||
|
||||
const contestants = await sql`
|
||||
SELECT id, battle_id, identity, model, lane, task_id, worktree_id,
|
||||
status, duration_ms, tokens_per_sec, cost_tokens, result_path, error,
|
||||
status, duration_ms, tokens_per_sec, cost_tokens, token_breakdown, result_path, error,
|
||||
created_at, updated_at
|
||||
FROM contestants
|
||||
WHERE battle_id = ${id}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Sql } from '../db.js';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import { resolveChatId } from './chat-resolve.js';
|
||||
import { applyAll } from '../services/pending_changes.js';
|
||||
import { asPermissionMode } from '../services/tools/types.js';
|
||||
|
||||
const AnswerUserInputBody = z.object({
|
||||
tool_call_id: z.string().min(1),
|
||||
@@ -44,7 +44,13 @@ const SendBody = z.object({
|
||||
});
|
||||
|
||||
interface InferenceApi {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||
enqueue: (
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
assistantId: string,
|
||||
user: string,
|
||||
permissionMode?: 'plan' | 'ask' | 'bypass',
|
||||
) => void;
|
||||
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
hasActive: (chatId: string) => boolean;
|
||||
}
|
||||
@@ -246,36 +252,16 @@ export function registerMessageRoutes(
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default');
|
||||
|
||||
// Bypass permission mode (native BooCode): auto-apply staged edits to disk
|
||||
// once the turn settles. `enqueue` registers synchronously, so hasActive is
|
||||
// true immediately; poll until it clears, apply, then re-publish
|
||||
// message_complete so the DiffPanel reflects the now-applied (non-pending)
|
||||
// state. Best-effort — failures stay in the pending queue for manual apply.
|
||||
if (mode_id === 'bypass') {
|
||||
const projectId = sessionRows[0]!.project_id;
|
||||
const assistantId = assistantMsg!.id;
|
||||
void (async () => {
|
||||
try {
|
||||
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${projectId}`;
|
||||
if (!proj?.path) return;
|
||||
for (let i = 0; i < 1200 && inference.hasActive(chatId); i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
const applied = await applyAll(sql, sessionId, proj.path);
|
||||
if (applied.length > 0) {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
} as unknown as WsFrame);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort auto-apply — leave staged changes for manual apply */
|
||||
}
|
||||
})();
|
||||
}
|
||||
// Native BooCode permission gate (plan/ask/bypass) — threaded into the
|
||||
// write-tool context so create/edit/delete and apply_pending honor it.
|
||||
// Plan = read-only, Ask = stage to the queue (agent can't self-apply),
|
||||
// Bypass = apply each write immediately. Other mode ids (e.g. an external
|
||||
// fallback's native mode) leave the gate undefined = legacy behavior.
|
||||
req.log.info(
|
||||
{ provider, mode_id, permissionMode: asPermissionMode(mode_id), chatId },
|
||||
'native enqueue — permission gate',
|
||||
);
|
||||
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default', asPermissionMode(mode_id));
|
||||
|
||||
reply.code(202);
|
||||
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
||||
|
||||
@@ -423,3 +423,18 @@ CREATE INDEX IF NOT EXISTS contestants_task_id_idx ON contestants(task_id);
|
||||
|
||||
-- Cross-examination listing per battle.
|
||||
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;
|
||||
|
||||
-- Orchestrator flow step events (append-only event log for resume/replay).
|
||||
CREATE TABLE IF NOT EXISTS flow_step_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
run_id UUID NOT NULL REFERENCES flow_runs(id),
|
||||
step_id VARCHAR(64) NOT NULL,
|
||||
event VARCHAR(32) NOT NULL,
|
||||
payload JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS flow_step_events_run_idx ON flow_step_events(run_id);
|
||||
|
||||
@@ -162,6 +162,24 @@ describe('computeBenchmark', () => {
|
||||
expect(bench.durationMs).toBe(0);
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -161,6 +161,52 @@ describe('locateMatch — strategy 4: Levenshtein', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('locateMatch — strategy 4: fail-closed on ambiguity (corruption guard)', () => {
|
||||
it('refuses (ambiguous) when two equally-similar anchored blocks both clear the bar', () => {
|
||||
// The repetitive-file case that duplicated blocks: two blocks share the same
|
||||
// first+last anchor lines and their middle lines are EQUALLY similar to the
|
||||
// (drifted) needle. Tier 4 must refuse rather than splice over one of them.
|
||||
const content = [
|
||||
'const x = {',
|
||||
' total = aa;',
|
||||
'};',
|
||||
'const x = {',
|
||||
' total = bb;',
|
||||
'};',
|
||||
].join('\n');
|
||||
const needle = ['const x = {', ' total = ab;', '};'].join('\n');
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result.kind).toBe('ambiguous');
|
||||
});
|
||||
|
||||
it('refuses a below-threshold near-miss that the old 0.66 floor would have spliced', () => {
|
||||
// ~0.7 similar: under the raised 0.85 floor this is now not_found, so the
|
||||
// caller surfaces a correctable error instead of corrupting the file.
|
||||
const content = 'const grandTotalAmount = a + b;\n';
|
||||
const needle = 'const totalValue = a + b;';
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result).toEqual({ kind: 'not_found' });
|
||||
});
|
||||
|
||||
it('still matches a single genuine high-similarity drift uniquely', () => {
|
||||
const content = 'const total = sum + tax;\n';
|
||||
const needle = 'const totals = sum + tax;'; // one-char typo, ~0.96
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result.kind).toBe('fuzzy');
|
||||
const { start, end } = span(result);
|
||||
expect(content.slice(start, end)).toBe('const total = sum + tax;');
|
||||
});
|
||||
|
||||
it('requires an exact first+last line anchor for multi-line needles', () => {
|
||||
// First line drifted too far to anchor → no window is scored → not_found,
|
||||
// even though the middle lines are identical.
|
||||
const content = ['function compute() {', ' return a + b;', ' return done;', '}'].join('\n');
|
||||
const needle = ['totally different opener', ' return a + b;', '}'].join('\n');
|
||||
const result = locateMatch(content, needle);
|
||||
expect(result).toEqual({ kind: 'not_found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('locateMatch — edge cases', () => {
|
||||
it('returns not_found for an empty needle', () => {
|
||||
expect(locateMatch('anything', '')).toEqual({ kind: 'not_found' });
|
||||
|
||||
@@ -83,6 +83,53 @@ describe.runIf(!!process.env.DATABASE_URL)('pending_changes integration', () =>
|
||||
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(false);
|
||||
});
|
||||
|
||||
it('re-emitted identical edits dedupe at queue and never duplicate on apply', async () => {
|
||||
// Regression: the 2-3x block-stamping corruption. An anchored insert queued
|
||||
// three times (a local model re-emitting the same tool call) must collapse to
|
||||
// ONE pending row and apply exactly once.
|
||||
await queueCreate(sql, testSessionId, null, 'dup.js', '<script>\nrender();\n', projectRoot)
|
||||
.then((c) => applyOne(sql, c.id, projectRoot));
|
||||
|
||||
const oldStr = '<script>';
|
||||
const newStr = '<script>\nconst recordFormats = ["gif"];';
|
||||
const a = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
const b = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
const c = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
// All three calls return the SAME pending row (deduped).
|
||||
expect(b.id).toBe(a.id);
|
||||
expect(c.id).toBe(a.id);
|
||||
|
||||
await applyOne(sql, a.id, projectRoot);
|
||||
let content = await readFile(resolve(testDir, 'dup.js'), 'utf8');
|
||||
expect((content.match(/const recordFormats/g) || []).length).toBe(1);
|
||||
|
||||
// Even a fresh, separately-queued identical edit re-applied is a no-op, not a stamp.
|
||||
const again = await queueEdit(sql, testSessionId, null, 'dup.js', oldStr, newStr, projectRoot);
|
||||
const res = await applyOne(sql, again.id, projectRoot);
|
||||
expect(res.success).toBe(true);
|
||||
content = await readFile(resolve(testDir, 'dup.js'), 'utf8');
|
||||
expect((content.match(/const recordFormats/g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves CRLF line endings on edit', async () => {
|
||||
await queueCreate(sql, testSessionId, null, 'crlf.txt', 'line one\r\nline two\r\nline three\r\n', projectRoot)
|
||||
.then((c) => applyOne(sql, c.id, projectRoot));
|
||||
const edit = await queueEdit(sql, testSessionId, null, 'crlf.txt', 'line two', 'line TWO', projectRoot);
|
||||
const res = await applyOne(sql, edit.id, projectRoot);
|
||||
expect(res.success).toBe(true);
|
||||
const content = await readFile(resolve(testDir, 'crlf.txt'), 'utf8');
|
||||
expect(content).toBe('line one\r\nline TWO\r\nline three\r\n');
|
||||
});
|
||||
|
||||
it('refuses an edit that matches multiple locations instead of corrupting', async () => {
|
||||
await queueCreate(sql, testSessionId, null, 'ambig.js', 'x=1;\ny=2;\nx=1;\n', projectRoot)
|
||||
.then((ch) => applyOne(sql, ch.id, projectRoot));
|
||||
const edit = await queueEdit(sql, testSessionId, null, 'ambig.js', 'x=1;', 'x=9;', projectRoot);
|
||||
const res = await applyOne(sql, edit.id, projectRoot);
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error).toMatch(/matches 2 locations/);
|
||||
});
|
||||
|
||||
it('rewindOne → verify reverted', async () => {
|
||||
// Setup: create and apply a file
|
||||
const createChange = await queueCreate(sql, testSessionId, null, 'rewindable.txt', 'initial', projectRoot);
|
||||
|
||||
69
apps/coder/src/services/__tests__/plan-edit.test.ts
Normal file
69
apps/coder/src/services/__tests__/plan-edit.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { planEdit } from '../pending_changes.js';
|
||||
|
||||
// planEdit is the pure core of applyOne's edit splice. These tests pin the
|
||||
// idempotency guards that stop the "block stamped 2-3x" corruption: applying the
|
||||
// same queued edit more than once must be a no-op, never a duplicate.
|
||||
|
||||
describe('planEdit — normal application', () => {
|
||||
it('applies a unique exact edit', () => {
|
||||
const content = 'a\nfoo\nb\n';
|
||||
const plan = planEdit(content, 'foo', 'bar');
|
||||
expect(plan).toEqual({ kind: 'apply', updated: 'a\nbar\nb\n' });
|
||||
});
|
||||
|
||||
it('reports ambiguous when old_string occurs more than once', () => {
|
||||
const content = 'foo\nx\nfoo\n';
|
||||
const plan = planEdit(content, 'foo', 'bar');
|
||||
expect(plan).toEqual({ kind: 'ambiguous', count: 2 });
|
||||
});
|
||||
|
||||
it('reports not_found when old_string is absent and new is not present', () => {
|
||||
const content = 'alpha\nbeta\n';
|
||||
const plan = planEdit(content, 'gamma that is clearly nowhere', 'delta');
|
||||
expect(plan).toEqual({ kind: 'not_found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('planEdit — idempotency (the corruption guard)', () => {
|
||||
it('treats a re-applied anchored insert as already-applied (no duplicate)', () => {
|
||||
// The exact mechanism that tripled `const recordFormats` in settings.html:
|
||||
// an anchored insert (old=anchor, new=anchor+block) where the anchor still
|
||||
// matches uniquely after the first apply.
|
||||
const oldStr = '<script>';
|
||||
const newStr = '<script>\nconst recordFormats = ["gif","mp4"];';
|
||||
const before = '<script>\nfunction render() {}\n</script>\n';
|
||||
|
||||
const first = planEdit(before, oldStr, newStr);
|
||||
expect(first.kind).toBe('apply');
|
||||
const after = first.kind === 'apply' ? first.updated : '';
|
||||
expect((after.match(/const recordFormats/g) || []).length).toBe(1);
|
||||
|
||||
// Re-applying the identical edit to the already-edited content is a no-op.
|
||||
const second = planEdit(after, oldStr, newStr);
|
||||
expect(second).toEqual({ kind: 'noop', reason: 'already-applied' });
|
||||
});
|
||||
|
||||
it('treats an edit whose old_string is gone but new_string is present as already-applied', () => {
|
||||
const content = 'const total = sum + tax;\n';
|
||||
const plan = planEdit(content, 'const subtotal = sum;', 'const total = sum + tax;');
|
||||
expect(plan).toEqual({ kind: 'noop', reason: 'already-applied' });
|
||||
});
|
||||
|
||||
it('treats a no-change splice as a noop', () => {
|
||||
const content = 'a\nfoo\nb\n';
|
||||
const plan = planEdit(content, 'foo', 'foo');
|
||||
expect(plan).toEqual({ kind: 'noop', reason: 'identical' });
|
||||
});
|
||||
|
||||
it('does not duplicate across three repeated applications', () => {
|
||||
const oldStr = 'function f() {';
|
||||
const newStr = 'function f() {\n const x = 1;';
|
||||
let content = 'function f() {\n return x;\n}\n';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const plan = planEdit(content, oldStr, newStr);
|
||||
if (plan.kind === 'apply') content = plan.updated;
|
||||
}
|
||||
expect((content.match(/const x = 1;/g) || []).length).toBe(1);
|
||||
});
|
||||
});
|
||||
31
apps/coder/src/services/__tests__/trigger-rules.test.ts
Normal file
31
apps/coder/src/services/__tests__/trigger-rules.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { evaluateTriggerRule } from '../flow-runner-decisions.js';
|
||||
|
||||
describe('evaluateTriggerRule', () => {
|
||||
it('all_success requires all deps done', () => {
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a', 'b']), new Set(), new Set())).toBe(true);
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set())).toBe(false);
|
||||
});
|
||||
|
||||
it('one_success fires on first completion', () => {
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set(), 'one_success')).toBe(true);
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(), new Set(), new Set(), 'one_success')).toBe(false);
|
||||
});
|
||||
|
||||
it('all_done includes skipped deps', () => {
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(['b']), new Set(), 'all_done')).toBe(true);
|
||||
});
|
||||
|
||||
it('all_success treats excluded deps as satisfied', () => {
|
||||
expect(evaluateTriggerRule(['a', 'b'], new Set(['a']), new Set(), new Set(['b']))).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults to all_success', () => {
|
||||
expect(evaluateTriggerRule(['a'], new Set(['a']), new Set(), new Set())).toBe(true);
|
||||
expect(evaluateTriggerRule(['a'], new Set(), new Set(), new Set())).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for empty deps', () => {
|
||||
expect(evaluateTriggerRule([], new Set(), new Set(), new Set())).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
* A contestant's status lifecycle:
|
||||
* queued → running → done | error
|
||||
*/
|
||||
import type { BattleType, ContestantLane } from '@boocode/contracts/arena';
|
||||
import type { BattleType, ContestantLane, TokenBreakdown } from '@boocode/contracts/arena';
|
||||
|
||||
// ─── Lane classification ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -73,6 +73,7 @@ export function isBattleComplete(contestants: readonly { status: string }[]): bo
|
||||
export interface Benchmark {
|
||||
durationMs: number;
|
||||
tokensPerSec: number | null;
|
||||
tokenBreakdown: TokenBreakdown | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,13 +87,14 @@ export function computeBenchmark(
|
||||
endedAt: Date,
|
||||
costTokens: number | null,
|
||||
lane: ContestantLane,
|
||||
tokenBreakdown: TokenBreakdown | null = null,
|
||||
): Benchmark {
|
||||
const durationMs = Math.max(0, endedAt.getTime() - startedAt.getTime());
|
||||
const tokensPerSec =
|
||||
lane === 'local' && costTokens !== null && durationMs > 0
|
||||
? (costTokens / durationMs) * 1000
|
||||
: null;
|
||||
return { durationMs, tokensPerSec };
|
||||
return { durationMs, tokensPerSec, tokenBreakdown };
|
||||
}
|
||||
|
||||
// ─── Slug / path helpers ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import type { Config } from '../config.js';
|
||||
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
|
||||
import { applyAll } from './pending_changes.js';
|
||||
import { asPermissionMode } from './tools/types.js';
|
||||
import { createCheckpoint } from './checkpoints.js';
|
||||
import { makeDcpStreamStripper } from './dcp-strip.js';
|
||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||
@@ -32,7 +32,13 @@ import {
|
||||
import { shouldFailOnMissingAgent } from './flow-runner-decisions.js';
|
||||
|
||||
interface InferenceRunner {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||
enqueue: (
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
assistantId: string,
|
||||
user: string,
|
||||
permissionMode?: 'plan' | 'ask' | 'bypass',
|
||||
) => void;
|
||||
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
hasActive: (chatId: string) => boolean;
|
||||
}
|
||||
@@ -358,8 +364,9 @@ export function createDispatcher(deps: Deps): {
|
||||
`;
|
||||
const assistantId = assistantMsg!.id;
|
||||
|
||||
// Enqueue inference
|
||||
inference.enqueue(sessionId, chatId, assistantId, 'default');
|
||||
// Enqueue inference — pass the native permission gate (plan/ask/bypass)
|
||||
// through to the write-tool context. Non-unified mode ids → undefined.
|
||||
inference.enqueue(sessionId, chatId, assistantId, 'default', asPermissionMode(task.mode_id));
|
||||
|
||||
// Wait for inference to complete (poll message status)
|
||||
const finalStatus = await waitForCompletion(assistantId);
|
||||
@@ -392,22 +399,6 @@ export function createDispatcher(deps: Deps): {
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
|
||||
// Bypass permission mode: auto-apply the staged edits to disk after the
|
||||
// turn. Ask/Plan leave them in the pending-changes queue for review.
|
||||
if (task.mode_id === 'bypass') {
|
||||
try {
|
||||
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${task.project_id}`;
|
||||
if (proj?.path) {
|
||||
const applied = await applyAll(sql, sessionId, proj.path);
|
||||
log.info({ taskId, applied: applied.length }, 'dispatcher: native bypass auto-applied pending changes');
|
||||
}
|
||||
} catch (applyErr) {
|
||||
log.warn(
|
||||
{ taskId, err: applyErr instanceof Error ? applyErr.message : String(applyErr) },
|
||||
'dispatcher: native bypass auto-apply failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const [msg] = await sql<{ content: string | null }[]>`
|
||||
SELECT content FROM messages WHERE id = ${assistantId}
|
||||
|
||||
47
apps/coder/src/services/edit-guards-imports.ts
Normal file
47
apps/coder/src/services/edit-guards-imports.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// edit-guards-imports — detects dropped imports in edited files.
|
||||
// Ported from opencode-morph-fast-apply (MIT).
|
||||
|
||||
export interface ImportCheckResult {
|
||||
ok: boolean;
|
||||
missingImports: string[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const IMPORT_PATTERNS = [
|
||||
/^import\s+(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"][^'"]+['"]\s*;?$/m,
|
||||
/^import\s+['"][^'"]+['"]\s*;?$/m,
|
||||
/^export\s+.*\s+from\s+['"][^'"]+['"]\s*;?$/m,
|
||||
/^require\s*\(\s*['"][^'"]+['"]\s*\)\s*;?$/m,
|
||||
/^import\s+type\s+\{[^}]*\}\s+from\s+['"][^'"]+['"]\s*;?$/m,
|
||||
];
|
||||
|
||||
function extractImportLines(content: string): string[] {
|
||||
return content.split('\n').filter((line) =>
|
||||
IMPORT_PATTERNS.some((p) => p.test(line.trim())),
|
||||
);
|
||||
}
|
||||
|
||||
export function checkDroppedImports(
|
||||
original: string,
|
||||
updated: string,
|
||||
filePath: string,
|
||||
): ImportCheckResult {
|
||||
const originalImports = extractImportLines(original);
|
||||
const updatedImports = extractImportLines(updated);
|
||||
|
||||
if (originalImports.length === 0) {
|
||||
return { ok: true, missingImports: [] };
|
||||
}
|
||||
|
||||
const missing = originalImports.filter((imp) => !updatedImports.includes(imp));
|
||||
|
||||
if (missing.length > 0 && originalImports.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
missingImports: missing,
|
||||
reason: `Edit would drop ${missing.length} import(s) from ${filePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, missingImports: [] };
|
||||
}
|
||||
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'}`;
|
||||
}
|
||||
23
apps/coder/src/services/flow-artifacts.ts
Normal file
23
apps/coder/src/services/flow-artifacts.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const ARTIFACTS_ROOT = 'data/flow-artifacts';
|
||||
|
||||
export function getArtifactPath(flowRunId: string, stepId: string): string {
|
||||
return join(ARTIFACTS_ROOT, flowRunId, `${stepId}.md`);
|
||||
}
|
||||
|
||||
export async function writeFlowArtifact(
|
||||
flowRunId: string,
|
||||
stepId: string,
|
||||
content: string,
|
||||
): Promise<string> {
|
||||
const dir = join(ARTIFACTS_ROOT, flowRunId);
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
const path = getArtifactPath(flowRunId, stepId);
|
||||
await writeFile(path, content, 'utf8');
|
||||
return path;
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
* "Settled" = done ∪ skipped ∪ excluded. Only settled deps unblock a step;
|
||||
* an inFlight dep does NOT (the runner waits for its terminal callback).
|
||||
*/
|
||||
import type { Flow, Step, StepContext } from '../conductor/types.js';
|
||||
import type { Flow, Step, StepContext, TriggerRule } from '../conductor/types.js';
|
||||
|
||||
export interface SchedulerState {
|
||||
/** step ids that completed successfully (results available) */
|
||||
@@ -62,7 +62,7 @@ export function readySteps(flow: Flow, state: SchedulerState): Step[] {
|
||||
!state.skipped.has(s.id) &&
|
||||
!state.inFlight.has(s.id) &&
|
||||
!state.excluded.has(s.id) &&
|
||||
(s.deps ?? []).every((d) => isSatisfied(state, d)),
|
||||
((s.deps ?? []).length === 0 || evaluateTriggerRule(s.deps ?? [], state.done, state.skipped, state.excluded, s.trigger_rule)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,6 +167,32 @@ export function shouldFailOnMissingAgent(agent: string, modeId: string | null):
|
||||
return agent === 'qwen' && modeId === 'plan';
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a trigger rule against dependency results.
|
||||
* - all_success: every dep must be done (not skipped/failed)
|
||||
* - one_success: at least one dep must be done
|
||||
* - all_done: every dep must be settled regardless of outcome
|
||||
*/
|
||||
export function evaluateTriggerRule(
|
||||
deps: string[],
|
||||
done: ReadonlySet<string>,
|
||||
skipped: ReadonlySet<string>,
|
||||
excluded: ReadonlySet<string>,
|
||||
rule?: TriggerRule,
|
||||
): boolean {
|
||||
if (deps.length === 0) return true;
|
||||
const satisfied = new Set([...done, ...skipped, ...excluded]);
|
||||
|
||||
switch (rule ?? 'all_success') {
|
||||
case 'all_success':
|
||||
return deps.every((d) => done.has(d) || skipped.has(d) || excluded.has(d));
|
||||
case 'one_success':
|
||||
return deps.some((d) => done.has(d));
|
||||
case 'all_done':
|
||||
return deps.every((d) => satisfied.has(d));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile every step of an in-flight run for startup resume. Returns one
|
||||
* decision per step. Pure — no IO.
|
||||
|
||||
@@ -346,6 +346,20 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
continue; // re-evaluate — code output can unblock the next wave
|
||||
}
|
||||
|
||||
// Approval gate steps: pause and wait for human decision.
|
||||
const approvalReady = toRun.filter((s) => s.kind === 'approval');
|
||||
if (approvalReady.length > 0) {
|
||||
for (const s of approvalReady) {
|
||||
await sql`
|
||||
UPDATE flow_steps SET status = 'blocked', updated_at = clock_timestamp()
|
||||
WHERE run_id = ${runId} AND step_id = ${s.id}
|
||||
`;
|
||||
await appendStepEvent(sql, runId, s.id, 'paused', { reason: 'awaiting approval' });
|
||||
publishStep(runId, s.id, 'blocked');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only agent steps remain ready → dispatch the whole parallel wave, then wait.
|
||||
for (const s of toRun) {
|
||||
await dispatchAgentStep(runId, run.project_id, model, s, ctx);
|
||||
@@ -378,7 +392,8 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
// flow's step.run already bakes in the evidence/YAGNI contracts.
|
||||
const persona = step.agent ? await loadPersona(step.agent) : '';
|
||||
const taskPrompt = await step.run(ctx);
|
||||
const fullPrompt = persona ? `${persona}\n\n---\n\n${taskPrompt}` : taskPrompt;
|
||||
const resolvedPrompt = resolveVariables(taskPrompt, ctx.results);
|
||||
const fullPrompt = persona ? `${persona}\n\n---\n\n${resolvedPrompt}` : resolvedPrompt;
|
||||
|
||||
// READ-ONLY (D-4): agent='qwen', mode_id='plan' are hardcoded, never
|
||||
// user-overridable. The dispatcher's qwen+plan rule forces the PTY hard gate.
|
||||
@@ -392,6 +407,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
SET task_id = ${task!.id}, status = 'running', input = ${fullPrompt}, updated_at = clock_timestamp()
|
||||
WHERE run_id = ${runId} AND step_id = ${step.id}
|
||||
`;
|
||||
await appendStepEvent(sql, runId, step.id, 'started', { taskId: task!.id });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -438,6 +454,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
WHERE run_id = ${runId} AND step_id = ${stepId}
|
||||
`;
|
||||
}
|
||||
await appendStepEvent(sql, runId, stepId, status, output ? { outputLength: output.length } : undefined);
|
||||
}
|
||||
|
||||
// ─── run completion ─────────────────────────────────────────────────────────
|
||||
@@ -483,6 +500,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
if (updated.count === 0) return;
|
||||
const stepId = failedStepId ?? (flow ? lastAgentStepId(flow, input, model) : 'run');
|
||||
log.warn({ runId, error }, 'flow-runner: run failed');
|
||||
await appendStepEvent(sql, runId, stepId, 'failed', { error });
|
||||
publishStep(runId, stepId, 'failed', { run_status: 'failed' });
|
||||
}
|
||||
|
||||
@@ -522,7 +540,7 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
function publishStep(
|
||||
runId: string,
|
||||
stepId: string,
|
||||
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled',
|
||||
status: 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'blocked',
|
||||
extra?: { run_status?: 'running' | 'completed' | 'failed' | 'cancelled'; report?: string },
|
||||
): void {
|
||||
publishUser({
|
||||
@@ -763,3 +781,40 @@ export function createFlowRunner(deps: Deps): FlowRunner {
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
// ─── Event log ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function appendStepEvent(
|
||||
sql: Sql,
|
||||
runId: string,
|
||||
stepId: string,
|
||||
event: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await sql`
|
||||
INSERT INTO flow_step_events (run_id, step_id, event, payload)
|
||||
VALUES (${runId}, ${stepId}, ${event}, ${payload ? sql.json(payload as never) : null})
|
||||
`;
|
||||
}
|
||||
|
||||
// ─── Variable substitution ───────────────────────────────────────────────────
|
||||
|
||||
const VAR_PATTERN = /\$(\w+)\.output(?:\.(\w+(?:\.\w+)*))?/g;
|
||||
|
||||
export function resolveVariables(prompt: string, results: Record<string, string>): string {
|
||||
return prompt.replace(VAR_PATTERN, (match, stepId, fieldPath) => {
|
||||
const output = results[stepId];
|
||||
if (!output) return match;
|
||||
if (!fieldPath) return output;
|
||||
try {
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
const parsed = line.match(new RegExp(`^${fieldPath}:\\s*(.+)$`, 'i'));
|
||||
if (parsed) return parsed[1]!.trim();
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +21,16 @@
|
||||
// punctuation to ASCII on both sides; the match is
|
||||
// mapped back to original offsets.
|
||||
// 4. levenshtein — best line-window by normalized edit-distance
|
||||
// similarity; accepted only at >= SIMILARITY_THRESHOLD.
|
||||
// similarity; accepted only at >= SIMILARITY_THRESHOLD,
|
||||
// anchored on an exact first+last line for multi-line
|
||||
// needles, and REFUSED (ambiguous) when a second window
|
||||
// scores within AMBIGUITY_EPSILON of the best. Like the
|
||||
// exact/whitespace tiers, this tier fails CLOSED — it
|
||||
// never splices over a merely-plausible guess, because a
|
||||
// wrong-window splice corrupts the file (it leaves the
|
||||
// real target intact and duplicates it). This mirrors
|
||||
// opencode/cline/qwen, whose fuzzy tiers all keep the
|
||||
// unique-match requirement rather than picking a winner.
|
||||
//
|
||||
// Pure and dependency-free (Levenshtein is the standard iterative two-row DP),
|
||||
// reimplemented from the general technique — no vendored source.
|
||||
@@ -31,8 +40,31 @@ export type MatchResult =
|
||||
| { kind: 'ambiguous'; count: number }
|
||||
| { kind: 'not_found' };
|
||||
|
||||
/** Levenshtein similarity floor for the final fuzzy fallback (strategy 4). */
|
||||
export const SIMILARITY_THRESHOLD = 0.66;
|
||||
/**
|
||||
* Levenshtein similarity floor for the final fuzzy fallback (strategy 4).
|
||||
* 0.66 was far too low — at two-thirds similarity a structurally-wrong window
|
||||
* (e.g. one of three near-identical form blocks) clears the bar and gets spliced
|
||||
* over, leaving the real target intact and duplicated. Competent agents anchor
|
||||
* far tighter (opencode's BlockAnchor needs an exact anchor; cline needs exact
|
||||
* first+last lines). 0.85 keeps genuine quantized-model drift (a typo, an indent
|
||||
* shift) while refusing a different block.
|
||||
*/
|
||||
export const SIMILARITY_THRESHOLD = 0.85;
|
||||
|
||||
/**
|
||||
* If a second candidate window scores within this of the best, the match is
|
||||
* ambiguous and tier 4 refuses rather than guessing — the same fail-closed
|
||||
* stance the exact and whitespace tiers take on multiple hits. Repetitive files
|
||||
* (the duplicate-block corruption case) produce near-tied windows; this is what
|
||||
* turns that into a clean "add more context" error instead of a wrong splice.
|
||||
*/
|
||||
export const AMBIGUITY_EPSILON = 0.05;
|
||||
|
||||
/** Multi-line needles at or above this length must anchor on an exact (after
|
||||
* trim + unicode-fold) first AND last line before similarity is even scored —
|
||||
* the cline/opencode block-anchor rule. Below it, threshold + uniqueness alone
|
||||
* guard the match. */
|
||||
const ANCHOR_MIN_LINES = 3;
|
||||
|
||||
export function locateMatch(content: string, needle: string): MatchResult {
|
||||
// Empty needle has no meaningful match.
|
||||
@@ -252,20 +284,39 @@ function locateByLevenshtein(content: string, needle: string): MatchResult | nul
|
||||
|
||||
const needleJoined = needleLines.map((l) => l.trim()).join('\n');
|
||||
|
||||
let best = -1;
|
||||
let bestSpan: { start: number; end: number } | null = null;
|
||||
// Block-anchor gate for multi-line needles: the first and last lines must match
|
||||
// exactly (after trim + unicode-fold) or the window is not even scored. This
|
||||
// stops a high interior-similarity from dragging a structurally-wrong window
|
||||
// over the threshold — the failure that duplicates blocks in repetitive files.
|
||||
const anchored = n >= ANCHOR_MIN_LINES;
|
||||
const needleFirst = canonicalize(needleLines[0]!.trim());
|
||||
const needleLast = canonicalize(needleLines[n - 1]!.trim());
|
||||
|
||||
const scored: Array<{ score: number; start: number; end: number }> = [];
|
||||
for (let i = 0; i + n <= contentLines.length; i++) {
|
||||
const window = contentLines.slice(i, i + n);
|
||||
const windowJoined = window.map((l) => l.text.trim()).join('\n');
|
||||
const score = similarity(windowJoined, needleJoined);
|
||||
if (score > best) {
|
||||
best = score;
|
||||
bestSpan = { start: window[0]!.start, end: window[n - 1]!.end };
|
||||
if (anchored) {
|
||||
const winFirst = canonicalize(window[0]!.text.trim());
|
||||
const winLast = canonicalize(window[n - 1]!.text.trim());
|
||||
if (winFirst !== needleFirst || winLast !== needleLast) continue;
|
||||
}
|
||||
const windowJoined = window.map((l) => l.text.trim()).join('\n');
|
||||
scored.push({
|
||||
score: similarity(windowJoined, needleJoined),
|
||||
start: window[0]!.start,
|
||||
end: window[n - 1]!.end,
|
||||
});
|
||||
}
|
||||
|
||||
if (bestSpan && best >= SIMILARITY_THRESHOLD) {
|
||||
return { kind: 'fuzzy', start: bestSpan.start, end: bestSpan.end };
|
||||
}
|
||||
return null;
|
||||
if (scored.length === 0) return null;
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const best = scored[0]!;
|
||||
if (best.score < SIMILARITY_THRESHOLD) return null;
|
||||
|
||||
// Uniqueness guard: refuse when a second window is within epsilon of the best.
|
||||
// Fail closed (ambiguous) rather than silently splicing one of several lookalikes.
|
||||
const tied = scored.filter((s) => s.score >= best.score - AMBIGUITY_EPSILON);
|
||||
if (tied.length > 1) return { kind: 'ambiguous', count: tied.length };
|
||||
|
||||
return { kind: 'fuzzy', start: best.start, end: best.end };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,8 +1,120 @@
|
||||
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { readFile, writeFile, unlink, mkdir, rename, realpath } from 'node:fs/promises';
|
||||
import { dirname, join, basename } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { Sql } from '../db.js';
|
||||
import { resolveWritePath } from './write_guard.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
|
||||
* target. rename(2) on the same filesystem is atomic, so a crash mid-write can
|
||||
* never leave a half-written (truncated/corrupt) source file — readers see
|
||||
* either the old content or the complete new content. The temp lives in the same
|
||||
* directory to guarantee a same-filesystem rename.
|
||||
*
|
||||
* Symlinks: a plain writeFile FOLLOWS a symlink and writes through to its target;
|
||||
* a bare rename would REPLACE the link with a regular file. We realpath an
|
||||
* existing target first so the rename lands on the real file and the link
|
||||
* survives — preserving the prior follow-through behavior. A missing target
|
||||
* (create, or a broken link) just writes the literal path.
|
||||
*/
|
||||
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
|
||||
let target = filePath;
|
||||
try {
|
||||
target = await realpath(filePath);
|
||||
} catch {
|
||||
// ENOENT (new file) or broken link — write the literal path.
|
||||
}
|
||||
const tmp = join(dirname(target), `.${basename(target)}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`);
|
||||
await writeFile(tmp, content, 'utf8');
|
||||
try {
|
||||
await rename(tmp, target);
|
||||
} catch (err) {
|
||||
await unlink(tmp).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect a file's dominant line ending so an edit can preserve it. */
|
||||
function detectEol(text: string): '\r\n' | '\n' {
|
||||
return text.includes('\r\n') ? '\r\n' : '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the read-modify-write of a single file so two concurrent applies
|
||||
* (e.g. two chat tabs sharing one worktree, or a Bypass write racing an
|
||||
* apply_pending) can't lose an update. In-process keying is sufficient —
|
||||
* BooCoder is a single Fastify process. One Map entry per distinct path.
|
||||
*/
|
||||
const fileLocks = new Map<string, Promise<void>>();
|
||||
async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = fileLocks.get(filePath) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((r) => { release = r; });
|
||||
fileLocks.set(filePath, prev.then(() => current));
|
||||
await prev.catch(() => {});
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edit-apply planning (pure, unit-tested) ---------------------------------
|
||||
|
||||
/**
|
||||
* Decision for applying one queued edit to a file's current content. Pulled out
|
||||
* of `applyOne` so the splice — the part that actually corrupted files — is pure
|
||||
* and testable without a DB or filesystem. Mirrors how opencode/cline/qwen keep
|
||||
* their matchers fail-closed and idempotent.
|
||||
*/
|
||||
export type EditPlan =
|
||||
| { kind: 'apply'; updated: string }
|
||||
| { kind: 'noop'; reason: 'identical' | 'already-applied' }
|
||||
| { kind: 'ambiguous'; count: number }
|
||||
| { kind: 'not_found' };
|
||||
|
||||
/**
|
||||
* Decide how (or whether) to apply an `old → new` edit to `content`.
|
||||
*
|
||||
* Idempotency is the whole point here: a queued edit can legitimately be
|
||||
* re-applied (a local model re-emits the same tool call; a turn is retried; the
|
||||
* same change sits in the queue twice). A naive splice stamps the new text again
|
||||
* each time — the 2–3× block duplication. Two guards make re-application a no-op:
|
||||
*
|
||||
* - already-applied (anchored insert): when `new` is `old` + an appended block
|
||||
* (`old="anchor"`, `new="anchor\n<block>"`), `old` still matches uniquely after
|
||||
* the first apply, so a second apply would duplicate `<block>`. If the full
|
||||
* `new` text is already present at the match site, the edit is already applied.
|
||||
* - already-applied (old gone): if `old` can't be located but `new` is already
|
||||
* in the file, the change landed on a prior pass — treat as a no-op, not an error.
|
||||
* - identical: the splice would not change the file.
|
||||
*
|
||||
* Anything ambiguous or genuinely absent fails CLOSED so the caller surfaces a
|
||||
* correctable error instead of writing a guess.
|
||||
*/
|
||||
export function planEdit(content: string, oldStr: string, newStr: string): EditPlan {
|
||||
const match = locateMatch(content, oldStr);
|
||||
|
||||
if (match.kind === 'ambiguous') return { kind: 'ambiguous', count: match.count };
|
||||
|
||||
if (match.kind === 'not_found') {
|
||||
if (newStr.length > 0 && content.includes(newStr)) {
|
||||
return { kind: 'noop', reason: 'already-applied' };
|
||||
}
|
||||
return { kind: 'not_found' };
|
||||
}
|
||||
|
||||
const updated = content.slice(0, match.start) + newStr + content.slice(match.end);
|
||||
// No-change splice first (covers old === new), then the anchored re-stamp guard:
|
||||
// the full replacement already sits at the match site (re-emitted anchored insert).
|
||||
if (updated === content) return { kind: 'noop', reason: 'identical' };
|
||||
if (content.slice(match.start, match.start + newStr.length) === newStr) {
|
||||
return { kind: 'noop', reason: 'already-applied' };
|
||||
}
|
||||
return { kind: 'apply', updated };
|
||||
}
|
||||
|
||||
// --- Types -------------------------------------------------------------------
|
||||
|
||||
@@ -47,6 +159,13 @@ export async function queueEdit(
|
||||
const resolved = resolveWritePath(projectRoot, filePath);
|
||||
const diff = JSON.stringify({ old: oldString, new: newString });
|
||||
|
||||
// Idempotent queue: collapse an identical edit that is still pending. Local
|
||||
// quantized models re-emit the same edit_file call within a turn, and a retried
|
||||
// turn re-queues — each duplicate row would apply and stamp another copy. One
|
||||
// pending row per (session, file, operation, diff) is enough.
|
||||
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'edit', diff);
|
||||
if (existing) return existing;
|
||||
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff}, ${agent})
|
||||
@@ -55,6 +174,28 @@ export async function queueEdit(
|
||||
return row!;
|
||||
}
|
||||
|
||||
/** Return an identical still-pending change for this (session, file, op, diff),
|
||||
* or undefined. Used to keep the queue idempotent against re-emitted edits. */
|
||||
async function findPendingDuplicate(
|
||||
sql: Sql,
|
||||
sessionId: string,
|
||||
resolvedPath: string,
|
||||
operation: 'create' | 'edit' | 'delete',
|
||||
diff: string,
|
||||
): Promise<PendingChange | undefined> {
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
SELECT * FROM pending_changes
|
||||
WHERE session_id = ${sessionId}
|
||||
AND file_path = ${resolvedPath}
|
||||
AND operation = ${operation}
|
||||
AND diff = ${diff}
|
||||
AND status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function queueCreate(
|
||||
sql: Sql,
|
||||
sessionId: string,
|
||||
@@ -68,6 +209,9 @@ export async function queueCreate(
|
||||
): Promise<PendingChange> {
|
||||
const resolved = resolveWritePath(projectRoot, filePath);
|
||||
|
||||
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'create', content);
|
||||
if (existing) return existing;
|
||||
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content}, ${agent})
|
||||
@@ -87,6 +231,9 @@ export async function queueDelete(
|
||||
): Promise<PendingChange> {
|
||||
const resolved = resolveWritePath(projectRoot, filePath);
|
||||
|
||||
const existing = await findPendingDuplicate(sql, sessionId, resolved, 'delete', '');
|
||||
if (existing) return existing;
|
||||
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '', ${agent})
|
||||
@@ -110,48 +257,64 @@ export async function applyOne(
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-validate path in case projectRoot has shifted
|
||||
resolveWritePath(projectRoot, change.file_path);
|
||||
return await withFileLock(change.file_path, async () => {
|
||||
// Re-validate path in case projectRoot has shifted
|
||||
resolveWritePath(projectRoot, change.file_path);
|
||||
|
||||
switch (change.operation) {
|
||||
case 'create': {
|
||||
await mkdir(dirname(change.file_path), { recursive: true });
|
||||
await writeFile(change.file_path, change.diff, 'utf8');
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||
const content = await readFile(change.file_path, 'utf8');
|
||||
const match = locateMatch(content, oldStr);
|
||||
if (match.kind === 'ambiguous') {
|
||||
throw new Error(
|
||||
`old_string matches ${match.count} locations — add surrounding context to disambiguate`,
|
||||
);
|
||||
switch (change.operation) {
|
||||
case 'create': {
|
||||
await mkdir(dirname(change.file_path), { recursive: true });
|
||||
await writeFileAtomic(change.file_path, change.diff);
|
||||
break;
|
||||
}
|
||||
if (match.kind === 'not_found') {
|
||||
throw new Error(
|
||||
'old_string not found in file (even fuzzily) — file may have changed since the edit was queued',
|
||||
);
|
||||
case 'edit': {
|
||||
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||
const raw = await readFile(change.file_path, 'utf8');
|
||||
// Normalize to LF for matching, then write back in the file's native EOL
|
||||
// so an LF-emitting model doesn't leave a CRLF file with mixed endings.
|
||||
const eol = detectEol(raw);
|
||||
const toLf = (t: string) => t.replaceAll('\r\n', '\n');
|
||||
const plan = planEdit(toLf(raw), toLf(oldStr), toLf(newStr));
|
||||
if (plan.kind === 'ambiguous') {
|
||||
throw new Error(
|
||||
`old_string matches ${plan.count} locations — add surrounding context to disambiguate`,
|
||||
);
|
||||
}
|
||||
if (plan.kind === 'not_found') {
|
||||
throw new Error(
|
||||
'old_string not found in file (even fuzzily) — file may have changed since the edit was queued',
|
||||
);
|
||||
}
|
||||
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;
|
||||
await writeFileAtomic(change.file_path, out);
|
||||
} else {
|
||||
// noop: the edit is already applied (re-emitted / retried) or a no-change.
|
||||
// Mark it applied without rewriting so it can't stamp a duplicate.
|
||||
console.log(`[pending] edit ${change.file_path} is a no-op (${plan.reason}) — not rewriting`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
const updated = content.slice(0, match.start) + newStr + content.slice(match.end);
|
||||
await writeFile(change.file_path, updated, 'utf8');
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
// Stash current content in diff for potential rewind
|
||||
try {
|
||||
const existing = await readFile(change.file_path, 'utf8');
|
||||
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
|
||||
} catch {
|
||||
// File may already be gone — proceed with status update
|
||||
case 'delete': {
|
||||
// Stash current content in diff for potential rewind
|
||||
try {
|
||||
const existing = await readFile(change.file_path, 'utf8');
|
||||
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
|
||||
} catch {
|
||||
// File may already be gone — proceed with status update
|
||||
}
|
||||
await unlink(change.file_path);
|
||||
break;
|
||||
}
|
||||
await unlink(change.file_path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
|
||||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||||
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
|
||||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
||||
@@ -220,13 +383,13 @@ export async function rewindOne(
|
||||
);
|
||||
}
|
||||
const reverted = content.slice(0, match.start) + oldStr + content.slice(match.end);
|
||||
await writeFile(change.file_path, reverted, 'utf8');
|
||||
await writeFileAtomic(change.file_path, reverted);
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
// Reverse a delete: recreate the file (diff holds the original content stashed at apply time)
|
||||
await mkdir(dirname(change.file_path), { recursive: true });
|
||||
await writeFile(change.file_path, change.diff, 'utf8');
|
||||
await writeFileAtomic(change.file_path, change.diff);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { analyzeMessages } from '../analyzer.js';
|
||||
|
||||
describe('analyzeMessages', () => {
|
||||
it('classifies user messages', () => {
|
||||
const breakdown = analyzeMessages([{ role: 'user', content: 'hello world' }]);
|
||||
expect(breakdown.user).toBeGreaterThan(0);
|
||||
expect(breakdown.total).toBe(breakdown.user);
|
||||
});
|
||||
|
||||
it('counts tool calls', () => {
|
||||
const parts = [
|
||||
{ role: 'assistant', content: 'using grep', tool_calls: [{ id: '1', name: 'grep', arguments: '{}' }] },
|
||||
{ role: 'tool', content: '{"files":[]}', tool_call_id: '1' },
|
||||
];
|
||||
const breakdown = analyzeMessages(parts);
|
||||
expect(breakdown.tools).toBeGreaterThan(0);
|
||||
expect(breakdown.assistant).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('separates reasoning tokens', () => {
|
||||
const parts = [
|
||||
{ role: 'assistant', content: 'short answer', reasoning_parts: [{ text: 'long chain of thought reasoning here' }] },
|
||||
];
|
||||
const breakdown = analyzeMessages(parts);
|
||||
expect(breakdown.reasoning).toBeGreaterThan(0);
|
||||
expect(breakdown.assistant).toBeLessThan(breakdown.reasoning);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('persistTaskBreakdown', () => {
|
||||
it('exports functions', async () => {
|
||||
const mod = await import('../persist.js');
|
||||
expect(typeof mod.persistTaskBreakdown).toBe('function');
|
||||
expect(typeof mod.getTaskBreakdown).toBe('function');
|
||||
expect(typeof mod.analyzeAndPersistTaskBreakdown).toBe('function');
|
||||
});
|
||||
});
|
||||
60
apps/coder/src/services/token-analysis/analyzer.ts
Normal file
60
apps/coder/src/services/token-analysis/analyzer.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// TokenScope analyzer — classifies message parts into category breakdown.
|
||||
// Ported from opencode-tokenscope (MIT).
|
||||
|
||||
export interface TokenBreakdown {
|
||||
system: number;
|
||||
user: number;
|
||||
assistant: number;
|
||||
tools: number;
|
||||
reasoning: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
export function analyzeMessages(parts: any[]): TokenBreakdown {
|
||||
const breakdown: TokenBreakdown = { system: 0, user: 0, assistant: 0, tools: 0, reasoning: 0, total: 0 };
|
||||
|
||||
for (const part of parts) {
|
||||
const role = part.role ?? '';
|
||||
const content = part.content ?? '';
|
||||
const tokens = estimateTokens(content);
|
||||
|
||||
switch (role) {
|
||||
case 'system':
|
||||
breakdown.system += tokens;
|
||||
break;
|
||||
case 'user':
|
||||
breakdown.user += tokens;
|
||||
break;
|
||||
case 'assistant':
|
||||
breakdown.assistant += tokens;
|
||||
if (part.tool_calls) {
|
||||
for (const tc of part.tool_calls) {
|
||||
breakdown.tools += estimateTokens(JSON.stringify(tc));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'tool':
|
||||
breakdown.tools += tokens;
|
||||
break;
|
||||
default:
|
||||
breakdown.assistant += tokens;
|
||||
}
|
||||
|
||||
if (part.reasoning_parts) {
|
||||
for (const rp of part.reasoning_parts) {
|
||||
const rTokens = estimateTokens(rp.text ?? '');
|
||||
breakdown.reasoning += rTokens;
|
||||
breakdown.assistant -= rTokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
breakdown.total = breakdown.system + breakdown.user + breakdown.assistant + breakdown.tools + breakdown.reasoning;
|
||||
return breakdown;
|
||||
}
|
||||
35
apps/coder/src/services/token-analysis/persist.ts
Normal file
35
apps/coder/src/services/token-analysis/persist.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// TokenScope persistence — writes breakdown to task records.
|
||||
import type { Sql } from '../../db.js';
|
||||
import type { TokenBreakdown } from './analyzer.js';
|
||||
|
||||
export async function persistTaskBreakdown(
|
||||
sql: Sql,
|
||||
taskId: string,
|
||||
breakdown: TokenBreakdown,
|
||||
): Promise<void> {
|
||||
await sql`
|
||||
UPDATE tasks SET token_breakdown = ${sql.json(breakdown as never)}
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getTaskBreakdown(
|
||||
sql: Sql,
|
||||
taskId: string,
|
||||
): Promise<TokenBreakdown | null> {
|
||||
const rows = await sql<{ token_breakdown: any }[]>`
|
||||
SELECT token_breakdown FROM tasks WHERE id = ${taskId}
|
||||
`;
|
||||
return rows[0]?.token_breakdown ?? null;
|
||||
}
|
||||
|
||||
export async function analyzeAndPersistTaskBreakdown(
|
||||
sql: Sql,
|
||||
taskId: string,
|
||||
parts: any[],
|
||||
): Promise<TokenBreakdown> {
|
||||
const { analyzeMessages } = await import('./analyzer.js');
|
||||
const breakdown = analyzeMessages(parts);
|
||||
await persistTaskBreakdown(sql, taskId, breakdown);
|
||||
return breakdown;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runWithInferenceContext, getInferenceContext } from '../inference_context.js';
|
||||
import type { Sql } from '../../../db.js';
|
||||
|
||||
const fakeSql = {} as unknown as Sql;
|
||||
|
||||
describe('inference context (AsyncLocalStorage isolation)', () => {
|
||||
it('throws when read outside a run', () => {
|
||||
expect(() => getInferenceContext()).toThrow(/outside inference context/);
|
||||
});
|
||||
|
||||
it('keeps each run its own context across overlapping awaits', async () => {
|
||||
// The race the global `let current` had: run B starts (and would overwrite a
|
||||
// shared global) while run A is awaiting. After A resumes it must still read
|
||||
// its OWN sessionId, not B's.
|
||||
const run = (id: string, delay: number) =>
|
||||
runWithInferenceContext({ sql: fakeSql, sessionId: id, taskId: null }, async () => {
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
return getInferenceContext().sessionId;
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([run('A', 20), run('B', 5)]);
|
||||
expect(a).toBe('A');
|
||||
expect(b).toBe('B');
|
||||
});
|
||||
|
||||
it('carries permissionMode and taskId per run', async () => {
|
||||
const result = await runWithInferenceContext(
|
||||
{ sql: fakeSql, sessionId: 's1', taskId: 't1', permissionMode: 'bypass' },
|
||||
async () => {
|
||||
await Promise.resolve();
|
||||
const ctx = getInferenceContext();
|
||||
return { taskId: ctx.taskId, mode: ctx.permissionMode };
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ taskId: 't1', mode: 'bypass' });
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,15 @@ export const applyPendingTool: ToolDef<ApplyPendingInputT> = {
|
||||
},
|
||||
},
|
||||
async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
// Under Ask (and Plan) the human approves via the Pending Changes panel — the
|
||||
// agent must not auto-apply. Bypass and legacy (undefined) may apply.
|
||||
if (context.permissionMode === 'ask' || context.permissionMode === 'plan') {
|
||||
return {
|
||||
status: 'denied',
|
||||
message:
|
||||
'Permission mode is Ask — staged changes must be approved by the user in the Pending Changes panel, not applied by the agent.',
|
||||
};
|
||||
}
|
||||
const results = await applyAll(context.sql, context.sessionId, projectRoot);
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueCreate } from '../pending_changes.js';
|
||||
import { denyReadOnly, finalizeWrite } from './write-gate.js';
|
||||
|
||||
const CreateFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
@@ -32,6 +33,7 @@ export const createFileTool: ToolDef<CreateFileInputT> = {
|
||||
},
|
||||
},
|
||||
async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
if (context.permissionMode === 'plan') return denyReadOnly('create_file');
|
||||
const change = await queueCreate(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
@@ -40,12 +42,11 @@ export const createFileTool: ToolDef<CreateFileInputT> = {
|
||||
input.content,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'create',
|
||||
message: `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
return finalizeWrite(
|
||||
context,
|
||||
projectRoot,
|
||||
change,
|
||||
`File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueDelete } from '../pending_changes.js';
|
||||
import { denyReadOnly, finalizeWrite } from './write-gate.js';
|
||||
|
||||
const DeleteFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
@@ -30,6 +31,7 @@ export const deleteFileTool: ToolDef<DeleteFileInputT> = {
|
||||
},
|
||||
},
|
||||
async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
if (context.permissionMode === 'plan') return denyReadOnly('delete_file');
|
||||
const change = await queueDelete(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
@@ -37,12 +39,11 @@ export const deleteFileTool: ToolDef<DeleteFileInputT> = {
|
||||
input.file_path,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'delete',
|
||||
message: `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
return finalizeWrite(
|
||||
context,
|
||||
projectRoot,
|
||||
change,
|
||||
`File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueEdit } from '../pending_changes.js';
|
||||
import { denyReadOnly, finalizeWrite } from './write-gate.js';
|
||||
|
||||
const EditFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
@@ -34,6 +35,7 @@ export const editFileTool: ToolDef<EditFileInputT> = {
|
||||
},
|
||||
},
|
||||
async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
if (context.permissionMode === 'plan') return denyReadOnly('edit_file');
|
||||
const change = await queueEdit(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
@@ -43,12 +45,11 @@ export const editFileTool: ToolDef<EditFileInputT> = {
|
||||
input.new_string,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'edit',
|
||||
message: `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
return finalizeWrite(
|
||||
context,
|
||||
projectRoot,
|
||||
change,
|
||||
`Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@ import { rewindTool } from './rewind.js';
|
||||
import { newTaskTool } from './new_task.js';
|
||||
import { listTasksTool } from './list_tasks.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';
|
||||
|
||||
@@ -26,4 +29,16 @@ export const WRITE_TOOLS: readonly ToolDef<any>[] = [
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -1,36 +1,49 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import type { Sql } from '../../db.js';
|
||||
import type { PermissionMode } from './types.js';
|
||||
|
||||
/**
|
||||
* Module-level inference context for write tools.
|
||||
* Per-run inference context for write tools.
|
||||
*
|
||||
* Set via `setInferenceContext()` before each inference run starts.
|
||||
* Write tools read it via `getInferenceContext()` during execute.
|
||||
* Same pattern as BooChat's `loadConfig()` singleton — tools need
|
||||
* ambient state that can't be threaded through the tool-phase execute
|
||||
* signature (which is `execute(input, projectRoot, extraRoots?)`).
|
||||
* Write tools need ambient state (sql, sessionId, the permission gate) that the
|
||||
* BooChat tool-phase `execute(input, projectRoot, extraRoots?)` signature can't
|
||||
* carry. This used to be a single module-level `let current` — but the inference
|
||||
* runner's `enqueue()` is fire-and-forget, so two overlapping runs (a user
|
||||
* message racing a dispatcher-polled native task; two chat tabs streaming) would
|
||||
* clobber each other's context, and `cancel()` cleared it for ALL in-flight runs.
|
||||
*
|
||||
* AsyncLocalStorage gives each run its own context: `enqueue()` starts its async
|
||||
* loop synchronously inside `runWithInferenceContext`, so the store propagates
|
||||
* through every awaited tool execution in that run — and only that run.
|
||||
*/
|
||||
|
||||
export interface InferenceContext {
|
||||
sql: Sql;
|
||||
sessionId: string;
|
||||
taskId: string | null;
|
||||
/** Native-BooCode permission gate, set per run from the request/task mode. */
|
||||
permissionMode?: PermissionMode;
|
||||
}
|
||||
|
||||
let current: InferenceContext | null = null;
|
||||
const storage = new AsyncLocalStorage<InferenceContext>();
|
||||
|
||||
export function setInferenceContext(ctx: InferenceContext): void {
|
||||
current = ctx;
|
||||
}
|
||||
|
||||
export function clearInferenceContext(): void {
|
||||
current = null;
|
||||
/**
|
||||
* Bind `ctx` for the duration of the (possibly detached) async chain `fn` starts.
|
||||
* The inference runner kicks off its loop synchronously within this call, so all
|
||||
* downstream `await`s — including write-tool `execute` via the adapter — read the
|
||||
* same store. Concurrent runs each get their own; nothing is shared or cleared
|
||||
* out from under an in-flight run.
|
||||
*/
|
||||
export function runWithInferenceContext<T>(ctx: InferenceContext, fn: () => T): T {
|
||||
return storage.run(ctx, fn);
|
||||
}
|
||||
|
||||
export function getInferenceContext(): InferenceContext {
|
||||
if (!current) {
|
||||
const ctx = storage.getStore();
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'Write tool called outside inference context — setInferenceContext() was not called before this run',
|
||||
'Write tool called outside inference context — runWithInferenceContext() did not wrap this run',
|
||||
);
|
||||
}
|
||||
return current;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
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'),
|
||||
agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
|
||||
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>;
|
||||
@@ -30,6 +31,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
||||
input: { type: 'string', description: 'Task description for the child subtask' },
|
||||
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
|
||||
model: { type: 'string', description: 'Optional: model override for the subtask' },
|
||||
background: { type: 'boolean', description: 'If true, returns immediately without waiting' },
|
||||
},
|
||||
required: ['input'],
|
||||
},
|
||||
@@ -50,6 +52,7 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
||||
return { error: 'Cannot determine project_id from current session' };
|
||||
}
|
||||
|
||||
const isBg = input.background === true;
|
||||
const [task] = await sql<{ id: string; state: string }[]>`
|
||||
INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
|
||||
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
|
||||
@@ -57,9 +60,12 @@ export const newTaskTool: ToolDef<NewTaskInputT> = {
|
||||
`;
|
||||
|
||||
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,
|
||||
state: task!.state,
|
||||
background: isBg,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import type { z } from 'zod';
|
||||
import type { Sql } from '../../db.js';
|
||||
|
||||
/**
|
||||
* Unified permission ladder for native BooCode inference. Gates the write tools:
|
||||
* plan — read-only: create/edit/delete are denied (no staging).
|
||||
* ask — stage to the pending-changes queue; `apply_pending` is denied so the
|
||||
* agent cannot self-apply (the human approves via the Diff panel).
|
||||
* bypass — apply each write immediately (no queue, no approval).
|
||||
* Undefined preserves the historical behavior (stage + `apply_pending` allowed).
|
||||
*/
|
||||
export type PermissionMode = 'plan' | 'ask' | 'bypass';
|
||||
|
||||
/** Narrow a raw task/request mode id to a unified PermissionMode, else undefined
|
||||
* (e.g. an external agent's native mode id, or null). */
|
||||
export function asPermissionMode(id: string | null | undefined): PermissionMode | undefined {
|
||||
return id === 'plan' || id === 'ask' || id === 'bypass' ? id : undefined;
|
||||
}
|
||||
|
||||
export interface ToolJsonSchema {
|
||||
type: 'function';
|
||||
function: {
|
||||
@@ -21,6 +37,8 @@ export interface ToolContext {
|
||||
sql: Sql;
|
||||
sessionId: string;
|
||||
taskId: string | null;
|
||||
/** Native-BooCode permission gate for write tools (undefined = legacy behavior). */
|
||||
permissionMode?: PermissionMode;
|
||||
}
|
||||
|
||||
export interface ToolDef<TInput> {
|
||||
|
||||
53
apps/coder/src/services/tools/write-gate.ts
Normal file
53
apps/coder/src/services/tools/write-gate.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Permission-gate helpers for native BooCode write tools. The gate comes from
|
||||
* the per-run inference context (`ToolContext.permissionMode`):
|
||||
* plan — deny the write (read-only); nothing is staged.
|
||||
* bypass — apply the staged change immediately (no queue, no approval).
|
||||
* ask / undefined — leave it in the pending-changes queue for review.
|
||||
*/
|
||||
import type { ToolContext } from './types.js';
|
||||
import { applyOne } from '../pending_changes.js';
|
||||
|
||||
/** Result returned when a write is denied under Plan (read-only) mode. */
|
||||
export function denyReadOnly(operation: string): unknown {
|
||||
return {
|
||||
status: 'denied',
|
||||
operation,
|
||||
message: `Read-only (Plan) permission mode — ${operation} is not permitted. Switch to Ask or Bypass to make changes.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Finalize a just-staged change per the permission gate: apply now under Bypass,
|
||||
* otherwise return it as queued for the human to approve. */
|
||||
export async function finalizeWrite(
|
||||
context: ToolContext,
|
||||
projectRoot: string,
|
||||
change: { id: string; file_path: string; operation: string },
|
||||
queuedHint: string,
|
||||
): Promise<unknown> {
|
||||
if (context.permissionMode === 'bypass') {
|
||||
const res = await applyOne(context.sql, change.id, projectRoot);
|
||||
console.log(
|
||||
`[write-gate] bypass apply ${change.operation} ${change.file_path} -> ${res.success ? 'applied' : 'FAILED: ' + (res.error ?? '?')}`,
|
||||
);
|
||||
return {
|
||||
status: res.success ? 'applied' : 'failed',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: change.operation,
|
||||
message: res.success
|
||||
? `${change.operation} applied to ${change.file_path}.`
|
||||
: `Apply failed for ${change.file_path}: ${res.error ?? 'unknown error'}. Left in the pending queue.`,
|
||||
};
|
||||
}
|
||||
console.log(
|
||||
`[write-gate] ${context.permissionMode ?? 'legacy'} queued ${change.operation} ${change.file_path}`,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: change.operation,
|
||||
message: queuedHint,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
- **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop — only `description` + `inputSchema: jsonSchema(parameters)`.
|
||||
- **`includeUsage: true` MUST be set on `createOpenAICompatible`** in `provider.ts`. The adapter defaults it false → no `stream_options.include_usage` → llama-swap emits no usage block → `result.usage` resolves `undefined` (NULL token counts). Don't remove during refactor.
|
||||
- **Tool-call-only turns may emit a leading `\n` text-delta.** `MessageList.flatten`'s `hasText` and `MessageBubble`'s `hasContent` both `.trim()` before the length check, else whitespace-only content renders an empty bubble + ActionRow between tool calls. `buildMessagesPayload` also skips `status='failed'` and complete-but-empty assistant rows (avoids "Cannot have 2 or more assistant messages at the end of the list" upstream rejection after cap-hit + Continue).
|
||||
- **`services/inference/tool-shim.ts`** — Recovers structured tool calls from plain-text model output. Some models (notably Qwen) emit `<tool_call><name>...</name><arguments>...</arguments></tool_call>` inline text instead of structured JSON. `extractToolCalls(text)` parses both XML and JSON inline formats. `hasToolCallMarkup(text)` is a fast pre-check. Used as a fallback in the stream phase when structured `tool_calls` parse fails. Does NOT require `FAST_MODEL` — operates on the existing turn's output text.
|
||||
- **`services/inference/loop-detectors.ts`** — Six detectors that catch repetitive model behavior: `detectContentRepeat` (same content N times), `detectToolLoop` (same tool called consecutively). `detectDoomLoop` combines both. These are additive to the existing `sentinels.ts` doom-loop detection.
|
||||
- **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart`; BooCode's OpenAI-shape history lacks it, so a forward-scan builds a `tool_call_id → toolName` map from prior assistant `tool_calls`. Tool outputs wrapped as `{ type: 'json' | 'text', value }` (v6 `ToolResultOutput`). Reasoning emits a `ReasoningPart` first in the content array.
|
||||
- **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn.
|
||||
- **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up.
|
||||
|
||||
@@ -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,
|
||||
loadContext,
|
||||
} from './payload.js';
|
||||
import { toDcpMessages, transformMessages, fromDcpMessages } from './dcp/index.js';
|
||||
import {
|
||||
finalizeCompletion,
|
||||
finalizeEmpty,
|
||||
@@ -156,9 +157,20 @@ export async function runAssistantTurn(
|
||||
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
|
||||
break;
|
||||
}
|
||||
const { session: iterSession, project: iterProject, history } = loaded;
|
||||
let { session: iterSession, project: iterProject, history } = loaded;
|
||||
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
|
||||
// the schema CHECK but not emitted here — writing to the assistant message
|
||||
// before the stream phase creates a sequence-0 collision with
|
||||
|
||||
37
apps/server/src/services/memory/__tests__/bm25.test.ts
Normal file
37
apps/server/src/services/memory/__tests__/bm25.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Bm25Ranker } from '../bm25.js';
|
||||
|
||||
describe('Bm25Ranker', () => {
|
||||
it('scores documents by term frequency', () => {
|
||||
const ranker = new Bm25Ranker();
|
||||
ranker.fit(['the cat sat on the mat', 'the dog chased the cat', 'the bird flew over the mat']);
|
||||
const results = ranker.rank('cat mat');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]!.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns empty for no matches', () => {
|
||||
const ranker = new Bm25Ranker();
|
||||
ranker.fit(['aaa bbb', 'ccc ddd']);
|
||||
const results = ranker.rank('zzz');
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles single document corpus', () => {
|
||||
const ranker = new Bm25Ranker();
|
||||
ranker.fit(['only document here']);
|
||||
const results = ranker.rank('document');
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ranks relevant docs higher', () => {
|
||||
const ranker = new Bm25Ranker();
|
||||
ranker.fit([
|
||||
'javascript is a programming language',
|
||||
'python is also a programming language',
|
||||
'the weather is nice today',
|
||||
]);
|
||||
const results = ranker.rank('javascript programming');
|
||||
expect(results[0]!.index).toBe(0);
|
||||
});
|
||||
});
|
||||
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('');
|
||||
});
|
||||
});
|
||||
27
apps/server/src/services/memory/__tests__/recall.test.ts
Normal file
27
apps/server/src/services/memory/__tests__/recall.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankByHybrid', () => {
|
||||
it('falls back to BM25 when embeddings unavailable', async () => {
|
||||
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 { rankByHybrid } = await import('../recall.js');
|
||||
const result = await rankByHybrid('indentation style', entries);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
67
apps/server/src/services/memory/bm25.ts
Normal file
67
apps/server/src/services/memory/bm25.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// BM25 ranker — pure Okapi BM25 scoring. No external deps.
|
||||
|
||||
interface Bm25Config {
|
||||
k1?: number;
|
||||
b?: number;
|
||||
}
|
||||
|
||||
export class Bm25Ranker {
|
||||
private k1: number;
|
||||
private b: number;
|
||||
private corpus: string[];
|
||||
private avgDocLen: number;
|
||||
private idfCache: Map<string, number>;
|
||||
private docCount: number;
|
||||
|
||||
constructor(config?: Bm25Config) {
|
||||
this.k1 = config?.k1 ?? 1.5;
|
||||
this.b = config?.b ?? 0.75;
|
||||
this.corpus = [];
|
||||
this.avgDocLen = 0;
|
||||
this.idfCache = new Map();
|
||||
this.docCount = 0;
|
||||
}
|
||||
|
||||
fit(docs: string[]): void {
|
||||
this.corpus = docs;
|
||||
this.docCount = docs.length;
|
||||
const lengths = docs.map((d) => d.split(/\s+/).length);
|
||||
this.avgDocLen = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
||||
this.idfCache.clear();
|
||||
}
|
||||
|
||||
private tokenize(text: string): string[] {
|
||||
return text.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
private idf(term: string): number {
|
||||
const cached = this.idfCache.get(term);
|
||||
if (cached !== undefined) return cached;
|
||||
const docsWithTerm = this.corpus.filter((d) => this.tokenize(d).includes(term)).length;
|
||||
const idf = Math.log(1 + (this.docCount - docsWithTerm + 0.5) / (docsWithTerm + 0.5));
|
||||
this.idfCache.set(term, idf);
|
||||
return idf;
|
||||
}
|
||||
|
||||
score(query: string, docIndex: number): number {
|
||||
if (docIndex < 0 || docIndex >= this.corpus.length) return 0;
|
||||
const doc = this.corpus[docIndex]!;
|
||||
const queryTerms = this.tokenize(query);
|
||||
const docTokens = this.tokenize(doc);
|
||||
const docLen = docTokens.length;
|
||||
|
||||
let total = 0;
|
||||
for (const term of queryTerms) {
|
||||
const tf = docTokens.filter((t) => t === term).length;
|
||||
if (tf === 0) continue;
|
||||
const idfVal = this.idf(term);
|
||||
total += idfVal * ((tf * (this.k1 + 1)) / (tf + this.k1 * (1 - this.b + this.b * docLen / this.avgDocLen)));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
rank(query: string, topN: number = 10): Array<{ index: number; score: number }> {
|
||||
const scores = this.corpus.map((_, i) => ({ index: i, score: this.score(query, i) }));
|
||||
return scores.sort((a, b) => b.score - a.score).slice(0, topN).filter((s) => s.score > 0);
|
||||
}
|
||||
}
|
||||
55
apps/server/src/services/memory/embeddings.ts
Normal file
55
apps/server/src/services/memory/embeddings.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Embedding module — ONNX-based local embeddings.
|
||||
// Falls back gracefully when the model file is not available.
|
||||
|
||||
let model: any = null;
|
||||
let ortModule: any = null;
|
||||
|
||||
export function isEmbeddingAvailable(): boolean {
|
||||
return model !== null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const dynamicRequire = typeof require !== 'undefined' ? require : null;
|
||||
|
||||
export async function initEmbeddings(modelPath?: string): Promise<boolean> {
|
||||
try {
|
||||
if (dynamicRequire) {
|
||||
try { ortModule = dynamicRequire('onnxruntime-node'); } catch { ortModule = null; }
|
||||
}
|
||||
if (!ortModule) {
|
||||
try { ortModule = await import('onnxruntime-node' as any); } catch { ortModule = null; }
|
||||
}
|
||||
if (!ortModule) return false;
|
||||
const path = modelPath ?? process.env['EMBEDDING_MODEL_PATH'] ?? '';
|
||||
if (!path) return false;
|
||||
model = await ortModule.InferenceSession.create(path);
|
||||
return true;
|
||||
} catch {
|
||||
model = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function embed(texts: string[]): Promise<number[][] | null> {
|
||||
if (!model) return null;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const ort: { Tensor: new (...args: unknown[]) => unknown } | null = ortModule || null;
|
||||
if (!ort) return null;
|
||||
const input = new ort.Tensor('string', texts, [texts.length]);
|
||||
const feeds: Record<string, any> = {};
|
||||
feeds[model.inputNames[0]] = input;
|
||||
const results = await model.run(feeds);
|
||||
const output = results[model.outputNames[0]];
|
||||
if (!output || !output.data) return null;
|
||||
const dim = output.dims?.[1] ?? 384;
|
||||
const data = output.data as Float32Array;
|
||||
const vectors: number[][] = [];
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
vectors.push(Array.from(data.slice(i * dim, (i + 1) * dim)));
|
||||
}
|
||||
return vectors;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
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>`;
|
||||
}
|
||||
100
apps/server/src/services/memory/recall.ts
Normal file
100
apps/server/src/services/memory/recall.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { MemoryEntry } from './entries.js';
|
||||
import { scanProjectMemory } from './scan.js';
|
||||
import { Bm25Ranker } from './bm25.js';
|
||||
import { embed, isEmbeddingAvailable } from './embeddings.js';
|
||||
|
||||
const SEARCH_MODE = process.env['MEMORY_SEARCH'] ?? 'hybrid';
|
||||
|
||||
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 rankByHybrid(
|
||||
query: string,
|
||||
entries: MemoryEntry[],
|
||||
): Promise<MemoryEntry[]> {
|
||||
if (entries.length === 0) return [];
|
||||
const texts = entries.map((e) => `${e.title} ${e.content} ${e.tags.join(' ')}`);
|
||||
|
||||
const bm25 = new Bm25Ranker();
|
||||
bm25.fit(texts);
|
||||
const bm25Scores = texts.map((_, i) => bm25.score(query, i));
|
||||
const maxBm25 = Math.max(...bm25Scores, 1);
|
||||
const normBm25 = bm25Scores.map((s) => s / maxBm25);
|
||||
|
||||
let cosineScores: number[] = [];
|
||||
if (isEmbeddingAvailable()) {
|
||||
const vectors = await embed([query, ...texts]);
|
||||
if (vectors) {
|
||||
const queryVec = vectors[0]!;
|
||||
cosineScores = texts.map((_, i) => {
|
||||
const vec = vectors[i + 1];
|
||||
if (!vec) return 0;
|
||||
let dot = 0, nA = 0, nB = 0;
|
||||
for (let j = 0; j < queryVec.length; j++) {
|
||||
dot += queryVec[j]! * vec[j]!;
|
||||
nA += queryVec[j]! * queryVec[j]!;
|
||||
nB += vec[j]! * vec[j]!;
|
||||
}
|
||||
const denom = Math.sqrt(nA) * Math.sqrt(nB);
|
||||
return denom === 0 ? 0 : dot / denom;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const scored = entries.map((entry, i) => {
|
||||
const combined = (normBm25[i]! * 0.3) + ((cosineScores[i] ?? 0) * 0.7);
|
||||
return { entry, score: combined };
|
||||
});
|
||||
|
||||
return scored
|
||||
.filter((s) => s.score >= 0.15)
|
||||
.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
|
||||
? SEARCH_MODE === 'keyword'
|
||||
? rankByRelevance(query, entries)
|
||||
: await rankByHybrid(query, entries)
|
||||
: entries.slice(0, 5);
|
||||
|
||||
return relevant.map((e) => `[${e.topic}] ${e.title}: ${e.content}`);
|
||||
}
|
||||
|
||||
export { initEmbeddings } from './embeddings.js';
|
||||
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_framework_analysis',
|
||||
'get_semantic_neighborhoods',
|
||||
'get_blast_radius',
|
||||
]);
|
||||
|
||||
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 { getAgentsMtimes } from './agents.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) =>
|
||||
`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);
|
||||
const guidance = await getContainerGuidance();
|
||||
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) {
|
||||
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 };
|
||||
44
apps/server/src/services/tools/extract_memory.ts
Normal file
44
apps/server/src/services/tools/extract_memory.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../tools/types.js';
|
||||
import { ensureMemoryScaffold, getMemoryRoot } from '../memory/paths.js';
|
||||
import { writeEntry } from '../memory/store.js';
|
||||
|
||||
const ExtractMemoryInput = z.object({
|
||||
topic: z.enum(['project', 'user', 'reference']).describe('Memory topic category'),
|
||||
title: z.string().min(1).max(200).describe('Entry title (will be normalized to filename)'),
|
||||
content: z.string().min(1).describe('Memory content body'),
|
||||
tags: z.array(z.string()).optional().describe('Optional tags for search'),
|
||||
});
|
||||
|
||||
type InputT = z.infer<typeof ExtractMemoryInput>;
|
||||
|
||||
export const extractMemoryTool: ToolDef<InputT> = {
|
||||
name: 'extract_memory',
|
||||
description: 'Persist a memory entry to .boocode/memory/ for cross-session recall. Use for project conventions, user preferences, and architectural decisions.',
|
||||
inputSchema: ExtractMemoryInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'extract_memory',
|
||||
description: 'Persist a memory entry for cross-session recall',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
topic: { type: 'string', enum: ['project', 'user', 'reference'] },
|
||||
title: { type: 'string', description: 'Entry title' },
|
||||
content: { type: 'string', description: 'Memory content' },
|
||||
tags: { type: 'array', items: { type: 'string' }, description: 'Search tags' },
|
||||
},
|
||||
required: ['topic', 'title', 'content'],
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input: InputT, projectRoot: string): Promise<unknown> {
|
||||
const root = getMemoryRoot(projectRoot);
|
||||
await ensureMemoryScaffold(root);
|
||||
await writeEntry(root, input.topic, input.title, input.content, input.tags ?? []);
|
||||
return {
|
||||
result: `Memory entry "${input.title}" saved to .boocode/memory/${input.topic}/`,
|
||||
};
|
||||
},
|
||||
};
|
||||
40
apps/server/src/services/tools/search_memory.ts
Normal file
40
apps/server/src/services/tools/search_memory.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../tools/types.js';
|
||||
import { scanProjectMemory } from '../memory/scan.js';
|
||||
import { rankByHybrid } from '../memory/recall.js';
|
||||
|
||||
const SearchMemoryInput = z.object({
|
||||
query: z.string().min(1).describe('Search query to match against memory entries'),
|
||||
});
|
||||
|
||||
type InputT = z.infer<typeof SearchMemoryInput>;
|
||||
|
||||
export const searchMemoryTool: ToolDef<InputT> = {
|
||||
name: 'search_memory',
|
||||
description: 'Search the .boocode/memory/ store for relevant entries. Returns ranked results matching the query. Use before asking about project conventions or preferences.',
|
||||
inputSchema: SearchMemoryInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_memory',
|
||||
description: 'Search memory store for relevant entries',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input: InputT, projectRoot: string): Promise<unknown> {
|
||||
const entries = await scanProjectMemory(projectRoot);
|
||||
if (entries.length === 0) return { result: 'No memory entries found.' };
|
||||
|
||||
const relevant = await rankByHybrid(input.query, entries);
|
||||
if (relevant.length === 0) return { result: 'No matching memory entries.' };
|
||||
|
||||
const lines = relevant.map((e) => `[${e.topic}] ${e.title}: ${e.content}`);
|
||||
return { result: `Found ${relevant.length} entry(ies):\n${lines.join('\n')}` };
|
||||
},
|
||||
};
|
||||
@@ -218,6 +218,16 @@ function ContestantRow({
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border/50 bg-muted/10 max-h-[55vh] overflow-y-auto">
|
||||
{data.token_breakdown && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 text-xs text-muted-foreground border-b border-border/30">
|
||||
{data.token_breakdown.system > 0 && <span title="system">{data.token_breakdown.system}s</span>}
|
||||
{data.token_breakdown.user > 0 && <span title="user">{data.token_breakdown.user}u</span>}
|
||||
{data.token_breakdown.assistant > 0 && <span title="assistant">{data.token_breakdown.assistant}a</span>}
|
||||
{data.token_breakdown.tools > 0 && <span title="tools">{data.token_breakdown.tools}t</span>}
|
||||
{data.token_breakdown.reasoning > 0 && <span title="reasoning" className="text-amber-500">{data.token_breakdown.reasoning}r</span>}
|
||||
{data.token_breakdown.total > 0 && <span className="font-medium tabular-nums ml-1">∑{data.token_breakdown.total}</span>}
|
||||
</div>
|
||||
)}
|
||||
{output.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
|
||||
{data.status === 'queued'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BooCode — External Code Review & Lift Inventory
|
||||
|
||||
Last updated: 2026-05-25
|
||||
Last updated: 2026-06-07
|
||||
|
||||
This document tracks every open source repo BooCode references or lifts code from. Pin this so we don't lose attribution and don't re-evaluate the same projects twice.
|
||||
|
||||
@@ -346,6 +346,78 @@ Don't ship Phase 1 against AGPL/GPL code; build clean. Patterns are free; code i
|
||||
- **Why it matters:** Python/Svelte, wrong stack. RAG pipeline only worth a read if BooLab needs improvement — unrelated to BooCode.
|
||||
- **How we use it:** Skip for BooCode.
|
||||
|
||||
### v2.8 fork-lifts (2026-06-07) — new lift sources
|
||||
|
||||
#### 18. boocontext (MIT — sidecar replacement)
|
||||
|
||||
- **URL:** <https://github.com/nmakod/codecontext> (upstream); `/opt/forks/boocontext` (fork)
|
||||
- **License:** MIT
|
||||
- **What it is:** Unified MCP codebase context server, aggregates codesight + tree-sitter-analyzer + type-inject as child MCP servers.
|
||||
- **How we use it:** Replaced the old Go codecontext MCP server. Multi-stage Dockerfile builds from `/opt/forks/boocontext`. `shim.go` spawns it via `CODECONTEXT_CHILD` env var. Deep tools (`get_symbol_details`, `get_call_graph`, `get_blast_radius`) registered as server tool wrappers.
|
||||
|
||||
#### 19. tree-sitter-analyzer (MIT — child MCP server)
|
||||
|
||||
- **URL:** <https://github.com/AimasterAcc/tree-sitter-analyzer>
|
||||
- **License:** MIT
|
||||
- **What it is:** Tree-sitter analysis tools delivered as a Rust binary via `uvx`.
|
||||
- **How we use it:** boocontext child MCP server. Enables structured code queries (symbols, callgraph, impact analysis) over MCP.
|
||||
|
||||
#### 20. type-inject (MIT — child MCP server)
|
||||
|
||||
- **URL:** `/opt/forks/type-inject/packages/mcp/`
|
||||
- **License:** MIT
|
||||
- **What it is:** TypeScript type recovery and inference via MCP.
|
||||
- **How we use it:** boocontext child MCP server for `infer_type` and `resolve_signature` tools.
|
||||
|
||||
#### 21. opencode-morph-fast-apply (MIT — edit guards)
|
||||
|
||||
- **URL:** `/opt/forks/opencode-morph-fast-apply/src/`
|
||||
- **License:** MIT
|
||||
- **What it is:** Exact-match edit safety guards (truncation, import drop, marker leakage detection).
|
||||
- **How we use it:** `edit-guards.ts` and `edit-guards-imports.ts` ported guard logic verbatim (MIT allows copy). Called from `pending_changes.ts` before `writeFileAtomic`.
|
||||
|
||||
#### 22. opencode-tokenscope (MIT — token classification)
|
||||
|
||||
- **URL:** `/opt/forks/opencode-tokenscope/plugin/tokenscope-lib/`
|
||||
- **License:** MIT
|
||||
- **What it is:** Per-category token breakdown classification (system/user/assistant/tools/reasoning).
|
||||
- **How we use it:** `token-analysis/analyzer.ts` ports classification logic. Persisted as `TokenBreakdown` JSONB on contestant/task records.
|
||||
|
||||
#### 23. opencode-dynamic-context-pruning (AGPL — patterns only)
|
||||
|
||||
- **URL:** `/opt/forks/opencode-dynamic-context-pruning/lib/`
|
||||
- **License:** AGPL-3.0
|
||||
- **What it is:** Message deduplication, error purging, and search-based compression strategies.
|
||||
- **How we use it:** Clean-room reimplementation — behavior reference only, zero AGPL code copied. `dcp/` module with dedup + purge-errors strategies.
|
||||
|
||||
#### 24. qwen-code memory (Apache-2.0 — patterns)
|
||||
|
||||
- **URL:** `/opt/forks/qwen-code/packages/core/src/memory/`
|
||||
- **License:** Apache-2.0
|
||||
- **What it is:** File-based hierarchical memory with recall and injection.
|
||||
- **How we use it:** Reimplemented behavior patterns. `memory/` module with 4-scope scan (global/home/project/session) and keyword relevance matching. NOTICE attribution added.
|
||||
|
||||
#### 25. qwen-code LSP (Apache-2.0 — patterns)
|
||||
|
||||
- **URL:** `/opt/forks/qwen-code/packages/core/src/lsp/` + `tools/lsp.ts`
|
||||
- **License:** Apache-2.0
|
||||
- **What it is:** LSP code intelligence tools (diagnostics, goto-definition, find-references).
|
||||
- **How we use it:** Reimplemented operations table. `lsp/` module with config, JSON-RPC client, server-manager, and 3 agent tools. NOTICE attribution added.
|
||||
|
||||
#### 26. oh-my-openagent (SUL — patterns only)
|
||||
|
||||
- **URL:** `/opt/forks/oh-my-openagent/src/`
|
||||
- **License:** SUL-1.0
|
||||
- **What it is:** Plugin/hook composition architecture.
|
||||
- **How we use it:** Architecture study only — zero code copied. `plugins/host.ts` is an original implementation of the typed hook registry pattern.
|
||||
|
||||
#### 27. paseo protocol (AGPL — patterns only)
|
||||
|
||||
- **URL:** `/opt/forks/paseo/packages/protocol/`
|
||||
- **License:** AGPL-3.0
|
||||
- **What it is:** Agent protocol types (capability flags, permission frames, delegation metadata).
|
||||
- **How we use it:** Interface shapes only — no code copied. `agent-capabilities.ts` schema, `provider-snapshot.ts` streaming flags, `new_task` background mode.
|
||||
|
||||
-----
|
||||
|
||||
### Reviewed 2026-05-22 — agent CLIs, ensembler, skills, context tooling
|
||||
@@ -405,6 +477,14 @@ Don't ship Phase 1 against AGPL/GPL code; build clean. Patterns are free; code i
|
||||
|`eyaltoledano/claude-task-master` |Tiered tool-loading via env var (core/standard/all); three model roles; PRD-as-source-of-truth |MIT+Commons Clause (no code lift; pattern only)|`BOOCODE_TOOLS` env var for tiered loading; reaffirm three-model-role pattern |v1.12.x / v1.13 (tier hint) |
|
||||
|`sipyourdrink-ltd/bernstein` |HMAC-chained audit log; signed agent cards (Ed25519+JCS); per-artifact lineage; air-gap mode |Verify before lift |Reference for compliance-grade BooCode if/when needed; HMAC log small lift candidate |v2.0+ (audit log), speculative (full stack) |
|
||||
|`siropkin/budi` (tool, not lift) |5-hook Claude Code taxonomy; HTTP daemon + SQLite + dashboard |MIT |Install globally to observe Claude Code token costs; hook taxonomy as reference |Immediate (install) |
|
||||
|`/opt/forks/boocontext` |Unified MCP codebase context server; child MCP manager |MIT |`codecontext/Dockerfile`, `shim.go` child, deep tools (symbols/callgraph/impact) |**v2.8.0 ✅** |
|
||||
|`/opt/forks/opencode-morph-fast-apply`|Edit safety guards (truncation, import drop) |MIT |`edit-guards.ts`, `edit-guards-imports.ts` |**v2.8.0 ✅** |
|
||||
|`/opt/forks/opencode-tokenscope` |Per-category token breakdown classification |MIT |`token-analysis/analyzer.ts`, `TokenBreakdown` contract, DB persistence |**v2.8.0 ✅** |
|
||||
|`/opt/forks/opencode-dynamic-context-pruning`|Message dedup + error purge strategies (AGPL — behavior only) |AGPL-3.0 (patterns) |`dcp/` clean-room module |**v2.8.0 ✅** |
|
||||
|`/opt/forks/qwen-code` memory |File-based hierarchical memory with recall/prompt injection |Apache-2.0 |`memory/` module with 4-scope scan + keyword relevance |**v2.8.0 ✅** |
|
||||
|`/opt/forks/qwen-code` LSP |TypeScript LSP tools (diagnostics, goto-def, references) |Apache-2.0 |`lsp/` module with 3 agent tools |**v2.8.0 ✅** |
|
||||
|`/opt/forks/oh-my-openagent` |Plugin/hook composition architecture (SUL — patterns only) |SUL-1.0 (patterns) |`plugins/host.ts` typed hook registry |**v2.8.0 ✅** |
|
||||
|`/opt/forks/paseo` |Agent capability flags + permission frame shapes (AGPL — patterns only) |AGPL-3.0 (patterns) |`agent-capabilities.ts`, `provider-snapshot.ts` flags, `new_task` background mode |**v2.8.0 ✅** |
|
||||
|
||||
-----
|
||||
|
||||
@@ -464,3 +544,4 @@ Don't ship Phase 1 against AGPL/GPL code; build clean. Patterns are free; code i
|
||||
- **siropkin/budi accepted as tooling, not catalog entry (2026-05-22).** MIT, Rust, single 6MB binary, sub-millisecond hook latency. **WakaTime for Claude Code** — tracks tokens, costs, prompts, file activity, sub-agent spawns in local SQLite, dashboard at `localhost:7878/dashboard`. **Recommend immediate install** (`budi init --global`) for Claude Code session observability. The **5-hook Claude Code event taxonomy** (`SessionStart`, `UserPromptSubmit`, `PostToolUse`, `SubagentStart`, `Stop`) is the canonical reference and worth knowing when BooCode v2.0+ designs its own hook system.
|
||||
- **GeiserX/LynxPrompt tracked as architectural reference, code off-limits (2026-05-22).** **GPL-3.0 makes vendoring incompatible with BooCode's MIT licensing.** 27 stars, Next.js + PostgreSQL + Prisma. Self-hostable platform for managing AGENTS.md / CLAUDE.md / .cursor/rules / slash commands across **30+ AI assistant formats**. Single blueprint, export to N formats. Federated marketplace. The concept fits Sam's situation (5+ project CLAUDE.md/AGENTS.md files maintained separately) but the **manual AgentLint (#39) audit pass is the right ROI today** rather than adopting a full platform. If consolidation ever needed, reimplement the format-adapter pattern in MIT-licensed BooCode code, don't vendor.
|
||||
- **ShipWithAI/claude-code-mastery noted as docs reference (2026-05-22).** **CC BY-NC-SA 4.0** content + MIT code examples. 9 stars. Free 16-phase / 55-module / 136-lesson course on Claude Code workflows. **Two structural patterns worth borrowing:** (1) **7-block module structure** (WHY → CONCEPT → DEMO → PRACTICE → CHEAT SHEET → PITFALLS → REAL CASE) as a docs template; (2) **phase list as coverage checklist** to diff against Sam's own CLAUDE.md/AGENTS.md files — combine with AgentLint (#39) for a single audit pass. Don't redistribute content (NC license).
|
||||
- **v2.8.0-fork-lifts shipped 2026-06-07** — eight integrations from `/opt/forks`: boocontext sidecar (MIT), LSP code intelligence (Apache-2.0 patterns), DCP clean-room (AGPL behavior only), institutional memory (Apache-2.0 patterns), subagent protocol (AGPL/paseo patterns only), plugin hook host (SUL patterns only), inference reliability (tool-shim + loop detectors, original), and TokenScope token breakdown (MIT). Backfilled edit safety guards (MIT, from opencode-morph-fast-apply) and TokenScope analyzer/persist module (MIT, from opencode-tokenscope). All lift sources documented in the new `### v2.8 fork-lifts` subsection under Reference repos. `boocode_code_review.md` last-updated date bumped to 2026-06-07. See `CHANGELOG.md` for full per-commit detail.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# BooCode roadmap (v1.x–v2.x)
|
||||
|
||||
Last updated: 2026-06-03
|
||||
Last updated: 2026-06-07
|
||||
|
||||
> **Companion doc:** `boocode_code_review.md` holds the full external-repo inventory, lift rationale, and license analysis. This document is the canonical source for shipping state, version ordering, and what's planned vs. shipped.
|
||||
|
||||
> **Shipped since this doc's body was written (v2.7.12–v2.7.17, 2026-06-02→03; see `CHANGELOG.md` for detail):** `v2.7.12-audit-cleanup` (repo-wide dead-code/dedup pass, ~−4,600 LOC), `v2.7.13-contracts-ssot` (the `@boocode/contracts` shared wire-contract package — the "unified types" deferred item), `v2.7.14-backlog-hardening` (5 v2-review items incl. external task-cancel, stall-timeout, retire `:9502` SPA), `v2.7.15-git-diff-panel` + `v2.7.16-container-git-safedir` (Files/Git tab), and `v2.7.17-orchestrator` (the in-app multi-agent Orchestrator on local Qwen). The "Write/edit robustness" and "Claude provider SDK" milestones below — previously marked "planned" — are also now shipped (see those sections).
|
||||
> **Shipped since this doc's body was written (v2.7.12–v2.8.0, 2026-06-02→07; see `CHANGELOG.md` for detail):** `v2.7.12-audit-cleanup` (repo-wide dead-code/dedup pass, ~−4,600 LOC), `v2.7.13-contracts-ssot` (the `@boocode/contracts` shared wire-contract package), `v2.7.14-backlog-hardening` (5 v2-review items), `v2.7.15-git-diff-panel` + `v2.7.16-container-git-safedir` (Files/Git tab), `v2.7.17-orchestrator` (in-app multi-agent Orchestrator), and **`v2.8.0-fork-lifts`** (eight integrations — LSP, DCP, memory, boocontext, subagent protocol, plugins, inference reliability, TokenScope — plus edit safety guards and TokenScope analyzer). The "Write/edit robustness" and "Claude provider SDK" milestones below are also shipped.
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
@@ -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
|
||||
# (codecontext from source + our HTTP shim), then a minimal alpine:3.20
|
||||
# runtime holds both.
|
||||
# The shim spawns boocontext as a child MCP process over stdio NDJSON,
|
||||
# translating HTTP requests to MCP tools/call.
|
||||
#
|
||||
# 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.
|
||||
# To stage the fork source for a Docker build:
|
||||
# tar -czf codecontext/fork.tar.gz -C /opt/forks/boocontext \
|
||||
# --exclude=.git --exclude=node_modules --exclude=dist
|
||||
|
||||
FROM golang:1.24-alpine AS builder
|
||||
WORKDIR /build
|
||||
|
||||
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.
|
||||
# Stage 1: Go shim builder
|
||||
FROM golang:1.24-alpine AS shim-builder
|
||||
WORKDIR /build/shim
|
||||
RUN apk add --no-cache ca-certificates
|
||||
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.
|
||||
# Stage 2: boocontext MCP builder
|
||||
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
|
||||
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
|
||||
RUN apk add --no-cache ca-certificates nodejs uv
|
||||
COPY --from=shim-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
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
@@ -185,13 +186,14 @@ func notify(method string, params any) error {
|
||||
// ---- 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", "--respect-gitignore")
|
||||
// Support CODECONTEXT_CHILD env var for overriding the MCP child command.
|
||||
// Default to boocontext (Node.js MCP aggregator). Set in docker-compose.
|
||||
childCmd := os.Getenv("CODECONTEXT_CHILD")
|
||||
if childCmd == "" {
|
||||
childCmd = "node /usr/local/lib/boocontext/dist/index.js"
|
||||
}
|
||||
parts := strings.Split(childCmd, " ")
|
||||
child = exec.Command(parts[0], parts[1:]...)
|
||||
var err error
|
||||
childStdin, err = child.StdinPipe()
|
||||
if err != nil {
|
||||
@@ -417,6 +419,9 @@ func main() {
|
||||
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"))
|
||||
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{
|
||||
Addr: ":8080",
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"
|
||||
},
|
||||
"enabled": false
|
||||
},
|
||||
"boocontext": {
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": ["/opt/forks/boocontext/dist/index.js"],
|
||||
"env": {
|
||||
"TYPE_INJECT_MCP_PATH": "/opt/forks/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"
|
||||
},
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,10 +109,16 @@ services:
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
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:
|
||||
- boocode_net
|
||||
volumes:
|
||||
- /opt:/opt:ro
|
||||
- /opt/forks:/opt/forks:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
|
||||
interval: 30s
|
||||
|
||||
2
openspec/changes/add-behavioral-engine/.openspec.yaml
Normal file
2
openspec/changes/add-behavioral-engine/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-07
|
||||
32
openspec/changes/add-behavioral-engine/design.md
Normal file
32
openspec/changes/add-behavioral-engine/design.md
Normal file
@@ -0,0 +1,32 @@
|
||||
## Context
|
||||
|
||||
BooCode has no structured behavioral enforcement. Agent behavior is guided by system prompts and CLAUDE.md — advisory, not enforceable. The `boocontext-audit` package (already TypeScript, already in /opt/forks) provides a complete behavioral compliance engine: Guideline model, 6-batch matcher, relational resolver, audit trail, and graded recovery.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Import boocontext-audit's Guideline model (condition/action rules with criticality)
|
||||
- Import multi-batch matcher (Observational, Actionable, PreviouslyApplied, Disambiguation, ResponseAnalysis, LowCriticality)
|
||||
- Import RelationalResolver (DEPENDS_ON, PRIORITIZES, ENTAILS, TAG_ALL, TAG_PRIORITIZES)
|
||||
- Import audit middleware (PostToolUse, Stop, UserPromptSubmit hooks)
|
||||
- Import graded context recovery (L0-L4)
|
||||
- Wire guideline evaluation into agent's inference loop
|
||||
|
||||
**Non-Goals:**
|
||||
- Journey DAG integration (future scope)
|
||||
- MCP middleware integration (focus on in-process hooks)
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Direct import from local fork**: boocontext-audit is at `/opt/forks/boocontext-audit/`. Use workspace dependency or npm link.
|
||||
- **Guideline storage**: InMemoryGuidelineStore for development, FileRelationshipStore for production.
|
||||
- **Batch execution**: Run observable + actionable batches in parallel, then disambiguation, then response analysis.
|
||||
- **SchematicGenerator**: Abstract LLM caller. Configure per-batch model (use cheap model for matching, expensive for disambiguation).
|
||||
- **Audit hooks**: Wire PostToolUse → appendToBuffer(), Stop → flushBuffer(), UserPromptSubmit → injectSessionContext().
|
||||
- **Recovery**: Load L0 (index) by default. L2 (user corrections) on /recover. L3 (full) on /recover full.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **LLM overhead**: Each batch is an LLM call. 6 batches × N guidelines could be expensive. Mitigation: batch size limits, parallel execution.
|
||||
- **Cold start**: No guidelines exist initially. Users must define them. Ship with 5-10 built-in safety guidelines.
|
||||
- **boocontext-audit maturity**: v0.1.0. Review code quality before direct import.
|
||||
22
openspec/changes/add-behavioral-engine/proposal.md
Normal file
22
openspec/changes/add-behavioral-engine/proposal.md
Normal file
@@ -0,0 +1,22 @@
|
||||
## Why
|
||||
|
||||
BooCode has no structured way to enforce agent behavior rules. The `boocontext-audit` package (already TypeScript, zero external deps) provides a complete behavioral compliance engine ported from Parlant: Guideline condition/action model, multi-batch LLM matcher, relational resolver, audit middleware, and graded context recovery. Adding this gives BooCode structured rule enforcement far beyond simple CLAUDE.md guidelines.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Import boocontext-audit as a dependency in apps/coder/
|
||||
- Add Guideline model: natural language condition/action rules with criticality
|
||||
- Add multi-batch matcher: observational, actionable, previously-applied, disambiguation, response analysis batches
|
||||
- Add RelationalResolver: DEPENDS_ON, PRIORITIZES, ENTAILS, TAG_ALL relationship resolution
|
||||
- Add audit middleware: PostToolUse/Stop/UserPromptSubmit hooks with JSONL buffer
|
||||
- Add graded context recovery: L0-L4 recovery levels
|
||||
- Wire guideline evaluation into agent's inference loop
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `guideline-model`: Natural language condition/action rules with criticality and priority
|
||||
- `multi-batch-matcher`: 6-batch LLM evaluation for context-relevant rule matching
|
||||
- `relational-resolver`: Dependency/priority/entailment resolution with iterative convergence
|
||||
- `audit-middleware`: PostToolUse/Stop/UserPromptSubmit hooks with JSONL trail
|
||||
- `graded-recovery`: L0-L4 context recovery for session continuity
|
||||
@@ -0,0 +1,21 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: PostToolUse audit logging
|
||||
- **WHEN** a tool is used
|
||||
- **THEN** the tool name, input summary, and timestamp are appended to the JSONL audit buffer
|
||||
|
||||
### Requirement: Stop hook flush
|
||||
- **WHEN** a response completes
|
||||
- **THEN** the audit buffer is flushed to the session audit trail and index is updated
|
||||
|
||||
### Requirement: UserPromptSubmit context injection
|
||||
- **WHEN** a user message is submitted
|
||||
- **THEN** session context (session ID, record count, critical alerts) is injected into the prompt
|
||||
|
||||
### Requirement: Anomaly detection
|
||||
- **WHEN** audit records are checked against alert rules
|
||||
- **THEN** anomalies at CRITICAL level are injected into the context
|
||||
|
||||
#### Scenario: Full audit trail
|
||||
- **WHEN** an agent runs 10 tool calls across 3 turns
|
||||
- **THEN** the audit trail contains 10 JSONL records, a session summary, and an updated index
|
||||
@@ -0,0 +1,25 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: L0 recovery (index summary)
|
||||
- **WHEN** /recover is called without arguments
|
||||
- **THEN** the last 5 index entries are loaded (~200 tokens)
|
||||
|
||||
### Requirement: L1 recovery (session state)
|
||||
- **WHEN** /recover L1 is called
|
||||
- **THEN** current session.json + last 3 audit trail entries are loaded (~500 tokens)
|
||||
|
||||
### Requirement: L2 recovery (user corrections)
|
||||
- **WHEN** /recover L2 is called
|
||||
- **THEN** ALL user_correction records across all sessions are loaded (~1000 tokens)
|
||||
|
||||
### Requirement: L3 recovery (full context)
|
||||
- **WHEN** /recover L3 is called
|
||||
- **THEN** full audit trail + all pending records are loaded (~3000 tokens)
|
||||
|
||||
### Requirement: Priority loading
|
||||
- **WHEN** recovering context
|
||||
- **THEN** user_correction records are loaded first (highest priority)
|
||||
|
||||
#### Scenario: Session crash recovery
|
||||
- **WHEN** an agent session crashes and restarts with /recover
|
||||
- **THEN** the agent gets the index summary, last session state, and all user corrections
|
||||
@@ -0,0 +1,17 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Guideline creation
|
||||
- **WHEN** creating a guideline with condition, action, and criticality
|
||||
- **THEN** it is stored with unique ID and metadata
|
||||
|
||||
### Requirement: Guideline evaluation
|
||||
- **WHEN** an agent action triggers guideline evaluation
|
||||
- **THEN** matching guidelines are activated with score and rationale
|
||||
|
||||
### Requirement: Criticality levels
|
||||
- **WHEN** evaluating guidelines
|
||||
- **THEN** guidelines are filtered by criticality (low/medium/high/critical) with higher-criticality taking precedence
|
||||
|
||||
#### Scenario: Security policy enforcement
|
||||
- **WHEN** an agent attempts to edit a file matching a security guideline condition
|
||||
- **THEN** the guideline matcher returns the relevant rule with CRITICAL severity
|
||||
@@ -0,0 +1,17 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Six batch types
|
||||
- **WHEN** guidelines are evaluated
|
||||
- **THEN** they are processed through: Observational, Actionable, PreviouslyApplied, Disambiguation, ResponseAnalysis, and LowCriticality batches
|
||||
|
||||
### Requirement: Parallel batch execution
|
||||
- **WHEN** independent batches are ready
|
||||
- **THEN** they execute in parallel (observational + actionable run concurrently)
|
||||
|
||||
### Requirement: Structured LLM output per batch
|
||||
- **WHEN** a batch calls the LLM
|
||||
- **THEN** it uses a structured schema specific to the batch type (e.g., applies: boolean for actionable, was_followed: boolean for response analysis)
|
||||
|
||||
#### Scenario: Multi-rule evaluation
|
||||
- **WHEN** an agent action matches 3 guidelines across different criticalities
|
||||
- **THEN** the matcher returns all applicable matches with scores, with CRITICAL matches flagged
|
||||
@@ -0,0 +1,21 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: DEPENDS_ON resolution
|
||||
- **WHEN** guideline A depends on guideline B
|
||||
- **THEN** B is activated if A is activated
|
||||
|
||||
### Requirement: PRIORITIZES resolution
|
||||
- **WHEN** guideline A prioritizes over guideline B
|
||||
- **THEN** B is filtered out if both match
|
||||
|
||||
### Requirement: ENTAILS resolution
|
||||
- **WHEN** guideline A entails guideline B
|
||||
- **THEN** B is automatically activated when A is activated
|
||||
|
||||
### Requirement: Iterative convergence
|
||||
- **WHEN** resolving relationships
|
||||
- **THEN** the resolver iterates (max 100 iterations) until no more changes or stable state
|
||||
|
||||
#### Scenario: Conflicting guideline resolution
|
||||
- **WHEN** a HIGH priority guideline matches and a LOW priority guideline also matches
|
||||
- **THEN** the LOW priority guideline is filtered out via numerical priority resolution
|
||||
56
openspec/changes/add-behavioral-engine/tasks.md
Normal file
56
openspec/changes/add-behavioral-engine/tasks.md
Normal file
@@ -0,0 +1,56 @@
|
||||
## 1. Import boocontext-audit as dependency
|
||||
|
||||
- [ ] 1.1 Add boocontext-audit as workspace dependency
|
||||
- [ ] 1.2 Verify Guideline, GuidelineStore, SchematicGenerator exports
|
||||
|
||||
## 2. Implement Guideline model
|
||||
|
||||
- [ ] 2.1 Create GuidelineManager wrapping GuidelineStore
|
||||
- [ ] 2.2 Add CRUD operations for guidelines (create, read, update, delete, list)
|
||||
- [ ] 2.3 Add InMemoryGuidelineStore and FileRelationshipStore backends
|
||||
- [ ] 2.4 Add criticality filtering and priority sorting
|
||||
|
||||
## 3. Implement multi-batch matcher
|
||||
|
||||
- [ ] 3.1 Create MatcherService wrapping GenericGuidelineMatchingStrategy
|
||||
- [ ] 3.2 Add Observable, Actionable, PreviouslyApplied, Disambiguation, ResponseAnalysis, LowCriticality batch types
|
||||
- [ ] 3.3 Add parallel batch execution for independent batches
|
||||
- [ ] 3.4 Add SchematicGenerator abstraction for LLM batch calls
|
||||
|
||||
## 4. Implement RelationalResolver
|
||||
|
||||
- [ ] 4.1 Create ResolverService wrapping RelationalResolver
|
||||
- [ ] 4.2 Implement DEPENDS_ON, PRIORITIZES, ENTAILS, TAG_ALL, TAG_PRIORITIZES resolution
|
||||
- [ ] 4.3 Add iterative convergence loop (max 100 iterations)
|
||||
- [ ] 4.4 Add resolution logging
|
||||
|
||||
## 5. Implement audit middleware
|
||||
|
||||
- [ ] 5.1 Create AuditService with PostToolUse middleware (JSONL buffer append)
|
||||
- [ ] 5.2 Add Stop middleware (buffer flush to session trail)
|
||||
- [ ] 5.3 Add UserPromptSubmit middleware (session context injection + CRITICAL alerts)
|
||||
- [ ] 5.4 Wire audit middleware into agent's inference lifecycle
|
||||
|
||||
## 6. Implement graded context recovery
|
||||
|
||||
- [ ] 6.1 Create RecoveryService with L0-L4 recovery methods
|
||||
- [ ] 6.2 Implement L0: read last 5 index entries
|
||||
- [ ] 6.3 Implement L1: session.json + last 3 audit trail entries
|
||||
- [ ] 6.4 Implement L2: all user_correction records
|
||||
- [ ] 6.5 Implement L3: full audit trail
|
||||
- [ ] 6.6 Add priority loading (user corrections first)
|
||||
|
||||
## 7. Wire into agent inference loop
|
||||
|
||||
- [ ] 7.1 Run guideline evaluation before each agent turn
|
||||
- [ ] 7.2 Inject active guidelines into system prompt
|
||||
- [ ] 7.3 Record guideline matches in turn metadata
|
||||
- [ ] 7.4 Add guideline management commands (add-guideline, list-guidelines, remove-guideline)
|
||||
|
||||
## 8. Test and verify
|
||||
|
||||
- [ ] 8.1 Test guideline creation and storage
|
||||
- [ ] 8.2 Test multi-batch matching with sample guidelines
|
||||
- [ ] 8.3 Test relational resolution with dependencies
|
||||
- [ ] 8.4 Test audit middleware tool logging
|
||||
- [ ] 8.5 Test graded recovery at all levels
|
||||
2
openspec/changes/add-type-inject-mcp/.openspec.yaml
Normal file
2
openspec/changes/add-type-inject-mcp/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-07
|
||||
28
openspec/changes/add-type-inject-mcp/design.md
Normal file
28
openspec/changes/add-type-inject-mcp/design.md
Normal file
@@ -0,0 +1,28 @@
|
||||
## Context
|
||||
|
||||
BooCode has 0% TypeScript type recovery. When agents read files, they get raw text without type signatures. The type-inject project provides a published MCP server and hooks that extract TypeScript types and inject them contextually.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Add `@nick-vi/type-inject-mcp` as MCP server in BooCode config
|
||||
- Add auto-type-injection on file reads (Read tool hook)
|
||||
- Add type-check feedback on file writes (Write tool hook)
|
||||
- Add `lookup_type` and `list_types` tools for agents
|
||||
|
||||
**Non-Goals:**
|
||||
- Type extraction for non-TypeScript languages (future scope)
|
||||
- Full ts-morph project analysis (type-inject handles this)
|
||||
|
||||
## Decisions
|
||||
|
||||
- **MCP server registration**: One-line addition to mcpServers config: `npx -y @nick-vi/type-inject-mcp`
|
||||
- **Read hook**: Register a PostToolUse hook for `Read` tool that pipes content through type-inject
|
||||
- **Write hook**: Register a PostToolUse hook for `Write`/`Edit` tool that runs type checker
|
||||
- **Token budget**: Configure `maxTokens: 2000`, `skipBarrelFiles: true`, `onlyUsed: true` defaults
|
||||
- **Published package**: No local fork needed. Use published npm package.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Latency**: Type extraction adds ~200-500ms per file read. Token budget limits prevent runaway costs.
|
||||
- **Accuracy**: ts-morph-based extraction is accurate but may miss dynamic types. Acceptable trade-off.
|
||||
18
openspec/changes/add-type-inject-mcp/proposal.md
Normal file
18
openspec/changes/add-type-inject-mcp/proposal.md
Normal file
@@ -0,0 +1,18 @@
|
||||
## Why
|
||||
|
||||
BooCode's codecontext sidecar has 0% TypeScript type recovery — it cannot provide type signatures when the AI reads files. The `type-inject` project provides a published MCP server (`@nick-vi/type-inject-mcp`) that extracts TypeScript types, interfaces, function signatures from source files and injects them on file reads. Adding it to BooCode's MCP configuration directly solves the type blindness problem.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `@nick-vi/type-inject-mcp` as an MCP server in BooCode's server config
|
||||
- Add type-inject hooks for PostToolUse on Read (auto-inject types) and Write (type-check feedback)
|
||||
- Add `lookup_type` and `list_types` tools available to agents
|
||||
- Configure token budget and filtering options (onlyUsed, maxTokens, skipBarrelFiles)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `type-inject-mcp-server`: Register type-inject as MCP server in BooCode config
|
||||
- `auto-type-injection`: Hook type signatures into file reads automatically
|
||||
- `type-check-on-write`: Run type checker after file edits and report errors
|
||||
- `type-lookup-tools`: Add `lookup_type` and `list_types` MCP tools for agents
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user