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:
+5
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user