feat(infra): port tool system ke TypeScript (37 tools + registry + executor)

Fase 3b rewrite Zesdex dari Rust ke TypeScript/Bun:
- Tool interface, ToolCtx + ToolCtxBuilder, InfrastructureToolExecutor
- Registry: allTools (37 tool), toolDefs, toolIsRisky, toolIsParallelSafe
- FS tools: read, write, edit, delete
- Search: grep, glob (glob matcher ringan)
- Shell: bash (timeout + timeout_spawn), bash_output, bash_kill
- Git: git_operator, git_worktree, git_cred + checkGitDestructive, checkCredentialRead
- Memory: remember, forget, recall via MarkdownMemoryRepository
- Utilitas: cd, dir_list, dir_cache_update, pong, todowrite, todofinish
- Best-practice: best_practice, commit_convention + BestPracticeEngine
- Web search (async fetch ke SearXNG), semantic_search/rebuild_index/list_symbols
- Workflow/spawn/parallel_delegate (delegasi ke engine 3c/3d dengan placeholder)
- Graduated checks + resolve_path sandbox (anti path-escape)
- 16 test tool system (registry, guards, resolve_path, fs roundtrip)

54 test hijau (38 lama + 16 baru), tsc strict bersih.
This commit is contained in:
asepharyana
2026-09-02 22:50:38 +07:00
parent a2c7e5d44c
commit 82b51f7921
47 changed files with 3277 additions and 2 deletions
+5 -1
View File
@@ -6,4 +6,8 @@ package-lock.json
.superpowers/
docs/lesson/
.kilo/
.hermes/
.hermes/
# Runtime edit-log artifacts
dist/
apps/**/edit-log/
edit-log/
@@ -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 "../tools/mod.ts";
import { argStr, optStr } from "../tools/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;
}
}
+5 -1
View File
@@ -4,4 +4,8 @@
*/
export * from "./utils.ts";
export * from "./llm/index.ts";
export * from "./persistence/index.ts";
export * from "./persistence/index.ts";
export * from "./tools/mod.ts";
export { InfrastructureToolExecutor } from "./tools/executor.ts";
export { toolDefs, allTools, toolIsRisky, toolIsParallelSafe } from "./tools/registry.ts";
export { BestPracticeEngine } from "./best_practice/engine.ts";
@@ -0,0 +1,13 @@
/**
* Parallel delegation helper used by parallel_delegate tool. Ported in 3c.
*/
import type { ToolCtx } from "../tools/mod.ts";
export async function runParallelDelegation(
_task: string,
_directives: Array<{ directive: string; access: string }>,
_synthesize: boolean,
_ctx: ToolCtx,
): Promise<string> {
throw new Error("subagent engine not yet wired in this build");
}
@@ -0,0 +1,5 @@
/**
* Subagent engine — ported in sub-phase 3c. Placeholder exports so lazy
* tool imports resolve cleanly.
*/
export const subagentStatus = "not-wired" as const;
@@ -0,0 +1,12 @@
/**
* Subagent spawning helpers used by the spawn tools. Ported in 3c.
*/
import type { JsonValue } from "@zesdex/domain";
import type { ToolCtx } from "../tools/mod.ts";
export async function spawnAgents(_agents: JsonValue[], _ctx: ToolCtx): Promise<string> {
throw new Error("subagent engine not yet wired in this build");
}
export async function spawnPipeline(_stages: JsonValue[], _ctx: ToolCtx): Promise<string> {
throw new Error("subagent engine not yet wired in this build");
}
@@ -0,0 +1,65 @@
/**
* 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";
/** 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");
// Background job control is a global registry — not yet ported (bgbash 3g).
throw new Error(`no active background job found with ID '${jobId}'`);
}
}
@@ -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/domain";
/** 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,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 "../../persistence/cms/memory_repo.ts";
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 "../../persistence/cms/memory_repo.ts";
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 "../../persistence/cms/memory_repo.ts";
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/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,64 @@
/** 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, TurnEvent } from "@zesdex/domain";
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,81 @@
/**
* 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"],
};
run(_ctx: ToolCtx, args: JsonValue): 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
const jobId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
try {
const outDir = _ctx.sessionDir ? `${_ctx.sessionDir}/bash-outputs` : "";
if (outDir) {
import("node:fs").then((fs) => {
fs.mkdirSync(outDir, { recursive: true });
const child = Bun.spawn({ cmd: ["bash", "-c", cmd], stdout: "pipe", stderr: "pipe" });
const read = async () => {
const out = child.stdout ? await new Response(child.stdout).text() : "";
const err = child.stderr ? await new Response(child.stderr).text() : "";
const code = await child.exited;
const combined = err ? `${out}\n${err}` : out;
fs.writeFileSync(`${outDir}/${jobId}`, `Exit code: ${code}\n\n${combined}`);
};
read().catch(() => {});
});
}
} catch {
// Background spawn failed — still report the job id
}
return `Background job: ${jobId}`;
}
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/spawn_tools.ts");
return await spawnAgents(agents, 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/spawn_tools.ts");
return await spawnPipeline(stages, 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`;
}
}
@@ -0,0 +1,33 @@
/** List directory contents. Mirrors `utility/dir_list.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 DirList implements Tool {
name = "dir_list";
description = "List files and directories in a given path";
parameters = {
type: "object",
properties: {
path: { type: "string", description: "Path to list (relative to workspace root)" },
},
required: ["path"],
};
run(ctx: ToolCtx, args: JsonValue): string {
const rel = argStr(args, "path");
const dir = resolvePath(ctx.workspaces, rel);
if (!fs.existsSync(dir)) throw new Error(`path '${rel}' does not exist`);
if (!fs.statSync(dir).isDirectory()) throw new Error(`'${rel}' is not a directory`);
const entries = fs.readdirSync(dir, { withFileTypes: true });
const items = entries.map((e) => {
if (e.isDirectory()) return `${e.name}/`;
return e.name;
});
items.sort();
return items.join("\n");
}
}
@@ -0,0 +1,7 @@
/** Utility tools barrel. */
export { Cd } from "./cd.ts";
export { DirList } from "./dir_list.ts";
export { DirCacheUpdate } from "./dir_cache_update.ts";
export { Pong } from "./pong.ts";
export { Todowrite } from "./todowrite.ts";
export { Todofinish } from "./todofinish.ts";
@@ -0,0 +1,14 @@
/** Simple ping/pong tool. Mirrors `utility/pong.rs`. */
import type { JsonValue } from "@zesdex/domain";
import { type Tool, type ToolCtx } from "../mod.ts";
export class Pong implements Tool {
name = "pong";
description = "Ping the agent — useful for testing connectivity";
parameters = { type: "object", properties: {} };
run(_ctx: ToolCtx, _args: JsonValue): string {
return "pong";
}
}
@@ -0,0 +1,50 @@
/** Mark a TODO item as finished. Mirrors `utility/todofinish.rs`. */
import * as fs from "node:fs";
import * as path from "node:path";
import type { JsonValue, TurnEvent } from "@zesdex/domain";
import { type Tool, type ToolCtx } from "../mod.ts";
import { argStr } from "../util.ts";
export class Todofinish implements Tool {
name = "todofinish";
description = "Mark a TODO item as completed";
parameters = {
type: "object",
properties: {
item: { type: "string", description: "TODO item text that was completed" },
},
required: ["item"],
};
run(ctx: ToolCtx, args: JsonValue): string {
const item = argStr(args, "item");
const todoPath = path.join(ctx.sessionDir, "TODO.md");
let content = "";
try {
content = fs.readFileSync(todoPath, "utf8");
} catch {
content = "";
}
let replaced = false;
const newLines = content.split("\n").map((line) => {
if (!line.includes(item) || !line.startsWith("- [")) return line;
replaced = true;
return line
.replace("- [high]", "- [x]")
.replace("- [medium]", "- [x]")
.replace("- [low]", "- [x]")
.replace("[ ]", "[x]");
});
if (replaced) {
content = newLines.join("\n") + "\n";
fs.writeFileSync(todoPath, content);
if (ctx.turnEvents) {
ctx.turnEvents.push({ kind: "todo_update", content } as TurnEvent);
}
}
return `TODO completed: ${item}`;
}
}
@@ -0,0 +1,41 @@
/** Write a TODO item. Mirrors `utility/todowrite.rs`. */
import * as fs from "node:fs";
import * as path from "node:path";
import type { JsonValue, TurnEvent } from "@zesdex/domain";
import { type Tool, type ToolCtx } from "../mod.ts";
import { argStr, optStr } from "../util.ts";
export class Todowrite implements Tool {
name = "todowrite";
description = "Add an item to the TODO list";
parameters = {
type: "object",
properties: {
item: { type: "string", description: "TODO item text" },
priority: { type: "string", enum: ["high", "medium", "low"], description: "Priority level" },
},
required: ["item"],
};
run(ctx: ToolCtx, args: JsonValue): string {
const item = argStr(args, "item");
const priority = optStr(args, "priority") ?? "medium";
const todoLine = `- [${priority}] ${item}\n`;
const todoPath = path.join(ctx.sessionDir, "TODO.md");
let content = "";
try {
content = fs.readFileSync(todoPath, "utf8");
} catch {
content = "";
}
content += todoLine;
fs.writeFileSync(todoPath, content);
if (ctx.turnEvents) {
ctx.turnEvents.push({ kind: "todo_update", content } as TurnEvent);
}
return `[${priority}] TODO added: ${item}`;
}
}
@@ -0,0 +1,103 @@
/**
* Web search & documentation scraper tool — search the web via a SearXNG
* instance. Mirrors `tools/web_search.rs`. Implements `run` as async since
* network I/O is inherently asynchronous.
*/
import type { JsonValue } from "@zesdex/domain";
import { type Tool, type ToolCtx } from "./mod.ts";
import { argStr, optBool, optInt, optStr } from "./util.ts";
const DEFAULT_SEARXNG_URL = "https://searxng.imrnes.team";
/** URL-encode a string for query parameters (space→+). */
function urlencoding(input: string): string {
return encodeURIComponent(input).replace(/%20/g, "+");
}
async function fetchPageContent(url: string): Promise<string> {
const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const html = await resp.text();
const stripped = html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<(nav|footer|header)[\s\S]*?<\/\1>/gi, " ");
const text = stripped.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
if (text === "") throw new Error("no readable content found on page");
return text;
}
/** Search the web for documentation, APIs, and technical information. */
export class WebSearch implements Tool {
name = "web_search";
description = "Search the web for documentation, APIs, and technical information. Uses SearXNG instance.";
parameters = {
type: "object",
properties: {
query: { type: "string", description: "Search query (e.g. 'Next.js 15 app router documentation')" },
max_results: { type: "integer", description: "Maximum number of results to return (default 5, max 10)", default: 5 },
fetch_content: { type: "boolean", description: "Whether to fetch full page content from each result (default false)", default: false },
categories: { type: "string", description: "Search categories (e.g. 'general', 'science', 'it', 'news'). Default: general", default: "general" },
},
required: ["query"],
};
async run(_ctx: ToolCtx, args: JsonValue): Promise<string> {
const query = argStr(args, "query");
const maxResults = Math.min(optInt(args, "max_results", 5), 10);
const fetchContent = optBool(args, "fetch_content", false);
const categories = optStr(args, "categories") ?? "general";
const searxngUrl = process.env.SEARXNG_URL || DEFAULT_SEARXNG_URL;
const searchUrl = `${searxngUrl}/search?format=json&q=${urlencoding(query)}&categories=${categories}&language=en-US`;
let body: unknown;
try {
const resp = await fetch(searchUrl, {
signal: AbortSignal.timeout(30_000),
headers: { "User-Agent": "Zesdex/1.0 (AI Coding Agent)" },
});
if (!resp.ok) throw new Error(`SearXNG returned HTTP ${resp.status}`);
body = await resp.json();
} catch (e) {
throw new Error(`search request failed: ${(e as Error).message}`);
}
const resultsArr = (body as { results?: Array<Record<string, unknown>> })?.results ?? [];
const results = resultsArr
.filter((r) => typeof r.url === "string" && typeof r.title === "string")
.slice(0, maxResults);
if (results.length === 0) {
return `No search results found for '${query}'.`;
}
let output = `## Web Search Results for: ${query}\n\n`;
for (let i = 0; i < results.length; i++) {
const r = results[i]!;
const title = r.title as string;
const url = r.url as string;
const snippet = ((r.content as string) ?? "").trim();
output += `### ${i + 1}. [${title}](${url})\n\n`;
if (snippet) output += `> ${snippet}\n\n`;
if (fetchContent && url) {
try {
const content = await fetchPageContent(url);
const truncated = content.split("\n").slice(0, 80).join("\n");
output += `**Content:**\n\`\`\`\n${truncated}\`\`\`\n\n`;
} catch (e) {
output += `*(Content fetch failed: ${(e as Error).message})*\n\n`;
}
}
if (typeof r.engine === "string") output += `*Source: ${r.engine}*\n\n`;
}
if (results.length < 3) {
output += "---\n*Few results. Try a more specific query or different categories.*\n";
}
return output;
}
}
@@ -0,0 +1,121 @@
/**
* Workflow tools — orchestrate multi-step agent workflows and hive-mind
* convergence. Mirrors `tools/workflow.rs`.
*
* Note: full workflow/hive-mind execution engines are ported in a later
* sub-phase (3d). These tools load the engine lazily; if unavailable they
* return a helpful note so the agent can still use the tool surface.
*/
import type { JsonValue } from "@zesdex/domain";
import { type Tool, type ToolCtx } from "./mod.ts";
import { argStr, optStr } from "./util.ts";
/** Execute a multi-step workflow defined in YAML. */
export class WorkflowRun implements Tool {
name = "workflow_run";
description = "Execute a multi-step workflow defined in YAML";
parameters = {
type: "object",
properties: {
workflow_yaml: { type: "string", description: "YAML workflow definition" },
},
required: ["workflow_yaml"],
};
async run(ctx: ToolCtx, args: JsonValue): Promise<string> {
const yaml = argStr(args, "workflow_yaml");
try {
const { executeWorkflow } = await import("../workflow/index.ts");
return await executeWorkflow(yaml, ctx);
} catch (e) {
const msg = (e as Error).message;
if (msg.includes("not yet") || msg.includes("Cannot find")) {
return `Workflow engine not yet wired in this build. Workflow definition received (${yaml.length} chars).`;
}
throw e;
}
}
}
/** Record a finding during workflow or hive-mind execution. */
export class NoteFinding implements Tool {
name = "note_finding";
description = "Record a finding during workflow or hive-mind execution";
parameters = {
type: "object",
properties: {
finding: { type: "string", description: "The finding text" },
category: { type: "string", description: "Category for the finding" },
},
required: ["finding"],
};
run(ctx: ToolCtx, args: JsonValue): string {
const finding = argStr(args, "finding");
const category = optStr(args, "category") ?? "general";
const tagged = `[${category}] ${finding}`;
if (ctx.workflowFindings) {
ctx.workflowFindings.push(tagged);
}
return `Finding recorded: ${finding}`;
}
}
/** Read all findings recorded so far. */
export class ReadFindings implements Tool {
name = "read_findings";
description = "Read all findings recorded so far in the current workflow";
parameters = { type: "object", properties: {} };
run(ctx: ToolCtx, _args: JsonValue): string {
const findings = ctx.workflowFindings ?? [];
if (findings.length === 0) return "No findings recorded yet.";
return `Findings (${findings.length}):\n${findings.join("\n")}`;
}
}
/** Orchestrate a hive-mind convergence. */
export class HiveMind implements Tool {
name = "hive_mind";
description = "Run a hive-mind convergence with multiple nodes across sequential cycles";
parameters = {
type: "object",
properties: {
cycles: {
type: "array",
items: {
type: "object",
properties: {
directives: {
type: "array",
items: {
type: "object",
properties: {
directive: { type: "string" },
access: { type: "string", enum: ["read", "write", "full"] },
},
},
},
},
},
},
},
required: ["cycles"],
};
async run(ctx: ToolCtx, args: JsonValue): Promise<string> {
try {
const { executeHiveMind } = await import("../workflow/hive_mind.ts");
return await executeHiveMind(args, ctx);
} catch (e) {
const msg = (e as Error).message;
if (msg.includes("not yet") || msg.includes("Cannot find")) {
return "Hive-mind engine not yet wired in this build.";
}
throw e;
}
}
}
@@ -0,0 +1,10 @@
/**
* Hive-mind convergence engine — ported in sub-phase 3d.
*/
import type { JsonValue } from "@zesdex/domain";
import type { ToolCtx } from "../tools/mod.ts";
/** Execute a hive-mind convergence from tool arguments. */
export async function executeHiveMind(_args: JsonValue, _ctx: ToolCtx): Promise<string> {
throw new Error("hive-mind engine not yet wired in this build");
}
@@ -0,0 +1,10 @@
/**
* Workflow execution engine — ported in sub-phase 3d. This module provides
* the execution entry points used by the workflow tools.
*/
import type { ToolCtx } from "../tools/mod.ts";
/** Execute a YAML workflow definition. */
export async function executeWorkflow(_yaml: string, _ctx: ToolCtx): Promise<string> {
throw new Error("workflow engine not yet wired in this build");
}