src / tools / planTools.ts
import { tool, LMStudioClient } from "@lmstudio/sdk";
import { readFile, writeFile, mkdir, stat } from "fs/promises";
import { dirname } from "path";
import { exec } from "child_process";
import { promisify } from "util";
import { z } from "zod";
import { resolvePath, getWorkspaceDir, truncateOutput } from "../utils.js";
const execAsync = promisify(exec);
export interface PlanTask {
index: number;
text: string;
completed: boolean;
}
function parsePlanMarkdown(content: string): { title: string; tasks: PlanTask[] } {
const lines = content.split("\n");
let title = "Plan";
const tasks: PlanTask[] = [];
let index = 1;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("# ")) {
title = trimmed.substring(2).trim();
} else if (/^[-*]\s+\[([ xX])\]\s+(.*)$/.test(trimmed)) {
const match = trimmed.match(/^[-*]\s+\[([ xX])\]\s+(.*)$/);
if (match) {
const completed = match[1].toLowerCase() === "x";
const text = match[2].trim();
tasks.push({ index: index++, text, completed });
}
}
}
return { title, tasks };
}
function formatPlanMarkdown(title: string, description: string | undefined, tasks: PlanTask[]): string {
const lines: string[] = [`# ${title || "Plan"}`];
if (description) lines.push("", description.trim());
lines.push("", "## Tareas");
for (const t of tasks) {
lines.push(`- [${t.completed ? "x" : " "}] T${t.index}: ${t.text}`);
}
return lines.join("\n") + "\n";
}
// 1. plan_manager tool (ultra compacto)
export const plan_manager = tool({
name: "plan_manager",
description: "Manage task plan in workspace (plan.md).",
parameters: {
action: z.enum(["create", "read", "update_task", "add_task", "status"]).describe("Plan action."),
plan_path: z.string().optional().describe("Plan file (default: plan.md)."),
title: z.string().optional().describe("Plan title."),
description: z.string().optional().describe("Plan description."),
tasks: z.array(z.string()).optional().describe("Tasks list for create."),
task_index: z.number().optional().describe("1-based task number."),
completed: z.boolean().optional().describe("Status for update_task."),
new_task: z.string().optional().describe("Task description for add_task.")
},
implementation: async ({ action, plan_path, title, description, tasks, task_index, completed, new_task }) => {
try {
const targetPath = resolvePath(plan_path || "plan.md");
if (action === "create") {
const taskList: PlanTask[] = (tasks || []).map((t, idx) => ({
index: idx + 1,
text: t,
completed: false
}));
const md = formatPlanMarkdown(title || "Plan", description, taskList);
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, md, "utf-8");
return {
ok: true,
path: targetPath,
total: taskList.length,
msg: `Plan created (${taskList.length} tasks)`
};
}
if (action === "read" || action === "status") {
const content = await readFile(targetPath, "utf-8");
const parsed = parsePlanMarkdown(content);
const total = parsed.tasks.length;
const done = parsed.tasks.filter(t => t.completed).length;
return {
ok: true,
title: parsed.title,
progress: `${done}/${total} (${total > 0 ? Math.round((done / total) * 100) : 0}%)`,
done: done === total,
tasks: parsed.tasks
};
}
if (action === "update_task") {
if (task_index === undefined) return { ok: false, error: "task_index required" };
const content = await readFile(targetPath, "utf-8");
const parsed = parsePlanMarkdown(content);
const task = parsed.tasks.find(t => t.index === task_index);
if (!task) return { ok: false, error: `Task #${task_index} not found` };
task.completed = completed ?? true;
await writeFile(targetPath, formatPlanMarkdown(parsed.title, undefined, parsed.tasks), "utf-8");
const total = parsed.tasks.length;
const done = parsed.tasks.filter(t => t.completed).length;
return {
ok: true,
progress: `${done}/${total} (${Math.round((done / total) * 100)}%)`,
task: `T${task_index} -> ${task.completed ? "DONE" : "PENDING"}`
};
}
if (action === "add_task") {
if (!new_task) return { ok: false, error: "new_task required" };
let content = "";
try { content = await readFile(targetPath, "utf-8"); } catch { content = "# Plan\n\n## Tareas\n"; }
const parsed = parsePlanMarkdown(content);
const nextIdx = parsed.tasks.length + 1;
parsed.tasks.push({ index: nextIdx, text: new_task, completed: false });
await writeFile(targetPath, formatPlanMarkdown(parsed.title, undefined, parsed.tasks), "utf-8");
return { ok: true, total: parsed.tasks.length, msg: `Added T${nextIdx}` };
}
return { ok: false, error: "Invalid action" };
} catch (err: any) {
return { ok: false, error: err.message };
}
}
});
// 2. audit_plan tool (ultra compacto)
export const audit_plan = tool({
name: "audit_plan",
description: "Audit plan completion: verifies required files exist and runs optional test command.",
parameters: {
plan_path: z.string().optional().describe("Plan file (default: plan.md)."),
files_to_check: z.array(z.string()).optional().describe("Required file paths to verify."),
test_command: z.string().optional().describe("Optional test command (e.g. 'npm test').")
},
implementation: async ({ plan_path, files_to_check, test_command }) => {
try {
const missing: string[] = [];
const present: string[] = [];
if (files_to_check && files_to_check.length > 0) {
for (const file of files_to_check) {
const fullPath = resolvePath(file);
try {
const fileStat = await stat(fullPath);
present.push(`${file} (${fileStat.size}B)`);
} catch {
missing.push(file);
}
}
}
const targetPlan = resolvePath(plan_path || "plan.md");
let planProgress = "No plan.md";
let planAllDone = true;
try {
const planContent = await readFile(targetPlan, "utf-8");
const parsed = parsePlanMarkdown(planContent);
const total = parsed.tasks.length;
const done = parsed.tasks.filter(t => t.completed).length;
planProgress = `${done}/${total} tasks`;
planAllDone = total > 0 && done === total;
} catch {}
let testOutput = "";
let testExitCode = 0;
if (test_command && test_command.trim()) {
const cwd = getWorkspaceDir();
try {
const { stdout } = await execAsync(test_command, { cwd, timeout: 30000 });
testOutput = truncateOutput(stdout.trim(), 400);
} catch (testErr: any) {
testExitCode = testErr.code ?? 1;
testOutput = truncateOutput(testErr.stderr ? String(testErr.stderr).trim() : testErr.message, 400);
}
}
const passed = missing.length === 0 && testExitCode === 0 && planAllDone;
return {
status: passed ? "PASSED" : "FAILED",
plan: planProgress,
missing_files: missing.length > 0 ? missing : undefined,
verified_files: present,
test_exit_code: test_command ? testExitCode : undefined,
test_summary: testOutput || undefined
};
} catch (err: any) {
return { status: "FAILED", error: err.message };
}
}
});
// Endpoints de aceleración de hardware
const AMD_NODE_URL = process.env.AMD_NODE_URL || "http://127.0.0.1:1235/v1";
const INTEL_HOST_URL = process.env.INTEL_HOST_URL || "http://127.0.0.1:1234/v1";
async function fetchFirstModel(baseUrl: string): Promise<string | null> {
try {
const res = await fetch(`${baseUrl}/models`, { signal: AbortSignal.timeout(3000) });
if (res.ok) {
const data = (await res.json()) as any;
const list = data?.data || [];
if (list.length > 0 && list[0]?.id) {
return list[0].id;
}
}
} catch {}
return null;
}
async function executeViaHttp(
baseUrl: string,
modelName: string,
systemPrompt: string,
userPrompt: string,
maxTokens: number
): Promise<string> {
const res = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: modelName,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt }
],
max_tokens: maxTokens,
temperature: 0.3,
stream: false
}),
signal: AbortSignal.timeout(90000)
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
}
const data = (await res.json()) as any;
let content = data?.choices?.[0]?.message?.content || "";
if (content.includes("__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_")) {
const parts = content.split(/__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[a-f0-9]+__/);
content = parts[parts.length - 1].trim();
}
return content.trim();
}
// 3. subagent_task tool con balanceo de carga multi-hardware
export const subagent_task = tool({
name: "subagent_task",
description: "Delegate a focused task/review/refactoring to a subagent running on secondary hardware (AMD RX 480 eGPU or Intel Arc) to offload the main host.",
parameters: {
task_description: z.string().describe("Concise subtask description for the subagent."),
context: z.string().optional().describe("Brief context (keep concise for speed)."),
system_role: z.string().optional().describe("Role e.g. 'Auditor', 'Coder', 'Reviewer', 'Optimizer'."),
target_hardware: z.enum(["auto", "amd", "intel", "openvino"]).optional().default("auto").describe("Hardware to offload subtask to: 'amd' (dedicated RX 480 eGPU node :1235), 'intel' (host Arc :1234), or 'auto' (automatically routes to AMD eGPU to free main host)."),
max_tokens: z.number().optional().default(600).describe("Max tokens to generate (default: 600).")
},
implementation: async ({ task_description, context, system_role, target_hardware = "auto", max_tokens = 600 }) => {
const systemPrompt = `You are a concise ${system_role || "assistant"}. Return strictly brief, minimal token results without conversational fluff.`;
let prompt = `TASK: ${task_description}`;
if (context) prompt += `\nCONTEXT: ${context}`;
// 1. Ruta hacia AMD eGPU (:1235) si target es 'amd' o 'auto'
if (target_hardware === "amd" || target_hardware === "auto") {
try {
const amdModel = await fetchFirstModel(AMD_NODE_URL);
if (amdModel) {
const result = await executeViaHttp(AMD_NODE_URL, amdModel, systemPrompt, prompt, max_tokens);
return {
ok: true,
hardware: "AMD RX 480 (eGPU :1235)",
model: amdModel,
role: system_role || "Subagent",
result: truncateOutput(result, 1200)
};
}
} catch (amdErr: any) {
if (target_hardware === "amd") {
return { ok: false, error: `AMD node error: ${amdErr.message}` };
}
// En auto, si falla AMD continuamos hacia fallback
}
}
// 2. Ruta hacia Host / Intel / OpenVINO (:1234) o fallback
try {
const intelModel = await fetchFirstModel(INTEL_HOST_URL);
if (intelModel) {
const result = await executeViaHttp(INTEL_HOST_URL, intelModel, systemPrompt, prompt, max_tokens);
return {
ok: true,
hardware: target_hardware === "openvino" ? "OpenVINO (Host :1234)" : "Intel Arc (Host :1234)",
model: intelModel,
role: system_role || "Subagent",
result: truncateOutput(result, 1200)
};
}
} catch {}
// 3. Fallback directo al SDK local de LM Studio
try {
const client = new LMStudioClient();
const model = await client.llm.model();
const response = await model.respond([
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt }
], {
maxTokens: max_tokens
});
let outputText = response.content || "";
if (outputText.includes("__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_")) {
const parts = outputText.split(/__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[a-f0-9]+__/);
outputText = parts[parts.length - 1].trim();
}
return {
ok: true,
hardware: "Local LM Studio Client",
role: system_role || "Subagent",
result: truncateOutput(outputText, 1200)
};
} catch (err: any) {
return { ok: false, error: err.message };
}
}
});
src / tools / planTools.ts
import { tool, LMStudioClient } from "@lmstudio/sdk";
import { readFile, writeFile, mkdir, stat } from "fs/promises";
import { dirname } from "path";
import { exec } from "child_process";
import { promisify } from "util";
import { z } from "zod";
import { resolvePath, getWorkspaceDir, truncateOutput } from "../utils.js";
const execAsync = promisify(exec);
export interface PlanTask {
index: number;
text: string;
completed: boolean;
}
function parsePlanMarkdown(content: string): { title: string; tasks: PlanTask[] } {
const lines = content.split("\n");
let title = "Plan";
const tasks: PlanTask[] = [];
let index = 1;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("# ")) {
title = trimmed.substring(2).trim();
} else if (/^[-*]\s+\[([ xX])\]\s+(.*)$/.test(trimmed)) {
const match = trimmed.match(/^[-*]\s+\[([ xX])\]\s+(.*)$/);
if (match) {
const completed = match[1].toLowerCase() === "x";
const text = match[2].trim();
tasks.push({ index: index++, text, completed });
}
}
}
return { title, tasks };
}
function formatPlanMarkdown(title: string, description: string | undefined, tasks: PlanTask[]): string {
const lines: string[] = [`# ${title || "Plan"}`];
if (description) lines.push("", description.trim());
lines.push("", "## Tareas");
for (const t of tasks) {
lines.push(`- [${t.completed ? "x" : " "}] T${t.index}: ${t.text}`);
}
return lines.join("\n") + "\n";
}
// 1. plan_manager tool (ultra compacto)
export const plan_manager = tool({
name: "plan_manager",
description: "Manage task plan in workspace (plan.md).",
parameters: {
action: z.enum(["create", "read", "update_task", "add_task", "status"]).describe("Plan action."),
plan_path: z.string().optional().describe("Plan file (default: plan.md)."),
title: z.string().optional().describe("Plan title."),
description: z.string().optional().describe("Plan description."),
tasks: z.array(z.string()).optional().describe("Tasks list for create."),
task_index: z.number().optional().describe("1-based task number."),
completed: z.boolean().optional().describe("Status for update_task."),
new_task: z.string().optional().describe("Task description for add_task.")
},
implementation: async ({ action, plan_path, title, description, tasks, task_index, completed, new_task }) => {
try {
const targetPath = resolvePath(plan_path || "plan.md");
if (action === "create") {
const taskList: PlanTask[] = (tasks || []).map((t, idx) => ({
index: idx + 1,
text: t,
completed: false
}));
const md = formatPlanMarkdown(title || "Plan", description, taskList);
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, md, "utf-8");
return {
ok: true,
path: targetPath,
total: taskList.length,
msg: `Plan created (${taskList.length} tasks)`
};
}
if (action === "read" || action === "status") {
const content = await readFile(targetPath, "utf-8");
const parsed = parsePlanMarkdown(content);
const total = parsed.tasks.length;
const done = parsed.tasks.filter(t => t.completed).length;
return {
ok: true,
title: parsed.title,
progress: `${done}/${total} (${total > 0 ? Math.round((done / total) * 100) : 0}%)`,
done: done === total,
tasks: parsed.tasks
};
}
if (action === "update_task") {
if (task_index === undefined) return { ok: false, error: "task_index required" };
const content = await readFile(targetPath, "utf-8");
const parsed = parsePlanMarkdown(content);
const task = parsed.tasks.find(t => t.index === task_index);
if (!task) return { ok: false, error: `Task #${task_index} not found` };
task.completed = completed ?? true;
await writeFile(targetPath, formatPlanMarkdown(parsed.title, undefined, parsed.tasks), "utf-8");
const total = parsed.tasks.length;
const done = parsed.tasks.filter(t => t.completed).length;
return {
ok: true,
progress: `${done}/${total} (${Math.round((done / total) * 100)}%)`,
task: `T${task_index} -> ${task.completed ? "DONE" : "PENDING"}`
};
}
if (action === "add_task") {
if (!new_task) return { ok: false, error: "new_task required" };
let content = "";
try { content = await readFile(targetPath, "utf-8"); } catch { content = "# Plan\n\n## Tareas\n"; }
const parsed = parsePlanMarkdown(content);
const nextIdx = parsed.tasks.length + 1;
parsed.tasks.push({ index: nextIdx, text: new_task, completed: false });
await writeFile(targetPath, formatPlanMarkdown(parsed.title, undefined, parsed.tasks), "utf-8");
return { ok: true, total: parsed.tasks.length, msg: `Added T${nextIdx}` };
}
return { ok: false, error: "Invalid action" };
} catch (err: any) {
return { ok: false, error: err.message };
}
}
});
// 2. audit_plan tool (ultra compacto)
export const audit_plan = tool({
name: "audit_plan",
description: "Audit plan completion: verifies required files exist and runs optional test command.",
parameters: {
plan_path: z.string().optional().describe("Plan file (default: plan.md)."),
files_to_check: z.array(z.string()).optional().describe("Required file paths to verify."),
test_command: z.string().optional().describe("Optional test command (e.g. 'npm test').")
},
implementation: async ({ plan_path, files_to_check, test_command }) => {
try {
const missing: string[] = [];
const present: string[] = [];
if (files_to_check && files_to_check.length > 0) {
for (const file of files_to_check) {
const fullPath = resolvePath(file);
try {
const fileStat = await stat(fullPath);
present.push(`${file} (${fileStat.size}B)`);
} catch {
missing.push(file);
}
}
}
const targetPlan = resolvePath(plan_path || "plan.md");
let planProgress = "No plan.md";
let planAllDone = true;
try {
const planContent = await readFile(targetPlan, "utf-8");
const parsed = parsePlanMarkdown(planContent);
const total = parsed.tasks.length;
const done = parsed.tasks.filter(t => t.completed).length;
planProgress = `${done}/${total} tasks`;
planAllDone = total > 0 && done === total;
} catch {}
let testOutput = "";
let testExitCode = 0;
if (test_command && test_command.trim()) {
const cwd = getWorkspaceDir();
try {
const { stdout } = await execAsync(test_command, { cwd, timeout: 30000 });
testOutput = truncateOutput(stdout.trim(), 400);
} catch (testErr: any) {
testExitCode = testErr.code ?? 1;
testOutput = truncateOutput(testErr.stderr ? String(testErr.stderr).trim() : testErr.message, 400);
}
}
const passed = missing.length === 0 && testExitCode === 0 && planAllDone;
return {
status: passed ? "PASSED" : "FAILED",
plan: planProgress,
missing_files: missing.length > 0 ? missing : undefined,
verified_files: present,
test_exit_code: test_command ? testExitCode : undefined,
test_summary: testOutput || undefined
};
} catch (err: any) {
return { status: "FAILED", error: err.message };
}
}
});
// Endpoints de aceleración de hardware
const AMD_NODE_URL = process.env.AMD_NODE_URL || "http://127.0.0.1:1235/v1";
const INTEL_HOST_URL = process.env.INTEL_HOST_URL || "http://127.0.0.1:1234/v1";
async function fetchFirstModel(baseUrl: string): Promise<string | null> {
try {
const res = await fetch(`${baseUrl}/models`, { signal: AbortSignal.timeout(3000) });
if (res.ok) {
const data = (await res.json()) as any;
const list = data?.data || [];
if (list.length > 0 && list[0]?.id) {
return list[0].id;
}
}
} catch {}
return null;
}
async function executeViaHttp(
baseUrl: string,
modelName: string,
systemPrompt: string,
userPrompt: string,
maxTokens: number
): Promise<string> {
const res = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: modelName,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt }
],
max_tokens: maxTokens,
temperature: 0.3,
stream: false
}),
signal: AbortSignal.timeout(90000)
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
}
const data = (await res.json()) as any;
let content = data?.choices?.[0]?.message?.content || "";
if (content.includes("__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_")) {
const parts = content.split(/__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[a-f0-9]+__/);
content = parts[parts.length - 1].trim();
}
return content.trim();
}
// 3. subagent_task tool con balanceo de carga multi-hardware
export const subagent_task = tool({
name: "subagent_task",
description: "Delegate a focused task/review/refactoring to a subagent running on secondary hardware (AMD RX 480 eGPU or Intel Arc) to offload the main host.",
parameters: {
task_description: z.string().describe("Concise subtask description for the subagent."),
context: z.string().optional().describe("Brief context (keep concise for speed)."),
system_role: z.string().optional().describe("Role e.g. 'Auditor', 'Coder', 'Reviewer', 'Optimizer'."),
target_hardware: z.enum(["auto", "amd", "intel", "openvino"]).optional().default("auto").describe("Hardware to offload subtask to: 'amd' (dedicated RX 480 eGPU node :1235), 'intel' (host Arc :1234), or 'auto' (automatically routes to AMD eGPU to free main host)."),
max_tokens: z.number().optional().default(600).describe("Max tokens to generate (default: 600).")
},
implementation: async ({ task_description, context, system_role, target_hardware = "auto", max_tokens = 600 }) => {
const systemPrompt = `You are a concise ${system_role || "assistant"}. Return strictly brief, minimal token results without conversational fluff.`;
let prompt = `TASK: ${task_description}`;
if (context) prompt += `\nCONTEXT: ${context}`;
// 1. Ruta hacia AMD eGPU (:1235) si target es 'amd' o 'auto'
if (target_hardware === "amd" || target_hardware === "auto") {
try {
const amdModel = await fetchFirstModel(AMD_NODE_URL);
if (amdModel) {
const result = await executeViaHttp(AMD_NODE_URL, amdModel, systemPrompt, prompt, max_tokens);
return {
ok: true,
hardware: "AMD RX 480 (eGPU :1235)",
model: amdModel,
role: system_role || "Subagent",
result: truncateOutput(result, 1200)
};
}
} catch (amdErr: any) {
if (target_hardware === "amd") {
return { ok: false, error: `AMD node error: ${amdErr.message}` };
}
// En auto, si falla AMD continuamos hacia fallback
}
}
// 2. Ruta hacia Host / Intel / OpenVINO (:1234) o fallback
try {
const intelModel = await fetchFirstModel(INTEL_HOST_URL);
if (intelModel) {
const result = await executeViaHttp(INTEL_HOST_URL, intelModel, systemPrompt, prompt, max_tokens);
return {
ok: true,
hardware: target_hardware === "openvino" ? "OpenVINO (Host :1234)" : "Intel Arc (Host :1234)",
model: intelModel,
role: system_role || "Subagent",
result: truncateOutput(result, 1200)
};
}
} catch {}
// 3. Fallback directo al SDK local de LM Studio
try {
const client = new LMStudioClient();
const model = await client.llm.model();
const response = await model.respond([
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt }
], {
maxTokens: max_tokens
});
let outputText = response.content || "";
if (outputText.includes("__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_")) {
const parts = outputText.split(/__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[a-f0-9]+__/);
outputText = parts[parts.length - 1].trim();
}
return {
ok: true,
hardware: "Local LM Studio Client",
role: system_role || "Subagent",
result: truncateOutput(outputText, 1200)
};
} catch (err: any) {
return { ok: false, error: err.message };
}
}
});