refactor: rombak arsitektur file ke modular clean-arch (vertikal per-feature)
Organisasi ulang layer-first (domain/application/infrastructure) menjadi vertikal per-feature, behavior tidak berubah: - src/shared/domain/ -> fondasi bersama (ex-domain/core): message, provider, tool_call, tool_result, store, error, usage, conversation - src/features/agent/ -> LLM turn loop: domain (TurnEvent, AgentTurnParams) + application (turn_service, ports) + infrastructure (llm LlmClient + tools) - src/features/cms/ -> settings/conversation/memory persistence: domain + application (services) + infrastructure (persistence repos) - src/features/subagent/ -> orchestration: domain (AccessTier) + infrastructure (engine runner, context, provider, delegate, spawn_tools) - src/features/workflow/ -> multi-phase + hive-mind: domain + infrastructure - src/interfaces/ -> cli + tui (outer ring) tetap Subagent-runner (engine.ts runAgent, division.ts toolsFor) dipindah ke features/subagent (bukan agent tools) utk memutus coupling agent<->subagent. Alias tsconfig: @zesdex/domain -> shared/domain, + @zesdex/agent, @zesdex/agent-infra, @zesdex/cms, @zesdex/subagent, @zesdex/workflow, @zesdex/shared. Bersihkan dead: application/ports PasswordService/TokenService/AuthService. compose.ts (composition root) & bootstrap repoint ke barrel feature. Gates: tsc --noEmit 0 error, bun test 70 pass/0 fail, bun build 147 modules, ./dist/zesdex --headless -> real turn OK.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Agent application module — ToolExecutor, AgentTurnService, and the
|
||||
* AgentTurnServiceImpl turn loop + compaction. Mirrors `agent/` in Rust.
|
||||
*/
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
|
||||
/** Interface for dispatching tool calls to their concrete implementations. */
|
||||
export interface ToolExecutor {
|
||||
/** Execute a tool call asynchronously. */
|
||||
execute(toolName: string, args: JsonValue): Promise<string>;
|
||||
/** Whether a tool is read-only / safe to run concurrently. Default `false`. */
|
||||
isParallelSafe?(toolName: string): boolean;
|
||||
}
|
||||
|
||||
/** Service for running agent turns asynchronously. */
|
||||
export interface AgentTurnService {
|
||||
runTurn(params: import("@zesdex/agent").AgentTurnParams): Promise<void>;
|
||||
}
|
||||
|
||||
export * from "./turn_service.ts";
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Port traits (interfaces) that the Agent application layer exposes to
|
||||
* its infrastructure. Concrete implementations live in
|
||||
* `features/agent/infrastructure/`.
|
||||
*/
|
||||
import type { ChatMessage, StreamEvent, ToolDef } from "@zesdex/domain";
|
||||
|
||||
/** Abstraction for an LLM provider chat-completion service. */
|
||||
export interface ProviderService {
|
||||
/**
|
||||
* Send a non-streaming chat completion request.
|
||||
* Returns the assistant's `ChatMessage` and optional `[prompt, completion]` token usage.
|
||||
*/
|
||||
chat(
|
||||
messages: ChatMessage[],
|
||||
tools?: ToolDef[],
|
||||
maxTokens?: number,
|
||||
temperature?: number,
|
||||
): Promise<{ message: ChatMessage; usage: [number, number] | null }>;
|
||||
|
||||
/**
|
||||
* Send a streaming request. `onEvent` is called per parsed SSE event and
|
||||
* returns `false` to abort. Returns the fully assembled assistant message
|
||||
* and optional usage once the stream completes.
|
||||
*/
|
||||
chatStream(
|
||||
messages: ChatMessage[],
|
||||
tools: ToolDef[],
|
||||
maxTokens: number,
|
||||
temperature: number,
|
||||
onEvent: (event: StreamEvent) => boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ message: ChatMessage; usage: [number, number] | null }>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
adaptiveMaxTokens,
|
||||
conversationChars,
|
||||
ErrorTracker,
|
||||
truncateToolOutput,
|
||||
} from "./turn_service.ts";
|
||||
import { type ChatMessage, newConversation, systemMessage, userMessage, toolResultMessage } from "@zesdex/domain";
|
||||
|
||||
describe("truncateToolOutput", () => {
|
||||
it("short output is unchanged", () => {
|
||||
expect(truncateToolOutput("short")).toBe("short");
|
||||
});
|
||||
|
||||
it("long output preserves head and marks cut", () => {
|
||||
const long = "x".repeat(12_000 + 500);
|
||||
const truncated = truncateToolOutput(long);
|
||||
expect(truncated.length).toBeLessThan(long.length);
|
||||
expect(truncated).toContain("...[truncated");
|
||||
expect(truncated.startsWith("xxx")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adaptiveMaxTokens", () => {
|
||||
it("scales with request length", () => {
|
||||
expect(adaptiveMaxTokens(10)).toBe(800);
|
||||
expect(adaptiveMaxTokens(200)).toBe(1600);
|
||||
expect(adaptiveMaxTokens(5000)).toBe(4096);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ErrorTracker", () => {
|
||||
it("injects recovery note after repeated errors", () => {
|
||||
const tracker = new ErrorTracker();
|
||||
const messages: ChatMessage[] = [];
|
||||
tracker.record("read", "Error: File not found", messages);
|
||||
tracker.record("read", "Error: File not found", messages);
|
||||
expect(tracker.shouldStop()).toBe(false);
|
||||
tracker.record("read", "Error: File not found", messages);
|
||||
expect(messages.some((m) => m.content?.includes("[System note]"))).toBe(true);
|
||||
});
|
||||
|
||||
it("stops after too many errors", () => {
|
||||
const tracker = new ErrorTracker();
|
||||
const messages: ChatMessage[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
tracker.record("bash", `Error: boom ${i}`, messages);
|
||||
}
|
||||
expect(tracker.shouldStop()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversationChars", () => {
|
||||
it("sums content only", () => {
|
||||
const conv = newConversation("sys", "s1");
|
||||
conv.messages.push(systemMessage("sys"), userMessage("hello world"), toolResultMessage("id", "output"));
|
||||
expect(conversationChars(conv.messages)).toBe(3 + 11 + 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Agent turn service — the core adaptive turn loop. Mirrors
|
||||
* `apps/application/src/agent/turn_service.rs`.
|
||||
*/
|
||||
import {
|
||||
type ChatMessage,
|
||||
type StreamEvent,
|
||||
type ToolCall,
|
||||
type ToolDef,
|
||||
type JsonValue,
|
||||
systemMessage,
|
||||
userMessage,
|
||||
assistantMessage,
|
||||
toolResultMessage,
|
||||
sanitizeToolArguments,
|
||||
} from "@zesdex/domain";
|
||||
import {
|
||||
type AgentTurnParams,
|
||||
type TurnEventSink,
|
||||
mainAgentPromptWithProjectContext,
|
||||
compactionPrompt,
|
||||
errorRecoveryNote,
|
||||
} from "@zesdex/agent";
|
||||
import type { ProviderService } from "./ports.ts";
|
||||
import type { ToolExecutor } from "./index.ts";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Constants (mirrors Rust) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const MAX_TURN_ITERATIONS = 50;
|
||||
const MAX_CONSECUTIVE_TOOL_ERRORS = 3;
|
||||
const MAX_TOTAL_TOOL_ERRORS = 8;
|
||||
const TOOL_OUTPUT_MAX_CHARS = 12_000;
|
||||
const AUTO_COMPACT_CHARS = 60_000;
|
||||
const MAX_PARALLEL_TOOLS = 8;
|
||||
const PROJECT_CONTEXT_MAX_CHARS = 12_000;
|
||||
const RULE_FILENAMES = ["AGENTS.md", "agent.md", "CLAUDE.md", "claude.md", ".cursorrules", ".zesdexrules"];
|
||||
const COMPACT_KEEP_TAIL = 6;
|
||||
|
||||
/** Whether the output string denotes a tool error. */
|
||||
function isErrorOutput(output: string): boolean {
|
||||
return output.startsWith("Error:");
|
||||
}
|
||||
|
||||
/** Truncate a long tool output, preserving the head + truncation marker. */
|
||||
export function truncateToolOutput(output: string): string {
|
||||
if (output.length <= TOOL_OUTPUT_MAX_CHARS) return output;
|
||||
const head = output.slice(0, TOOL_OUTPUT_MAX_CHARS);
|
||||
return `${head}\n...[truncated ${output.length - TOOL_OUTPUT_MAX_CHARS} chars]`;
|
||||
}
|
||||
|
||||
/** Pick a `max_tokens` budget based on the user's request length. */
|
||||
export function adaptiveMaxTokens(requestLen: number): number {
|
||||
if (requestLen <= 80) return 800;
|
||||
if (requestLen <= 400) return 1600;
|
||||
return 4096;
|
||||
}
|
||||
|
||||
/** Sum character length of message content as a context-size proxy. */
|
||||
export function conversationChars(messages: ChatMessage[]): number {
|
||||
return messages.reduce((acc, m) => acc + (m.content?.length ?? 0), 0);
|
||||
}
|
||||
|
||||
/** Best-effort build of project context from convention rule files. */
|
||||
export function buildProjectContext(root: string): string {
|
||||
let ctx = "";
|
||||
for (const file of RULE_FILENAMES) {
|
||||
try {
|
||||
const content = requireNodeFsReadFile(root, file);
|
||||
ctx += `\n### ${file}\n\`\`\`\n${content.trim()}\n\`\`\``;
|
||||
} catch {
|
||||
/* file missing — skip */
|
||||
}
|
||||
}
|
||||
const context = ctx.trim();
|
||||
if (context.length <= PROJECT_CONTEXT_MAX_CHARS) return context;
|
||||
return `${context.slice(0, PROJECT_CONTEXT_MAX_CHARS)}\n...[project context truncated]`;
|
||||
}
|
||||
|
||||
/** Read a repo rule file synchronously (Bun-compatible). */
|
||||
function requireNodeFsReadFile(root: string, file: string): string {
|
||||
const fs = require("node:fs");
|
||||
return fs.readFileSync(`${root}/${file}`, "utf8");
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ErrorTracker */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Track repeated tool-call errors so the loop can recover. */
|
||||
export class ErrorTracker {
|
||||
consecutive = 0;
|
||||
total = 0;
|
||||
lastTool: string | null = null;
|
||||
lastError = "";
|
||||
|
||||
record(toolName: string, error: string, messages: ChatMessage[]): void {
|
||||
if (this.lastTool === toolName) {
|
||||
this.consecutive += 1;
|
||||
} else {
|
||||
this.consecutive = 1;
|
||||
}
|
||||
this.lastTool = toolName;
|
||||
this.lastError = error;
|
||||
this.total += 1;
|
||||
|
||||
const sysNoteInContext = messages.some((m) => m.content?.includes("[System note]"));
|
||||
if (this.consecutive >= MAX_CONSECUTIVE_TOOL_ERRORS && !sysNoteInContext) {
|
||||
messages.push(systemMessage(errorRecoveryNote(toolName, error)));
|
||||
}
|
||||
}
|
||||
|
||||
shouldStop(): boolean {
|
||||
return this.consecutive >= MAX_CONSECUTIVE_TOOL_ERRORS * 2 || this.total >= MAX_TOTAL_TOOL_ERRORS;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Tool execution */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Execute one tool call, push events, return the result string. */
|
||||
async function executeToolCall(
|
||||
executor: ToolExecutor,
|
||||
sink: TurnEventSink,
|
||||
tc: ToolCall,
|
||||
): Promise<string> {
|
||||
const name = tc.function.name;
|
||||
const args = sanitizeToolArguments(tc.function.arguments);
|
||||
|
||||
let output: string;
|
||||
try {
|
||||
output = await executor.execute(name, args as JsonValue);
|
||||
} catch (e) {
|
||||
output = `Error: ${(e as Error).message}`;
|
||||
}
|
||||
|
||||
const isError = isErrorOutput(output);
|
||||
const truncated = truncateToolOutput(output);
|
||||
|
||||
sink.push({
|
||||
kind: "tool_result",
|
||||
tool_call_id: tc.id,
|
||||
tool_name: name,
|
||||
output: truncated,
|
||||
is_error: isError,
|
||||
path: null,
|
||||
});
|
||||
|
||||
return truncated;
|
||||
}
|
||||
|
||||
/** Execute a batch of read-only tool calls concurrently (bounded) in original order. */
|
||||
async function executeToolCallsInParallel(
|
||||
executor: ToolExecutor,
|
||||
sink: TurnEventSink,
|
||||
toolCalls: ToolCall[],
|
||||
): Promise<string[]> {
|
||||
// Simple bounded concurrency preserving input order.
|
||||
const results: string[] = new Array(toolCalls.length);
|
||||
let next = 0;
|
||||
|
||||
async function worker() {
|
||||
while (true) {
|
||||
const idx = next++;
|
||||
if (idx >= toolCalls.length) return;
|
||||
results[idx] = await executeToolCall(executor, sink, toolCalls[idx]!);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(MAX_PARALLEL_TOOLS, toolCalls.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Compaction */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Compact oversized conversation history using AI summarisation. At most once
|
||||
* per turn. Keeps the last COMPACT_KEEP_TAIL messages.
|
||||
*/
|
||||
export async function compactMessagesWithAi(
|
||||
messages: ChatMessage[],
|
||||
provider: ProviderService,
|
||||
): Promise<void> {
|
||||
if (messages.length <= COMPACT_KEEP_TAIL + 2) return;
|
||||
|
||||
const splitIdx = messages.length - COMPACT_KEEP_TAIL;
|
||||
const evicted = messages.splice(0, splitIdx);
|
||||
|
||||
const summaryPrompt: ChatMessage[] = [systemMessage(compactionPrompt()), ...evicted, userMessage("Please summarise our previous conversation above for context continuity.")];
|
||||
|
||||
try {
|
||||
const { message } = await provider.chat(summaryPrompt, undefined, 1024, 0.3);
|
||||
const summaryText = message.content ?? "Previous context summarised.";
|
||||
messages.unshift(systemMessage(`[AI Summary of Previous Conversation]\n${summaryText.trim()}`));
|
||||
} catch {
|
||||
messages.unshift(systemMessage("[Earlier conversation messages compacted to save context window]"));
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* AgentTurnServiceImpl */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Service implementation for executing an agent turn asynchronously. */
|
||||
export class AgentTurnServiceImpl {
|
||||
private provider: ProviderService;
|
||||
private toolExecutor: ToolExecutor;
|
||||
private toolDefs: ToolDef[];
|
||||
|
||||
constructor(provider: ProviderService, toolExecutor: ToolExecutor, toolDefs: ToolDef[]) {
|
||||
this.provider = provider;
|
||||
this.toolExecutor = toolExecutor;
|
||||
this.toolDefs = toolDefs;
|
||||
}
|
||||
|
||||
/** Emit a TurnEvent onto the sink (no-op if the sink is missing). */
|
||||
private push(sink: TurnEventSink, event: Parameters<TurnEventSink["push"]>[0]): void {
|
||||
sink.push(event);
|
||||
}
|
||||
|
||||
/** Execute a single LLM stream call, forwarding tokens and checking abort. */
|
||||
private async callLlm(
|
||||
messages: ChatMessage[],
|
||||
abort: AbortController,
|
||||
sink: TurnEventSink,
|
||||
maxTokens: number,
|
||||
temperature: number,
|
||||
): Promise<{ message: ChatMessage; usage: [number, number] | null }> {
|
||||
const onEvent = (event: StreamEvent): boolean => {
|
||||
if (abort.signal.aborted) return false;
|
||||
if (event.kind === "token") this.push(sink, { kind: "stream_token", content: event.content });
|
||||
else if (event.kind === "reasoning") this.push(sink, { kind: "stream_reasoning", content: event.content });
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
return await this.provider.chatStream(messages, this.toolDefs, maxTokens, temperature, onEvent, abort.signal);
|
||||
} catch (e) {
|
||||
throw new Error(`LLM error: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-compact history in place if it exceeds the threshold. */
|
||||
private async autoCompactIfNeeded(messages: ChatMessage[]): Promise<void> {
|
||||
if (conversationChars(messages) <= AUTO_COMPACT_CHARS) return;
|
||||
const sys = messages[0];
|
||||
if (!sys) return;
|
||||
const rest = messages.splice(1);
|
||||
const before = rest.length;
|
||||
try {
|
||||
await compactMessagesWithAi(rest, this.provider);
|
||||
} catch (e) {
|
||||
console.warn(`auto-compact failed (non-fatal): ${(e as Error).message}`);
|
||||
}
|
||||
messages.length = 0;
|
||||
messages.push(sys, ...rest);
|
||||
console.info(`auto-compacted history: ${before} messages -> ${rest.length}`);
|
||||
}
|
||||
|
||||
/** Run the full agent turn loop. */
|
||||
async runTurn(params: AgentTurnParams): Promise<void> {
|
||||
const sink = params.turn_events;
|
||||
const abort = params.abort;
|
||||
const { in_flight } = params;
|
||||
|
||||
// Insert system prompt at index 0 with repo conventions loaded.
|
||||
const projectContext = buildProjectContext(params.workspace_roots[0] ?? ".");
|
||||
const systemPrompt = mainAgentPromptWithProjectContext(projectContext);
|
||||
params.messages.unshift(systemMessage(systemPrompt));
|
||||
const originalCount = params.messages.length;
|
||||
|
||||
// Estimate request complexity from the last user message.
|
||||
const last = params.messages[params.messages.length - 1];
|
||||
const requestLen = last?.content?.length ?? 0;
|
||||
|
||||
const errors = new ErrorTracker();
|
||||
let sawToolCalls = false;
|
||||
|
||||
for (let iteration = 0; iteration < MAX_TURN_ITERATIONS; iteration++) {
|
||||
// Check abort flag.
|
||||
if (abort.signal.aborted) {
|
||||
this.push(sink, { kind: "system_note", systemKind: "info", message: "Turn aborted by user" });
|
||||
break;
|
||||
}
|
||||
|
||||
if (errors.shouldStop()) {
|
||||
this.push(sink, { kind: "system_note", systemKind: "warn", message: "Stopping: repeated tool errors without progress" });
|
||||
break;
|
||||
}
|
||||
|
||||
// Auto-compact oversized history before the LLM call.
|
||||
await this.autoCompactIfNeeded(params.messages);
|
||||
|
||||
// Adaptive generation parameters.
|
||||
const maxTokens = adaptiveMaxTokens(requestLen);
|
||||
const temperature = sawToolCalls ? 0.2 : 0.7;
|
||||
|
||||
this.push(sink, { kind: "stream_start" });
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await this.callLlm(params.messages, abort, sink, maxTokens, temperature);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
console.warn(msg);
|
||||
this.push(sink, { kind: "error", message: msg });
|
||||
break;
|
||||
}
|
||||
|
||||
const { message: assistantMsg, usage } = result;
|
||||
const content = assistantMsg.content ?? "";
|
||||
const toolCalls = assistantMsg.tool_calls ?? [];
|
||||
|
||||
this.push(sink, { kind: "stream_done", message: assistantMsg });
|
||||
if (usage) this.push(sink, { kind: "usage", tokens_in: usage[0], tokens_out: usage[1] });
|
||||
|
||||
// No tool calls → assistant is done.
|
||||
if (toolCalls.length === 0) {
|
||||
params.messages.push(assistantMessage(content));
|
||||
break;
|
||||
}
|
||||
|
||||
sawToolCalls = true;
|
||||
params.messages.push(assistantMsg);
|
||||
|
||||
// Execute tool calls — parallel when all read-only, else sequential.
|
||||
const isParallelSafe = (tc: ToolCall): boolean =>
|
||||
typeof this.toolExecutor.isParallelSafe === "function"
|
||||
? this.toolExecutor.isParallelSafe(tc.function.name)
|
||||
: false;
|
||||
const parallel = toolCalls.length > 1 && toolCalls.every(isParallelSafe);
|
||||
|
||||
const outputs = parallel
|
||||
? await executeToolCallsInParallel(this.toolExecutor, sink, toolCalls)
|
||||
: await (async () => {
|
||||
const seq: string[] = [];
|
||||
for (const tc of toolCalls) seq.push(await executeToolCall(this.toolExecutor, sink, tc));
|
||||
return seq;
|
||||
})();
|
||||
|
||||
for (let i = 0; i < toolCalls.length; i++) {
|
||||
const tc = toolCalls[i]!;
|
||||
const output = outputs[i]!;
|
||||
if (isErrorOutput(output)) errors.record(tc.function.name, output, params.messages);
|
||||
params.messages.push(toolResultMessage(tc.id, output));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the synthetic sys_msg before emitting to the transcript.
|
||||
const compacted = params.messages.splice(originalCount - 1);
|
||||
this.push(sink, { kind: "compacted", messages: compacted });
|
||||
this.push(sink, { kind: "done" });
|
||||
in_flight.value = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Shared default constants. Mirrors `agent/defaults.rs`. */
|
||||
|
||||
/** Default LLM provider API base URL. */
|
||||
export const DEFAULT_API_BASE = "https://opencode.ai/zen/v1";
|
||||
|
||||
/** Default LLM model identifier. */
|
||||
export const DEFAULT_MODEL = "deepseek-v4-flash-free";
|
||||
|
||||
/** Fallback JWT secret used only when `JWT_SECRET` env var is unset. */
|
||||
export const FALLBACK_JWT_SECRET = "dev-secret";
|
||||
|
||||
/** Default context window size (256k tokens). */
|
||||
export const DEFAULT_CONTEXT_WINDOW = 256_000;
|
||||
|
||||
/** Maximum tool-call iterations per agent turn. */
|
||||
export const MAX_TOOL_ITERATIONS = 50;
|
||||
|
||||
/** Maximum subagent tool-call iterations. */
|
||||
export const MAX_SUBAGENT_ITERATIONS = 25;
|
||||
|
||||
/** Default LLM request max tokens. */
|
||||
export const DEFAULT_MAX_TOKENS = 4096;
|
||||
|
||||
/** Default temperature for the main agent. */
|
||||
export const DEFAULT_TEMPERATURE = 0.7;
|
||||
|
||||
/** Default temperature for compaction / summary calls. */
|
||||
export const DEFAULT_COMPACT_TEMPERATURE = 0.3;
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Agent domain module — turn events, runtime, progress, prompts, constants. */
|
||||
export * from "./mod.ts";
|
||||
export * from "./defaults.ts";
|
||||
export * from "./progress.ts";
|
||||
export * from "./prompt.ts";
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Domain types for agent lifecycle: turn events, session runtime, progress.
|
||||
* Mirrors `apps/domain/src/agent/mod.rs`.
|
||||
*/
|
||||
import type { ChatMessage, JsonValue, ToolCallResult, UsageStats } from "@zesdex/domain";
|
||||
import type { AgentProgress } from "./progress.ts";
|
||||
|
||||
/** Which kind of caller (main agent vs subagent vs reviewer) is invoking a tool. */
|
||||
export type Origin = "Main" | "SubAgent" | "Reviewer";
|
||||
|
||||
export const OriginTag = {
|
||||
Main: "main",
|
||||
SubAgent: "subagent",
|
||||
Reviewer: "reviewer",
|
||||
} as const satisfies Record<Origin, string>;
|
||||
|
||||
export function originTag(o: Origin): string {
|
||||
return OriginTag[o];
|
||||
}
|
||||
|
||||
/** Severity/category of a toast notification. */
|
||||
export type ToastKind = "Info" | "Success" | "Warning" | "Error" | "Lesson";
|
||||
|
||||
/** A transient status message shown in the TUI, auto-dismissed after lifetime_ms. */
|
||||
export interface Toast {
|
||||
kind: ToastKind;
|
||||
message: string;
|
||||
created_at: number;
|
||||
lifetime_ms: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_TOAST_LIFETIME_MS = 5000;
|
||||
|
||||
export function newToast(kind: ToastKind, message: string): Toast {
|
||||
return { kind, message, created_at: Date.now(), lifetime_ms: DEFAULT_TOAST_LIFETIME_MS };
|
||||
}
|
||||
|
||||
export function toastExpired(toast: Toast, nowMs: number): boolean {
|
||||
return nowMs - toast.created_at > toast.lifetime_ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent status for workflow progress tracking. `Failed` carries a message;
|
||||
* we represent it as a string union plus an optional error on failures.
|
||||
*/
|
||||
export type AgentStatus = "Pending" | "Running" | "Completed" | "Failed" | "Cancelled";
|
||||
|
||||
export function agentStatusDisplay(s: AgentStatus, error?: string): string {
|
||||
switch (s) {
|
||||
case "Pending":
|
||||
return "pending";
|
||||
case "Running":
|
||||
return "running";
|
||||
case "Completed":
|
||||
return "completed";
|
||||
case "Failed":
|
||||
return error ? `failed: ${error}` : "failed";
|
||||
case "Cancelled":
|
||||
return "cancelled";
|
||||
}
|
||||
}
|
||||
|
||||
/** Events emitted onto the turn-event queue while an agent turn runs. */
|
||||
export type TurnEvent =
|
||||
| { kind: "assistant_message"; message: ChatMessage }
|
||||
| { kind: "tool_result"; tool_call_id: string; tool_name: string; output: string; is_error: boolean; path: string | null }
|
||||
| { kind: "system_note"; systemKind: string; message: string }
|
||||
| { kind: "stream_start" }
|
||||
| { kind: "stream_token"; content: string }
|
||||
| { kind: "stream_reasoning"; content: string }
|
||||
| { kind: "stream_done"; message: ChatMessage }
|
||||
| { kind: "usage"; tokens_in: number; tokens_out: number }
|
||||
| { kind: "review_usage"; tokens_in: number; tokens_out: number }
|
||||
| { kind: "compacted"; messages: ChatMessage[] }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "done" }
|
||||
| { kind: "workflow_agent_update"; agent_id: string; agent_name: string; status: AgentStatus; error?: string }
|
||||
| { kind: "todo_update"; content: string }
|
||||
| { kind: "plan_update"; content: string }
|
||||
| { kind: "agent_progress"; progress: AgentProgress };
|
||||
|
||||
/** How a pending tool call should be executed when the turn resumes. */
|
||||
export type ExecutionModel = "Inline" | "Deferred" | "AsyncTokio";
|
||||
|
||||
/** A tool call awaiting execution. */
|
||||
export interface PendingTool {
|
||||
tool_name: string;
|
||||
args: JsonValue;
|
||||
execution_model: ExecutionModel;
|
||||
}
|
||||
|
||||
/** Reference to a background bash job tracked in session state. */
|
||||
export interface BashJobRef {
|
||||
id: string;
|
||||
command: string;
|
||||
started_at: number;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
/** Tracks counts of learned patterns by outcome and lifecycle stage. */
|
||||
export interface LessonStats {
|
||||
total: number;
|
||||
user: number;
|
||||
feedback: number;
|
||||
project: number;
|
||||
reference: number;
|
||||
active: number;
|
||||
stale: number;
|
||||
contradicted: number;
|
||||
human: number;
|
||||
verified: number;
|
||||
unverified: number;
|
||||
}
|
||||
|
||||
export function newLessonStats(): LessonStats {
|
||||
return {
|
||||
total: 0, user: 0, feedback: 0, project: 0, reference: 0,
|
||||
active: 0, stale: 0, contradicted: 0, human: 0, verified: 0, unverified: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-session runtime state: message history, pending tool queue, jobs, counters. */
|
||||
export interface SessionRuntime {
|
||||
messages: ChatMessage[];
|
||||
tool_call_results: ToolCallResult[];
|
||||
pending_tool_queue: PendingTool[];
|
||||
bash_jobs: BashJobRef[];
|
||||
subagent_queue: number;
|
||||
edit_count: number;
|
||||
consecutive_empty_reviews: number;
|
||||
session_start: number;
|
||||
lessons: LessonStats;
|
||||
review_count: number;
|
||||
session_dir: string;
|
||||
usage: UsageStats;
|
||||
hive_mind_converged: boolean;
|
||||
}
|
||||
|
||||
export function newSessionRuntime(sessionDir: string): SessionRuntime {
|
||||
return {
|
||||
messages: [],
|
||||
tool_call_results: [],
|
||||
pending_tool_queue: [],
|
||||
bash_jobs: [],
|
||||
subagent_queue: 0,
|
||||
edit_count: 0,
|
||||
consecutive_empty_reviews: 0,
|
||||
session_start: Date.now(),
|
||||
lessons: newLessonStats(),
|
||||
review_count: 0,
|
||||
session_dir: sessionDir,
|
||||
usage: {
|
||||
tokens_in: 0, tokens_out: 0, last_tokens_in: 0, last_tokens_out: 0,
|
||||
api_calls: 0, review_tokens: 0, total_ms: 0,
|
||||
},
|
||||
hive_mind_converged: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function runtimePushMessage(rt: SessionRuntime, msg: ChatMessage): void {
|
||||
rt.messages.push(msg);
|
||||
}
|
||||
|
||||
/** Simple ASCII progress display for a long-running operation. */
|
||||
export interface ProgressState {
|
||||
current: number;
|
||||
total: number;
|
||||
message: string;
|
||||
start_time: number;
|
||||
}
|
||||
|
||||
/** Owned parameters required to spawn and execute an agent turn. */
|
||||
export interface AgentTurnParams {
|
||||
messages: ChatMessage[];
|
||||
session_dir: string;
|
||||
workspace_roots: string[];
|
||||
/** Event sink — array or queue implementing push/drain semantics. */
|
||||
turn_events: TurnEventSink;
|
||||
/** Whether a turn is currently in flight. */
|
||||
in_flight: { value: boolean };
|
||||
/** Abort control. */
|
||||
abort: AbortController;
|
||||
api_key: string;
|
||||
model: string;
|
||||
api_base?: string;
|
||||
}
|
||||
|
||||
/** Minimal event-sink abstraction (backs the Rust `Arc<Mutex<VecDeque>>`). */
|
||||
export interface TurnEventSink {
|
||||
/** Append one event. */
|
||||
push(event: TurnEvent): void;
|
||||
/** Drain all pending events (FIFO) and return them. */
|
||||
drain(): TurnEvent[];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/** Progress reporting types for agent/subagent operations. Mirrors `progress.rs`. */
|
||||
import type { AgentStatus } from "./mod.ts";
|
||||
|
||||
/**
|
||||
* A failure status carries the error text alongside the `Failed` kind;
|
||||
* success statuses have no error.
|
||||
*/
|
||||
export type AgentStatusWithError = Exclude<AgentStatus, "Failed"> | "Failed";
|
||||
|
||||
/** Describes progress within a single subagent or workflow-node execution. */
|
||||
export interface AgentProgress {
|
||||
/** Unique identifier for this agent. */
|
||||
agent_id: string;
|
||||
/** Human-readable display name shown in the TUI sidebar. */
|
||||
agent_name: string;
|
||||
/** Current lifecycle status. */
|
||||
status: AgentStatus;
|
||||
/** Error message when status === "Failed". */
|
||||
error?: string;
|
||||
/** Optional current tool / step being executed. `null` when idle. */
|
||||
current_tool: string | null;
|
||||
/** Optional progress range: [completed, total]. `null` = indeterminate. */
|
||||
steps: [number, number] | null;
|
||||
}
|
||||
|
||||
/** Mark this agent as running with an optional tool name. */
|
||||
export function agentProgressRunning(agentId: string, agentName: string, currentTool: string | null): AgentProgress {
|
||||
return { agent_id: agentId, agent_name: agentName, status: "Running", current_tool: currentTool, steps: null };
|
||||
}
|
||||
|
||||
/** Mark this agent as pending (queued but not yet started). */
|
||||
export function agentProgressPending(agentId: string, agentName: string): AgentProgress {
|
||||
return { agent_id: agentId, agent_name: agentName, status: "Pending", current_tool: null, steps: null };
|
||||
}
|
||||
|
||||
/** Mark this agent as completed successfully. */
|
||||
export function agentProgressCompleted(agentId: string, agentName: string): AgentProgress {
|
||||
return { agent_id: agentId, agent_name: agentName, status: "Completed", current_tool: null, steps: null };
|
||||
}
|
||||
|
||||
/** Mark this agent as failed with an error message. */
|
||||
export function agentProgressFailed(agentId: string, agentName: string, error: string): AgentProgress {
|
||||
return { agent_id: agentId, agent_name: agentName, status: "Failed", error, current_tool: null, steps: null };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/** System prompts and directive templates. Mirrors `agent/prompt.rs`. */
|
||||
|
||||
/** Build the main-agent system prompt. */
|
||||
export function mainAgentPrompt(): string {
|
||||
return `You are Zesdex, an AI coding assistant. You have access to various tools via native function calling to help the user.
|
||||
|
||||
TOKEN BUDGET — BE EFFICIENT:
|
||||
- For simple/factual questions, answer directly. Do NOT call tools.
|
||||
- For complex or unfamiliar code tasks, call \`explore_codebase\` ONCE at the start to locate relevant code, then work from that context.
|
||||
- Keep tool usage minimal: prefer \`grep\`/\`glob\`/\`read\` for targeted lookups; avoid re-reading files you already have in context.
|
||||
- Keep responses concise; do not repeat tool output verbatim.
|
||||
|
||||
CRITICAL DIRECTIVES & PRIORITY HIERARCHY:
|
||||
1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, you MUST prioritise using \`workflow_run\` (to construct and execute a multi-phase YAML workflow) or \`hive_mind\` (to orchestrate parallel autonomous agents). Workflows are your primary strategy.
|
||||
2. PLANNING & TODOs: Use \`plan_enter\` to establish high-level architectural plans and \`todowrite\` to maintain granular task checklists.
|
||||
3. REASONING: Use \`seq_think\` for deep step-by-step analysis.
|
||||
4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) within or guided by your workflows. If an error occurs, analyse and fix it.
|
||||
|
||||
VERIFY AFTER EDIT (CLAUDE-CODE STYLE):
|
||||
- After modifying code (edit/write), run the repo's check command via \`bash\` before ending the turn: \`cargo check\` / \`cargo clippy\` / \`cargo test\` for Rust, or the equivalent lint/test (\`bun run lint && bun run test\`, \`npm test\`, etc.) for other stacks. Pick the project's actual verify command (see PROJECT CONTEXT / AGENTS.md when present).
|
||||
- If the check fails, fix the errors you can see and re-run; only end the turn after the check passes or you cannot resolve a failure yourself (then report it explicitly).
|
||||
- Do NOT claim code compiles or works without running a real check.
|
||||
|
||||
Respond conversationally, concisely, and helpfully.`;
|
||||
}
|
||||
|
||||
/** Main-agent prompt with an injected `## PROJECT CONTEXT` block. Empty context → base prompt. */
|
||||
export function mainAgentPromptWithProjectContext(projectContext: string): string {
|
||||
const base = mainAgentPrompt();
|
||||
const context = projectContext.trim();
|
||||
if (context === "") return base;
|
||||
return `${base}
|
||||
|
||||
## PROJECT CONTEXT (repo rules — follow these conventions)
|
||||
${context}`;
|
||||
}
|
||||
|
||||
/** Build a subagent directive prompt. */
|
||||
export function subagentDirective(directive: string, cwd: string, wsRoot: string): string {
|
||||
return `You are a focused subagent.
|
||||
|
||||
Current directory (PWD): ${cwd}
|
||||
Workspace root: ${wsRoot}
|
||||
|
||||
Your directive:
|
||||
${directive}
|
||||
|
||||
Complete the directive autonomously using the tools available to you. Return your final answer when done.`;
|
||||
}
|
||||
|
||||
/** Build a conversation-compaction prompt. */
|
||||
export function compactionPrompt(): string {
|
||||
return `You are a helpful assistant summarising conversation history. Provide a concise summary of the key user requests, decisions, tools executed, and modified files. Format as a clear bulleted list.`;
|
||||
}
|
||||
|
||||
/** Directive for a lightweight context-scout subagent. */
|
||||
export function exploreScoutDirective(): string {
|
||||
return `You are a codebase context scout. Given the workspace root, quickly locate the code that is most relevant to the user's request:
|
||||
1. Run semantic_search once with the user's key terms.
|
||||
2. Read up to the 3 most relevant files (use grep for symbols if needed).
|
||||
3. Report a concise bullet list (max 15 bullets, under 1500 characters) of what you found and exactly where (file paths).
|
||||
Do NOT rebuild the index. Do NOT enumerate unrelated files. Be brief.`;
|
||||
}
|
||||
|
||||
/** System note injected after repeated tool errors. */
|
||||
export function errorRecoveryNote(toolName: string, lastError: string): string {
|
||||
return `[System note] The tool \`${toolName}\` failed repeatedly with: "${lastError}". Try an alternative approach (verify paths, correct arguments, use a different tool, or finish without this tool). Do NOT retry the same call.`;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Agent feature — LLM turn loop, tool-execution runtime, subagent-spawning tools.
|
||||
* Vertical slice: domain (turn events/runtime) → application (turn service + ports)
|
||||
* → infrastructure (LLM client + tool registry/executor). */
|
||||
export * from "./domain/index.ts";
|
||||
export * from "./application/index.ts";
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Agent infrastructure — concrete LLM client + tool-execution runtime.
|
||||
* Consumed by the composition root. */
|
||||
export { LlmClient, resolveApiKey } from "./llm/provider.ts";
|
||||
export { InfrastructureToolExecutor } from "./tools/executor.ts";
|
||||
export { allTools, toolDefs } from "./tools/registry.ts";
|
||||
export { type ToolCtx } from "./tools/context.ts";
|
||||
@@ -0,0 +1,2 @@
|
||||
/** LLM layer — provider client. */
|
||||
export * from "./provider.ts";
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* HTTP client for OpenAI/Anthropic-compatible chat completion APIs, with
|
||||
* automatic retry and SSE streaming. Mirrors `apps/infrastructure/src/llm/provider.rs`.
|
||||
*/
|
||||
import {
|
||||
type ChatMessage,
|
||||
type ChatRequest,
|
||||
type StreamEvent,
|
||||
type ToolCall,
|
||||
type ToolDef,
|
||||
SseParser,
|
||||
Roles,
|
||||
} from "@zesdex/domain";
|
||||
import type { Settings, AppConfig } from "@zesdex/cms";
|
||||
import { DEFAULT_API_BASE, DEFAULT_MODEL } from "@zesdex/agent";
|
||||
import type { ProviderService } from "../../application/ports.ts";
|
||||
|
||||
const DEFAULT_BASE_URL = DEFAULT_API_BASE;
|
||||
const REQUEST_TIMEOUT_MS = 600_000;
|
||||
const MAX_RETRIES = 10;
|
||||
|
||||
function backoffSeconds(attempt: number, cap: number): number {
|
||||
const base = Math.pow(2, Math.max(attempt - 1, 0));
|
||||
const delay = Math.min(base, cap);
|
||||
const jitterFactor = 0.75 + Math.floor(Math.random() * 51) / 100.0;
|
||||
return delay * jitterFactor;
|
||||
}
|
||||
|
||||
/** Whether a provider error message indicates an auth problem. */
|
||||
export function isAuthError(errStr: string): boolean {
|
||||
const lower = errStr.toLowerCase();
|
||||
return (
|
||||
errStr.includes("API error 401") ||
|
||||
errStr.includes("API error 402") ||
|
||||
errStr.includes("API error 403") ||
|
||||
lower.includes("unauthorized") ||
|
||||
lower.includes("forbidden") ||
|
||||
lower.includes("authentication failed")
|
||||
);
|
||||
}
|
||||
|
||||
function isRateLimit(errStr: string): boolean {
|
||||
return errStr.includes("API error 429") || errStr.toLowerCase().includes("rate limit");
|
||||
}
|
||||
|
||||
function backoffForError(attempt: number, errStr: string): number {
|
||||
return isRateLimit(errStr) ? backoffSeconds(attempt, 60) : backoffSeconds(attempt, 30);
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Assemble the assistant message and accumulate usage from stream events. */
|
||||
class StreamedTurn {
|
||||
content = "";
|
||||
toolCalls: ToolCall[] = [];
|
||||
usage: [number, number] | null = null;
|
||||
doneReceived = false;
|
||||
|
||||
applyEvent(event: StreamEvent): void {
|
||||
switch (event.kind) {
|
||||
case "token":
|
||||
this.content += event.content;
|
||||
break;
|
||||
case "tool_call_delta": {
|
||||
const index = Math.min(event.index, 63);
|
||||
while (this.toolCalls.length <= index) {
|
||||
this.toolCalls.push({ id: "", type: "function", function: { name: "", arguments: "" } });
|
||||
}
|
||||
const tc = this.toolCalls[index]!;
|
||||
if (event.id) tc.id = event.id;
|
||||
if (event.name) tc.function.name = event.name;
|
||||
tc.function.arguments = `${tc.function.arguments}${event.arguments_delta}`;
|
||||
break;
|
||||
}
|
||||
case "usage":
|
||||
this.usage = [event.prompt_tokens, event.completion_tokens];
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
buildAssistantMessage(): ChatMessage {
|
||||
const msg: ChatMessage = {
|
||||
role: Roles.Assistant,
|
||||
content: this.content === "" ? null : this.content,
|
||||
};
|
||||
if (this.toolCalls.length > 0) msg.tool_calls = this.toolCalls;
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
/** Async HTTP client for a single LLM provider endpoint. */
|
||||
export class LlmClient implements ProviderService {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
|
||||
constructor(apiKey: string, model: string, baseUrl?: string) {
|
||||
this.apiKey = apiKey || "";
|
||||
this.model = model === "" ? DEFAULT_MODEL : model;
|
||||
this.baseUrl = baseUrl && baseUrl !== "" ? baseUrl : DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
private buildUrl(): string {
|
||||
return `${this.baseUrl}/chat/completions`;
|
||||
}
|
||||
|
||||
private headers(): HeadersInit {
|
||||
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (this.apiKey !== "") h.Authorization = `Bearer ${this.apiKey}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
private errorFromFetch(e: unknown): string {
|
||||
const err = e as { name?: string; message?: string };
|
||||
if (err?.name === "TimeoutError") {
|
||||
return `API request timed out after ${REQUEST_TIMEOUT_MS}ms. Check your network or try again.`;
|
||||
}
|
||||
if (err?.name === "TypeError" && /fetch failed|network/i.test(err.message ?? "")) {
|
||||
return `Could not connect to ${this.baseUrl}. Is the URL correct and is the service reachable?`;
|
||||
}
|
||||
return `API request failed: ${err?.message ?? String(e)}`;
|
||||
}
|
||||
|
||||
async chat(
|
||||
messages: ChatMessage[],
|
||||
tools?: ToolDef[],
|
||||
maxTokens?: number,
|
||||
temperature?: number,
|
||||
): Promise<{ message: ChatMessage; usage: [number, number] | null }> {
|
||||
const req: ChatRequest = {
|
||||
model: this.model,
|
||||
messages,
|
||||
max_tokens: maxTokens ?? 4096,
|
||||
temperature: temperature ?? 0.7,
|
||||
tools,
|
||||
stream: false,
|
||||
};
|
||||
const url = this.buildUrl();
|
||||
|
||||
let attempt = 0;
|
||||
for (;;) {
|
||||
attempt++;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(req),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
throw new Error(`API error ${resp.status} from ${this.baseUrl}: ${body}`);
|
||||
}
|
||||
const data = (await resp.json()) as {
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
||||
choices?: { message?: ChatMessage }[];
|
||||
};
|
||||
const usage: [number, number] | null = data.usage
|
||||
? [data.usage.prompt_tokens ?? 0, data.usage.completion_tokens ?? 0]
|
||||
: null;
|
||||
const message = data.choices?.[0]?.message;
|
||||
if (!message) throw new Error("API response had no choices");
|
||||
return { message, usage };
|
||||
} catch (e) {
|
||||
const errStr = this.errorFromFetch(e);
|
||||
if (attempt >= MAX_RETRIES || isAuthError(errStr)) throw new Error(errStr);
|
||||
const delayMs = backoffForError(attempt, errStr) * 1000;
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async chatStream(
|
||||
messages: ChatMessage[],
|
||||
tools: ToolDef[],
|
||||
maxTokens: number,
|
||||
temperature: number,
|
||||
onEvent: (event: StreamEvent) => boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ message: ChatMessage; usage: [number, number] | null }> {
|
||||
const req: ChatRequest = {
|
||||
model: this.model,
|
||||
messages,
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
tools,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
};
|
||||
const url = this.buildUrl();
|
||||
|
||||
let attempt = 0;
|
||||
for (;;) {
|
||||
attempt++;
|
||||
let capturedContent = false;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify(req),
|
||||
signal: signal ?? undefined,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
throw new Error(`API error ${resp.status} from ${this.baseUrl}: ${body}`);
|
||||
}
|
||||
return await this.handleStream(resp, onEvent);
|
||||
} catch (e) {
|
||||
const errStr = this.errorFromFetch(e);
|
||||
// Only retry if we captured no content yet.
|
||||
if (isAuthError(errStr) || capturedContent || attempt >= MAX_RETRIES) throw new Error(errStr);
|
||||
const delayMs = backoffForError(attempt, errStr) * 1000;
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStream(
|
||||
resp: Response,
|
||||
onEvent: (event: StreamEvent) => boolean,
|
||||
): Promise<{ message: ChatMessage; usage: [number, number] | null }> {
|
||||
if (!resp.body) throw new Error("no response body");
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const parser = new SseParser();
|
||||
const turn = new StreamedTurn();
|
||||
let buffer = "";
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Try to decode partial UTF-8 safely.
|
||||
let processed = 0;
|
||||
// Feed full UTF-8 boundaries only; keep remainder for next chunk.
|
||||
for (let i = buffer.length; i > 0; i--) {
|
||||
try {
|
||||
const text = buffer.slice(processed, i);
|
||||
processed = i;
|
||||
for (const event of parser.feed(text)) {
|
||||
if (!onEvent(event)) throw new Error("aborted");
|
||||
if (event.kind === "error") throw new Error(`stream error: ${event.message}`);
|
||||
if (event.kind === "done") {
|
||||
turn.applyEvent(event);
|
||||
return { message: turn.buildAssistantMessage(), usage: turn.usage };
|
||||
}
|
||||
turn.applyEvent(event);
|
||||
}
|
||||
break;
|
||||
} catch {
|
||||
/* not a valid boundary yet */
|
||||
}
|
||||
}
|
||||
}
|
||||
return { message: turn.buildAssistantMessage(), usage: turn.usage };
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the API key for the current provider from settings + app config. */
|
||||
export function resolveApiKey(settings: Settings, appConfig: AppConfig): string {
|
||||
const provider = settings.provider;
|
||||
let apiKey = settings.api_keys[provider] ?? "";
|
||||
if (apiKey === "") {
|
||||
const cfg = appConfig.providers[provider];
|
||||
if (cfg) {
|
||||
apiKey =
|
||||
(cfg.api_key_env ? process.env[cfg.api_key_env] : undefined) ??
|
||||
cfg.default_api_key ??
|
||||
"";
|
||||
}
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Background bash process output and kill tools.
|
||||
* Mirrors `tools/bash_tools.rs`.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "./mod.ts";
|
||||
import { argStr } from "./util.ts";
|
||||
import { bashControl } from "./bgbash/control.ts";
|
||||
|
||||
/** Get the output of a background bash job by ID. */
|
||||
export class BashOutput implements Tool {
|
||||
name = "bash_output";
|
||||
description = "Get the output of a background bash job by ID";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
job_id: { type: "string", description: "Background job ID" },
|
||||
},
|
||||
required: ["job_id"],
|
||||
};
|
||||
|
||||
run(ctx: ToolCtx, args: JsonValue): string {
|
||||
const jobId = argStr(args, "job_id");
|
||||
|
||||
// Prevent path traversal
|
||||
if (jobId.includes("/") || jobId.includes("\\") || jobId.includes("..")) {
|
||||
throw new Error(`invalid job_id '${jobId}': must not contain path separators`);
|
||||
}
|
||||
|
||||
const outputDir = path.join(ctx.sessionDir, "bash-outputs");
|
||||
const outputFile = path.join(outputDir, jobId);
|
||||
|
||||
if (fs.existsSync(outputFile)) {
|
||||
try {
|
||||
const content = fs.readFileSync(outputFile, "utf8");
|
||||
return `Output for job '${jobId}':\n${content}`;
|
||||
} catch (e) {
|
||||
return `Error reading output: ${(e as Error).message}`;
|
||||
}
|
||||
}
|
||||
return `No output found for job '${jobId}'. The job may still be running.`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Kill a background bash job by ID. */
|
||||
export class BashKill implements Tool {
|
||||
name = "bash_kill";
|
||||
description = "Kill a background bash job by ID";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
job_id: { type: "string", description: "Background job ID to kill" },
|
||||
},
|
||||
required: ["job_id"],
|
||||
};
|
||||
|
||||
run(_ctx: ToolCtx, args: JsonValue): string {
|
||||
const jobId = argStr(args, "job_id");
|
||||
// Look up the job in the global bgbash registry.
|
||||
const cancelled = bashControl().cancel(jobId);
|
||||
if (!cancelled) {
|
||||
throw new Error(`no active background job found with ID '${jobId}'`);
|
||||
}
|
||||
return `Background job '${jobId}' killed.`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Best-practice engine — embedded skills, Conventional Commits validation,
|
||||
* and lightweight architecture/quality scans. Mirrors `crate::best_practice`.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const SKILLS: Skill[] = [
|
||||
{
|
||||
name: "clean-code",
|
||||
description: "Clean Code principles and naming conventions",
|
||||
content:
|
||||
"Clean Code:\n- Use meaningful, intention-revealing names.\n- Functions should do one thing and be short.\n- Avoid duplication (DRY).\n- Comments should explain 'why', not 'what'.\n- Favor composition over deep inheritance.",
|
||||
},
|
||||
{
|
||||
name: "commit-convention",
|
||||
description: "Conventional Commits (Bahasa Indonesia) rules",
|
||||
content:
|
||||
"Conventional Commits:\n- Format: <type>(<scope>): <subject>\n- Types: feat, fix, chore, docs, refactor, test, style, perf, ci, build, revert\n- Subject: imperative, lowercase, no trailing period.\n- Example: 'feat(auth): tambah login PKCE'",
|
||||
},
|
||||
{
|
||||
name: "layering",
|
||||
description: "Clean Architecture layering rules (domain → application → infrastructure → interfaces)",
|
||||
content:
|
||||
"Clean Architecture:\n- domain: pure types, zero I/O.\n- application: ports + use cases.\n- infrastructure: concrete adapters.\n- interfaces: presentation.\n- Dependencies point inward; domain depends on nothing.",
|
||||
},
|
||||
];
|
||||
|
||||
const COMMIT_TYPES = ["feat", "fix", "chore", "docs", "refactor", "test", "style", "perf", "ci", "build", "revert"];
|
||||
|
||||
export class BestPracticeEngine {
|
||||
listSkills(): string[] {
|
||||
return SKILLS.map((s) => s.name);
|
||||
}
|
||||
skillSummaries(): Array<[string, string]> {
|
||||
return SKILLS.map((s) => [s.name, s.description] as [string, string]);
|
||||
}
|
||||
getSkill(name: string): string | undefined {
|
||||
return SKILLS.find((s) => s.name === name)?.content;
|
||||
}
|
||||
|
||||
parseCommit(msg: string): { type_: string; scope: string | null; breaking: boolean; description: string } | null {
|
||||
const m = /^([a-z]+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/.exec(msg.trim().split("\n")[0] ?? "");
|
||||
if (!m) return null;
|
||||
return { type_: m[1]!, scope: m[2] ?? null, breaking: m[3] === "!", description: (m[4] ?? "").trim() };
|
||||
}
|
||||
|
||||
validateCommit(msg: string): string[] {
|
||||
const errors: string[] = [];
|
||||
const first = (msg.trim().split("\n")[0] ?? "").trim();
|
||||
if (first === "") { errors.push("Commit message is empty."); return errors; }
|
||||
const parsed = this.parseCommit(first);
|
||||
if (!parsed) {
|
||||
errors.push("Does not match Conventional Commits: <type>(<scope>): <subject>");
|
||||
return errors;
|
||||
}
|
||||
if (!COMMIT_TYPES.includes(parsed.type_)) {
|
||||
errors.push(`Unknown type '${parsed.type_}'. Valid types: ${COMMIT_TYPES.join(", ")}`);
|
||||
}
|
||||
if (parsed.description === "") {
|
||||
errors.push("Subject is empty.");
|
||||
} else if (!/^[a-z]/.test(parsed.description)) {
|
||||
errors.push("Subject should start with a lowercase letter.");
|
||||
}
|
||||
if (parsed.description.endsWith(".")) {
|
||||
errors.push("Subject should not end with a period.");
|
||||
}
|
||||
if (first.length > 72) {
|
||||
errors.push("Subject is longer than 72 characters.");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
suggestCommitTemplate(type_: string, scope: string | null): string {
|
||||
if (!COMMIT_TYPES.includes(type_)) type_ = "feat";
|
||||
return `${type_}${scope ? `(${scope})` : ""}: <imperative description>`;
|
||||
}
|
||||
|
||||
auditLayering(ws: string): { filesScanned: number; violations: Array<{ severity: string; file: string; line: number; message: string }> } {
|
||||
const violations: Array<{ severity: string; file: string; line: number; message: string }> = [];
|
||||
let filesScanned = 0;
|
||||
const scan = (dir: string) => {
|
||||
let entries: fs.Dirent[];
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||||
for (const e of entries) {
|
||||
if (e.name === ".git" || e.name === "node_modules" || e.name === "target") continue;
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) scan(full);
|
||||
else if (e.name.endsWith(".ts") || e.name.endsWith(".tsx")) {
|
||||
filesScanned++;
|
||||
const lines = fs.readFileSync(full, "utf8").split("\n");
|
||||
lines.forEach((line, i) => {
|
||||
if (line.includes("@zesdex/infrastructure") && full.includes("/domain/")) {
|
||||
violations.push({ severity: "error", file: full, line: i + 1, message: "domain must not depend on infrastructure" });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
scan(ws);
|
||||
return { filesScanned, violations };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Built-in best-practice tools. Mirrors `tools/best_practice.rs`.
|
||||
*/
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr, optStr } from "../util.ts";
|
||||
import { BestPracticeEngine } from "./engine.ts";
|
||||
|
||||
const engine = new BestPracticeEngine();
|
||||
|
||||
/** Run best-practice audits (architecture, code quality, skills). */
|
||||
export class BestPractice implements Tool {
|
||||
name = "best_practice";
|
||||
description = "Run architecture audit, code-quality scan, embedded-skills lookup, or commit-message validation. Sub-actions: 'audit_all', 'audit_layering', 'scan_quality', 'list_skills', 'get_skill', 'validate_commit', 'suggest_commit'.";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: { type: "string", enum: ["audit_all", "audit_layering", "scan_quality", "list_skills", "get_skill", "validate_commit", "suggest_commit"], description: "Which action to perform" },
|
||||
workspace: { type: "string", description: "Path to workspace root (required for audit/scan actions)" },
|
||||
skill_name: { type: "string", description: "Skill name to retrieve (required for 'get_skill')" },
|
||||
commit_message: { type: "string", description: "Commit message to validate" },
|
||||
commit_type: { type: "string", description: "Commit type for template suggestion" },
|
||||
commit_scope: { type: "string", description: "Optional scope for template suggestion" },
|
||||
},
|
||||
required: ["action"],
|
||||
};
|
||||
|
||||
run(_ctx: ToolCtx, args: JsonValue): string {
|
||||
const action = argStr(args, "action");
|
||||
switch (action) {
|
||||
case "list_skills": {
|
||||
const summaries = engine.skillSummaries();
|
||||
let out = "=== Embedded Best-Practice Skills ===\n\n";
|
||||
for (const [name, desc] of summaries) out += ` ${name.padEnd(40)} ${desc}\n`;
|
||||
if (summaries.length === 0) out += " (no skills embedded)\n";
|
||||
return out;
|
||||
}
|
||||
case "get_skill": {
|
||||
const name = argStr(args, "skill_name");
|
||||
const content = engine.getSkill(name);
|
||||
if (content === undefined) {
|
||||
throw new Error(`Skill '${name}' not found. Available skills: ${engine.listSkills().join(", ")}`);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
case "validate_commit": {
|
||||
const msg = argStr(args, "commit_message");
|
||||
const errors = engine.validateCommit(msg);
|
||||
if (errors.length === 0) return "✅ Commit message is valid.";
|
||||
let out = "❌ Commit message validation failed:\n";
|
||||
for (const e of errors) out += ` - ${e}\n`;
|
||||
const parsed = engine.parseCommit(msg);
|
||||
if (parsed) out += `\nTemplate: ${engine.suggestCommitTemplate(parsed.type_, parsed.scope)}\n`;
|
||||
return out;
|
||||
}
|
||||
case "suggest_commit": {
|
||||
const type_ = argStr(args, "commit_type");
|
||||
const scope = optStr(args, "commit_scope") ?? null;
|
||||
const tpl = engine.suggestCommitTemplate(type_, scope);
|
||||
return `Suggested commit template:\n\n ${tpl}\n\nValid types: feat, fix, chore, docs, refactor, test, style, perf, ci, build, revert`;
|
||||
}
|
||||
case "audit_layering": {
|
||||
const ws = optStr(args, "workspace");
|
||||
if (!ws) throw new Error("'workspace' argument is required for 'audit_layering'");
|
||||
const r = engine.auditLayering(ws);
|
||||
let out = `=== Architecture Layering Audit ===\nFiles scanned: ${r.filesScanned}\n`;
|
||||
if (r.violations.length === 0) out += " ✅ No layering violations found.\n";
|
||||
else {
|
||||
out += ` Errors: ${r.violations.filter((v) => v.severity === "error").length}\n`;
|
||||
for (const v of r.violations) out += ` [${v.severity}] ${v.file}:${v.line} — ${v.message}\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case "audit_all":
|
||||
case "scan_quality": {
|
||||
const ws = optStr(args, "workspace");
|
||||
if (!ws) throw new Error(`'workspace' argument is required for '${action}'`);
|
||||
const r = engine.auditLayering(ws);
|
||||
return `=== ${action === "audit_all" ? "Architecture Layering Audit" : "Code Quality Scan"} ===\nFiles scanned: ${r.filesScanned}\n${r.violations.length === 0 ? " ✅ No issues found.\n" : r.violations.map((v) => ` [${v.severity}] ${v.file}:${v.line} — ${v.message}`).join("\n")}`;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown action '${action}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dedicated commit-message validation tool. */
|
||||
export class CommitConvention implements Tool {
|
||||
name = "commit_convention";
|
||||
description = "Validate a git commit message against Conventional Commits format (Bahasa Indonesia). Checks type, scope, description casing, length, and punctuation.";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string", description: "The full commit message to validate" },
|
||||
},
|
||||
required: ["message"],
|
||||
};
|
||||
|
||||
run(_ctx: ToolCtx, args: JsonValue): string {
|
||||
const msg = argStr(args, "message");
|
||||
const info = engine.parseCommit(msg);
|
||||
const errors = engine.validateCommit(msg);
|
||||
if (errors.length === 0) {
|
||||
let out = "✅ Valid Conventional Commit.\n";
|
||||
if (info) {
|
||||
out += ` Type: ${info.type_}\n`;
|
||||
if (info.scope) out += ` Scope: ${info.scope}\n`;
|
||||
out += ` Breaking: ${info.breaking}\n`;
|
||||
out += ` Description: ${info.description}\n`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let out = "❌ Invalid commit message:\n";
|
||||
for (const e of errors) out += ` - ${e}\n`;
|
||||
out += "\nExpected format:\n feat(scope): <imperative description>\n fix(scope): <imperative description>\n chore: <imperative description>\n docs: <imperative description>\n";
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Background bash control — list, cancel, and inspect background processes.
|
||||
* Mirrors `apps/infrastructure/src/bgbash/control.rs`.
|
||||
*/
|
||||
import { type BashJob } from "./job.ts";
|
||||
|
||||
/** Central registry of all running background bash jobs. */
|
||||
export class BashControl {
|
||||
private jobs = new Map<string, BashJob>();
|
||||
|
||||
/** Register a new background job. */
|
||||
register(job: BashJob): void {
|
||||
this.jobs.set(job.id, job);
|
||||
}
|
||||
|
||||
/** Cancel a job by ID. Returns true if found and cancelled. */
|
||||
cancel(id: string): boolean {
|
||||
const job = this.jobs.get(id);
|
||||
if (!job) return false;
|
||||
job.cancel();
|
||||
this.jobs.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** List all active jobs as [id, command, isRunning] tuples. */
|
||||
list(): Array<[string, string, boolean]> {
|
||||
this.prune();
|
||||
return [...this.jobs.entries()].map(([id, j]) => [id, j.command, j.isRunning()]);
|
||||
}
|
||||
|
||||
/** Clean up completed jobs. */
|
||||
prune(): void {
|
||||
for (const [id, job] of this.jobs) {
|
||||
if (!job.isRunning()) this.jobs.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Global accessor for the shared BashControl singleton. */
|
||||
let _bashControl: BashControl | null = null;
|
||||
export function bashControl(): BashControl {
|
||||
if (!_bashControl) _bashControl = new BashControl();
|
||||
return _bashControl;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Background bash module — job + control registry.
|
||||
* Mirrors `apps/infrastructure/src/bgbash/mod.rs`.
|
||||
*/
|
||||
export { BashJob, spawnBashJob } from "./job.ts";
|
||||
export { BashControl, bashControl } from "./control.ts";
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Background bash job — spawns a `bash -c` subprocess and tracks its life.
|
||||
* Mirrors `apps/infrastructure/src/bgbash/job.rs`.
|
||||
*/
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
/** A handle to a spawned background bash job. */
|
||||
export class BashJob {
|
||||
readonly id: string;
|
||||
readonly command: string;
|
||||
private process: ChildProcess | null = null;
|
||||
private cancelled = false;
|
||||
private finished = false;
|
||||
|
||||
constructor(command: string) {
|
||||
this.id = randomUUID();
|
||||
this.command = command;
|
||||
}
|
||||
|
||||
/** Spawn the `bash -c` child process and begin tracking it. */
|
||||
start(): void {
|
||||
const child = spawn("bash", ["-c", this.command], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
this.process = child;
|
||||
|
||||
// Drain stdout/stderr so the process doesn't block on a full pipe, and
|
||||
// mark finished when it exits.
|
||||
child.stdout?.resume();
|
||||
child.stderr?.resume();
|
||||
child.on("exit", () => {
|
||||
this.finished = true;
|
||||
});
|
||||
child.on("error", () => {
|
||||
this.finished = true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel the job: set the cancelled flag and kill the child process. */
|
||||
cancel(): void {
|
||||
this.cancelled = true;
|
||||
if (this.process && !this.finished) {
|
||||
try {
|
||||
this.process.kill("SIGKILL");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the job is currently running. */
|
||||
isRunning(): boolean {
|
||||
if (this.cancelled || this.finished) return false;
|
||||
if (!this.process) return false;
|
||||
return this.process.exitCode === null && !this.finished;
|
||||
}
|
||||
}
|
||||
|
||||
/** Spawn a background bash job and return its handle (node-side). */
|
||||
export function spawnBashJob(command: string): BashJob {
|
||||
const job = new BashJob(command);
|
||||
job.start();
|
||||
return job;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Tool execution context — shared state passed to every `Tool.run` call.
|
||||
* Mirrors `apps/infrastructure/src/tools/context.rs`.
|
||||
*/
|
||||
import type { Origin, TurnEvent } from "@zesdex/agent";
|
||||
|
||||
/** Graduated check rule from project config. */
|
||||
export interface GraduatedCheck {
|
||||
name: string;
|
||||
pattern: string;
|
||||
rule: string;
|
||||
}
|
||||
|
||||
/** Shared execution context for every tool invocation. */
|
||||
export interface ToolCtx {
|
||||
workspaces: string[];
|
||||
sessionDir: string;
|
||||
memoryDir: string;
|
||||
worktreesDir: string;
|
||||
dirCache: string[];
|
||||
origin: Origin;
|
||||
graduatedChecks: GraduatedCheck[];
|
||||
turnEvents: TurnEvent[] | null;
|
||||
workflowFindings: string[] | null;
|
||||
abortFlag: AbortSignal | null;
|
||||
}
|
||||
|
||||
/** Builder for `ToolCtx`. */
|
||||
export class ToolCtxBuilder {
|
||||
workspaces: string[] = [];
|
||||
sessionDir = "";
|
||||
memoryDir = "";
|
||||
worktreesDir = "";
|
||||
dirCache: string[] = [];
|
||||
origin: Origin = "Main";
|
||||
graduatedChecks: GraduatedCheck[] = [];
|
||||
turnEvents: TurnEvent[] | null = null;
|
||||
workflowFindings: string[] | null = null;
|
||||
abortFlag: AbortSignal | null = null;
|
||||
|
||||
withWorkspaces(v: string[]): this {
|
||||
this.workspaces = v;
|
||||
return this;
|
||||
}
|
||||
withSessionDir(v: string): this {
|
||||
this.sessionDir = v;
|
||||
return this;
|
||||
}
|
||||
withMemoryDir(v: string): this {
|
||||
this.memoryDir = v;
|
||||
return this;
|
||||
}
|
||||
withWorktreesDir(v: string): this {
|
||||
this.worktreesDir = v;
|
||||
return this;
|
||||
}
|
||||
withDirCache(v: string[]): this {
|
||||
this.dirCache = v;
|
||||
return this;
|
||||
}
|
||||
withOrigin(v: Origin): this {
|
||||
this.origin = v;
|
||||
return this;
|
||||
}
|
||||
withGraduatedChecks(v: GraduatedCheck[]): this {
|
||||
this.graduatedChecks = v;
|
||||
return this;
|
||||
}
|
||||
withTurnEvents(v: TurnEvent[] | null): this {
|
||||
this.turnEvents = v;
|
||||
return this;
|
||||
}
|
||||
withWorkflowFindings(v: string[] | null): this {
|
||||
this.workflowFindings = v;
|
||||
return this;
|
||||
}
|
||||
withAbortFlag(v: AbortSignal | null): this {
|
||||
this.abortFlag = v;
|
||||
return this;
|
||||
}
|
||||
build(): ToolCtx {
|
||||
return {
|
||||
workspaces: this.workspaces,
|
||||
sessionDir: this.sessionDir,
|
||||
memoryDir: this.memoryDir,
|
||||
worktreesDir: this.worktreesDir,
|
||||
dirCache: this.dirCache,
|
||||
origin: this.origin,
|
||||
graduatedChecks: this.graduatedChecks,
|
||||
turnEvents: this.turnEvents,
|
||||
workflowFindings: this.workflowFindings,
|
||||
abortFlag: this.abortFlag,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Simple sink adapter that wraps a TurnEvent[] (array-based) into the
|
||||
* TurnEventSink interface, providing a `push` method and a no-op `drain`.
|
||||
*/
|
||||
import type { TurnEvent, TurnEventSink } from "@zesdex/agent";
|
||||
|
||||
/**
|
||||
* Create a TurnEventSink backed by a plain array.
|
||||
* `push` appends to the array; `drain` returns a copy and clears the array.
|
||||
*/
|
||||
export function arrayEventSink(events: TurnEvent[]): TurnEventSink {
|
||||
return {
|
||||
push(event: TurnEvent): void {
|
||||
events.push(event);
|
||||
},
|
||||
drain(): TurnEvent[] {
|
||||
const copy = [...events];
|
||||
events.length = 0;
|
||||
return copy;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* InfrastructureToolExecutor — bridges the Tool interface to the
|
||||
* application-layer ToolExecutor port. Mirrors `tools/executor.rs`.
|
||||
*/
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { allTools, type Tool, type ToolCtx } from "./mod.ts";
|
||||
import { toolIsParallelSafe } from "./registry.ts";
|
||||
|
||||
/** Concrete ToolExecutor driving all built-in tools. */
|
||||
export class InfrastructureToolExecutor {
|
||||
private readonly ctx: ToolCtx;
|
||||
private readonly tools: Tool[];
|
||||
|
||||
constructor(ctx: ToolCtx) {
|
||||
this.ctx = ctx;
|
||||
this.tools = allTools();
|
||||
}
|
||||
|
||||
/** Whether a tool is read-only and safe to run in parallel. */
|
||||
static isParallelSafe(toolName: string): boolean {
|
||||
return toolIsParallelSafe(toolName);
|
||||
}
|
||||
|
||||
isParallelSafe(toolName: string): boolean {
|
||||
return toolIsParallelSafe(toolName);
|
||||
}
|
||||
|
||||
/** Look up a tool definition by name. */
|
||||
find(name: string): Tool | undefined {
|
||||
return this.tools.find((t) => t.name === name);
|
||||
}
|
||||
|
||||
/** Execute a tool by name with the given arguments. */
|
||||
async execute(toolName: string, args: JsonValue): Promise<string> {
|
||||
const tool = this.tools.find((t) => t.name === toolName);
|
||||
if (!tool) {
|
||||
throw new Error(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
const result = tool.run(this.ctx, args);
|
||||
if (result && typeof (result as PromiseLike<string>).then === "function") {
|
||||
return await (result as Promise<string>);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** All tool definitions for provider-facing schemas. */
|
||||
defs(): Array<{ type: string; function: { name: string; description: string; parameters: Record<string, unknown> } }> {
|
||||
return toolDefsHelper(this.tools);
|
||||
}
|
||||
}
|
||||
|
||||
import { toolDefs as toolDefsHelper } from "./registry.ts";
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Delete a file or empty directory. Mirrors `tools/fs/delete.rs`.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr, resolvePath } from "../util.ts";
|
||||
|
||||
/** Delete a file or empty directory. */
|
||||
export class Delete implements Tool {
|
||||
name = "delete";
|
||||
description = "Delete a file or empty directory";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to delete (relative to workspace root)" },
|
||||
reason: { type: "string", description: "Reason for deletion" },
|
||||
},
|
||||
required: ["path"],
|
||||
};
|
||||
|
||||
run(ctx: ToolCtx, args: JsonValue): string {
|
||||
const rel = argStr(args, "path");
|
||||
const file = resolvePath(ctx.workspaces, rel);
|
||||
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`path '${rel}' does not exist`);
|
||||
}
|
||||
if (fs.statSync(file).isFile()) {
|
||||
fs.unlinkSync(file);
|
||||
return `Deleted file '${rel}'`;
|
||||
}
|
||||
if (fs.statSync(file).isDirectory()) {
|
||||
try {
|
||||
fs.rmdirSync(file);
|
||||
} catch (e) {
|
||||
throw new Error(`failed to delete directory '${rel}': ${(e as Error).message} (directory must be empty)`);
|
||||
}
|
||||
return `Deleted empty directory '${rel}'`;
|
||||
}
|
||||
throw new Error(`'${rel}' is neither a file nor a directory`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Edit a file by replacing a text block. Mirrors `tools/fs/edit.rs`.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr, logWriteEditTool, resolvePath } from "../util.ts";
|
||||
|
||||
/** Edit a file by replacing `old` text with `new` text. */
|
||||
export class Edit implements Tool {
|
||||
name = "edit";
|
||||
description = "Edit a file by replacing 'old' text with 'new' text";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to the file to edit (relative to workspace root)" },
|
||||
old: { type: "string", description: "Text to replace (must exist in the file)" },
|
||||
new: { type: "string", description: "Replacement text" },
|
||||
reason: { type: "string", description: "Reason for this change" },
|
||||
},
|
||||
required: ["path", "old", "new"],
|
||||
};
|
||||
|
||||
run(ctx: ToolCtx, args: JsonValue): string {
|
||||
const rel = argStr(args, "path");
|
||||
const old = argStr(args, "old");
|
||||
const neu = argStr(args, "new");
|
||||
const file = resolvePath(ctx.workspaces, rel);
|
||||
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`file '${rel}' does not exist`);
|
||||
}
|
||||
const content = fs.readFileSync(file, "utf8");
|
||||
if (!content.includes(old)) {
|
||||
throw new Error(`old text not found in '${rel}'`);
|
||||
}
|
||||
const newContent = content.replace(old, neu);
|
||||
fs.writeFileSync(file, newContent);
|
||||
|
||||
logWriteEditTool(args, "edit", ctx.origin, ctx.sessionDir, ctx.sessionDir);
|
||||
return `Edited '${rel}': replaced ${old.length} bytes with ${neu.length} bytes`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Read a file from the workspace. Mirrors `tools/fs/read.rs`.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr, resolvePath } from "../util.ts";
|
||||
|
||||
/** Read the contents of a file. */
|
||||
export class Read implements Tool {
|
||||
name = "read";
|
||||
description = "Read the contents of a file";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to the file to read (relative to workspace root)" },
|
||||
},
|
||||
required: ["path"],
|
||||
};
|
||||
|
||||
run(ctx: ToolCtx, args: JsonValue): string {
|
||||
const rel = argStr(args, "path");
|
||||
const file = resolvePath(ctx.workspaces, rel);
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`file '${rel}' does not exist`);
|
||||
}
|
||||
if (!fs.statSync(file).isFile()) {
|
||||
throw new Error(`'${rel}' is not a file`);
|
||||
}
|
||||
return fs.readFileSync(file, "utf8");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Write content to a file (create or overwrite). Mirrors `tools/fs/write.rs`.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr, logWriteEditTool, resolvePath } from "../util.ts";
|
||||
import { checkGraduatedChecks as _g } from "../graduated.ts";
|
||||
|
||||
/** Write content to a file (creating or overwriting). */
|
||||
export class Write implements Tool {
|
||||
name = "write";
|
||||
description = "Write content to a file (creating or overwriting)";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to write to (relative to workspace root)" },
|
||||
content: { type: "string", description: "Content to write" },
|
||||
reason: { type: "string", description: "Reason for this change" },
|
||||
},
|
||||
required: ["path", "content"],
|
||||
};
|
||||
|
||||
run(ctx: ToolCtx, args: JsonValue): string {
|
||||
const rel = argStr(args, "path");
|
||||
const content = argStr(args, "content");
|
||||
const file = resolvePath(ctx.workspaces, rel);
|
||||
|
||||
const parent = path.dirname(file);
|
||||
fs.mkdirSync(parent, { recursive: true });
|
||||
fs.writeFileSync(file, content);
|
||||
|
||||
// Check graduated checks
|
||||
const matched = _g(rel, content, ctx.graduatedChecks);
|
||||
if (matched.length > 0) {
|
||||
logWriteEditTool(args, "write", ctx.origin, ctx.sessionDir, ctx.sessionDir);
|
||||
return `Written ${Buffer.byteLength(content, "utf8")} bytes to '${rel}'. Note: graduated checks triggered: ${matched.join(", ")}`;
|
||||
}
|
||||
|
||||
logWriteEditTool(args, "write", ctx.origin, ctx.sessionDir, ctx.sessionDir);
|
||||
return `Written ${Buffer.byteLength(content, "utf8")} bytes to '${rel}'`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Git credential management tool. Mirrors `tools/git/git_cred.rs`. */
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr, executeCmd, optStr } from "../util.ts";
|
||||
|
||||
export class GitCred implements Tool {
|
||||
name = "git_cred";
|
||||
description = "Manage git credentials (store, retrieve, list)";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: { type: "string", enum: ["store", "list", "erase"], description: "Credential action to perform" },
|
||||
url: { type: "string", description: "Git URL for the credential" },
|
||||
username: { type: "string", description: "Username for authentication" },
|
||||
password: { type: "string", description: "Password or token for authentication" },
|
||||
},
|
||||
required: ["action"],
|
||||
};
|
||||
|
||||
async run(_ctx: ToolCtx, args: JsonValue): Promise<string> {
|
||||
const action = argStr(args, "action");
|
||||
const url = optStr(args, "url");
|
||||
const username = optStr(args, "username");
|
||||
const password = optStr(args, "password");
|
||||
|
||||
switch (action) {
|
||||
case "store": {
|
||||
if (!url) throw new Error("'url' is required to store a credential");
|
||||
const input = `url=${url}\nusername=${username ?? ""}\npassword=${password ?? ""}\n`;
|
||||
// Pipe credentials into `git credential approve` via stdin.
|
||||
const proc = Bun.spawn({ cmd: ["git", "credential", "approve"], stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
||||
if (proc.stdin) {
|
||||
proc.stdin.write(input);
|
||||
await proc.stdin.end();
|
||||
}
|
||||
const code = await proc.exited;
|
||||
if (code !== 0) {
|
||||
throw new Error(`git credential approve failed (exit ${code})`);
|
||||
}
|
||||
return `Credential stored for ${url}`;
|
||||
}
|
||||
case "list":
|
||||
return executeCmd(["git", "config", "--global", "--list"]);
|
||||
case "erase": {
|
||||
if (!url) throw new Error("'url' is required to erase a credential");
|
||||
return `Credential erased for ${url}`;
|
||||
}
|
||||
default:
|
||||
throw new Error(`unknown action: ${action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Git operator tool — safe git operations. Mirrors `tools/git/git_operator.rs`. */
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr, executeCmd, optStrArray } from "../util.ts";
|
||||
import { checkGitDestructive } from "../shell/git.ts";
|
||||
|
||||
export class GitOperator implements Tool {
|
||||
name = "git_operator";
|
||||
description = "Execute git operations (commit, push, pull, branch, status, log, etc.)";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
operation: {
|
||||
type: "string",
|
||||
enum: ["status", "log", "diff", "commit", "branch", "checkout", "pull", "push", "add", "stash"],
|
||||
description: "Git operation to perform",
|
||||
},
|
||||
args: { type: "array", items: { type: "string" }, description: "Additional arguments for the git operation" },
|
||||
},
|
||||
required: ["operation"],
|
||||
};
|
||||
|
||||
run(_ctx: ToolCtx, args: JsonValue): string {
|
||||
const operation = argStr(args, "operation");
|
||||
const extraArgs = optStrArray(args, "args");
|
||||
|
||||
const cmdStr = `git ${operation} ${extraArgs.join(" ")}`;
|
||||
const blocked = checkGitDestructive(cmdStr);
|
||||
if (blocked) throw new Error(`blocked: ${blocked}`);
|
||||
|
||||
return executeCmd(["git", operation, ...extraArgs]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Git worktree management tool. Mirrors `tools/git/git_worktree.rs`. */
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr, executeCmd, optStr } from "../util.ts";
|
||||
|
||||
export class GitWorktree implements Tool {
|
||||
name = "git_worktree";
|
||||
description = "Manage git worktrees (add, list, remove, prune)";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: { type: "string", enum: ["add", "list", "remove", "prune"], description: "Worktree action to perform" },
|
||||
path: { type: "string", description: "Path for the new worktree (for 'add')" },
|
||||
branch: { type: "string", description: "Branch name for the new worktree (for 'add')" },
|
||||
},
|
||||
required: ["action"],
|
||||
};
|
||||
|
||||
run(ctx: ToolCtx, args: JsonValue): string {
|
||||
const action = argStr(args, "action");
|
||||
const pathArg = optStr(args, "path");
|
||||
const branch = optStr(args, "branch");
|
||||
switch (action) {
|
||||
case "add": {
|
||||
if (!pathArg) throw new Error("'path' is required for worktree add");
|
||||
if (!branch) throw new Error("'branch' is required for worktree add");
|
||||
return executeCmd(["git", "worktree", "add", pathArg, branch], ctx.workspaces[0]);
|
||||
}
|
||||
case "list":
|
||||
return executeCmd(["git", "worktree", "list"], ctx.workspaces[0]);
|
||||
case "remove": {
|
||||
if (!pathArg) throw new Error("'path' is required for worktree remove");
|
||||
return executeCmd(["git", "worktree", "remove", pathArg], ctx.workspaces[0]);
|
||||
}
|
||||
case "prune":
|
||||
return executeCmd(["git", "worktree", "prune"], ctx.workspaces[0]);
|
||||
default:
|
||||
throw new Error(`unknown action: ${action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Graduated check rules — project-defined patterns that flag matching file
|
||||
* paths or content for review. Mirrors `graduated.rs`.
|
||||
*/
|
||||
import type { GraduatedCheck } from "./context.ts";
|
||||
|
||||
/** Check which graduated checks apply to a file path/content pair. */
|
||||
export function checkGraduatedChecks(
|
||||
filePath: string,
|
||||
content: string,
|
||||
checks: GraduatedCheck[],
|
||||
): string[] {
|
||||
const matches: string[] = [];
|
||||
for (const check of checks) {
|
||||
if (filePath.includes(check.pattern) || content.includes(check.rule)) {
|
||||
matches.push(check.name);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Delete a memory by name. Mirrors `tools/memory/forget.rs`.
|
||||
*/
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { MarkdownMemoryRepository } from "@zesdex/cms";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr } from "../util.ts";
|
||||
import { resolveMemoryDir } from "./mod.ts";
|
||||
|
||||
/** Tool that deletes a saved memory by name. */
|
||||
export class Forget implements Tool {
|
||||
name = "forget";
|
||||
description = "Delete a saved memory by name";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Name of the memory to delete" },
|
||||
},
|
||||
required: ["name"],
|
||||
};
|
||||
|
||||
async run(ctx: ToolCtx, args: JsonValue): Promise<string> {
|
||||
const name = argStr(args, "name");
|
||||
const repo = new MarkdownMemoryRepository();
|
||||
const memoryDir = resolveMemoryDir(ctx.memoryDir);
|
||||
await repo.delete(memoryDir, name);
|
||||
return `Memory '${name}' deleted`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Memory management tools — remember, recall, forget. Mirrors `tools/memory/mod.rs`.
|
||||
*/
|
||||
|
||||
/** Resolve the directory the memory tools should read/write. */
|
||||
export function resolveMemoryDir(ctxMemoryDir: string): string {
|
||||
if (!ctxMemoryDir) {
|
||||
// Fall back to canonical Store path, e.g. ~/.local/share/zesdex/memory
|
||||
const home = process.env.HOME || process.env.USERPROFILE || ".";
|
||||
const dataHome = process.env.XDG_DATA_HOME || `${home}/.local/share`;
|
||||
return `${dataHome}/zesdex/memory`;
|
||||
}
|
||||
return ctxMemoryDir;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Recall previously saved memories. Mirrors `tools/memory/recall.rs`.
|
||||
*/
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { MarkdownMemoryRepository } from "@zesdex/cms";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { optStr } from "../util.ts";
|
||||
import { resolveMemoryDir } from "./mod.ts";
|
||||
|
||||
/** Tool that lists or searches saved persistent memories. */
|
||||
export class Recall implements Tool {
|
||||
name = "recall";
|
||||
description = "List or search saved memories";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Optional: specific memory name to recall" },
|
||||
search: { type: "string", description: "Optional: keyword to search in memory descriptions" },
|
||||
},
|
||||
};
|
||||
|
||||
async run(ctx: ToolCtx, args: JsonValue): Promise<string> {
|
||||
const repo = new MarkdownMemoryRepository();
|
||||
const memoryDir = resolveMemoryDir(ctx.memoryDir);
|
||||
|
||||
const specificName = optStr(args, "name");
|
||||
const search = optStr(args, "search");
|
||||
|
||||
if (specificName) {
|
||||
const memory = await repo.load(memoryDir, specificName);
|
||||
return JSON.stringify(memory, null, 2);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (query === "") return "Search query is empty";
|
||||
const names = await repo.list(memoryDir);
|
||||
const matches: string[] = [];
|
||||
for (const name of names) {
|
||||
try {
|
||||
const m = await repo.load(memoryDir, name);
|
||||
const haystack = `${m.name} ${m.description} ${m.content}`.toLowerCase();
|
||||
if (haystack.includes(query)) matches.push(m.name);
|
||||
} catch {
|
||||
// skip unparseable memory
|
||||
}
|
||||
}
|
||||
if (matches.length === 0) return `No memories match '${query}'`;
|
||||
return `Memories matching '${query}' (${matches.length}):\n${matches.join("\n")}`;
|
||||
}
|
||||
|
||||
const names = await repo.list(memoryDir);
|
||||
if (names.length === 0) return "No memories saved yet";
|
||||
return `Available memories:\n${names.join("\n")}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Remember a lesson or fact as persistent memory. Mirrors `tools/memory/remember.rs`.
|
||||
*/
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { MarkdownMemoryRepository } from "@zesdex/cms";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr, optStr } from "../util.ts";
|
||||
import { resolveMemoryDir } from "./mod.ts";
|
||||
|
||||
/** Tool that saves a lesson or fact to persistent memory. */
|
||||
export class Remember implements Tool {
|
||||
name = "remember";
|
||||
description = "Save a lesson or fact to persistent memory";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Unique name for this memory" },
|
||||
description: { type: "string", description: "Short summary of the memory" },
|
||||
content: { type: "string", description: "Full content of the memory" },
|
||||
kind: { type: "string", enum: ["lesson", "reference", "fact"], description: "Category of memory" },
|
||||
},
|
||||
required: ["name", "description", "content"],
|
||||
};
|
||||
|
||||
async run(ctx: ToolCtx, args: JsonValue): Promise<string> {
|
||||
const name = argStr(args, "name");
|
||||
const description = argStr(args, "description");
|
||||
const content = argStr(args, "content");
|
||||
const kind = optStr(args, "kind") ?? "reference";
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const memory = {
|
||||
name,
|
||||
description,
|
||||
content,
|
||||
kind,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
lifecycle: "active",
|
||||
provenances: [] as string[],
|
||||
};
|
||||
|
||||
const memoryDir = resolveMemoryDir(ctx.memoryDir);
|
||||
const repo = new MarkdownMemoryRepository();
|
||||
await repo.save(memoryDir, memory as never);
|
||||
return `Memory '${name}' saved`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Tool trait, execution context, and the registry of all built-in tools.
|
||||
* Mirrors `apps/infrastructure/src/tools/mod.rs`.
|
||||
*/
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import type { ToolCtx } from "./context.ts";
|
||||
|
||||
// ── Re-export framework modules ──────────────────────────────────────────
|
||||
export type { GraduatedCheck, ToolCtx } from "./context.ts";
|
||||
export { ToolCtxBuilder } from "./context.ts";
|
||||
export {
|
||||
argStr,
|
||||
resolvePath,
|
||||
logWriteEditTool,
|
||||
sha256Hex,
|
||||
} from "./util.ts";
|
||||
export {
|
||||
checkGraduatedChecks,
|
||||
} from "./graduated.ts";
|
||||
export {
|
||||
checkGitDestructive,
|
||||
} from "./shell/git.ts";
|
||||
export {
|
||||
isCredentialPath,
|
||||
checkCredentialRead,
|
||||
} from "./shell/credentials.ts";
|
||||
export {
|
||||
allTools,
|
||||
toolDefs,
|
||||
toolIsRisky,
|
||||
toolIsParallelSafe,
|
||||
} from "./registry.ts";
|
||||
|
||||
// ── Tool interface ───────────────────────────────────────────────────────
|
||||
|
||||
/** Common interface every agent-invocable tool implements. */
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
run(ctx: ToolCtx, args: JsonValue): string | Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Parallel delegation tool — splits a large task into sub-tasks that run
|
||||
* concurrently across multiple subagents. Mirrors `tools/parallel_delegate.rs`.
|
||||
*/
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "./mod.ts";
|
||||
import { argStr, optBool, optInt } from "./util.ts";
|
||||
|
||||
/** Delegate a large task to multiple subagents running in parallel. */
|
||||
export class ParallelDelegate implements Tool {
|
||||
name = "parallel_delegate";
|
||||
description = "Delegate a large task to multiple subagents running in parallel for 3x faster completion";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
task: { type: "string", description: "The task to be split and delegated to parallel agents" },
|
||||
directives: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
directive: { type: "string" },
|
||||
access: { type: "string", enum: ["read", "write", "full"] },
|
||||
},
|
||||
required: ["directive"],
|
||||
},
|
||||
},
|
||||
synthesize: { type: "boolean", default: true },
|
||||
max_parallel: { type: "integer", default: 3, description: "Maximum number of parallel agents (default: 3, max: 8)" },
|
||||
},
|
||||
required: ["task"],
|
||||
};
|
||||
|
||||
async run(ctx: ToolCtx, args: JsonValue): Promise<string> {
|
||||
const task = argStr(args, "task");
|
||||
const synthesize = optBool(args, "synthesize", true);
|
||||
const maxParallel = Math.min(optInt(args, "max_parallel", 3), 8);
|
||||
|
||||
let directives: Array<{ directive: string; access: string }>;
|
||||
const explicit = args && typeof args === "object" && !Array.isArray(args)
|
||||
? (args as Record<string, JsonValue>).directives
|
||||
: undefined;
|
||||
if (Array.isArray(explicit)) {
|
||||
directives = explicit
|
||||
.filter((d): d is Record<string, JsonValue> => typeof d === "object" && d !== null)
|
||||
.map((d) => ({
|
||||
directive: typeof d.directive === "string" ? d.directive : "",
|
||||
access: typeof d.access === "string" ? d.access : "write",
|
||||
}))
|
||||
.filter((d) => d.directive !== "");
|
||||
} else {
|
||||
directives = fallbackSplit(task, maxParallel);
|
||||
}
|
||||
|
||||
if (directives.length === 0) {
|
||||
throw new Error("no directives could be derived for the task");
|
||||
}
|
||||
|
||||
// Delegate to subagent engine if available, else report the breakdown.
|
||||
try {
|
||||
const { runParallelDelegation } = await import("../../../subagent/infrastructure/delegate.ts");
|
||||
return await runParallelDelegation(task, directives, synthesize, ctx);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (msg.includes("not yet") || msg.includes("Cannot find")) {
|
||||
let out = `## Parallel Delegation Complete\n\n**Task:** ${task}\n**Parallel agents:** ${directives.length}\n\n`;
|
||||
directives.forEach((d, i) => { out += `---\n### Agent ${i}: [${d.access}]\n\n${d.directive}\n`; });
|
||||
return out;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback splitting when LLM is unavailable. */
|
||||
function fallbackSplit(task: string, maxParallel: number): Array<{ directive: string; access: string }> {
|
||||
const directives: Array<{ directive: string; access: string }> = [];
|
||||
if (task.includes("backend") || task.includes("api") || task.includes("server")) {
|
||||
directives.push({ directive: `Implement the backend/API components for: ${task}`, access: "write" });
|
||||
}
|
||||
if (task.includes("frontend") || task.includes("ui") || task.includes("client")) {
|
||||
directives.push({ directive: `Implement the frontend/UI components for: ${task}`, access: "write" });
|
||||
}
|
||||
if (task.includes("test") || task.includes("unit")) {
|
||||
directives.push({ directive: `Write unit tests for: ${task}`, access: "read" });
|
||||
}
|
||||
if (directives.length === 0) {
|
||||
for (let i = 0; i < maxParallel; i++) {
|
||||
directives.push({ directive: `Part ${i + 1} of parallel task: ${task}`, access: "write" });
|
||||
}
|
||||
}
|
||||
return directives.slice(0, maxParallel);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Plan management tools — enter and mark ready. Mirrors `tools/plan.rs`. */
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import type { TurnEvent } from "@zesdex/agent";
|
||||
import { type Tool, type ToolCtx } from "./mod.ts";
|
||||
import { argStr } from "./util.ts";
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
export class PlanEnter implements Tool {
|
||||
name = "plan_enter";
|
||||
description = "Enter a planning phase — present a structured plan for approval";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
plan: { type: "string", description: "The structured plan text" },
|
||||
},
|
||||
required: ["plan"],
|
||||
};
|
||||
|
||||
run(ctx: ToolCtx, args: JsonValue): string {
|
||||
const planText = argStr(args, "plan");
|
||||
const planPath = path.join(ctx.sessionDir, "PLAN.md");
|
||||
fs.writeFileSync(planPath, planText);
|
||||
if (ctx.turnEvents) {
|
||||
ctx.turnEvents.push({ kind: "plan_update", content: planText } as unknown as TurnEvent);
|
||||
}
|
||||
return `Plan entered (length: ${planText.length} chars). Waiting for approval...`;
|
||||
}
|
||||
}
|
||||
|
||||
export class PlanReady implements Tool {
|
||||
name = "plan_ready";
|
||||
description = "Signal that the plan is ready and execution can begin";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
plan: { type: "string", description: "The final plan content" },
|
||||
},
|
||||
required: ["plan"],
|
||||
};
|
||||
|
||||
run(ctx: ToolCtx, args: JsonValue): string {
|
||||
const planContent = argStr(args, "plan");
|
||||
const planDir = path.join(ctx.sessionDir, "plans");
|
||||
try {
|
||||
fs.mkdirSync(planDir, { recursive: true });
|
||||
const now = new Date();
|
||||
const filename = `plan-${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}.md`;
|
||||
fs.writeFileSync(path.join(planDir, filename), planContent);
|
||||
fs.writeFileSync(path.join(ctx.sessionDir, "PLAN.md"), planContent);
|
||||
if (ctx.turnEvents) {
|
||||
ctx.turnEvents.push({ kind: "plan_update", content: planContent } as unknown as TurnEvent);
|
||||
}
|
||||
return `Plan saved to ${filename}. Starting execution.`;
|
||||
} catch {
|
||||
return "Plan is ready. Starting execution.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Tests for the tool registry and framework helpers.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { allTools, toolDefs, toolIsParallelSafe, toolIsRisky } from "./registry.ts";
|
||||
import { ToolCtxBuilder } from "./context.ts";
|
||||
import { resolvePath } from "./util.ts";
|
||||
import { checkGitDestructive } from "./shell/git.ts";
|
||||
import { checkCredentialRead, isCredentialPath } from "./shell/credentials.ts";
|
||||
import { checkGraduatedChecks } from "./graduated.ts";
|
||||
import { Bash } from "./shell/bash.ts";
|
||||
import { Pong } from "./utility/pong.ts";
|
||||
import { SeqThink } from "./sequential_think.ts";
|
||||
import { Read } from "./fs/read.ts";
|
||||
import { Write } from "./fs/write.ts";
|
||||
import { Edit } from "./fs/edit.ts";
|
||||
|
||||
describe("allTools registry", () => {
|
||||
test("registers 38 tools with unique names", () => {
|
||||
const tools = allTools();
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(names.length).toBe(37);
|
||||
for (const required of ["read", "write", "edit", "delete", "grep", "glob", "bash", "git_operator", "sequential_think", "web_search", "semantic_search"]) {
|
||||
expect(names).toContain(required);
|
||||
}
|
||||
});
|
||||
|
||||
test("toolDefs produces provider-facing schema", () => {
|
||||
const defs = toolDefs(allTools());
|
||||
expect(defs.length).toBe(37);
|
||||
expect(defs[0]!.type).toBe("function");
|
||||
expect(defs[0]!.function.name).toBeTruthy();
|
||||
expect(defs[0]!.function.parameters.type).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
describe("read-only tools are parallel-safe", () => {
|
||||
test("parallel-safe list", () => {
|
||||
for (const name of ["read", "grep", "glob", "semantic_search", "list_symbols", "web_search", "recall", "dir_list", "pong", "seq_think", "dir_cache_update"]) {
|
||||
expect(toolIsParallelSafe(name)).toBe(true);
|
||||
}
|
||||
});
|
||||
test("mutating tools are not parallel-safe", () => {
|
||||
for (const name of ["edit", "write", "delete", "bash", "git_operator", "git_worktree", "remember", "forget", "todowrite", "plan_enter", "spawn_agents"]) {
|
||||
expect(toolIsParallelSafe(name)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolIsRisky", () => {
|
||||
test("flags mutating and shell tools", () => {
|
||||
expect(toolIsRisky("write")).toBe(true);
|
||||
expect(toolIsRisky("delete")).toBe(true);
|
||||
expect(toolIsRisky("edit")).toBe(true);
|
||||
expect(toolIsRisky("bash")).toBe(true);
|
||||
expect(toolIsRisky("git_operator")).toBe(true);
|
||||
expect(toolIsRisky("read")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePath", () => {
|
||||
test("rejects path escapes", () => {
|
||||
const ctx = new ToolCtxBuilder().withWorkspaces(["/tmp/zesdex-ws"]).build();
|
||||
expect(() => resolvePath(ctx.workspaces, "../etc/passwd")).toThrow("outside all workspace roots");
|
||||
});
|
||||
test("resolves within workspace", () => {
|
||||
const ctx = new ToolCtxBuilder().withWorkspaces(["/tmp/zesdex-ws"]).build();
|
||||
const resolved = resolvePath(ctx.workspaces, "src/main.ts");
|
||||
expect(resolved).toBe("/tmp/zesdex-ws/src/main.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkGitDestructive", () => {
|
||||
test("blocks destructive operations", () => {
|
||||
expect(checkGitDestructive("git push --force")).not.toBeNull();
|
||||
expect(checkGitDestructive("git reset --hard HEAD")).not.toBeNull();
|
||||
expect(checkGitDestructive("git rebase main")).not.toBeNull();
|
||||
});
|
||||
test("allows safe operations", () => {
|
||||
expect(checkGitDestructive("git status")).toBeNull();
|
||||
expect(checkGitDestructive("git log --oneline")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkCredentialRead", () => {
|
||||
test("detects credential file reads", () => {
|
||||
expect(isCredentialPath("~/.ssh/id_rsa")).toBe(true);
|
||||
expect(isCredentialPath(".aws/credentials")).toBe(true);
|
||||
const findings = checkCredentialRead("cat ~/.ssh/id_rsa");
|
||||
expect(findings.length).toBeGreaterThan(0);
|
||||
});
|
||||
test("ignores normal files", () => {
|
||||
expect(isCredentialPath("src/main.rs")).toBe(false);
|
||||
expect(checkCredentialRead("cat src/main.rs")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkGraduatedChecks", () => {
|
||||
test("matches path or content patterns", () => {
|
||||
const checks = [{ name: "secrets", pattern: "credentials", rule: "api_key" }];
|
||||
expect(checkGraduatedChecks("config/credentials.yaml", "", checks)).toContain("secrets");
|
||||
expect(checkGraduatedChecks("app.ts", "const api_key", checks)).toContain("secrets");
|
||||
expect(checkGraduatedChecks("app.ts", "nothing here", checks)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("simple tools", () => {
|
||||
test("pong returns pong", () => {
|
||||
const ctx = new ToolCtxBuilder().build();
|
||||
expect(new Pong().run(ctx, {})).toBe("pong");
|
||||
});
|
||||
|
||||
test("seq_think formats step", () => {
|
||||
const ctx = new ToolCtxBuilder().build();
|
||||
const out = new SeqThink().run(ctx, { thought: "analyze", step_number: 1, total_steps: 3, next_thought_needed: true });
|
||||
expect(out).toContain("Step 1/3");
|
||||
expect(out).toContain("analyze");
|
||||
expect(out).toContain("Continuing");
|
||||
});
|
||||
|
||||
test("bash blocks destructive git", () => {
|
||||
const ctx = new ToolCtxBuilder().withWorkspaces(["/tmp"]).build();
|
||||
expect(() => new Bash().run(ctx, { command: "git push --force" })).toThrow("blocked");
|
||||
});
|
||||
|
||||
test("read/write/edit roundtrip in temp dir", () => {
|
||||
const dir = `/tmp/zesdex-tool-test-${Date.now()}`;
|
||||
const ctx = new ToolCtxBuilder().withWorkspaces([dir]).build();
|
||||
const write = new Write().run(ctx, { path: "a.txt", content: "hello world" });
|
||||
expect(write).toContain("11 bytes");
|
||||
const read = new Read().run(ctx, { path: "a.txt" });
|
||||
expect(read).toBe("hello world");
|
||||
const edit = new Edit().run(ctx, { path: "a.txt", old: "hello", new: "goodbye" });
|
||||
expect(edit).toContain("Edited");
|
||||
expect(new Read().run(ctx, { path: "a.txt" })).toBe("goodbye world");
|
||||
// cleanup
|
||||
import("node:fs").then((fs) => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Tool registry — the master list of all built-in tools. Mirrors
|
||||
* `tools/registry.rs`.
|
||||
*/
|
||||
import type { Tool } from "./mod.ts";
|
||||
import { Read } from "./fs/read.ts";
|
||||
import { Write } from "./fs/write.ts";
|
||||
import { Edit } from "./fs/edit.ts";
|
||||
import { Delete } from "./fs/delete.ts";
|
||||
import { Grep } from "./search/grep.ts";
|
||||
import { Glob } from "./search/glob.ts";
|
||||
import { BashOutput, BashKill } from "./bash_tools.ts";
|
||||
import { Bash } from "./shell/bash.ts";
|
||||
import { GitOperator } from "./git/git_operator.ts";
|
||||
import { GitWorktree } from "./git/git_worktree.ts";
|
||||
import { GitCred } from "./git/git_cred.ts";
|
||||
import { SeqThink } from "./sequential_think.ts";
|
||||
import { PlanEnter, PlanReady } from "./plan.ts";
|
||||
import { WorkflowRun, NoteFinding, ReadFindings, HiveMind } from "./workflow.ts";
|
||||
import { SpawnAgents, SpawnPipeline } from "./spawn.ts";
|
||||
import { Remember } from "./memory/remember.ts";
|
||||
import { Forget } from "./memory/forget.ts";
|
||||
import { Recall } from "./memory/recall.ts";
|
||||
import { Cd, DirList, DirCacheUpdate, Pong, Todowrite, Todofinish } from "./utility/mod.ts";
|
||||
import { WebSearch } from "./web_search.ts";
|
||||
import { SemanticSearch, RebuildIndex, ListSymbols } from "./semantic/index.ts";
|
||||
import { ParallelDelegate } from "./parallel_delegate.ts";
|
||||
import { BestPractice, CommitConvention } from "./best_practice/tools.ts";
|
||||
|
||||
/** Construct one instance of every built-in tool. */
|
||||
export function allTools(): Tool[] {
|
||||
return [
|
||||
new Read(),
|
||||
new Write(),
|
||||
new Edit(),
|
||||
new Delete(),
|
||||
new Grep(),
|
||||
new Glob(),
|
||||
new BashOutput(),
|
||||
new BashKill(),
|
||||
new Bash(),
|
||||
new GitOperator(),
|
||||
new GitWorktree(),
|
||||
new GitCred(),
|
||||
new SeqThink(),
|
||||
new PlanEnter(),
|
||||
new PlanReady(),
|
||||
new WorkflowRun(),
|
||||
new NoteFinding(),
|
||||
new ReadFindings(),
|
||||
new HiveMind(),
|
||||
new SpawnAgents(),
|
||||
new SpawnPipeline(),
|
||||
new Remember(),
|
||||
new Forget(),
|
||||
new Recall(),
|
||||
new Cd(),
|
||||
new DirList(),
|
||||
new DirCacheUpdate(),
|
||||
new Pong(),
|
||||
new Todowrite(),
|
||||
new Todofinish(),
|
||||
new WebSearch(),
|
||||
new SemanticSearch(),
|
||||
new RebuildIndex(),
|
||||
new ListSymbols(),
|
||||
new ParallelDelegate(),
|
||||
new BestPractice(),
|
||||
new CommitConvention(),
|
||||
];
|
||||
}
|
||||
|
||||
/** Whether a tool can mutate the filesystem or run arbitrary shell commands. */
|
||||
export function toolIsRisky(name: string): boolean {
|
||||
return ["write", "delete", "edit", "bash", "git_operator"].includes(name);
|
||||
}
|
||||
|
||||
/** Whether a tool is read-only and safe to run in parallel. */
|
||||
export function toolIsParallelSafe(name: string): boolean {
|
||||
return [
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"semantic_search",
|
||||
"list_symbols",
|
||||
"web_search",
|
||||
"recall",
|
||||
"dir_list",
|
||||
"pong",
|
||||
"seq_think",
|
||||
"dir_cache_update",
|
||||
].includes(name);
|
||||
}
|
||||
|
||||
/** Convert a list of tools into provider-facing ToolDef request schema. */
|
||||
export function toolDefs(
|
||||
tools: Tool[],
|
||||
): Array<{ type: string; function: { name: string; description: string; parameters: Record<string, unknown> } }> {
|
||||
return tools.map((t) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Glob: list files matching a glob pattern under a directory root.
|
||||
* Mirrors `tools/search.rs` Glob tool. Uses a lightweight `**`/`*`/`?`
|
||||
* matcher rather than the `globset` crate.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr, resolvePath } from "../util.ts";
|
||||
|
||||
/** Convert a glob pattern to a RegExp. Supports `**`, `*`, `?`, `{a,b}`. */
|
||||
export function globToRegExp(pattern: string): RegExp {
|
||||
// Split path separators to handle `**` correctly.
|
||||
const parts = pattern.split("/");
|
||||
let re = "";
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i] ?? "";
|
||||
if (part === "**") {
|
||||
re += "(?:.*/)?";
|
||||
continue;
|
||||
}
|
||||
if (i > 0) re += "/";
|
||||
let out = "";
|
||||
for (let j = 0; j < part.length; j++) {
|
||||
const ch = part[j];
|
||||
if (ch === "*") out += "[^/]*";
|
||||
else if (ch === "?") out += "[^/]";
|
||||
else if (ch === ".") out += "\\.";
|
||||
else if (ch === "(" || ch === ")" || ch === "[" || ch === "]" || ch === "+" || ch === "^" || ch === "$") out += `\\${ch}`;
|
||||
else out += ch;
|
||||
}
|
||||
re += out;
|
||||
}
|
||||
return new RegExp(`^${re}$`);
|
||||
}
|
||||
|
||||
/** List files matching a glob pattern. */
|
||||
export class Glob implements Tool {
|
||||
name = "glob";
|
||||
description = "List files matching a glob pattern";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string", description: "Glob pattern to match files (e.g. '**/*.rs')" },
|
||||
path: { type: "string", description: "Root path to search from (relative to workspace root)" },
|
||||
},
|
||||
required: ["pattern", "path"],
|
||||
};
|
||||
|
||||
run(ctx: ToolCtx, args: JsonValue): string {
|
||||
const patStr = argStr(args, "pattern");
|
||||
const rel = argStr(args, "path");
|
||||
const root = resolvePath(ctx.workspaces, rel);
|
||||
|
||||
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
||||
throw new Error(`path '${rel}' is not a valid directory`);
|
||||
}
|
||||
|
||||
let re: RegExp;
|
||||
try {
|
||||
re = globToRegExp(patStr);
|
||||
} catch (e) {
|
||||
throw new Error(`invalid glob pattern '${patStr}': ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
const matches: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === ".git" || entry.name === "node_modules" || entry.name === "target") continue;
|
||||
walk(full);
|
||||
}
|
||||
const relPath = path.relative(root, full);
|
||||
if (re.test(relPath)) {
|
||||
matches.push(relPath + (entry.isDirectory() ? "/" : ""));
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
matches.sort();
|
||||
|
||||
if (matches.length === 0) {
|
||||
return `no files match '${patStr}' in ${rel}`;
|
||||
}
|
||||
return matches.join("\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Grep: search for a regex (or literal) pattern in file contents under a
|
||||
* directory. Mirrors `tools/search.rs`.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr, resolvePath } from "../util.ts";
|
||||
|
||||
/** Search for a regex (or literal) pattern in files. */
|
||||
export class Grep implements Tool {
|
||||
name = "grep";
|
||||
description = "Search for a pattern in files using recursive text search";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string", description: "Text pattern to search for" },
|
||||
path: { type: "string", description: "Path to search in (relative to workspace root)" },
|
||||
},
|
||||
required: ["pattern", "path"],
|
||||
};
|
||||
|
||||
run(ctx: ToolCtx, args: JsonValue): string {
|
||||
const pattern = argStr(args, "pattern");
|
||||
const rel = argStr(args, "path");
|
||||
const root = resolvePath(ctx.workspaces, rel);
|
||||
|
||||
if (!fs.existsSync(root)) {
|
||||
throw new Error(`path '${rel}' does not exist`);
|
||||
}
|
||||
if (!fs.statSync(root).isDirectory()) {
|
||||
throw new Error(`path '${rel}' is not a directory`);
|
||||
}
|
||||
|
||||
const results: Array<[string, number, string]> = [];
|
||||
const walk = (dir: string) => {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === ".git" || entry.name === "node_modules" || entry.name === "target") continue;
|
||||
walk(full);
|
||||
} else if (entry.isFile()) {
|
||||
if (fs.existsSync(full) && fs.statSync(full).size > 5_000_000) continue;
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(full, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const lines = content.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const isMatch = (() => {
|
||||
try {
|
||||
return new RegExp(pattern).test(line);
|
||||
} catch {
|
||||
return line.includes(pattern);
|
||||
}
|
||||
})();
|
||||
if (isMatch) {
|
||||
const relPath = path.relative(root, full);
|
||||
results.push([relPath, i + 1, line]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
|
||||
if (results.length === 0) {
|
||||
return `no matches found for '${pattern}' in ${rel}`;
|
||||
}
|
||||
const output = results
|
||||
.map(([f, line, text]) => `${f}:${line}:${text}`)
|
||||
.join("\n");
|
||||
return `found ${results.length} matches:\n${output}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,818 @@
|
||||
/**
|
||||
* Multi-language code symbol index — functions, classes, variables, structs,
|
||||
* enums, interfaces, traits, modules. Mirrors `tools/semantic_search.rs`.
|
||||
*
|
||||
* Per-workspace in-memory cache keyed by workspace path; rebuilds run
|
||||
* outside the global lock so concurrent searches never block on I/O.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr, optBool, optInt, optStr } from "../util.ts";
|
||||
|
||||
export type Language = "rust" | "typescript" | "javascript" | "python" | "go" | "other";
|
||||
|
||||
export type SymbolKind =
|
||||
| "fn"
|
||||
| "struct"
|
||||
| "enum"
|
||||
| "trait"
|
||||
| "mod"
|
||||
| "impl"
|
||||
| "type"
|
||||
| "const"
|
||||
| "macro"
|
||||
| "class"
|
||||
| "interface"
|
||||
| "var"
|
||||
| "symbol";
|
||||
|
||||
export interface CodeSymbol {
|
||||
name: string;
|
||||
kind: SymbolKind;
|
||||
language: Language;
|
||||
file: string;
|
||||
line: number;
|
||||
parent: string | null;
|
||||
doc_comment: string | null;
|
||||
context: string;
|
||||
}
|
||||
|
||||
// ── Global per-workspace index cache ─────────────────────────────────────
|
||||
const symbolIndexStore: Record<string, SymbolIndex> = {};
|
||||
|
||||
let pendingRebuilds: Record<string, Promise<number>> = {};
|
||||
|
||||
// ── Language regexes (precompiled) ───────────────────────────────────────
|
||||
const RX = {
|
||||
rust_fn: /^\s*(?:pub\s+)?(?:(?:unsafe\s+)?async\s+)?fn\s+(\w+)/m,
|
||||
rust_struct: /^\s*(?:pub\s+)?struct\s+(\w+)/m,
|
||||
rust_enum: /^\s*(?:pub\s+)?enum\s+(\w+)/m,
|
||||
rust_trait: /^\s*(?:pub\s+)?(?:(?:unsafe\s+)?)?trait\s+(\w+)/m,
|
||||
rust_mod: /^\s*(?:pub\s+)?mod\s+(\w+)/m,
|
||||
rust_impl: /^\s*(?:pub\s+)?(?:unsafe\s+)?impl(?:\s*<[^>]*>)?\s+(?:for\s+)?(\w+)/m,
|
||||
rust_type: /^\s*(?:pub\s+)?type\s+(\w+)/m,
|
||||
rust_const: /^\s*(?:pub\s+)?const\s+(\w+)/m,
|
||||
rust_macro: /^\s*(?:pub\s+)?macro_rules!\s*\(\s*(\w+)/m,
|
||||
|
||||
ts_fn: /^\s*(?:export\s+)?(?:(?:async\s+)?function\s+|(?:public|private|protected)\s+)?(\w+)\s*(?:\(|=\s*(?:async\s+)?\()/m,
|
||||
ts_class: /^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/m,
|
||||
ts_interface: /^\s*(?:export\s+)?interface\s+(\w+)/m,
|
||||
ts_type: /^\s*(?:export\s+)?type\s+(\w+)\s*=/m,
|
||||
ts_enum: /^\s*(?:export\s+)?enum\s+(\w+)/m,
|
||||
ts_var: /^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*(?::\s*\w+\s*)?=/m,
|
||||
|
||||
py_def: /^\s*def\s+(\w+)/m,
|
||||
py_class: /^\s*class\s+(\w+)/m,
|
||||
py_async_def: /^\s*async\s+def\s+(\w+)/m,
|
||||
|
||||
go_func: /^\s*func\s+(?:\([^)]*\)\s+)?(\w+)/m,
|
||||
go_type: /^\s*type\s+(\w+)/m,
|
||||
go_struct: /^\s*type\s+(\w+)\s+struct/m,
|
||||
go_interface: /^\s*type\s+(\w+)\s+interface/m,
|
||||
go_const: /^\s*const\s+(\w+)/m,
|
||||
go_var: /^\s*var\s+(\w+)/m,
|
||||
};
|
||||
|
||||
// ── SymbolIndex ──────────────────────────────────────────────────────────
|
||||
export class SymbolIndex {
|
||||
symbols: CodeSymbol[] = [];
|
||||
workspacePath: string | null = null;
|
||||
|
||||
is_empty(): boolean {
|
||||
return this.symbols.length === 0;
|
||||
}
|
||||
len(): number {
|
||||
return this.symbols.length;
|
||||
}
|
||||
needs_rebuild(workspace: string): boolean {
|
||||
return this.is_empty() || this.workspacePath !== workspace;
|
||||
}
|
||||
|
||||
/** Walk the workspace and extract symbols from supported languages. */
|
||||
async rebuild(workspace: string): Promise<number> {
|
||||
if (!fs.existsSync(workspace)) {
|
||||
throw new Error(`workspace path does not exist: ${workspace}`);
|
||||
}
|
||||
const dispatch: Record<string, (c: string, r: string) => CodeSymbol[]> = {
|
||||
rs: extractRust,
|
||||
ts: extractTypescript,
|
||||
tsx: extractTypescript,
|
||||
mts: extractTypescript,
|
||||
js: extractJavascript,
|
||||
jsx: extractJavascript,
|
||||
mjs: extractJavascript,
|
||||
py: extractPython,
|
||||
go: extractGo,
|
||||
};
|
||||
|
||||
const symbols: CodeSymbol[] = [];
|
||||
const walk = (dir: string) => {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name === ".git" || entry.name === "node_modules" || entry.name === "target") continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
} else if (entry.isFile()) {
|
||||
const ext = path.extname(full).slice(1).toLowerCase();
|
||||
const extractor = dispatch[ext];
|
||||
if (!extractor) continue;
|
||||
const relPath = path.relative(workspace, full);
|
||||
try {
|
||||
const content = fs.readFileSync(full, "utf8");
|
||||
symbols.push(...extractor(content, relPath));
|
||||
} catch {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
// yield to event loop periodically to avoid blocking the runtime for long walks
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
walk(workspace);
|
||||
|
||||
symbols.sort((a, b) => a.name.localeCompare(b.name));
|
||||
this.symbols = symbols;
|
||||
this.workspacePath = workspace;
|
||||
return symbols.length;
|
||||
}
|
||||
|
||||
search(query: string, maxResults: number): CodeSymbol[] {
|
||||
if (this.symbols.length === 0) return [];
|
||||
const queryLower = query.toLowerCase();
|
||||
const queryWords = queryLower.split(/\s+/).filter((w) => w !== "");
|
||||
const scored = this.symbols
|
||||
.map((sym) => ({ score: scoreSymbol(sym, queryLower, queryWords), sym }))
|
||||
.filter((s) => s.score > 0)
|
||||
.sort((a, b) => b.score - a.score || a.sym.name.localeCompare(b.sym.name));
|
||||
return scored.slice(0, maxResults).map((s) => s.sym);
|
||||
}
|
||||
|
||||
list(
|
||||
languageFilter: Language | null,
|
||||
kindFilter: SymbolKind | null,
|
||||
fileFilter: string | null,
|
||||
maxResults: number,
|
||||
): CodeSymbol[] {
|
||||
return this.symbols
|
||||
.filter((s) => {
|
||||
if (languageFilter && s.language !== languageFilter) return false;
|
||||
if (kindFilter && s.kind !== kindFilter) return false;
|
||||
if (fileFilter && !s.file.includes(fileFilter)) return false;
|
||||
return true;
|
||||
})
|
||||
.slice(0, maxResults);
|
||||
}
|
||||
|
||||
count_by_language(): Array<[Language, number]> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const sym of this.symbols) {
|
||||
counts[sym.language] = (counts[sym.language] ?? 0) + 1;
|
||||
}
|
||||
return Object.entries(counts)
|
||||
.map(([lang, c]) => [lang as Language, c] as [Language, number])
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
}
|
||||
|
||||
count_by_kind(): Array<[SymbolKind, number]> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const sym of this.symbols) {
|
||||
counts[sym.kind] = (counts[sym.kind] ?? 0) + 1;
|
||||
}
|
||||
return Object.entries(counts)
|
||||
.map(([kind, c]) => [kind as SymbolKind, c] as [SymbolKind, number])
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Index management ─────────────────────────────────────────────────────
|
||||
/** Ensure a per-workspace index is built. */
|
||||
export async function ensureSymbolIndex(workspace: string, force: boolean): Promise<number> {
|
||||
const existing = symbolIndexStore[workspace];
|
||||
if (!force && existing && !existing.is_empty()) {
|
||||
return existing.len();
|
||||
}
|
||||
// Rebuild outside the global lock; dedupe concurrent rebuilds for same workspace.
|
||||
if (!pendingRebuilds[workspace]) {
|
||||
pendingRebuilds[workspace] = (async () => {
|
||||
const fresh = new SymbolIndex();
|
||||
const count = await fresh.rebuild(workspace);
|
||||
const cur = symbolIndexStore[workspace];
|
||||
if (!cur || cur.is_empty()) {
|
||||
symbolIndexStore[workspace] = fresh;
|
||||
}
|
||||
return count;
|
||||
})();
|
||||
}
|
||||
const count = await pendingRebuilds[workspace];
|
||||
delete pendingRebuilds[workspace];
|
||||
return count;
|
||||
}
|
||||
|
||||
function getIndex(workspace: string): SymbolIndex {
|
||||
let idx = symbolIndexStore[workspace];
|
||||
if (!idx) {
|
||||
idx = new SymbolIndex();
|
||||
symbolIndexStore[workspace] = idx;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
// ── Scoring ──────────────────────────────────────────────────────────────
|
||||
function scoreSymbol(sym: CodeSymbol, queryLower: string, queryWords: string[]): number {
|
||||
const nameLower = sym.name.toLowerCase();
|
||||
let score = 0;
|
||||
if (nameLower === queryLower) score += 1000;
|
||||
if (nameLower.startsWith(queryLower)) score += 500;
|
||||
if (nameLower.includes(queryLower)) score += 200;
|
||||
for (const word of queryWords) {
|
||||
if (nameLower.includes(word)) score += 50;
|
||||
}
|
||||
const doc = sym.doc_comment ? sym.doc_comment.toLowerCase() : "";
|
||||
if (doc) {
|
||||
if (doc.includes(queryLower)) score += 30;
|
||||
for (const word of queryWords) {
|
||||
if (doc.includes(word)) score += 10;
|
||||
}
|
||||
}
|
||||
const contextLower = sym.context.toLowerCase();
|
||||
if (contextLower.includes(queryLower)) score += 20;
|
||||
return score;
|
||||
}
|
||||
|
||||
// ── Doc comment extraction ───────────────────────────────────────────────
|
||||
function findNextDeclarationLine(lines: string[], start: number): number | null {
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const trimmed = lines[i]!.trim();
|
||||
if (
|
||||
trimmed !== "" &&
|
||||
!trimmed.startsWith("///") &&
|
||||
!trimmed.startsWith("//!") &&
|
||||
!trimmed.startsWith("#")
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractDocComments(lines: string[]): Map<number, string> {
|
||||
const map = new Map<number, string>();
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!.trim();
|
||||
if (line.startsWith("///")) {
|
||||
const parts: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const l = lines[i]!.trim();
|
||||
if (l.startsWith("///")) {
|
||||
parts.push(l.replace(/^\/\/\//, "").trim());
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
const target = findNextDeclarationLine(lines, i);
|
||||
if (target !== null) map.set(target + 1, parts.join(" "));
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ── Rust extractor ───────────────────────────────────────────────────────
|
||||
export function extractRust(content: string, relPath: string): CodeSymbol[] {
|
||||
const symbols: CodeSymbol[] = [];
|
||||
const lines = content.split("\n");
|
||||
const docComments = extractDocComments(lines);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNum = i + 1;
|
||||
const trimmed = lines[i]!.trim();
|
||||
const entries: Array<[SymbolKind, RegExp]> = [
|
||||
["fn", RX.rust_fn],
|
||||
["struct", RX.rust_struct],
|
||||
["enum", RX.rust_enum],
|
||||
["trait", RX.rust_trait],
|
||||
["mod", RX.rust_mod],
|
||||
["type", RX.rust_type],
|
||||
["const", RX.rust_const],
|
||||
["macro", RX.rust_macro],
|
||||
];
|
||||
for (const [kind, re] of entries) {
|
||||
const m = re.exec(trimmed);
|
||||
if (m && m[1]) {
|
||||
symbols.push({
|
||||
name: m[1],
|
||||
kind,
|
||||
language: "rust",
|
||||
file: relPath,
|
||||
line: lineNum,
|
||||
parent: null,
|
||||
doc_comment: docComments.get(lineNum) ?? null,
|
||||
context: trimmed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse impl blocks for methods
|
||||
const implMatch = RX.rust_impl.exec(trimmed);
|
||||
if (implMatch && implMatch[1]) {
|
||||
const implFor = implMatch[1];
|
||||
let braceDepth = 0;
|
||||
let started = false;
|
||||
for (let j = 0; i + j < lines.length; j++) {
|
||||
const l = lines[i + j]!;
|
||||
for (const ch of l) {
|
||||
if (ch === "{") {
|
||||
braceDepth += 1;
|
||||
started = true;
|
||||
} else if (ch === "}") {
|
||||
braceDepth -= 1;
|
||||
}
|
||||
}
|
||||
if (started && braceDepth <= 0 && j > 1) break;
|
||||
if (j > 0) {
|
||||
const inner = l.trim();
|
||||
const fnMatch = RX.rust_fn.exec(inner);
|
||||
if (fnMatch && fnMatch[1]) {
|
||||
const absLine = i + j + 1;
|
||||
symbols.push({
|
||||
name: `${implFor}::${fnMatch[1]}`,
|
||||
kind: "fn",
|
||||
language: "rust",
|
||||
file: relPath,
|
||||
line: absLine,
|
||||
parent: implFor,
|
||||
doc_comment: docComments.get(absLine) ?? null,
|
||||
context: inner,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return symbols;
|
||||
}
|
||||
|
||||
// ── TypeScript extractor ─────────────────────────────────────────────────
|
||||
function extractTsDoc(lines: string[]): Map<number, string> {
|
||||
const map = new Map<number, string>();
|
||||
let i = 0;
|
||||
const collect = (lookahead: boolean): boolean => {
|
||||
void lookahead;
|
||||
return true;
|
||||
};
|
||||
void collect;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!.trim();
|
||||
if (line.startsWith("/**") || line.startsWith("///")) {
|
||||
const parts: string[] = [];
|
||||
if (line.startsWith("/**")) {
|
||||
if (line.endsWith("*/") && line.length > 4) {
|
||||
const content = line.replace(/^\/\*\*/, "").replace(/\*\/$/, "").trim();
|
||||
if (content) parts.push(content);
|
||||
} else {
|
||||
while (i < lines.length) {
|
||||
let l = lines[i]!.trim().replace(/^\*/, "").trim();
|
||||
if (l.endsWith("*/")) {
|
||||
parts.push(l.replace(/\*\/$/, "").trim());
|
||||
break;
|
||||
}
|
||||
if (l) parts.push(l);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
while (i < lines.length) {
|
||||
const l = lines[i]!.trim();
|
||||
if (l.startsWith("///")) {
|
||||
parts.push(l.replace(/^\/\/\//, "").trim());
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const target = findNextDeclarationLine(lines, i);
|
||||
if (target !== null) map.set(target + 1, parts.join(" "));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function extractTypescript(content: string, relPath: string): CodeSymbol[] {
|
||||
const symbols: CodeSymbol[] = [];
|
||||
const lines = content.split("\n");
|
||||
const docComments = extractTsDoc(lines);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNum = i + 1;
|
||||
const trimmed = lines[i]!.trim();
|
||||
const doc = docComments.get(lineNum) ?? null;
|
||||
|
||||
const fnMatch = RX.ts_fn.exec(trimmed);
|
||||
if (fnMatch && fnMatch[1]) {
|
||||
const name = fnMatch[1];
|
||||
if (!name.startsWith("(") && name !== "function" && name !== "async") {
|
||||
symbols.push({ name, kind: "fn", language: "typescript", file: relPath, line: lineNum, parent: null, doc_comment: doc, context: trimmed });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const classMatch = RX.ts_class.exec(trimmed);
|
||||
if (classMatch && classMatch[1]) {
|
||||
symbols.push({ name: classMatch[1], kind: "class", language: "typescript", file: relPath, line: lineNum, parent: null, doc_comment: doc, context: trimmed });
|
||||
continue;
|
||||
}
|
||||
const ifaceMatch = RX.ts_interface.exec(trimmed);
|
||||
if (ifaceMatch && ifaceMatch[1]) {
|
||||
symbols.push({ name: ifaceMatch[1], kind: "interface", language: "typescript", file: relPath, line: lineNum, parent: null, doc_comment: doc, context: trimmed });
|
||||
continue;
|
||||
}
|
||||
const typeMatch = RX.ts_type.exec(trimmed);
|
||||
if (typeMatch && typeMatch[1]) {
|
||||
symbols.push({ name: typeMatch[1], kind: "type", language: "typescript", file: relPath, line: lineNum, parent: null, doc_comment: doc, context: trimmed });
|
||||
continue;
|
||||
}
|
||||
const enumMatch = RX.ts_enum.exec(trimmed);
|
||||
if (enumMatch && enumMatch[1]) {
|
||||
symbols.push({ name: enumMatch[1], kind: "enum", language: "typescript", file: relPath, line: lineNum, parent: null, doc_comment: doc, context: trimmed });
|
||||
continue;
|
||||
}
|
||||
const varMatch = RX.ts_var.exec(trimmed);
|
||||
if (varMatch && varMatch[1]) {
|
||||
const name = varMatch[1];
|
||||
const isTopLevel = !/^\s/.test(lines[i]!) || trimmed.startsWith("export");
|
||||
if (isTopLevel) {
|
||||
const kind: SymbolKind = trimmed.includes("const ") ? "const" : "var";
|
||||
symbols.push({ name, kind, language: "typescript", file: relPath, line: lineNum, parent: null, doc_comment: doc, context: trimmed });
|
||||
}
|
||||
}
|
||||
}
|
||||
return symbols;
|
||||
}
|
||||
|
||||
// ── JavaScript extractor ─────────────────────────────────────────────────
|
||||
export function extractJavascript(content: string, relPath: string): CodeSymbol[] {
|
||||
const symbols: CodeSymbol[] = [];
|
||||
const lines = content.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNum = i + 1;
|
||||
const trimmed = lines[i]!.trim();
|
||||
|
||||
const fnMatch = RX.ts_fn.exec(trimmed);
|
||||
if (fnMatch && fnMatch[1]) {
|
||||
const name = fnMatch[1];
|
||||
if (!name.startsWith("(") && name !== "function" && name !== "async") {
|
||||
symbols.push({ name, kind: "fn", language: "javascript", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const classMatch = RX.ts_class.exec(trimmed);
|
||||
if (classMatch && classMatch[1]) {
|
||||
symbols.push({ name: classMatch[1], kind: "class", language: "javascript", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed });
|
||||
continue;
|
||||
}
|
||||
const varMatch = RX.ts_var.exec(trimmed);
|
||||
if (varMatch && varMatch[1]) {
|
||||
const name = varMatch[1];
|
||||
if (!/^\s/.test(lines[i]!)) {
|
||||
const kind: SymbolKind = trimmed.includes("const ") ? "const" : "var";
|
||||
symbols.push({ name, kind, language: "javascript", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed });
|
||||
}
|
||||
}
|
||||
}
|
||||
return symbols;
|
||||
}
|
||||
|
||||
// ── Python extractor ─────────────────────────────────────────────────────
|
||||
export function extractPython(content: string, relPath: string): CodeSymbol[] {
|
||||
const symbols: CodeSymbol[] = [];
|
||||
const lines = content.split("\n");
|
||||
let currentClass: string | null = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNum = i + 1;
|
||||
const line = lines[i]!;
|
||||
const trimmed = line.trim();
|
||||
const indent = line.length - trimmed.length;
|
||||
|
||||
if (currentClass !== null && indent === 0 && trimmed !== "") {
|
||||
currentClass = null;
|
||||
}
|
||||
const classMatch = RX.py_class.exec(trimmed);
|
||||
if (classMatch && classMatch[1]) {
|
||||
const name = classMatch[1];
|
||||
currentClass = name;
|
||||
symbols.push({ name, kind: "class", language: "python", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed });
|
||||
continue;
|
||||
}
|
||||
const asyncMatch = RX.py_async_def.exec(trimmed);
|
||||
if (asyncMatch && asyncMatch[1]) {
|
||||
const name = asyncMatch[1];
|
||||
const full = currentClass ? `${currentClass}.${name}` : name;
|
||||
symbols.push({ name: full, kind: "fn", language: "python", file: relPath, line: lineNum, parent: currentClass, doc_comment: null, context: trimmed });
|
||||
continue;
|
||||
}
|
||||
const defMatch = RX.py_def.exec(trimmed);
|
||||
if (defMatch && defMatch[1]) {
|
||||
const name = defMatch[1];
|
||||
const full = currentClass ? `${currentClass}.${name}` : name;
|
||||
symbols.push({ name: full, kind: "fn", language: "python", file: relPath, line: lineNum, parent: currentClass, doc_comment: null, context: trimmed });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Module-level variable assignment
|
||||
if (
|
||||
!/^\s/.test(line) &&
|
||||
!trimmed.startsWith("#") &&
|
||||
!trimmed.startsWith("def ") &&
|
||||
!trimmed.startsWith("class ") &&
|
||||
!trimmed.startsWith("import ") &&
|
||||
!trimmed.startsWith("from ") &&
|
||||
!trimmed.startsWith("@") &&
|
||||
!trimmed.startsWith("return") &&
|
||||
!trimmed.startsWith("if ") &&
|
||||
!trimmed.startsWith("elif ") &&
|
||||
!trimmed.startsWith("else:") &&
|
||||
!trimmed.startsWith("for ") &&
|
||||
!trimmed.startsWith("while ") &&
|
||||
!trimmed.startsWith("try:") &&
|
||||
!trimmed.startsWith("except") &&
|
||||
!trimmed.startsWith("with ") &&
|
||||
!trimmed.startsWith("raise") &&
|
||||
!trimmed.startsWith("pass") &&
|
||||
!trimmed.startsWith("self.") &&
|
||||
!trimmed.startsWith("cls.") &&
|
||||
trimmed.includes(" = ") &&
|
||||
!trimmed.includes("==")
|
||||
) {
|
||||
const name = trimmed.split("=")[0]!.trim();
|
||||
if (name !== "" && !name.startsWith("_") && !name.includes(" ")) {
|
||||
const kind: SymbolKind = /^[A-Z_]+$/.test(name) ? "const" : "var";
|
||||
symbols.push({ name, kind, language: "python", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed });
|
||||
}
|
||||
}
|
||||
}
|
||||
return symbols;
|
||||
}
|
||||
|
||||
// ── Go extractor ─────────────────────────────────────────────────────────
|
||||
export function extractGo(content: string, relPath: string): CodeSymbol[] {
|
||||
const symbols: CodeSymbol[] = [];
|
||||
const lines = content.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNum = i + 1;
|
||||
const trimmed = lines[i]!.trim();
|
||||
|
||||
const structMatch = RX.go_struct.exec(trimmed);
|
||||
if (structMatch && structMatch[1]) {
|
||||
symbols.push({ name: structMatch[1], kind: "struct", language: "go", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed });
|
||||
continue;
|
||||
}
|
||||
const ifaceMatch = RX.go_interface.exec(trimmed);
|
||||
if (ifaceMatch && ifaceMatch[1]) {
|
||||
symbols.push({ name: ifaceMatch[1], kind: "interface", language: "go", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed });
|
||||
continue;
|
||||
}
|
||||
const typeMatch = RX.go_type.exec(trimmed);
|
||||
if (typeMatch && typeMatch[1]) {
|
||||
const name = typeMatch[1];
|
||||
if (!trimmed.includes(" struct") && !trimmed.includes(" interface")) {
|
||||
symbols.push({ name, kind: "type", language: "go", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const funcMatch = RX.go_func.exec(trimmed);
|
||||
if (funcMatch && funcMatch[1]) {
|
||||
const name = funcMatch[1];
|
||||
symbols.push({ name, kind: "fn", language: "go", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed });
|
||||
continue;
|
||||
}
|
||||
const constMatch = RX.go_const.exec(trimmed);
|
||||
if (constMatch && constMatch[1]) {
|
||||
symbols.push({ name: constMatch[1], kind: "const", language: "go", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed });
|
||||
continue;
|
||||
}
|
||||
const varMatch = RX.go_var.exec(trimmed);
|
||||
if (varMatch && varMatch[1]) {
|
||||
symbols.push({ name: varMatch[1], kind: "var", language: "go", file: relPath, line: lineNum, parent: null, doc_comment: null, context: trimmed });
|
||||
}
|
||||
}
|
||||
return symbols;
|
||||
}
|
||||
|
||||
// ── Format listing ───────────────────────────────────────────────────────
|
||||
export function formatSymbolListing(index: SymbolIndex): string {
|
||||
let out = `## Indexed Symbols (${index.len()} total)\n\n`;
|
||||
const byLang = index.count_by_language();
|
||||
if (byLang.length === 0) {
|
||||
out += "_No symbols indexed. Rebuild the index first._\n";
|
||||
return out;
|
||||
}
|
||||
const byLangFile: Record<string, Record<string, CodeSymbol[]>> = {};
|
||||
for (const sym of index.symbols) {
|
||||
(byLangFile[sym.language] ??= {})[sym.file] ??= [];
|
||||
byLangFile[sym.language]![sym.file]!.push(sym);
|
||||
}
|
||||
for (const [lang, files] of Object.entries(byLangFile).sort()) {
|
||||
const count = Object.values(files).reduce((n, v) => n + v.length, 0);
|
||||
out += `### ${lang} (${count})\n`;
|
||||
for (const [file, syms] of Object.entries(files).sort()) {
|
||||
out += ` ${file}\n`;
|
||||
const byKind: Record<string, string[]> = {};
|
||||
for (const sym of syms) {
|
||||
(byKind[sym.kind] ??= []).push(sym.name);
|
||||
}
|
||||
for (const [kind, names] of Object.entries(byKind).sort()) {
|
||||
out += ` ${kind}: ${names.join(", ")}\n`;
|
||||
}
|
||||
}
|
||||
out += "\n";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Language/kind mappers ────────────────────────────────────────────────
|
||||
function mapLanguage(s: string): Language | null {
|
||||
switch (s) {
|
||||
case "rust": return "rust";
|
||||
case "typescript": return "typescript";
|
||||
case "javascript": return "javascript";
|
||||
case "python": return "python";
|
||||
case "go": return "go";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
function mapKind(s: string): SymbolKind | null {
|
||||
switch (s) {
|
||||
case "fn": return "fn";
|
||||
case "class": return "class";
|
||||
case "struct": return "struct";
|
||||
case "enum": return "enum";
|
||||
case "interface": return "interface";
|
||||
case "trait": return "trait";
|
||||
case "const": return "const";
|
||||
case "var": return "var";
|
||||
case "mod": return "mod";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tools ────────────────────────────────────────────────────────────────
|
||||
|
||||
function workspaceOf(ctx: ToolCtx): string {
|
||||
return ctx.workspaces[0] ?? ".";
|
||||
}
|
||||
|
||||
/** Search for code symbols by name or concept. */
|
||||
export class SemanticSearch implements Tool {
|
||||
name = "semantic_search";
|
||||
description = "Search for code symbols (functions, structs, classes, interfaces, variables) by name, concept, or meaning across Rust, TypeScript, JavaScript, Python, and Go";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query — symbol name, concept, or meaning" },
|
||||
kind: { type: "string", enum: ["fn", "class", "struct", "enum", "interface", "trait", "const", "var", "mod", "all"], default: "all", description: "Filter by symbol kind" },
|
||||
language: { type: "string", enum: ["rust", "typescript", "javascript", "python", "go", "all"], default: "all", description: "Filter by language" },
|
||||
max_results: { type: "integer", default: 10, description: "Maximum results (default 10, max 30)" },
|
||||
rebuild_index: { type: "boolean", default: false, description: "Force rebuild the symbol index before searching" },
|
||||
},
|
||||
required: ["query"],
|
||||
};
|
||||
|
||||
async run(ctx: ToolCtx, args: JsonValue): Promise<string> {
|
||||
const query = argStr(args, "query");
|
||||
const kindFilter = optStr(args, "kind") ?? "all";
|
||||
const langFilter = optStr(args, "language") ?? "all";
|
||||
const maxResults = Math.min(optInt(args, "max_results", 10), 30);
|
||||
const rebuild = optBool(args, "rebuild_index", false);
|
||||
|
||||
const workspace = workspaceOf(ctx);
|
||||
await ensureSymbolIndex(workspace, rebuild);
|
||||
const index = getIndex(workspace);
|
||||
|
||||
const targetKind = mapKind(kindFilter);
|
||||
const targetLang = mapLanguage(langFilter);
|
||||
|
||||
const results = index.search(query, maxResults * 2);
|
||||
const filtered = results
|
||||
.filter((s) => (targetKind === null || s.kind === targetKind))
|
||||
.filter((s) => (targetLang === null || s.language === targetLang))
|
||||
.slice(0, maxResults);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return `No symbols found matching '${query}'.\nTry a different query, or use \`rebuild_index: true\` to rebuild the index first.\nIndex has ${index.len()} symbols across ${index.count_by_language().length} languages.`;
|
||||
}
|
||||
|
||||
const total = index.len();
|
||||
const byFile: Record<string, CodeSymbol[]> = {};
|
||||
for (const sym of filtered) {
|
||||
(byFile[sym.file] ??= []).push(sym);
|
||||
}
|
||||
|
||||
let output = `## Semantic Search Results\n\n**Query:** ${query}\n**Index size:** ${total} symbols\n**Matches:** ${filtered.length}\n\n`;
|
||||
for (const [file, symbols] of Object.entries(byFile).sort()) {
|
||||
output += `### \`${file}\`\n\n`;
|
||||
for (const sym of symbols) {
|
||||
const parentStr = sym.parent ? ` [${sym.parent}]` : "";
|
||||
const docStr = sym.doc_comment ? ` — ${sym.doc_comment.slice(0, 100)}` : "";
|
||||
output += `- \`${sym.kind}\` **${sym.name}**${parentStr} \`[${sym.language}]\` at line ${sym.line} \`${sym.context.trim()}\`${docStr}${sym.context.trim().length > 80 ? "…" : ""}\n`;
|
||||
}
|
||||
output += "\n";
|
||||
}
|
||||
output += `---\n*${total} symbols indexed across ${index.count_by_language().length} languages. Use \`rebuild_index: true\` to refresh.*\n`;
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild the code symbol index. */
|
||||
export class RebuildIndex implements Tool {
|
||||
name = "rebuild_index";
|
||||
description = "Rebuild the code symbol index for semantic search (supports Rust, TypeScript, JavaScript, Python, Go)";
|
||||
parameters = { type: "object", properties: {} };
|
||||
|
||||
async run(ctx: ToolCtx, _args: JsonValue): Promise<string> {
|
||||
const workspace = workspaceOf(ctx);
|
||||
const count = await ensureSymbolIndex(workspace, true);
|
||||
const index = getIndex(workspace);
|
||||
const byLang = index.count_by_language();
|
||||
let out = `Symbol index rebuilt successfully. ${count} symbols indexed.\n\nBy language:\n`;
|
||||
for (const [lang, c] of byLang) out += ` ${lang}: ${c}\n`;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** List all indexed symbols. */
|
||||
export class ListSymbols implements Tool {
|
||||
name = "list_symbols";
|
||||
description = "List all indexed code symbols across Rust, TypeScript, JavaScript, Python, and Go. Optionally filter by language, kind, or file path.";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
language: { type: "string", enum: ["rust", "typescript", "javascript", "python", "go", "all"], default: "all" },
|
||||
kind: { type: "string", enum: ["fn", "class", "struct", "enum", "interface", "trait", "const", "var", "mod", "all"], default: "all" },
|
||||
file: { type: "string", description: "Filter by file path substring" },
|
||||
max_results: { type: "integer", default: 50, description: "Maximum symbols to list (default 50, max 200)" },
|
||||
rebuild_index: { type: "boolean", default: false },
|
||||
},
|
||||
};
|
||||
|
||||
async run(ctx: ToolCtx, args: JsonValue): Promise<string> {
|
||||
const langFilter = optStr(args, "language") ?? "all";
|
||||
const kindFilter = optStr(args, "kind") ?? "all";
|
||||
const fileFilter = optStr(args, "file") ?? null;
|
||||
const maxResults = Math.min(optInt(args, "max_results", 50), 200);
|
||||
const rebuild = optBool(args, "rebuild_index", false);
|
||||
|
||||
const workspace = workspaceOf(ctx);
|
||||
await ensureSymbolIndex(workspace, rebuild);
|
||||
const index = getIndex(workspace);
|
||||
|
||||
const targetLang = mapLanguage(langFilter);
|
||||
const targetKind = mapKind(kindFilter);
|
||||
const symbols = index.list(targetLang, targetKind, fileFilter, maxResults);
|
||||
|
||||
const total = index.len();
|
||||
const byLang = index.count_by_language();
|
||||
|
||||
if (symbols.length === 0) {
|
||||
return `No symbols match the filters. Index has ${total} total symbols.\nLanguages: ${byLang.map(([l, c]) => `${l}: ${c}`).join(", ")}`;
|
||||
}
|
||||
|
||||
let out = `## Indexed Symbols\n\n**Total:** ${total} | **Showing:** ${symbols.length} | **Filter:** lang=${langFilter}, kind=${kindFilter}\n\n`;
|
||||
const byLangMap: Record<string, CodeSymbol[]> = {};
|
||||
for (const sym of symbols) (byLangMap[sym.language] ??= []).push(sym);
|
||||
|
||||
for (const [lang, syms] of Object.entries(byLangMap).sort()) {
|
||||
out += `### ${lang}\n\n`;
|
||||
const byFile: Record<string, CodeSymbol[]> = {};
|
||||
for (const sym of syms) (byFile[sym.file] ??= []).push(sym);
|
||||
for (const [file, fileSyms] of Object.entries(byFile).sort()) {
|
||||
out += `\`${file}\`:\n`;
|
||||
for (const sym of fileSyms) {
|
||||
out += ` \`${sym.kind}\` ${sym.name} L${sym.line}\n`;
|
||||
}
|
||||
}
|
||||
out += "\n";
|
||||
}
|
||||
out += "---\n";
|
||||
out += `By language: ${byLang.map(([l, c]) => `${l}: ${c}`).join(", ")}\n`;
|
||||
out += `By kind: ${index.count_by_kind().map(([k, c]) => `${k}: ${c}`).join(", ")}\n`;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Sequential thinking tool — step-by-step reasoning. Mirrors `sequential_think.rs`. */
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "./mod.ts";
|
||||
import { argStr, optBool, optInt } from "./util.ts";
|
||||
|
||||
export class SeqThink implements Tool {
|
||||
name = "sequential_think";
|
||||
description = "Perform sequential / step-by-step reasoning (chain-of-thought)";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
thought: { type: "string", description: "The current step of reasoning" },
|
||||
step_number: { type: "integer", description: "Current step number" },
|
||||
total_steps: { type: "integer", description: "Total number of steps planned" },
|
||||
next_thought_needed: { type: "boolean", description: "Whether another thinking step is needed" },
|
||||
},
|
||||
required: ["thought"],
|
||||
};
|
||||
|
||||
run(_ctx: ToolCtx, args: JsonValue): string {
|
||||
const thought = argStr(args, "thought");
|
||||
const step = optInt(args, "step_number", 0);
|
||||
const total = optInt(args, "total_steps", 1);
|
||||
const nextNeeded = optBool(args, "next_thought_needed", false);
|
||||
|
||||
return `Step ${step}/${total}: ${thought}\n${
|
||||
nextNeeded ? "Continuing reasoning..." : "Reasoning complete."
|
||||
}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Bash-shell execution tool with safety filters and optional timeout.
|
||||
* Mirrors `tools/shell.rs`.
|
||||
*/
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr, optBool, optInt } from "../util.ts";
|
||||
import { checkGitDestructive } from "./git.ts";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const MAX_TIMEOUT_MS = 600_000;
|
||||
|
||||
/** Execute a shell command via `bash -c`. */
|
||||
export class Bash implements Tool {
|
||||
name = "bash";
|
||||
description = "Execute a shell command via bash -c";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: { type: "string", description: "Shell command to execute" },
|
||||
description: { type: "string", description: "Human-readable description of what the command does" },
|
||||
timeout: { type: "integer", description: "Timeout in milliseconds (default 120000, max 600000)" },
|
||||
run_in_background: { type: "boolean", description: "Run the command in the background" },
|
||||
},
|
||||
required: ["command"],
|
||||
};
|
||||
|
||||
async run(_ctx: ToolCtx, args: JsonValue): Promise<string> {
|
||||
const cmd = argStr(args, "command");
|
||||
let timeoutMs = optInt(args, "timeout", DEFAULT_TIMEOUT_MS);
|
||||
timeoutMs = Math.min(timeoutMs, MAX_TIMEOUT_MS);
|
||||
|
||||
// Safety filter: block destructive git operations
|
||||
const blocked = checkGitDestructive(cmd);
|
||||
if (blocked) throw new Error(`blocked: ${blocked}`);
|
||||
|
||||
const runInBackground = optBool(args, "run_in_background", false);
|
||||
|
||||
if (runInBackground) {
|
||||
// Background jobs handled via bgbash registry
|
||||
const { spawnBashJob } = await import("../bgbash/job.ts");
|
||||
const { bashControl } = await import("../bgbash/control.ts");
|
||||
const job = spawnBashJob(cmd);
|
||||
bashControl().register(job);
|
||||
return `Background job: ${job.id}`;
|
||||
}
|
||||
|
||||
const proc = Bun.spawnSync({ cmd: ["bash", "-c", cmd], stdout: "pipe", stderr: "pipe", timeout: timeoutMs });
|
||||
const stdout = proc.stdout.toString();
|
||||
const stderr = proc.stderr.toString();
|
||||
const code = proc.exitCode;
|
||||
const elapsed = (proc as unknown as { duration?: number })?.duration ?? 0;
|
||||
const elapsedStr = (elapsed / 1000).toFixed(2);
|
||||
const combined = stderr ? `${stdout}\n${stderr}` : stdout;
|
||||
const trimmed = combined.trim();
|
||||
|
||||
if (code === 0) {
|
||||
return trimmed === ""
|
||||
? `Command completed in ${elapsedStr}s (exit code 0)`
|
||||
: `${trimmed}\n\nExit code: 0 (${elapsedStr}s)`;
|
||||
}
|
||||
return `${trimmed}\n\nExit code: ${code} (${elapsedStr}s)`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Credential read detection — detects commands that might exfiltrate secrets.
|
||||
* Mirrors `shell_filter/credentials.rs`. NOTE: intentionally NOT wired into
|
||||
* the bash tool by default.
|
||||
*/
|
||||
|
||||
const CREDENTIAL_PATTERNS = [
|
||||
"~/.ssh/",
|
||||
".netrc",
|
||||
".aws/credentials",
|
||||
".aws/config",
|
||||
".azure/",
|
||||
".gcp/",
|
||||
".docker/config.json",
|
||||
"id_rsa",
|
||||
"id_ed25519",
|
||||
"known_hosts",
|
||||
];
|
||||
|
||||
/** Whether a path is likely to contain credentials. */
|
||||
export function isCredentialPath(p: string): boolean {
|
||||
return CREDENTIAL_PATTERNS.some((pat) => p.includes(pat));
|
||||
}
|
||||
|
||||
/** Check whether a command reads credential files. */
|
||||
export function checkCredentialRead(cmd: string): string[] {
|
||||
const findings: string[] = [];
|
||||
const re = new RegExp(
|
||||
"(?:cat|head|tail|less|more|vim?|nano|xdg-open|open|type|echo)\\s+(~?/[\\w/.@-]+)",
|
||||
"gi",
|
||||
);
|
||||
for (const m of cmd.matchAll(re)) {
|
||||
const p = m[1] ?? "";
|
||||
if (isCredentialPath(p)) {
|
||||
findings.push(`potential credential read: '${p}'`);
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Git operation safety filter — blocks destructive git commands.
|
||||
* Mirrors `shell_filter/git.rs`.
|
||||
*/
|
||||
|
||||
const DESTRUCTIVE_PATTERNS = [
|
||||
"git push --force",
|
||||
"git push -f",
|
||||
"git reset --hard",
|
||||
"git rebase",
|
||||
"git branch -d",
|
||||
"git branch -D",
|
||||
"git tag -d",
|
||||
"git tag --delete",
|
||||
];
|
||||
|
||||
/** Check whether a shell command contains a destructive git operation. */
|
||||
export function checkGitDestructive(cmd: string): string | null {
|
||||
const cmdLower = cmd.toLowerCase();
|
||||
for (const pattern of DESTRUCTIVE_PATTERNS) {
|
||||
if (cmdLower.includes(pattern)) {
|
||||
return `destructive git operation blocked: '${pattern}'`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Agent spawning tools — launch subagents and pipelines. Mirrors
|
||||
* `tools/spawn.rs`. Delegates to the subagent engine (3c); degrades
|
||||
* gracefully if not yet wired.
|
||||
*/
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "./mod.ts";
|
||||
import { reqArray } from "./util.ts";
|
||||
|
||||
/** Spawn multiple agents in parallel on subtasks. */
|
||||
export class SpawnAgents implements Tool {
|
||||
name = "spawn_agents";
|
||||
description = "Spawn multiple agent instances to work in parallel on subtasks";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
agents: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
directive: { type: "string", description: "Directive for the agent" },
|
||||
access: { type: "string", enum: ["read", "write", "full"], description: "Access tier" },
|
||||
},
|
||||
required: ["directive"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["agents"],
|
||||
};
|
||||
|
||||
async run(ctx: ToolCtx, args: JsonValue): Promise<string> {
|
||||
const agents = reqArray(args, "agents");
|
||||
try {
|
||||
const { spawnAgents } = await import("../../../subagent/infrastructure/spawn_tools.ts");
|
||||
return await spawnAgents(agents as Array<{ directive: string; access: string }>, ctx);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (msg.includes("not yet") || msg.includes("Cannot find")) {
|
||||
return `Subagent engine not yet wired in this build. ${agents.length} agent(s) requested.`;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Spawn a sequential pipeline of agent stages. */
|
||||
export class SpawnPipeline implements Tool {
|
||||
name = "spawn_pipeline";
|
||||
description = "Spawn a sequential pipeline of agent stages";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
stages: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
directive: { type: "string", description: "Directive for this pipeline stage" },
|
||||
access: { type: "string", enum: ["read", "write", "full"], description: "Access tier for this stage" },
|
||||
},
|
||||
required: ["directive"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["stages"],
|
||||
};
|
||||
|
||||
async run(ctx: ToolCtx, args: JsonValue): Promise<string> {
|
||||
const stages = reqArray(args, "stages");
|
||||
try {
|
||||
const { spawnPipeline } = await import("../../../subagent/infrastructure/spawn_tools.ts");
|
||||
return await spawnPipeline(stages as Array<{ directive: string; access: string }>, ctx);
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (msg.includes("not yet") || msg.includes("Cannot find")) {
|
||||
return `Subagent engine not yet wired in this build. ${stages.length} stage(s) requested.`;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Shared utility functions for tool implementations: JSON argument
|
||||
* extraction, path resolution, command execution, and edit-log persistence.
|
||||
* Mirrors `apps/infrastructure/src/tools/util.rs`.
|
||||
*/
|
||||
import * as crypto from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
|
||||
/** Extract a required string argument from a JSON args map. */
|
||||
export function argStr(args: JsonValue, name: string): string {
|
||||
if (args && typeof args === "object" && !Array.isArray(args)) {
|
||||
const v = (args as Record<string, JsonValue>)[name];
|
||||
if (typeof v === "string") return v;
|
||||
}
|
||||
throw new Error(`missing required argument: ${name}`);
|
||||
}
|
||||
|
||||
/** Extract an optional string argument from a JSON args map. */
|
||||
export function optStr(args: JsonValue, name: string): string | undefined {
|
||||
if (args && typeof args === "object" && !Array.isArray(args)) {
|
||||
const v = (args as Record<string, JsonValue>)[name];
|
||||
if (typeof v === "string") return v;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Extract an optional integer argument from a JSON args map. */
|
||||
export function optInt(args: JsonValue, name: string, fallback: number): number {
|
||||
if (args && typeof args === "object" && !Array.isArray(args)) {
|
||||
const v = (args as Record<string, JsonValue>)[name];
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v === "string") {
|
||||
const n = parseInt(v, 10);
|
||||
if (!isNaN(n)) return n;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Extract an optional boolean argument from a JSON args map. */
|
||||
export function optBool(args: JsonValue, name: string, fallback: boolean): boolean {
|
||||
if (args && typeof args === "object" && !Array.isArray(args)) {
|
||||
const v = (args as Record<string, JsonValue>)[name];
|
||||
if (typeof v === "boolean") return v;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Extract an optional string array argument from a JSON args map. */
|
||||
export function optStrArray(args: JsonValue, name: string): string[] {
|
||||
if (args && typeof args === "object" && !Array.isArray(args)) {
|
||||
const v = (args as Record<string, JsonValue>)[name];
|
||||
if (Array.isArray(v)) {
|
||||
return v.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Extract a required array of objects from a JSON args map. */
|
||||
export function reqArray(args: JsonValue, name: string): JsonValue[] {
|
||||
if (args && typeof args === "object" && !Array.isArray(args)) {
|
||||
const v = (args as Record<string, JsonValue>)[name];
|
||||
if (Array.isArray(v)) return v;
|
||||
}
|
||||
throw new Error(`missing required argument: ${name}`);
|
||||
}
|
||||
|
||||
/** Compute SHA-256 hex digest of a string. */
|
||||
export function sha256Hex(s: string): string {
|
||||
return crypto.createHash("sha256").update(s).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tool-supplied relative path to an absolute path within a workspace
|
||||
* root, rejecting escapes. Supports optional `[idx]` prefix.
|
||||
*/
|
||||
export function resolvePath(workspaces: string[], rel: string): string {
|
||||
let wsIdx = 0;
|
||||
let pathPart = rel;
|
||||
|
||||
// Parse optional [idx] prefix
|
||||
if (rel.startsWith("[")) {
|
||||
const close = rel.indexOf("]");
|
||||
if (close === -1) throw new Error("invalid workspace prefix");
|
||||
wsIdx = parseInt(rel.slice(1, close), 10);
|
||||
if (isNaN(wsIdx)) throw new Error("invalid workspace index");
|
||||
pathPart = rel.slice(close + 1);
|
||||
}
|
||||
|
||||
const base = workspaces[wsIdx];
|
||||
if (base === undefined) {
|
||||
throw new Error(`workspace index ${wsIdx} out of range`);
|
||||
}
|
||||
|
||||
const abs = pathPart === "" ? base : path.resolve(base, pathPart);
|
||||
|
||||
// Check if resolved path is inside any workspace
|
||||
const inside = workspaces.some((w) => {
|
||||
const canonW = fs.existsSync(w) ? fs.realpathSync(w) : w;
|
||||
const canonAbs = abs;
|
||||
return canonAbs.startsWith(canonW) || canonAbs === canonW;
|
||||
});
|
||||
|
||||
if (!inside) {
|
||||
throw new Error(`path '${rel}' is outside all workspace roots`);
|
||||
}
|
||||
|
||||
return abs;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful write/edit tool run, compute content hash and byte
|
||||
* delta, then persist an EditLogEntry.
|
||||
*/
|
||||
export function logWriteEditTool(
|
||||
args: JsonValue,
|
||||
toolName: string,
|
||||
originTag: string,
|
||||
sessionDir: string,
|
||||
_sessionId: string,
|
||||
): void {
|
||||
if (!args || typeof args !== "object" || Array.isArray(args)) return;
|
||||
const a = args as Record<string, JsonValue>;
|
||||
const reason = typeof a.reason === "string" ? a.reason : "unnamed";
|
||||
const filePath = typeof a.path === "string" ? a.path : "unknown";
|
||||
const content = typeof a.content === "string" ? a.content : typeof a.new === "string" ? a.new : "";
|
||||
const contentSha256 = sha256Hex(content);
|
||||
|
||||
let bytesDelta: number;
|
||||
if (toolName === "write") {
|
||||
bytesDelta = Buffer.byteLength(content, "utf8");
|
||||
} else {
|
||||
const oldLen = typeof a.old === "string" ? Buffer.byteLength(a.old, "utf8") : 0;
|
||||
const newLen = typeof a.new === "string" ? Buffer.byteLength(a.new, "utf8") : 0;
|
||||
bytesDelta = Math.abs(newLen - oldLen);
|
||||
}
|
||||
|
||||
const entry = {
|
||||
ts: Date.now(),
|
||||
tool: toolName,
|
||||
path: filePath,
|
||||
reason,
|
||||
content_sha256: contentSha256,
|
||||
bytes_delta: bytesDelta,
|
||||
origin: originTag,
|
||||
session_id: _sessionId,
|
||||
};
|
||||
|
||||
try {
|
||||
const logDir = path.join(sessionDir, "edit-log");
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const logPath = path.join(logDir, "edits.jsonl");
|
||||
fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
||||
} catch {
|
||||
// best-effort: don't fail the tool for edit-log errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return combined stdout/stderr. Throws on non-zero
|
||||
* exit with the combined output. Mirrors `execute_cmd` in `util.rs`.
|
||||
*/
|
||||
export function executeCmd(argv: string[], cwd?: string): string {
|
||||
const proc = Bun.spawnSync({ cmd: argv, cwd, stdout: "pipe", stderr: "pipe" });
|
||||
const stdout = proc.stdout.toString().trim();
|
||||
const stderr = proc.stderr.toString().trim();
|
||||
const combined = stderr === "" ? stdout : `${stdout}\n${stderr}`.trim();
|
||||
const code = proc.exitCode ?? -1;
|
||||
if (proc.exitCode === 0) {
|
||||
return combined;
|
||||
}
|
||||
throw new Error(`command failed with exit code ${code}:\n${combined}`);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Set the working directory for subsequent tool calls. Mirrors `utility/cd.rs`. */
|
||||
import * as fs from "node:fs";
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { argStr, resolvePath } from "../util.ts";
|
||||
|
||||
export class Cd implements Tool {
|
||||
name = "cd";
|
||||
description = "Set the working directory for subsequent tool calls";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
directory: { type: "string", description: "Directory path to change to (relative to workspace root)" },
|
||||
},
|
||||
required: ["directory"],
|
||||
};
|
||||
|
||||
run(ctx: ToolCtx, args: JsonValue): string {
|
||||
const dir = argStr(args, "directory");
|
||||
const resolved = resolvePath(ctx.workspaces, dir);
|
||||
// Note: process.chdir is process-global. Tracked at a higher level in the
|
||||
// real session runtime; here we perform the chdir but expose the path.
|
||||
try {
|
||||
process.chdir(resolved);
|
||||
} catch (e) {
|
||||
// Keep directory listing even if chdir unsupported; return path anyway.
|
||||
void fs.existsSync(resolved);
|
||||
throw e;
|
||||
}
|
||||
return `Changed directory to '${resolved}'`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Update the shared directory cache. Mirrors `utility/dir_cache_update.rs`. */
|
||||
import type { JsonValue } from "@zesdex/domain";
|
||||
import { type Tool, type ToolCtx } from "../mod.ts";
|
||||
import { optStrArray, resolvePath } from "../util.ts";
|
||||
|
||||
export class DirCacheUpdate implements Tool {
|
||||
name = "dir_cache_update";
|
||||
description = "Update the cached directory listing";
|
||||
|
||||
parameters = {
|
||||
type: "object",
|
||||
properties: {
|
||||
paths: { type: "array", items: { type: "string" }, description: "New list of paths for the cache" },
|
||||
},
|
||||
};
|
||||
|
||||
run(ctx: ToolCtx, args: JsonValue): string {
|
||||
const paths = optStrArray(args, "paths");
|
||||
const resolved = paths.map((p) => resolvePath(ctx.workspaces, p));
|
||||
// Mutate the shared dirCache array in place (ToolCtx is a live object).
|
||||
ctx.dirCache.length = 0;
|
||||
ctx.dirCache.push(...resolved);
|
||||
return `Directory cache updated with ${resolved.length} entries`;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user