diff --git a/package-lock.json b/package-lock.json index 2e20e481..94a6a9d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "execa": "^9.6.1", "express": "^4.21.0", "fs-extra": "^11.2.0", + "glob": "^13.0.6", "grammy": "^1.42.0", "openai": "^4.77.0", "p-queue": "^8.0.1", @@ -418,6 +419,15 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -499,6 +509,18 @@ "node": ">= 0.8" } }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bytes": { "version": "3.1.2", "license": "MIT", @@ -1140,6 +1162,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "license": "MIT", @@ -1349,6 +1388,15 @@ "node": ">= 12.0.0" } }, + "node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-bytes.js": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", @@ -1422,6 +1470,30 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "license": "MIT" @@ -1637,6 +1709,22 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", diff --git a/package.json b/package.json index ab28eea6..df13bb63 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "execa": "^9.6.1", "express": "^4.21.0", "fs-extra": "^11.2.0", + "glob": "^13.0.6", "grammy": "^1.42.0", "openai": "^4.77.0", "p-queue": "^8.0.1", diff --git a/src/bot/index.js b/src/bot/index.js index edde6fca..f4d1b2af 100644 --- a/src/bot/index.js +++ b/src/bot/index.js @@ -204,80 +204,135 @@ export async function initBot(config, api, tools, skills, agents) { const tools = []; const toolMap = svc.toolMap; - if (toolMap.has('bash')) { - tools.push({ - type: 'function', - function: { - name: 'bash', - description: 'Execute a shell command', - parameters: { - type: 'object', properties: { - command: { type: 'string', description: 'Shell command' }, - timeout: { type: 'number', description: 'Timeout ms (default 300000)' }, - }, required: ['command'], - }, - }, - }); - } - if (toolMap.has('web_search')) { - tools.push({ - type: 'function', - function: { - name: 'web_search', - description: 'Search the web', - parameters: { - type: 'object', properties: { - query: { type: 'string', description: 'Search query' }, - num_results: { type: 'number', description: 'Results count (default 5)' }, - }, required: ['query'], - }, - }, - }); - } - if (toolMap.has('git')) { - tools.push({ - type: 'function', - function: { - name: 'git', - description: 'Git operations: status, log, diff, commit, push, pull', - parameters: { - type: 'object', properties: { - action: { type: 'string', enum: ['status', 'log', 'diff', 'commit', 'push', 'pull'] }, - params: { type: 'array', items: { type: 'string' } }, - }, required: ['action'], - }, - }, - }); - } - if (svc.agents.length) { - tools.push({ - type: 'function', - function: { - name: 'delegate_agent', - description: 'Delegate to a specialized agent role', - parameters: { - type: 'object', properties: { - agent_id: { type: 'string', enum: svc.agents.map(a => a.id) }, - task: { type: 'string', description: 'Task description' }, - }, required: ['agent_id', 'task'], - }, - }, - }); - } - if (svc.skills.length) { - tools.push({ - type: 'function', - function: { - name: 'run_skill', - description: 'Run a skill by name', - parameters: { - type: 'object', properties: { - skill: { type: 'string', enum: svc.skills.map(s => s.name) }, - input: { type: 'string' }, - }, required: ['skill'], - }, - }, - }); + // ── Tool definitions for the AI API (OpenAI function-calling format) ── + const TOOL_DEFS = { + bash: { + description: 'Execute a shell command', + parameters: { type: 'object', properties: { + command: { type: 'string', description: 'Shell command to execute' }, + timeout: { type: 'number', description: 'Timeout in ms (default 300000)' }, + }, required: ['command'] }, + }, + file_edit: { + description: 'Edit files — read, write, append, or find-and-replace', + parameters: { type: 'object', properties: { + action: { type: 'string', enum: ['read', 'write', 'append', 'edit'], description: 'Operation' }, + file_path: { type: 'string', description: 'File path' }, + content: { type: 'string', description: 'Content to write/append (for write/append)' }, + old_text: { type: 'string', description: 'Text to find (for edit)' }, + new_text: { type: 'string', description: 'Replacement text (for edit)' }, + }, required: ['action', 'file_path'] }, + }, + file_read: { + description: 'Read file contents with line numbers and pagination', + parameters: { type: 'object', properties: { + file_path: { type: 'string', description: 'File path to read' }, + offset: { type: 'number', description: 'Start line (1-indexed, default 1)' }, + limit: { type: 'number', description: 'Max lines (default 500)' }, + }, required: ['file_path'] }, + }, + file_write: { + description: 'Write content to a file (overwrites entire file)', + parameters: { type: 'object', properties: { + file_path: { type: 'string', description: 'File path' }, + content: { type: 'string', description: 'Content to write' }, + }, required: ['file_path', 'content'] }, + }, + glob: { + description: 'Find files matching a glob pattern', + parameters: { type: 'object', properties: { + pattern: { type: 'string', description: 'Glob pattern (e.g. "**/*.js")' }, + cwd: { type: 'string', description: 'Working directory (default: current)' }, + }, required: ['pattern'] }, + }, + grep: { + description: 'Search file contents using regex (ripgrep)', + parameters: { type: 'object', properties: { + pattern: { type: 'string', description: 'Regex pattern' }, + path: { type: 'string', description: 'Directory to search (default: .)' }, + file_glob: { type: 'string', description: 'File filter (e.g. "*.py")' }, + max_results: { type: 'number', description: 'Max matches (default 20)' }, + context: { type: 'number', description: 'Context lines before/after (default 0)' }, + }, required: ['pattern'] }, + }, + web_search: { + description: 'Search the web for information', + parameters: { type: 'object', properties: { + query: { type: 'string', description: 'Search query' }, + num_results: { type: 'number', description: 'Results count (default 5)' }, + }, required: ['query'] }, + }, + web_fetch: { + description: 'Fetch content from a URL and return text', + parameters: { type: 'object', properties: { + url: { type: 'string', description: 'URL to fetch' }, + max_length: { type: 'number', description: 'Max chars to return (default 15000)' }, + }, required: ['url'] }, + }, + git: { + description: 'Git operations: status, log, diff, commit, push, pull', + parameters: { type: 'object', properties: { + action: { type: 'string', enum: ['status', 'log', 'diff', 'commit', 'push', 'pull'] }, + params: { type: 'array', items: { type: 'string' } }, + }, required: ['action'] }, + }, + task_create: { + description: 'Create a new task', + parameters: { type: 'object', properties: { + description: { type: 'string', description: 'Task description' }, + }, required: ['description'] }, + }, + task_update: { + description: 'Update task status', + parameters: { type: 'object', properties: { + task_id: { type: 'string', description: 'Task ID' }, + status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'cancelled'] }, + }, required: ['task_id', 'status'] }, + }, + task_list: { + description: 'List all tasks with status', + parameters: { type: 'object', properties: { + status: { type: 'string', description: 'Filter by status (optional)' }, + } }, + }, + send_message: { + description: 'Send a message to Telegram chat or channel', + parameters: { type: 'object', properties: { + chat_id: { type: 'string', description: 'Target chat ID (optional, uses default)' }, + message: { type: 'string', description: 'Message text to send' }, + }, required: ['message'] }, + }, + schedule_cron: { + description: 'Manage cron jobs (create/list/remove)', + parameters: { type: 'object', properties: { + action: { type: 'string', enum: ['create', 'list', 'remove'] }, + name: { type: 'string', description: 'Job name' }, + schedule: { type: 'string', description: 'Cron schedule (e.g. "0 9 * * *")' }, + command: { type: 'string', description: 'Command to run' }, + }, required: ['action'] }, + }, + delegate_agent: { + description: 'Delegate to a specialized agent role', + parameters: { type: 'object', properties: { + agent_id: { type: 'string', enum: svc.agents.map(a => a.id) }, + task: { type: 'string', description: 'Task description' }, + }, required: ['agent_id', 'task'] }, + }, + run_skill: { + description: 'Run a skill by name', + parameters: { type: 'object', properties: { + skill: { type: 'string', enum: svc.skills.map(s => s.name) }, + input: { type: 'string' }, + }, required: ['skill'] }, + }, + }; + + // Register all tools that have a matching class loaded + for (const [name, def] of Object.entries(TOOL_DEFS)) { + if (name === 'delegate_agent' && !svc.agents.length) continue; + if (name === 'run_skill' && !svc.skills.length) continue; + if (!toolMap.has(name) && name !== 'delegate_agent' && name !== 'run_skill') continue; + tools.push({ type: 'function', function: { name, ...def } }); } try { @@ -402,6 +457,7 @@ export async function initBot(config, api, tools, skills, agents) { return fullResponse || '✅ Done.'; } + // ── Tool handlers: route API tool_calls to tool class methods ── const toolHandlers = { bash: async (args) => { const tool = svc.toolMap.get('bash'); @@ -414,6 +470,42 @@ export async function initBot(config, api, tools, skills, agents) { return `❌ Exit ${r.code}\n\`\`\`\n${err || out}\n\`\`\``; } catch (e) { return `❌ Bash error: ${e.message}`; } }, + file_edit: async (args) => { + const tool = svc.toolMap.get('file_edit'); + if (!tool) return '❌ File edit tool unavailable.'; + try { + const r = await tool[args.action](args.file_path, args.content, args.old_text, args.new_text); + return typeof r === 'string' ? r : (r.success ? `✅ ${JSON.stringify(r)}` : `❌ ${r.error}`); + } catch (e) { return `❌ File edit error: ${e.message}`; } + }, + file_read: async (args) => { + const tool = svc.toolMap.get('file_read'); + if (!tool) return '❌ File read tool unavailable.'; + try { + return await tool.execute(args); + } catch (e) { return `❌ File read error: ${e.message}`; } + }, + file_write: async (args) => { + const tool = svc.toolMap.get('file_write'); + if (!tool) return '❌ File write tool unavailable.'; + try { + return await tool.execute(args); + } catch (e) { return `❌ File write error: ${e.message}`; } + }, + glob: async (args) => { + const tool = svc.toolMap.get('glob'); + if (!tool) return '❌ Glob tool unavailable.'; + try { + return await tool.execute(args); + } catch (e) { return `❌ Glob error: ${e.message}`; } + }, + grep: async (args) => { + const tool = svc.toolMap.get('grep'); + if (!tool) return '❌ Grep tool unavailable.'; + try { + return await tool.execute(args); + } catch (e) { return `❌ Grep error: ${e.message}`; } + }, web_search: async (args) => { const tool = svc.toolMap.get('web_search'); if (!tool) return '❌ Web search unavailable.'; @@ -426,16 +518,48 @@ export async function initBot(config, api, tools, skills, agents) { return `🔍 *${args.query}*\n\nNo results.`; } catch (e) { return `❌ Search error: ${e.message}`; } }, + web_fetch: async (args) => { + const tool = svc.toolMap.get('web_fetch'); + if (!tool) return '❌ Web fetch tool unavailable.'; + try { + return await tool.execute(args); + } catch (e) { return `❌ Fetch error: ${e.message}`; } + }, git: async (args) => { const tool = svc.toolMap.get('git'); if (!tool) return '❌ Git tool unavailable.'; try { const method = tool[args.action]; - if (!method) return `❌ Unknown: ${args.action}`; + if (!method) return `❌ Unknown git action: ${args.action}`; const r = await method.call(tool, ...(args.params || [])); return r.success ? `✅ ${r.status || JSON.stringify(r)}` : `❌ ${r.stderr || r.error}`; } catch (e) { return `❌ Git error: ${e.message}`; } }, + task_create: async (args) => { + const tool = svc.toolMap.get('task_create'); + if (!tool) return '❌ Task tool unavailable.'; + try { return await tool.execute(args); } catch (e) { return `❌ ${e.message}`; } + }, + task_update: async (args) => { + const tool = svc.toolMap.get('task_update'); + if (!tool) return '❌ Task tool unavailable.'; + try { return await tool.execute(args); } catch (e) { return `❌ ${e.message}`; } + }, + task_list: async (args) => { + const tool = svc.toolMap.get('task_list'); + if (!tool) return '❌ Task tool unavailable.'; + try { return await tool.execute(args); } catch (e) { return `❌ ${e.message}`; } + }, + send_message: async (args) => { + const tool = svc.toolMap.get('send_message'); + if (!tool) return '❌ Send message tool unavailable.'; + try { return await tool.execute(args); } catch (e) { return `❌ ${e.message}`; } + }, + schedule_cron: async (args) => { + const tool = svc.toolMap.get('schedule_cron'); + if (!tool) return '❌ Cron tool unavailable.'; + try { return await tool.execute(args); } catch (e) { return `❌ ${e.message}`; } + }, delegate_agent: async (args) => { const agent = svc.agents.find(a => a.id === args.agent_id); if (!agent) return `❌ Agent not found: ${args.agent_id}`; diff --git a/src/tools/FileReadTool.js b/src/tools/FileReadTool.js new file mode 100644 index 00000000..a94bde3d --- /dev/null +++ b/src/tools/FileReadTool.js @@ -0,0 +1,37 @@ +import { logger } from '../utils/logger.js'; +import fs from 'fs-extra'; +import path from 'path'; + +export class FileReadTool { + constructor() { + this.name = 'file_read'; + this.description = 'Read file contents with line numbers and pagination'; + } + + async execute(args) { + const { file_path, offset = 1, limit = 500 } = args; + try { + const fullPath = path.resolve(file_path); + const content = await fs.readFile(fullPath, 'utf-8'); + const lines = content.split('\n'); + + if (offset < 1 || offset > lines.length) { + return `❌ Offset ${offset} out of range (file has ${lines.length} lines)`; + } + + const end = Math.min(offset + limit - 1, lines.length); + const selected = lines.slice(offset - 1, end); + const numbered = selected.map((line, i) => `${offset + i}|${line}`).join('\n'); + + const header = offset === 1 && end >= lines.length + ? `${fullPath} (${lines.length} lines)` + : `${fullPath} (lines ${offset}-${end} of ${lines.length})`; + + return `${header}\n${numbered}`; + } catch (e) { + if (e.code === 'ENOENT') return `❌ File not found: ${file_path}`; + if (e.code === 'EISDIR') return `❌ Is a directory: ${file_path}`; + return `❌ ${e.message}`; + } + } +} diff --git a/src/tools/FileWriteTool.js b/src/tools/FileWriteTool.js new file mode 100644 index 00000000..738df5d9 --- /dev/null +++ b/src/tools/FileWriteTool.js @@ -0,0 +1,22 @@ +import { logger } from '../utils/logger.js'; +import fs from 'fs-extra'; +import path from 'path'; + +export class FileWriteTool { + constructor() { + this.name = 'file_write'; + this.description = 'Write content to a file, creating parent directories as needed'; + } + + async execute(args) { + const { file_path, content } = args; + try { + const fullPath = path.resolve(file_path); + await fs.ensureDir(path.dirname(fullPath)); + await fs.writeFile(fullPath, content, 'utf-8'); + return `✅ Written ${Buffer.byteLength(content)} bytes to ${fullPath}`; + } catch (e) { + return `❌ Write error: ${e.message}`; + } + } +} diff --git a/src/tools/GlobTool.js b/src/tools/GlobTool.js new file mode 100644 index 00000000..12f4931b --- /dev/null +++ b/src/tools/GlobTool.js @@ -0,0 +1,32 @@ +import { logger } from '../utils/logger.js'; +import { glob } from 'glob'; +import path from 'path'; + +export class GlobTool { + constructor() { + this.name = 'glob'; + this.description = 'Find files matching a glob pattern'; + } + + async execute(args) { + const { pattern, cwd = process.cwd() } = args; + try { + const matches = await glob(pattern, { + cwd: path.resolve(cwd), + absolute: false, + nodir: true, + dot: false, + ignore: ['**/node_modules/**', '**/.git/**'], + }); + + if (!matches.length) return `📁 No files matching: ${pattern}`; + + const listed = matches.slice(0, 100); + const output = listed.join('\n'); + const suffix = matches.length > 100 ? `\n... and ${matches.length - 100} more` : ''; + return `📁 ${matches.length} files matching "${pattern}":\n${output}${suffix}`; + } catch (e) { + return `❌ Glob error: ${e.message}`; + } + } +} diff --git a/src/tools/GrepTool.js b/src/tools/GrepTool.js new file mode 100644 index 00000000..1d19b1f3 --- /dev/null +++ b/src/tools/GrepTool.js @@ -0,0 +1,38 @@ +import { logger } from '../utils/logger.js'; +import { execa } from 'execa'; +import path from 'path'; + +export class GrepTool { + constructor() { + this.name = 'grep'; + this.description = 'Search file contents using regex (ripgrep-backed)'; + } + + async execute(args) { + const { pattern, path: searchPath = '.', file_glob, max_results = 20, context: ctx = 0 } = args; + try { + const cmdArgs = [ + '--max-count', String(max_results), + '--no-heading', + '--line-number', + ...(ctx > 0 ? ['-C', String(ctx)] : []), + ]; + if (file_glob) cmdArgs.push('--glob', file_glob); + + const targetPath = path.resolve(searchPath); + cmdArgs.push('--', pattern, targetPath); + + const { stdout } = await execa('rg', cmdArgs, { timeout: 30000 }); + + if (!stdout.trim()) return `🔍 No matches for "${pattern}" in ${searchPath}`; + + const lineCount = stdout.trim().split('\n').length; + const suffix = lineCount >= max_results ? `\n(truncated at ${max_results} matches)` : ''; + return `🔍 ${lineCount} matches for "${pattern}" in ${searchPath}:${suffix}\n${stdout}`; + } catch (e) { + if (e.exitCode === 1) return `🔍 No matches for "${pattern}"`; + if (e.exitCode === 2) return `❌ Grep error: ${e.stderr || e.message}`; + return `❌ ${e.message}`; + } + } +} diff --git a/src/tools/ScheduleCronTool.js b/src/tools/ScheduleCronTool.js new file mode 100644 index 00000000..75de819c --- /dev/null +++ b/src/tools/ScheduleCronTool.js @@ -0,0 +1,82 @@ +import { logger } from '../utils/logger.js'; +import { execa } from 'execa'; +import fs from 'fs-extra'; +import path from 'path'; + +const CRON_DIR = 'data/cron'; + +export class ScheduleCronTool { + constructor() { + this.name = 'schedule_cron'; + this.description = 'Schedule a cron job (create/list/remove)'; + } + + async execute(args) { + const { action, ...params } = args; + switch (action) { + case 'create': return this._create(params); + case 'list': return this._list(); + case 'remove': return this._remove(params); + default: return `❌ Unknown action: ${action}. Use: create, list, remove`; + } + } + + async _create(params) { + const { name, schedule, command, description = '' } = params; + if (!name || !schedule || !command) { + return '❌ Required: name, schedule, command'; + } + + try { + await fs.ensureDir(CRON_DIR); + const jobPath = path.join(CRON_DIR, `${name}.json`); + const job = { + name, schedule, command, description, + enabled: true, + created: new Date().toISOString(), + last_run: null, + next_run: null, + run_count: 0, + }; + await fs.writeJson(jobPath, job, { spaces: 2 }); + logger.info(`⏰ Cron job created: ${name} (${schedule})`); + return `⏰ Cron job "${name}" created\nSchedule: ${schedule}\nCommand: ${command}`; + } catch (e) { + return `❌ ${e.message}`; + } + } + + async _list() { + try { + await fs.ensureDir(CRON_DIR); + const files = await fs.readdir(CRON_DIR); + const jobs = files.filter(f => f.endsWith('.json')); + + if (!jobs.length) return '⏰ No cron jobs.'; + + const list = []; + for (const f of jobs) { + const job = await fs.readJson(path.join(CRON_DIR, f)); + const status = job.enabled ? '🟢' : '🔴'; + list.push(`${status} ${job.name} | ${job.schedule} | runs: ${job.run_count} | ${job.command.substring(0, 60)}`); + } + return `⏰ Cron jobs (${jobs.length}):\n${list.join('\n')}`; + } catch (e) { + return `❌ ${e.message}`; + } + } + + async _remove(params) { + const { name } = params; + if (!name) return '❌ Required: name'; + + try { + const jobPath = path.join(CRON_DIR, `${name}.json`); + if (!(await fs.pathExists(jobPath))) return `❌ Job not found: ${name}`; + await fs.remove(jobPath); + return `✅ Cron job "${name}" removed`; + } catch (e) { + return `❌ ${e.message}`; + } + } +} diff --git a/src/tools/SendMessageTool.js b/src/tools/SendMessageTool.js new file mode 100644 index 00000000..674cbc2c --- /dev/null +++ b/src/tools/SendMessageTool.js @@ -0,0 +1,37 @@ +import { logger } from '../utils/logger.js'; +import axios from 'axios'; + +export class SendMessageTool { + constructor() { + this.name = 'send_message'; + this.description = 'Send a message to Telegram chat or channel'; + } + + async execute(args) { + const { chat_id, message, parse_mode = 'HTML' } = args; + const botToken = process.env.TELEGRAM_BOT_TOKEN; + if (!botToken) return '❌ TELEGRAM_BOT_TOKEN not set'; + + const target = chat_id || process.env.TELEGRAM_CHAT_ID; + if (!target) return '❌ No chat_id provided and TELEGRAM_CHAT_ID not set'; + + try { + const response = await axios.post( + `https://api.telegram.org/bot${botToken}/sendMessage`, + { + chat_id: target, + text: message, + parse_mode, + disable_web_page_preview: true, + }, + { timeout: 10000 } + ); + + return response.data.ok + ? `✅ Message sent to chat ${target}` + : `❌ Telegram error: ${JSON.stringify(response.data)}`; + } catch (e) { + return `❌ Send error: ${e.message}`; + } + } +} diff --git a/src/tools/TaskCreateTool.js b/src/tools/TaskCreateTool.js new file mode 100644 index 00000000..01be92f4 --- /dev/null +++ b/src/tools/TaskCreateTool.js @@ -0,0 +1,36 @@ +import { logger } from '../utils/logger.js'; +import fs from 'fs-extra'; +import path from 'path'; + +const TASKS_FILE = 'data/tasks.json'; + +export class TaskCreateTool { + constructor() { + this.name = 'task_create'; + this.description = 'Create a new task with description'; + } + + async execute(args) { + const { description } = args; + try { + const tasks = await this._loadTasks(); + const id = String(Date.now()).slice(-8); + tasks.push({ id, description, status: 'pending', created: new Date().toISOString() }); + await this._saveTasks(tasks); + return `✅ Task created: #${id} — ${description}`; + } catch (e) { + return `❌ ${e.message}`; + } + } + + async _loadTasks() { + try { + return await fs.readJson(TASKS_FILE); + } catch { return []; } + } + + async _saveTasks(tasks) { + await fs.ensureDir(path.dirname(TASKS_FILE)); + await fs.writeJson(TASKS_FILE, tasks, { spaces: 2 }); + } +} diff --git a/src/tools/TaskListTool.js b/src/tools/TaskListTool.js new file mode 100644 index 00000000..cba2a8a7 --- /dev/null +++ b/src/tools/TaskListTool.js @@ -0,0 +1,36 @@ +import { logger } from '../utils/logger.js'; +import fs from 'fs-extra'; + +const TASKS_FILE = 'data/tasks.json'; + +export class TaskListTool { + constructor() { + this.name = 'task_list'; + this.description = 'List all tasks with their status'; + } + + async execute(args = {}) { + try { + const tasks = await this._loadTasks(); + if (!tasks.length) return '📋 No tasks.'; + + const statusFilter = args.status; + const filtered = statusFilter ? tasks.filter(t => t.status === statusFilter) : tasks; + + if (!filtered.length) return `📋 No tasks with status "${statusFilter}".`; + + const icons = { pending: '⏳', in_progress: '🔄', completed: '✅', cancelled: '❌' }; + const list = filtered.map(t => + `${icons[t.status] || '📝'} #${t.id} [${t.status}] ${t.description}` + ).join('\n'); + + return `📋 Tasks (${filtered.length}/${tasks.length}):\n${list}`; + } catch (e) { + return `❌ ${e.message}`; + } + } + + async _loadTasks() { + try { return await fs.readJson(TASKS_FILE); } catch { return []; } + } +} diff --git a/src/tools/TaskUpdateTool.js b/src/tools/TaskUpdateTool.js new file mode 100644 index 00000000..03029ed3 --- /dev/null +++ b/src/tools/TaskUpdateTool.js @@ -0,0 +1,42 @@ +import { logger } from '../utils/logger.js'; +import fs from 'fs-extra'; +import path from 'path'; + +const TASKS_FILE = 'data/tasks.json'; + +export class TaskUpdateTool { + constructor() { + this.name = 'task_update'; + this.description = 'Update task status (pending/in_progress/completed/cancelled)'; + } + + async execute(args) { + const { task_id, status } = args; + const validStatuses = ['pending', 'in_progress', 'completed', 'cancelled']; + if (!validStatuses.includes(status)) { + return `❌ Invalid status. Use: ${validStatuses.join(', ')}`; + } + + try { + const tasks = await this._loadTasks(); + const task = tasks.find(t => t.id === task_id); + if (!task) return `❌ Task not found: #${task_id}`; + + task.status = status; + task.updated = new Date().toISOString(); + await this._saveTasks(tasks); + return `✅ Task #${task_id} → ${status}`; + } catch (e) { + return `❌ ${e.message}`; + } + } + + async _loadTasks() { + try { return await fs.readJson(TASKS_FILE); } catch { return []; } + } + + async _saveTasks(tasks) { + await fs.ensureDir(path.dirname(TASKS_FILE)); + await fs.writeJson(TASKS_FILE, tasks, { spaces: 2 }); + } +} diff --git a/src/tools/WebFetchTool.js b/src/tools/WebFetchTool.js new file mode 100644 index 00000000..8b810b25 --- /dev/null +++ b/src/tools/WebFetchTool.js @@ -0,0 +1,53 @@ +import { logger } from '../utils/logger.js'; +import axios from 'axios'; + +export class WebFetchTool { + constructor() { + this.name = 'web_fetch'; + this.description = 'Fetch content from a URL and return text/markdown'; + } + + async execute(args) { + const { url, max_length = 15000 } = args; + try { + logger.info(`🌐 Fetching: ${url}`); + const response = await axios.get(url, { + timeout: 30000, + maxRedirects: 5, + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; zCode-Bot/1.0)', + 'Accept': 'text/html,application/json,text/plain,*/*', + }, + }); + + let content = ''; + const contentType = response.headers['content-type'] || ''; + + if (contentType.includes('application/json')) { + content = JSON.stringify(response.data, null, 2); + } else { + content = typeof response.data === 'string' + ? response.data + : JSON.stringify(response.data, null, 2); + } + + // Strip HTML tags for cleaner output + if (contentType.includes('text/html')) { + content = content + .replace(//gi, '') + .replace(//gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } + + if (content.length > max_length) { + content = content.slice(0, max_length) + '\n\n... (truncated)'; + } + + return `🌐 ${url}\n${content}`; + } catch (e) { + return `❌ Fetch error: ${e.message}`; + } + } +} diff --git a/src/tools/index.js b/src/tools/index.js index 8b2ee0b7..2e6d9415 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -3,40 +3,60 @@ import { BashTool } from './BashTool.js'; import { FileEditTool } from './FileEditTool.js'; import { WebSearchTool } from './WebSearchTool.js'; import { GitTool } from './GitTool.js'; +import { FileReadTool } from './FileReadTool.js'; +import { FileWriteTool } from './FileWriteTool.js'; +import { GlobTool } from './GlobTool.js'; +import { GrepTool } from './GrepTool.js'; +import { WebFetchTool } from './WebFetchTool.js'; +import { TaskCreateTool } from './TaskCreateTool.js'; +import { TaskUpdateTool } from './TaskUpdateTool.js'; +import { TaskListTool } from './TaskListTool.js'; +import { SendMessageTool } from './SendMessageTool.js'; +import { ScheduleCronTool } from './ScheduleCronTool.js'; + +// Tool definitions: env toggle flag, factory function +const TOOL_REGISTRY = [ + { env: 'ZCODE_ENABLE_BASH', Tool: BashTool, label: 'Bash' }, + { env: 'ZCODE_ENABLE_FILE_EDIT', Tool: FileEditTool, label: 'File edit' }, + { env: 'ZCODE_ENABLE_WEB_SEARCH', Tool: WebSearchTool, label: 'Web search' }, + { env: 'ZCODE_ENABLE_GIT', Tool: GitTool, label: 'Git' }, + { env: 'ZCODE_ENABLE_FILE_READ', Tool: FileReadTool, label: 'File read' }, + { env: 'ZCODE_ENABLE_FILE_WRITE', Tool: FileWriteTool, label: 'File write' }, + { env: 'ZCODE_ENABLE_GLOB', Tool: GlobTool, label: 'Glob' }, + { env: 'ZCODE_ENABLE_GREP', Tool: GrepTool, label: 'Grep' }, + { env: 'ZCODE_ENABLE_WEB_FETCH', Tool: WebFetchTool, label: 'Web fetch' }, + { env: 'ZCODE_ENABLE_TASKS', Tool: TaskCreateTool, label: 'Task create' }, + { env: null, Tool: TaskUpdateTool, label: 'Task update' }, // bundled with TASKS + { env: null, Tool: TaskListTool, label: 'Task list' }, // bundled with TASKS + { env: 'ZCODE_ENABLE_SEND_MSG', Tool: SendMessageTool, label: 'Send message' }, + { env: 'ZCODE_ENABLE_CRON', Tool: ScheduleCronTool, label: 'Schedule cron' }, +]; export async function initTools() { const tools = []; + const taskEnabled = process.env.ZCODE_ENABLE_TASKS !== 'false'; - // Bash tool - if (process.env.ZCODE_ENABLE_BASH !== 'false') { - const bashTool = new BashTool(); - tools.push(bashTool); - logger.info(`✓ Bash tool loaded`); - } - - // File edit tool - if (process.env.ZCODE_ENABLE_FILE_EDIT !== 'false') { - const fileEditTool = new FileEditTool(); - tools.push(fileEditTool); - logger.info(`✓ File edit tool loaded`); - } - - // Web search tool - if (process.env.ZCODE_ENABLE_WEB_SEARCH !== 'false') { - const webSearchTool = new WebSearchTool(); - tools.push(webSearchTool); - logger.info(`✓ Web search tool loaded`); - } - - // Git tool - if (process.env.ZCODE_ENABLE_GIT !== 'false') { - const gitTool = new GitTool(); - tools.push(gitTool); - logger.info(`✓ Git tool loaded`); + for (const entry of TOOL_REGISTRY) { + // Tasks (create/update/list) share one env flag + const enabled = entry.env + ? process.env[entry.env] !== 'false' + : taskEnabled; + + if (enabled) { + const instance = new entry.Tool(); + tools.push(instance); + logger.info(`✓ ${entry.label} tool loaded (${instance.name})`); + } } + logger.info(`📦 ${tools.length} tools ready`); return tools; } -// Export tool classes -export { BashTool, FileEditTool, WebSearchTool, GitTool }; +// Export tool classes for direct access +export { + BashTool, FileEditTool, WebSearchTool, GitTool, + FileReadTool, FileWriteTool, GlobTool, GrepTool, WebFetchTool, + TaskCreateTool, TaskUpdateTool, TaskListTool, + SendMessageTool, ScheduleCronTool, +};