Project Files
src / tools / systemTools.ts
/**
* @file systemTools.ts
* System/misc tools: system info, clipboard, open file, preview HTML,
* read document, analyze project, create visual treatment, notification, database.
*/
import { spawn } from "child_process";
import { writeFile, readdir, readFile, stat } from "fs/promises";
import * as os from "os";
import { join, resolve, dirname } from "path";
import { text, tool, type Tool } from "@lmstudio/sdk";
import { z } from "zod";
import { validatePath, type ToolContext } from "./shared";
export function createSystemTools(
ctx: ToolContext,
config: { allowNotify: boolean; allowDb: boolean },
): Tool[] {
const tools: Tool[] = [];
// --- Read Document ---
tools.push(tool({
name: "read_document",
description: "Read content from PDF or DOCX files.",
parameters: { file_path: z.string() },
implementation: async ({ file_path }) => {
const fpath = validatePath(ctx.cwd, file_path);
const ext = fpath.split('.').pop()?.toLowerCase();
try {
if (ext === 'pdf') {
if (typeof global.DOMMatrix === 'undefined') {
(global as any).DOMMatrix = class DOMMatrix {
constructor(arg?: any) {
(this as any).a = 1; (this as any).b = 0; (this as any).c = 0; (this as any).d = 1; (this as any).e = 0; (this as any).f = 0;
if (Array.isArray(arg)) { (this as any).a = arg[0]; (this as any).b = arg[1]; (this as any).c = arg[2]; (this as any).d = arg[3]; (this as any).e = arg[4]; (this as any).f = arg[5]; }
}
};
}
const { PDFParse } = require("pdf-parse");
const dataBuffer = await readFile(fpath);
const parser = new PDFParse({ data: dataBuffer });
const textResult = await parser.getText();
const infoResult = await parser.getInfo();
await parser.destroy();
return { content: textResult.text, metadata: infoResult.info };
} else if (ext === 'docx') {
const mammoth = await import("mammoth");
const result = await mammoth.extractRawText({ path: fpath });
return { content: result.value, messages: result.messages };
}
return { error: "Unsupported document format. Use read_file for text files." };
} catch (e) {
return { error: `Failed to read document: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
// --- Analyze Project ---
tools.push(tool({
name: "analyze_project",
description: "Run project-wide analysis (linting) to find errors and warnings.",
parameters: {},
implementation: async () => {
let command = "", type = "unknown";
try {
const pkg = JSON.parse(await readFile(join(ctx.cwd, "package.json"), "utf-8"));
if (pkg.scripts?.lint) { command = "npm run lint"; type = "npm-script"; }
else if (pkg.devDependencies?.eslint || pkg.dependencies?.eslint) { command = "npx eslint . --format json"; type = "eslint"; }
} catch {
const entries = await readdir(ctx.cwd);
if (entries.some(f => f.endsWith(".py"))) { command = "pylint ."; type = "python-lint"; }
}
if (!command) return { error: "Could not detect a supported linter (ESLint script or Python)." };
try {
const child = spawn(command, { shell: true, cwd: ctx.cwd, timeout: 60000 } as any);
let stdout = "", stderr = "";
child.stdout.on("data", (d: Buffer) => stdout += d);
child.stderr.on("data", (d: Buffer) => stderr += d);
await new Promise(resolve => child.on("close", resolve));
return { tool: command, type, report: (stdout + stderr).substring(0, 3000) };
} catch (e) {
return { error: `Analysis failed: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
// --- Notification ---
if (config.allowNotify) {
tools.push(tool({
name: "send_notification",
description: "Send a system notification to the user.",
parameters: { title: z.string(), message: z.string() },
implementation: async ({ title, message }) => {
const notifier = await import("node-notifier");
notifier.notify({ title, message, sound: true, wait: false });
return { success: true, message: "Notification sent." };
},
}));
}
// --- Database ---
if (config.allowDb) {
tools.push(tool({
name: "query_database",
description: "Execute a read-only query on a SQLite database file.",
parameters: { db_path: z.string(), query: z.string() },
implementation: async ({ db_path, query }) => {
const fpath = validatePath(ctx.cwd, db_path);
if (/^\s*(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|REPLACE)\b/i.test(query)) return { error: "Only SELECT/read queries are allowed for safety." };
try {
const Database = (await import("better-sqlite3")).default;
const db = new Database(fpath, { readonly: true });
const results = db.prepare(query).all();
db.close();
return { results };
} catch (e) {
return { error: `Database query failed: ${e instanceof Error ? e.message : String(e)}` };
}
},
}));
}
// --- System Info ---
tools.push(tool({
name: "get_system_info",
description: "Get information about the system (OS, CPU, Memory).",
parameters: {},
implementation: async () => ({
platform: os.platform(), arch: os.arch(), release: os.release(), hostname: os.hostname(),
total_memory: os.totalmem(), free_memory: os.freemem(), cpus: os.cpus().length, node_version: process.version,
}),
}));
// --- Clipboard ---
tools.push(tool({
name: "read_clipboard",
description: "Read text content from the system clipboard.",
parameters: {},
implementation: async () => {
let command = "", args: string[] = [];
if (process.platform === "win32") { command = "powershell"; args = ["-command", "Get-Clipboard"]; }
else if (process.platform === "darwin") { command = "pbpaste"; }
else { command = "xclip"; args = ["-selection", "clipboard", "-o"]; }
return Promise.race([
new Promise(resolve => {
const child = spawn(command, args);
let output = "", error = "";
child.stdout.on("data", d => output += d.toString());
child.stderr.on("data", d => error += d.toString());
child.on("close", code => resolve(code === 0 ? { content: output.trim() } : { error: `Failed. Code: ${code}. ${error}` }));
child.on("error", err => resolve({ error: `Failed to spawn: ${err.message}` }));
}),
new Promise((_, rej) => setTimeout(() => rej(new Error("Clipboard timeout")), 5000)),
]).catch(err => ({ error: (err as Error).message }));
},
}));
tools.push(tool({
name: "write_clipboard",
description: "Write text content to the system clipboard.",
parameters: { content: z.string() },
implementation: async ({ content }) => {
let command = "", args: string[] = [], input = content;
if (process.platform === "win32") {
command = "powershell";
const b64 = Buffer.from(content, 'utf8').toString('base64');
args = ["-command", `$str = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${b64}')); Set-Clipboard -Value $str`];
input = "";
} else if (process.platform === "darwin") { command = "pbcopy"; }
else { command = "xclip"; args = ["-selection", "clipboard", "-i"]; }
return Promise.race([
new Promise(resolve => {
const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'pipe'] });
if (input && process.platform !== "win32") { child.stdin.write(input); child.stdin.end(); } else { child.stdin.end(); }
let error = "";
child.stderr.on("data", d => error += d.toString());
child.on("close", code => resolve(code === 0 ? { success: true } : { error: `Failed. Code: ${code}. ${error}` }));
child.on("error", err => resolve({ error: `Failed to spawn: ${err.message}` }));
}),
new Promise((_, rej) => setTimeout(() => rej(new Error("Clipboard timeout")), 5000)),
]).catch(err => ({ error: (err as Error).message }));
},
}));
// --- Open File ---
tools.push(tool({
name: "open_file",
description: "Open a file or URL in the system's default application.",
parameters: { target: z.string().describe("File path or URL") },
implementation: async ({ target }) => {
let targetToOpen = target;
if (!target.startsWith("http://") && !target.startsWith("https://")) targetToOpen = validatePath(ctx.cwd, target);
let command = "", args: string[] = [];
if (process.platform === "win32") { command = "cmd"; args = ["/c", "start", "", targetToOpen]; }
else if (process.platform === "darwin") { command = "open"; args = [targetToOpen]; }
else { command = "xdg-open"; args = [targetToOpen]; }
const child = spawn(command, args, { stdio: 'ignore', detached: true });
child.unref();
return { success: true, message: `Opened ${targetToOpen}` };
},
}));
// --- Preview HTML ---
tools.push(tool({
name: "preview_html",
description: text`
Preview an HTML file in the browser AND return a structural analysis.
Can accept raw HTML content or a file path to an existing HTML file.
Returns: section count, image references, duplicates, missing assets, and potential overflow warnings.
Use this to verify your HTML output without asking the user to check manually.
`,
parameters: {
html_content: z.string().optional().describe("Raw HTML content to preview. If omitted, file_name is required."),
file_name: z.string().optional().describe("Path to an existing HTML file, or filename for new content."),
},
implementation: async ({ html_content, file_name }) => {
let filePath: string;
let html: string;
if (!html_content && file_name) {
// Read existing file
filePath = resolve(ctx.cwd, file_name);
try {
html = await readFile(filePath, "utf-8");
} catch {
return { error: `File not found: ${filePath}` };
}
} else if (html_content) {
const name = file_name || `preview_${Date.now()}.html`;
filePath = validatePath(ctx.cwd, name);
await writeFile(filePath, html_content, "utf-8");
html = html_content;
} else {
return { error: "Provide either html_content or file_name" };
}
// Open in browser
let command = "", args: string[] = [];
if (process.platform === "win32") { command = "cmd"; args = ["/c", "start", "", filePath]; }
else if (process.platform === "darwin") { command = "open"; args = [filePath]; }
else { command = "xdg-open"; args = [filePath]; }
const child = spawn(command, args, { stdio: 'ignore', detached: true });
child.unref();
// Structural analysis
const analysis: Record<string, unknown> = {};
// Count sections/containers
const sectionMatches = html.match(/<(section|article|div[^>]*class[^>]*(?:slide|section|scene|page|hero))/gi);
analysis.sections = sectionMatches ? sectionMatches.length : 0;
// Extract all image references
const imgRefs: string[] = [];
const imgRegex = /(?:src|data-src|data-bg|background(?:-image)?)\s*[:=]\s*["']?(?:url\(["']?)?([^"');\s>]+\.(jpg|jpeg|png|gif|webp|svg|avif|bmp|tiff))/gi;
let m;
while ((m = imgRegex.exec(html)) !== null) {
if (!/^(https?:|data:)/i.test(m[1])) imgRefs.push(m[1]);
}
// Detect duplicate images
const imgCounts = new Map<string, number>();
for (const ref of imgRefs) {
imgCounts.set(ref, (imgCounts.get(ref) || 0) + 1);
}
const duplicateImgs = [...imgCounts.entries()].filter(([, c]) => c > 1).map(([f, c]) => `${f} (${c}x)`);
analysis.total_images = imgRefs.length;
analysis.unique_images = imgCounts.size;
analysis.duplicate_images = duplicateImgs.length > 0 ? duplicateImgs : "none";
// Check for missing image files
const baseDir = dirname(filePath);
const missingImgs: string[] = [];
for (const ref of imgCounts.keys()) {
try {
await stat(resolve(baseDir, ref));
} catch {
missingImgs.push(ref);
}
}
analysis.missing_images = missingImgs.length > 0 ? missingImgs : "none";
// Text overflow risk — find text blocks > 500 chars inside elements
const textBlocks = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "");
const betweenTags = textBlocks.match(/>([^<]{500,})</g);
analysis.long_text_blocks = betweenTags ? betweenTags.length : 0;
if (betweenTags && betweenTags.length > 0) {
analysis.overflow_warning = `${betweenTags.length} text block(s) exceed 500 chars — may overflow containers. Consider splitting into more sections.`;
}
// File size
analysis.file_size_kb = Math.round(Buffer.byteLength(html, "utf-8") / 1024 * 10) / 10;
return { success: true, path: filePath, message: "HTML preview launched in browser.", analysis };
},
}));
// --- Create Visual Treatment ---
tools.push(tool({
name: "create_visual_treatment",
description: "Generate a visual treatment HTML template with editorial design. Creates a self-contained HTML file with hero sections, image galleries, and cinematic typography.",
parameters: {
title: z.string().describe("Project title displayed as the main heading."),
subtitle: z.string().optional().describe("Subtitle or tagline."),
scenes: z.array(z.object({
heading: z.string(), text: z.string(),
images: z.array(z.string()).optional().describe("Image filenames for this scene."),
})).describe("Array of scenes/sections to include."),
output_file: z.string().optional().describe("Output filename. Default: treatment.html"),
dark_mode: z.boolean().optional().describe("Use dark background theme. Default: true"),
},
implementation: async ({ title, subtitle, scenes, output_file, dark_mode }: {
title: string; subtitle?: string; scenes: Array<{ heading: string; text: string; images?: string[] }>; output_file?: string; dark_mode?: boolean;
}) => {
const isDark = dark_mode !== false;
const bg = isDark ? "#0a0a0a" : "#fafafa", fg = isDark ? "#f0f0f0" : "#1a1a1a";
const mutedFg = isDark ? "#888" : "#666", accent = isDark ? "#e0c97f" : "#8b6914";
const scenesHtml = scenes.map((scene, i) => {
const imgsHtml = (scene.images || []).map(img => ` <img src="${img}" alt="${scene.heading}" loading="lazy">`).join("\n");
const gallery = imgsHtml ? `\n <div class="gallery">\n${imgsHtml}\n </div>` : "";
return ` <section class="scene" data-index="${i + 1}">\n <span class="scene-number">${String(i + 1).padStart(2, "0")}</span>\n <h2>${scene.heading}</h2>\n <p>${scene.text}</p>${gallery}\n </section>`;
}).join("\n\n");
const html = `<!DOCTYPE html>\n<html lang="pt-BR">\n<head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>${title}</title>\n <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&family=Inter:wght@300;400;600&display=swap" rel="stylesheet">\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { background: ${bg}; color: ${fg}; font-family: 'Inter', system-ui, sans-serif; font-weight: 300; line-height: 1.8; }\n .hero { min-height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 4rem 2rem; }\n .hero h1 { font-family: 'Playfair Display', Georgia, serif; font-size: clamp(3rem, 8vw, 7rem); font-weight: 900; letter-spacing: -0.02em; line-height: 1.05; margin-bottom: 1rem; }\n .hero .subtitle { font-size: clamp(1rem, 2vw, 1.5rem); color: ${mutedFg}; letter-spacing: 0.3em; text-transform: uppercase; font-weight: 300; }\n .scene { padding: 6rem 2rem; max-width: 1200px; margin: 0 auto; opacity: 0; transform: translateY(40px); transition: opacity 0.8s ease, transform 0.8s ease; }\n .scene.visible { opacity: 1; transform: translateY(0); }\n .scene-number { font-family: 'Playfair Display', serif; font-size: 5rem; color: ${accent}; opacity: 0.3; display: block; margin-bottom: -1rem; }\n .scene h2 { font-family: 'Playfair Display', serif; font-size: clamp(1.8rem, 4vw, 3rem); margin-bottom: 1.5rem; font-weight: 700; }\n .scene p { font-size: 1.1rem; max-width: 65ch; color: ${mutedFg}; }\n .gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin-top: 2rem; }\n .gallery img { width: 100%; height: 350px; object-fit: cover; border-radius: 4px; transition: transform 0.4s ease, filter 0.4s ease; filter: ${isDark ? "brightness(0.85) contrast(1.1)" : "none"}; }\n .gallery img:hover { transform: scale(1.03); filter: brightness(1); }\n @media (max-width: 768px) { .scene { padding: 3rem 1.5rem; } .gallery { grid-template-columns: 1fr; } .gallery img { height: 250px; } }\n </style>\n</head>\n<body>\n <header class="hero">\n <h1>${title}</h1>\n ${subtitle ? `<p class="subtitle">${subtitle}</p>` : ""}\n </header>\n\n${scenesHtml}\n\n <script>\n const observer = new IntersectionObserver((entries) => {\n entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible'); });\n }, { threshold: 0.15 });\n document.querySelectorAll('.scene').forEach(s => observer.observe(s));\n </script>\n</body>\n</html>`;
const fileName = output_file || "treatment.html";
const filePath = validatePath(ctx.cwd, fileName);
await writeFile(filePath, html, "utf-8");
return { success: true, path: filePath, sections: scenes.length, message: `Visual treatment saved. Use preview_html or open_file to view it.` };
},
}));
return tools;
}