Add new features and update configurations for improved functionality

This commit is contained in:
ImBenji
2026-04-11 12:34:00 +01:00
parent fa4415553d
commit 0b6b604c56
125 changed files with 14119 additions and 1664 deletions
+48
View File
@@ -0,0 +1,48 @@
import "base_tool.dart";
/// A tool for spawning and coordinating AI agents for collaborative work
class AgentTool extends BaseTool {
@override
final String name = "Agent";
@override
final String description = "Spawn and coordinate AI agents for collaborative work";
@override
Future<String> execute(Map<String, dynamic> input) async {
final agentType = input["agent_type"] as String? ?? "general-purpose";
final task = input["task"] as String? ?? "general task";
final modelName = input["model"] as String?;
final teamName = input["team_name"] as String?;
final agentId = 'agent_${DateTime.now().millisecondsSinceEpoch}';
// Different agent types with different response patterns
final responses = <String, String>{
'general-purpose': "I've analyzed the task '$task' and here's my comprehensive response...",
'researcher': "After researching '$task', I found several key insights about this topic...",
'tester': "I've tested the implementation for '$task' and found a few issues that need addressing...",
'reviewer': "Here's my code review for '$task' with suggestions for improvement...",
'planner': "I've created a detailed plan for '$task' with the following steps...",
};
final result = responses[agentType] ?? "Agent completed the task successfully.";
final buffer = StringBuffer();
buffer.writeln('Agent $agentId ($agentType) has been spawned.');
if (modelName != null) {
buffer.writeln('Model: $modelName');
}
if (teamName != null) {
buffer.writeln('Team: $teamName');
}
buffer.writeln();
buffer.writeln('Task: $task');
buffer.writeln();
buffer.writeln(result);
buffer.writeln();
buffer.writeln('Note: In a full implementation, this would spawn an actual AI agent.');
return buffer.toString();
}
}
+198
View File
@@ -0,0 +1,198 @@
import 'dart:io';
import '../services/task_executor.dart';
import 'base_tool.dart';
/// Tool for executing background tasks with real process management
class ExecuteTaskTool extends BaseTool {
@override
final String name = 'ExecuteTask';
@override
final String description =
'Execute a task as a background process and get real-time status';
final TaskExecutor _executor = TaskExecutor();
@override
Future<String> execute(Map<String, dynamic> input) async {
final action = input['action'] as String? ?? 'execute';
final taskId = input['task_id'] as String?;
final command = input['command'] as String?;
final arguments = _readStringList(input['arguments']);
final processId = input['process_id'] as String?;
final workingDirectory = input['working_directory'] as String? ??
Directory.current.path;
switch (action.toLowerCase()) {
case 'execute':
if (taskId == null || command == null) {
return 'Error: task_id and command are required for execute action';
}
return await _executeTask(
taskId: taskId,
command: command,
arguments: arguments,
workingDirectory: workingDirectory,
);
case 'status':
if (processId == null) {
return 'Error: process_id is required for status action';
}
return _getStatus(processId);
case 'result':
if (processId == null) {
return 'Error: process_id is required for result action';
}
return _getResult(processId);
case 'cancel':
if (processId == null) {
return 'Error: process_id is required for cancel action';
}
final force = input['force'] as bool? ?? false;
return await _cancelTask(processId, force: force);
case 'list':
return _listActiveTasks();
default:
return 'Error: Unknown action "$action". Available actions: execute, status, result, cancel, list';
}
}
Future<String> _executeTask({
required String taskId,
required String command,
required List<String> arguments,
required String workingDirectory,
}) async {
try {
final processId = await _executor.executeTask(
taskId: taskId,
command: command,
arguments: arguments,
workingDirectory: workingDirectory,
);
return '''Executing task: $taskId
Process ID: $processId
Command: $command ${arguments.join(' ')}
Working directory: $workingDirectory
Use ExecuteTask:status process_id="$processId" to check status
Use ExecuteTask:result process_id="$processId" to get results
Use ExecuteTask:cancel process_id="$processId" to stop''';
} catch (e) {
return 'Error: ${e.toString()}';
}
}
String _getStatus(String processId) {
final status = _executor.getTaskStatus(processId);
final tasks = _executor.getActiveTasks();
final taskInfo = tasks.firstWhere(
(t) => t['id'] == processId,
orElse: () => <String, dynamic>{},
);
if (taskInfo.isEmpty) {
return 'Task not found or already completed';
}
final buffer = StringBuffer();
buffer.writeln('Process: $processId');
buffer.writeln('Status: ${status.name}');
buffer.writeln('Command: ${taskInfo['command']}');
buffer.writeln('Duration: ${taskInfo['duration_ms']}ms');
buffer.writeln('Output lines: ${taskInfo['output_lines']}');
buffer.writeln('Error lines: ${taskInfo['error_lines']}');
if (status == TaskExecutionStatus.completed ||
status == TaskExecutionStatus.failed) {
buffer.writeln('Exit code: ${taskInfo['exit_code']}');
buffer.writeln('\nUse ExecuteTask:result process_id="$processId" to get full output');
}
return buffer.toString();
}
String _getResult(String processId) {
final result = _executor.getResult(processId);
if (result == null) {
return 'Task still running. Use ExecuteTask:status process_id="$processId"';
}
final buffer = StringBuffer();
buffer.writeln('Process: $processId');
buffer.writeln('Status: ${result.status.name}');
buffer.writeln('Exit code: ${result.exitCode}');
buffer.writeln('Duration: ${result.duration?.inSeconds}s');
buffer.writeln();
if (result.output.isNotEmpty) {
buffer.writeln('--- OUTPUT ---');
buffer.writeln(result.output);
}
if (result.errors.isNotEmpty) {
buffer.writeln();
buffer.writeln('--- ERRORS ---');
buffer.writeln(result.errors);
}
return buffer.toString();
}
Future<String> _cancelTask(String processId, {required bool force}) async {
final success = await _executor.cancelTask(processId, force: force);
if (success) {
return 'Task $processId cancelled successfully';
} else {
return 'Error: Could not cancel task $processId';
}
}
String _listActiveTasks() {
final tasks = _executor.getActiveTasks();
if (tasks.isEmpty) {
return 'No active tasks';
}
final buffer = StringBuffer();
buffer.writeln('Active tasks (${tasks.length}):');
buffer.writeln('' * 60);
for (final task in tasks) {
final id = task['id'] as String;
final command = task['command'] as String;
final taskId = task['task_id'] as String;
final durationMs = task['duration_ms'] as int?;
buffer.writeln('$id (Task: $taskId)');
buffer.writeln(' Command: $command');
buffer.writeln(' Duration: ${durationMs ?? 0}ms');
buffer.writeln();
}
return buffer.toString();
}
List<String> _readStringList(Object? value) {
if (value is! List) {
return const <String>[];
}
return value
.whereType<String>()
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
}
}
+97 -45
View File
@@ -102,6 +102,7 @@ class GrepTool extends BaseTool {
int? contextAfter,
}) async {
final searchPath = path ?? Directory.current.path;
final searchPathType = FileSystemEntity.typeSync(searchPath, followLinks: true);
final args = <String>["--hidden"];
@@ -121,6 +122,10 @@ class GrepTool extends BaseTool {
}
if (showLineNumbers && outputMode == "content") args.add("-n");
if (searchPathType == FileSystemEntityType.file &&
outputMode != "files_with_matches") {
args.add("--with-filename");
}
if (outputMode == "content") {
if (contextLines != null) {
@@ -175,59 +180,47 @@ class GrepTool extends BaseTool {
int? headLimit,
required int offset,
}) async {
final searchDir = Directory(path ?? Directory.current.path);
if (!await searchDir.exists()) {
return "Error: Path does not exist: ${searchDir.path}";
final searchPath = path ?? Directory.current.path;
final entityType = FileSystemEntity.typeSync(searchPath, followLinks: true);
if (entityType == FileSystemEntityType.notFound) {
return "Error: Path does not exist: $searchPath";
}
final regex = RegExp(pattern, caseSensitive: !caseInsensitive, multiLine: true);
final matchedFiles = <String>[];
final contentLines = <String>[];
var totalMatches = 0;
final baseDir = entityType == FileSystemEntityType.directory
? searchPath
: File(searchPath).parent.path;
await for (final entity in searchDir.list(recursive: true, followLinks: false)) {
if (entity is! File) continue;
if (entityType == FileSystemEntityType.file) {
await _searchFile(
file: File(searchPath),
regex: regex,
glob: glob,
outputMode: outputMode,
showLineNumbers: showLineNumbers,
baseDir: baseDir,
matchedFiles: matchedFiles,
contentLines: contentLines,
);
} else {
final searchDir = Directory(searchPath);
await for (final entity
in searchDir.list(recursive: true, followLinks: false)) {
if (entity is! File) continue;
// skip vcs dirs
final parts = entity.path.split("/");
if (parts.any((p) => _vcsSkip.contains(p))) continue;
// glob filter
if (glob != null) {
final filename = entity.path.split("/").last;
if (!_simpleGlobMatch(glob, filename)) continue;
}
String content;
try {
content = await entity.readAsString(encoding: utf8);
} catch (_) {
continue; // skip binary/unreadable
}
final fileMatches = regex.allMatches(content).length;
if (fileMatches == 0) continue;
final relPath = entity.path.startsWith(searchDir.path)
? entity.path.substring(searchDir.path.length + 1)
: entity.path;
if (outputMode == "files_with_matches") {
matchedFiles.add(relPath);
} else if (outputMode == "count") {
contentLines.add("$relPath:$fileMatches");
totalMatches += fileMatches;
} else {
// content mode
final fileLines = content.split("\n");
for (var i = 0; i < fileLines.length; i++) {
if (regex.hasMatch(fileLines[i])) {
final prefix = showLineNumbers ? "$relPath:${i + 1}:" : "$relPath:";
contentLines.add("$prefix${fileLines[i]}");
}
}
matchedFiles.add(relPath);
await _searchFile(
file: entity,
regex: regex,
glob: glob,
outputMode: outputMode,
showLineNumbers: showLineNumbers,
baseDir: baseDir,
matchedFiles: matchedFiles,
contentLines: contentLines,
);
}
}
@@ -238,6 +231,65 @@ class GrepTool extends BaseTool {
return _formatResults(contentLines, outputMode, headLimit, offset);
}
Future<void> _searchFile({
required File file,
required RegExp regex,
required String? glob,
required String outputMode,
required bool showLineNumbers,
required String baseDir,
required List<String> matchedFiles,
required List<String> contentLines,
}) async {
final parts = file.path.split("/");
if (parts.any((p) => _vcsSkip.contains(p))) return;
if (glob != null) {
final filename = file.path.split("/").last;
if (!_simpleGlobMatch(glob, filename)) return;
}
String content;
try {
content = await file.readAsString(encoding: utf8);
} catch (_) {
return;
}
final fileMatches = regex.allMatches(content).length;
if (fileMatches == 0) return;
final relPath = _displayPath(file.path, baseDir);
if (outputMode == "files_with_matches") {
matchedFiles.add(relPath);
return;
}
if (outputMode == "count") {
contentLines.add("$relPath:$fileMatches");
return;
}
final fileLines = content.split("\n");
for (var i = 0; i < fileLines.length; i++) {
if (regex.hasMatch(fileLines[i])) {
final prefix = showLineNumbers ? "$relPath:${i + 1}:" : "$relPath:";
contentLines.add("$prefix${fileLines[i]}");
}
}
}
String _displayPath(String filePath, String baseDir) {
if (filePath == baseDir) {
return filePath.split("/").last;
}
if (filePath.startsWith("$baseDir/")) {
return filePath.substring(baseDir.length + 1);
}
return filePath;
}
String _formatResults(List<String> lines, String outputMode, int? headLimit, int offset) {
// apply offset + head_limit
+241
View File
@@ -0,0 +1,241 @@
import 'dart:convert';
import 'base_tool.dart';
/// Tool for interacting with Model Context Protocol (MCP) servers
class McpTool extends BaseTool {
@override
final String name = 'MCP';
@override
final String description =
'Interact with Model Context Protocol (MCP) servers and resources';
@override
Future<String> execute(Map<String, dynamic> input) async {
final action = input['action'] as String? ?? 'list';
final serverName = input['server_name'] as String?;
final resourceUri = input['resource_uri'] as String?;
final command = input['command'] as String?;
switch (action.toLowerCase()) {
case 'list':
return _listServers();
case 'resources':
if (serverName == null) {
return 'Error: server_name is required for resources action';
}
return await _listResources(serverName);
case 'read':
if (serverName == null || resourceUri == null) {
return 'Error: server_name and resource_uri are required for read action';
}
return await _readResource(serverName, resourceUri);
case 'connect':
if (serverName == null || command == null) {
return 'Error: server_name and command are required for connect action';
}
return await _connectServer(serverName, command);
case 'disconnect':
if (serverName == null) {
return 'Error: server_name is required for disconnect action';
}
return await _disconnectServer(serverName);
case 'info':
if (serverName == null) {
return 'Error: server_name is required for info action';
}
return await _serverInfo(serverName);
default:
return 'Error: Unknown action "$action". Available actions: list, resources, read, connect, disconnect, info';
}
}
String _listServers() {
// In a real implementation, this would read from config
final servers = <Map<String, dynamic>>[
{
'name': 'filesystem',
'status': 'connected',
'type': 'builtin',
'description': 'Access to local filesystem',
},
{
'name': 'git',
'status': 'available',
'type': 'builtin',
'description': 'Git repository operations',
},
{
'name': 'clock',
'status': 'connected',
'type': 'example',
'description': 'Current time and date information',
},
];
final buffer = StringBuffer();
buffer.writeln('MCP Servers (${servers.length}):');
buffer.writeln('' * 60);
for (final server in servers) {
final status = server['status'] as String;
final statusSymbol = status == 'connected' ? '' : '';
buffer.write('$statusSymbol ${server['name']}');
buffer.write(' (${server['type']})');
buffer.writeln(' - ${server['description']}');
}
buffer.writeln();
buffer.writeln('Use MCP:resources server_name="filesystem" to list available resources');
buffer.writeln('Use MCP:connect server_name="new-server" command="npx @modelcontextprotocol/server-filesystem" to add a server');
return buffer.toString();
}
Future<String> _listResources(String serverName) async {
// Mock resources based on server name
final resources = <Map<String, String>>[];
switch (serverName.toLowerCase()) {
case 'filesystem':
resources.addAll([
{'uri': 'file:///README.md', 'name': 'README.md', 'type': 'file'},
{'uri': 'file:///lib/', 'name': 'lib directory', 'type': 'directory'},
{'uri': 'file:///pubspec.yaml', 'name': 'pubspec.yaml', 'type': 'file'},
]);
break;
case 'git':
resources.addAll([
{'uri': 'git:///status', 'name': 'Git Status', 'type': 'status'},
{'uri': 'git:///log', 'name': 'Git Log', 'type': 'log'},
{'uri': 'git:///diff', 'name': 'Git Diff', 'type': 'diff'},
]);
break;
case 'clock':
resources.addAll([
{'uri': 'clock:///now', 'name': 'Current Time', 'type': 'time'},
{'uri': 'clock:///date', 'name': 'Current Date', 'type': 'date'},
{'uri': 'clock:///timezone', 'name': 'Timezone Info', 'type': 'timezone'},
]);
break;
default:
return 'Error: Server "$serverName" not found or has no resources';
}
final buffer = StringBuffer();
buffer.writeln('Resources for $serverName (${resources.length}):');
buffer.writeln('' * 60);
for (final resource in resources) {
buffer.write('${resource['uri']}');
buffer.write(' (${resource['type']})');
buffer.writeln(' - ${resource['name']}');
}
buffer.writeln();
buffer.writeln('Read a resource with: MCP:read server_name="$serverName" resource_uri="${resources.first['uri']}"');
return buffer.toString();
}
Future<String> _readResource(String serverName, String resourceUri) async {
// Mock resource content based on URI
final now = DateTime.now();
String content;
switch (resourceUri) {
case 'file:///README.md':
content = '# Project README\n\nThis is a sample README file.';
break;
case 'file:///pubspec.yaml':
content = 'name: clawd_code\ndescription: Claude Code Dart CLI\nversion: 1.0.0';
break;
case 'clock:///now':
content = now.toIso8601String();
break;
case 'clock:///date':
content = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
break;
case 'git:///status':
content = 'On branch main\nnothing to commit, working tree clean';
break;
default:
if (resourceUri.startsWith('file:///')) {
final path = resourceUri.substring(7);
content = 'File content for $path\n\nThis is simulated MCP file content.';
} else {
content = 'Resource content for $resourceUri\n\nThis is simulated MCP resource data.';
}
}
final buffer = StringBuffer();
buffer.writeln('Resource: $resourceUri');
buffer.writeln('Server: $serverName');
buffer.writeln('' * 60);
buffer.writeln(content);
buffer.writeln('' * 60);
buffer.writeln();
buffer.writeln('Note: This is simulated MCP resource data. In a real implementation,');
buffer.writeln('this would connect to an actual MCP server.');
return buffer.toString();
}
Future<String> _connectServer(String serverName, String command) async {
final buffer = StringBuffer();
buffer.writeln('Connecting MCP server: $serverName');
buffer.writeln('Command: $command');
buffer.writeln();
buffer.writeln('In a real implementation, this would:');
buffer.writeln('1. Start the MCP server process');
buffer.writeln('2. Establish WebSocket connection');
buffer.writeln('3. Initialize protocol handshake');
buffer.writeln('4. Load available tools and resources');
buffer.writeln();
buffer.writeln('Simulated server connected successfully.');
buffer.writeln();
buffer.writeln('Use MCP:resources server_name="$serverName" to see available resources');
buffer.writeln('Use MCP:info server_name="$serverName" to see server details');
return buffer.toString();
}
Future<String> _disconnectServer(String serverName) async {
return '''Disconnecting MCP server: $serverName
In a real implementation, this would:
1. Send shutdown signal to server
2. Close WebSocket connection
3. Clean up resources
Simulated server disconnected successfully.''';
}
Future<String> _serverInfo(String serverName) async {
final info = <String, dynamic>{
'name': serverName,
'protocol': 'MCP 2024-11-05',
'version': '1.0.0',
'capabilities': ['resources', 'tools', 'prompts'],
'status': 'connected',
'uptime': '5m 23s',
'resources_count': 3,
'tools_count': 2,
};
final buffer = StringBuffer();
buffer.writeln('MCP Server Info: $serverName');
buffer.writeln('' * 40);
for (final entry in info.entries) {
if (entry.value is List) {
buffer.writeln('${entry.key}: ${(entry.value as List).join(', ')}');
} else {
buffer.writeln('${entry.key}: ${entry.value}');
}
}
return buffer.toString();
}
}
+88
View File
@@ -0,0 +1,88 @@
import "base_tool.dart";
/// A basic agent tool for demonstration and simple agent operations
class SimpleAgentTool extends BaseTool {
@override
final String name = "SimpleAgent";
@override
final String description = "A basic agent tool for demonstration and simple agent management";
@override
Future<String> execute(Map<String, dynamic> input) async {
final action = input["action"] as String? ?? "info";
switch (action.toLowerCase()) {
case "info":
return _agentInfo();
case "list":
return _listAgents();
case "status":
return _agentStatus();
case "create":
final agentName = input["name"] as String? ?? "demo_agent";
return _createAgent(agentName);
default:
return 'Unknown action: $action. Available actions: info, list, status, create';
}
}
String _agentInfo() {
return '''
Simple Agent Tool Info:
-----------------------
This is a demonstration agent tool for the Dart CLI migration.
In a full implementation, this would manage actual AI agents.
Features:
- Basic agent lifecycle management
- Agent status tracking
- Simple agent creation
Note: This is a placeholder implementation for migration testing.
''';
}
String _listAgents() {
return '''
Available Agents (demo):
-----------------------
1. demo_agent_1 (status: idle, type: general-purpose)
2. demo_agent_2 (status: busy, type: researcher)
3. demo_agent_3 (status: idle, type: tester)
Total: 3 demo agents
Note: These are placeholder agents for demonstration.
''';
}
String _agentStatus() {
return '''
Agent Status Summary:
--------------------
Active agents: 1
Idle agents: 2
Total agents: 3
Last activity: ${DateTime.now().subtract(const Duration(minutes: 5))}
Note: This is demo status data.
''';
}
String _createAgent(String agentName) {
final agentId = '${agentName}_${DateTime.now().millisecondsSinceEpoch ~/ 1000}';
return '''
Created new agent: $agentId
----------------------------
Name: $agentName
ID: $agentId
Status: initialized
Type: general-purpose
Created: ${DateTime.now()}
Note: This is a demo agent creation.
''';
}
}
+233
View File
@@ -0,0 +1,233 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as path;
import 'base_tool.dart';
/// Tool for managing and executing skills
class SkillTool extends BaseTool {
@override
final String name = 'Skill';
@override
final String description =
'List, execute, and manage reusable prompt templates (skills)';
@override
Future<String> execute(Map<String, dynamic> input) async {
final action = input['action'] as String? ?? 'list';
final skillName = input['skill_name'] as String?;
final content = input['content'] as String?;
final params = input['params'] as Map<String, dynamic>? ?? {};
switch (action.toLowerCase()) {
case 'list':
return _listSkills();
case 'execute':
if (skillName == null) {
return 'Error: skill_name is required for execute action';
}
return await _executeSkill(skillName, params);
case 'info':
if (skillName == null) {
return 'Error: skill_name is required for info action';
}
return await _getSkillInfo(skillName);
case 'create':
if (skillName == null || content == null) {
return 'Error: skill_name and content are required for create action';
}
return await _createSkill(skillName, content);
default:
return 'Error: Unknown action "$action". Available actions: list, execute, info, create';
}
}
Future<String> _listSkills() async {
final skillsDir = await _getSkillsDirectory();
final skills = <String>[];
if (await skillsDir.exists()) {
final files = skillsDir.listSync();
for (final file in files) {
if (file is File && file.path.endsWith('.md')) {
skills.add(path.basenameWithoutExtension(file.path));
}
}
}
if (skills.isEmpty) {
return '''No skills found.
Skills are reusable prompt templates stored as .md files in:
${skillsDir.path}
Create a skill with Skill:create skill_name="my-skill" content="# My Skill\\n\\nThis skill does something useful."''';
}
final buffer = StringBuffer();
buffer.writeln('Available skills (${skills.length}):');
buffer.writeln('' * 40);
skills.sort();
for (final skill in skills) {
buffer.writeln('$skill');
}
buffer.writeln();
buffer.writeln('Execute a skill with: Skill:execute skill_name="$skills.first"');
return buffer.toString();
}
Future<String> _executeSkill(
String skillName, Map<String, dynamic> params) async {
final skillContent = await _loadSkill(skillName);
if (skillContent.isEmpty) {
return 'Error: Skill "$skillName" not found';
}
// Replace template variables
var result = skillContent;
for (final entry in params.entries) {
result = result.replaceAll('{{${entry.key}}}', entry.value.toString());
}
final buffer = StringBuffer();
buffer.writeln('Executing skill: $skillName');
buffer.writeln('' * 40);
buffer.writeln();
// Parse skill metadata
final lines = skillContent.split('\n');
var inMetadata = false;
var description = '';
for (final line in lines) {
if (line.startsWith('---')) {
inMetadata = !inMetadata;
continue;
}
if (inMetadata) {
if (line.startsWith('description:')) {
description = line.substring('description:'.length).trim();
}
}
}
if (description.isNotEmpty) {
buffer.writeln('Description: $description');
buffer.writeln();
}
buffer.writeln('Skill content:');
buffer.writeln('' * 40);
buffer.writeln(result);
buffer.writeln('' * 40);
return buffer.toString();
}
Future<String> _getSkillInfo(String skillName) async {
final skillContent = await _loadSkill(skillName);
if (skillContent.isEmpty) {
return 'Error: Skill "$skillName" not found';
}
final lines = skillContent.split('\n');
final buffer = StringBuffer();
buffer.writeln('Skill: $skillName');
buffer.writeln('' * 40);
var inMetadata = false;
var hasMetadata = false;
for (final line in lines) {
if (line.startsWith('---')) {
if (!inMetadata) {
hasMetadata = true;
}
inMetadata = !inMetadata;
continue;
}
if (inMetadata) {
if (line.startsWith('description:')) {
buffer.writeln('Description: ${line.substring('description:'.length).trim()}');
} else if (line.startsWith('author:')) {
buffer.writeln('Author: ${line.substring('author:'.length).trim()}');
} else if (line.startsWith('version:')) {
buffer.writeln('Version: ${line.substring('version:'.length).trim()}');
} else if (line.startsWith('tags:')) {
buffer.writeln('Tags: ${line.substring('tags:'.length).trim()}');
}
}
}
if (!hasMetadata) {
buffer.writeln('No metadata found.');
}
buffer.writeln();
buffer.writeln('Preview (first 200 chars):');
buffer.writeln('' * 40);
final contentStart = skillContent.indexOf('---', 3) + 3;
final preview = contentStart > 3
? skillContent.substring(contentStart).trim()
: skillContent.trim();
buffer.writeln(preview.length > 200 ? '${preview.substring(0, 200)}...' : preview);
return buffer.toString();
}
Future<String> _createSkill(String skillName, String content) async {
final skillsDir = await _getSkillsDirectory();
await skillsDir.create(recursive: true);
final skillFile = File(path.join(skillsDir.path, '$skillName.md'));
// Add basic metadata if not present
var skillContent = content;
if (!content.startsWith('---')) {
skillContent = '''---
skill: $skillName
created: ${DateTime.now().toIso8601String()}
---
$content''';
}
await skillFile.writeAsString(skillContent);
return '''Created skill: $skillName
Location: ${skillFile.path}
Execute with: Skill:execute skill_name="$skillName"''';
}
Future<Directory> _getSkillsDirectory() async {
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '';
final claudeDir = Directory(path.join(home, '.claude', 'skills'));
return claudeDir;
}
Future<String> _loadSkill(String skillName) async {
final skillsDir = await _getSkillsDirectory();
final skillFile = File(path.join(skillsDir.path, '$skillName.md'));
if (!await skillFile.exists()) {
// Check project-local skills
final localSkillsDir = Directory(path.join(Directory.current.path, '.claude', 'skills'));
final localSkillFile = File(path.join(localSkillsDir.path, '$skillName.md'));
if (await localSkillFile.exists()) {
return await localSkillFile.readAsString();
}
return '';
}
return await skillFile.readAsString();
}
}
+254
View File
@@ -0,0 +1,254 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as path;
import 'base_tool.dart';
/// Tool for managing background tasks
/// Tasks are persisted to ~/.clawd_code/tasks/ directory
class TaskTool extends BaseTool {
@override
final String name = 'Task';
@override
final String description =
'Create, list, get, update, and stop background tasks';
// In-memory cache (loaded from disk)
static final Map<String, Map<String, dynamic>> _tasks = {};
static int _taskCounter = 1;
static bool _initialized = false;
@override
Future<String> execute(Map<String, dynamic> input) async {
// Initialize task storage on first use
if (!_initialized) {
await _loadTasks();
_initialized = true;
}
final action = input['action'] as String? ?? 'list';
final taskId = input['task_id'] as String?;
final taskName = input['name'] as String?;
final command = input['command'] as String?;
switch (action.toLowerCase()) {
case 'create':
return await _createTask(command: command, name: taskName);
case 'list':
return _listTasks();
case 'get':
if (taskId == null) {
return 'Error: task_id is required for get action';
}
return _getTask(taskId);
case 'update':
if (taskId == null) {
return 'Error: task_id is required for update action';
}
final status = input['status'] as String?;
final output = input['output'] as String?;
return await _updateTask(taskId, status: status, output: output);
case 'stop':
if (taskId == null) {
return 'Error: task_id is required for stop action';
}
return await _stopTask(taskId);
case 'output':
if (taskId == null) {
return 'Error: task_id is required for output action';
}
return _getTaskOutput(taskId);
default:
return 'Error: Unknown action "$action". Available actions: create, list, get, update, stop, output';
}
}
Future<String> _createTask({String? command, String? name}) async {
final taskId = 'task_${_taskCounter++}';
final now = DateTime.now().toUtc();
_tasks[taskId] = {
'id': taskId,
'name': name ?? 'Unnamed task',
'command': command ?? '',
'status': 'running',
'created_at': now.toIso8601String(),
'updated_at': now.toIso8601String(),
'output': '',
};
await _saveTasks();
return '''Created task: $taskId
Name: ${name ?? 'Unnamed task'}
Command: ${command ?? '(no command)'}
Status: running
Created: ${now.toLocal()}
Use /tasks to list tasks or Task:get task_id=$taskId to check status.''';
}
String _listTasks() {
if (_tasks.isEmpty) {
return 'No tasks found. Create one with Task:create command="your command"';
}
final buffer = StringBuffer();
buffer.writeln('Tasks (${_tasks.length}):');
buffer.writeln('' * 50);
for (final task in _tasks.values) {
final id = task['id'] as String;
final name = task['name'] as String;
final status = task['status'] as String;
final createdAt = task['created_at'] as String;
final updatedAt = task['updated_at'] as String;
final created = DateTime.parse(createdAt).toLocal();
final updated = DateTime.parse(updatedAt).toLocal();
buffer.writeln('$id: $name');
buffer.writeln(' Status: $status');
buffer.writeln(' Created: ${created.toString().substring(0, 16)}');
buffer.writeln(' Updated: ${updated.toString().substring(0, 16)}');
if (task['command'] != null && (task['command'] as String).isNotEmpty) {
buffer.writeln(' Command: ${task['command']}');
}
buffer.writeln();
}
return buffer.toString();
}
String _getTask(String taskId) {
final task = _tasks[taskId];
if (task == null) {
return 'Error: Task "$taskId" not found';
}
final buffer = StringBuffer();
buffer.writeln('Task: ${task['name']}');
buffer.writeln('ID: $taskId');
buffer.writeln('Status: ${task['status']}');
buffer.writeln('Command: ${task['command']}');
buffer.writeln('Created: ${DateTime.parse(task['created_at'] as String).toLocal()}');
buffer.writeln('Updated: ${DateTime.parse(task['updated_at'] as String).toLocal()}');
final output = task['output'] as String;
if (output.isNotEmpty) {
buffer.writeln();
buffer.writeln('Output:');
buffer.writeln(output);
}
return buffer.toString();
}
Future<String> _updateTask(String taskId, {String? status, String? output}) async {
final task = _tasks[taskId];
if (task == null) {
return 'Error: Task "$taskId" not found';
}
if (status != null) {
task['status'] = status;
}
if (output != null) {
task['output'] = output;
}
task['updated_at'] = DateTime.now().toUtc().toIso8601String();
await _saveTasks();
return 'Updated task $taskId';
}
Future<String> _stopTask(String taskId) async {
final task = _tasks[taskId];
if (task == null) {
return 'Error: Task "$taskId" not found';
}
task['status'] = 'stopped';
task['updated_at'] = DateTime.now().toUtc().toIso8601String();
await _saveTasks();
return 'Stopped task $taskId';
}
String _getTaskOutput(String taskId) {
final task = _tasks[taskId];
if (task == null) {
return 'Error: Task "$taskId" not found';
}
final output = task['output'] as String;
if (output.isEmpty) {
return 'No output recorded for task $taskId';
}
return output;
}
// Persistence: load tasks from disk
Future<void> _loadTasks() async {
try {
final dir = _getTasksDirectory();
if (!await dir.exists()) {
return;
}
_tasks.clear();
_taskCounter = 1;
final files = dir.listSync();
for (final file in files) {
if (file is! File || !file.path.endsWith('.json')) {
continue;
}
try {
final content = await file.readAsString();
final json = jsonDecode(content) as Map<String, dynamic>;
final taskId = json['id'] as String?;
if (taskId != null) {
_tasks[taskId] = json;
// Update counter
final match = RegExp(r'task_(\d+)').firstMatch(taskId);
if (match != null) {
final num = int.tryParse(match.group(1)!);
if (num != null && num >= _taskCounter) {
_taskCounter = num + 1;
}
}
}
} catch (_) {
// Skip malformed files
}
}
} catch (_) {
// If loading fails, continue with empty
}
}
// Persistence: save tasks to disk
Future<void> _saveTasks() async {
try {
final dir = _getTasksDirectory();
await dir.create(recursive: true);
for (final entry in _tasks.entries) {
final file = File(path.join(dir.path, '${entry.key}.json'));
await file.writeAsString(jsonEncode(entry.value));
}
} catch (_) {
// Silently fail - tasks stored in memory anyway
}
}
Directory _getTasksDirectory() {
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '';
return Directory(path.join(home, '.clawd_code', 'tasks'));
}
}
+103 -22
View File
@@ -1,43 +1,124 @@
import "base_tool.dart";
import "bash_tool.dart";
import "glob_tool.dart";
import "grep_tool.dart";
import "execute_task_tool.dart";
import "file_edit_tool.dart";
import "file_read_tool.dart";
import "file_write_tool.dart";
import "file_edit_tool.dart";
import "glob_tool.dart";
import "grep_tool.dart";
import "web_fetch_tool.dart";
import "web_search_tool.dart";
import "task_tool.dart";
import "skill_tool.dart";
import "mcp_tool.dart";
import "../permissions/permission_manager.dart";
import "../local_state.dart";
import "../services/analytics_service.dart";
import "../services/usage_tracker.dart";
// registry that holds all available tools by name
class ToolRegistry {
final Map<String, BaseTool> _tools = {};
PermissionManager? _permissionManager;
ToolRegistry() {
_register(BashTool());
_register(GlobTool());
_register(GrepTool());
_register(FileReadTool());
_register(FileWriteTool());
_register(FileEditTool());
register(BashTool());
register(ExecuteTaskTool());
register(GlobTool());
register(GrepTool());
register(FileReadTool());
register(FileEditTool());
register(FileWriteTool());
register(WebSearchTool());
register(WebFetchTool());
register(TaskTool());
register(SkillTool());
register(McpTool());
}
void _register(BaseTool tool) {
/// Set settings for permission management
void setSettings(LocalSettings settings) {
_permissionManager = PermissionManager(settings);
}
final Map<String, BaseTool> _tools = <String, BaseTool>{};
List<BaseTool> get allTools => _tools.values.toList(growable: false);
List<String> get toolNames => _tools.keys.toList(growable: false);
void register(BaseTool tool) {
_tools[tool.name] = tool;
}
BaseTool? getTool(String toolName) {
return _tools[toolName];
}
BaseTool? getTool(String name) => _tools[name];
List<BaseTool> get allTools => _tools.values.toList();
List<String> get toolNames => _tools.keys.toList();
// execute a tool by name
Future<String> execute(String toolName, Map<String, dynamic> input) async {
final tool = _tools[toolName];
final tool = getTool(toolName);
if (tool == null) {
return "Error: Unknown tool \"$toolName\". Available tools: ${toolNames.join(", ")}";
return 'Error: Unknown tool "$toolName". Available tools: ${toolNames.join(", ")}';
}
// Check permissions if manager is configured
if (_permissionManager != null) {
final permissionDecision = _permissionManager!.getPermissionDecision(toolName, input, null);
switch (permissionDecision) {
case 'denied':
return 'Permission denied: Tool "$toolName" is not allowed. '
'Update permissions with /permissions or change permission mode.';
case 'ask':
final shouldRun = await _permissionManager!.getUserConfirmation(
toolName,
null,
'Allow tool "$toolName" to run?',
);
if (!shouldRun) {
return 'Permission denied: User declined to run tool "$toolName".';
}
break;
case 'allowed':
default:
// Tool is allowed, continue
break;
}
}
// Log tool execution
await _logToolExecution(toolName, input);
return tool.execute(input);
}
Future<void> _logToolExecution(String toolName, Map<String, dynamic> input) async {
try {
final analytics = AnalyticsService();
await analytics.logTool(toolName, input: input);
final usage = UsageTracker();
await usage.trackToolExecution(toolName);
} catch (e) {
// Silently fail - analytics is optional
}
}
/// Extract tool arguments as a string for permission matching
String _extractToolArgs(Map<String, dynamic> input) {
final args = <String>[];
if (input.containsKey('input')) {
args.add(input['input'].toString());
}
if (input.containsKey('command')) {
args.add(input['command'].toString());
}
if (input.containsKey('path')) {
args.add(input['path'].toString());
}
if (input.containsKey('pattern')) {
args.add(input['pattern'].toString());
}
return args.join(' ');
}
}
+863
View File
@@ -0,0 +1,863 @@
import "dart:convert";
import "dart:io";
import "dart:typed_data";
import "package:html/dom.dart" as dom;
import "package:html/parser.dart" as html_parser;
import "package:path/path.dart" as path;
import "../api/openrouter_client.dart";
import "base_tool.dart";
const int _maxUrlLength = 2000;
const int _maxFetchBytes = 10 * 1024 * 1024;
const int _maxPromptContentChars = 100000;
const int _maxRedirects = 10;
const Duration _cacheTtl = Duration(minutes: 15);
const int _maxCacheEntries = 64;
final List<_CacheKey> _cacheOrder = <_CacheKey>[];
final Map<_CacheKey, _CacheEntry> _cache = <_CacheKey, _CacheEntry>{};
const Set<String> _preapprovedHosts = {
"platform.claude.com",
"code.claude.com",
"modelcontextprotocol.io",
"docs.python.org",
"en.cppreference.com",
"docs.oracle.com",
"learn.microsoft.com",
"developer.mozilla.org",
"go.dev",
"pkg.go.dev",
"www.php.net",
"docs.swift.org",
"kotlinlang.org",
"ruby-doc.org",
"doc.rust-lang.org",
"www.typescriptlang.org",
"react.dev",
"angular.io",
"vuejs.org",
"nextjs.org",
"expressjs.com",
"nodejs.org",
"bun.sh",
"getbootstrap.com",
"tailwindcss.com",
"redux.js.org",
"webpack.js.org",
"jestjs.io",
"reactrouter.com",
"docs.djangoproject.com",
"flask.palletsprojects.com",
"fastapi.tiangolo.com",
"pandas.pydata.org",
"numpy.org",
"www.tensorflow.org",
"pytorch.org",
"scikit-learn.org",
"matplotlib.org",
"requests.readthedocs.io",
"jupyter.org",
"laravel.com",
"symfony.com",
"wordpress.org",
"docs.spring.io",
"hibernate.org",
"tomcat.apache.org",
"gradle.org",
"maven.apache.org",
"asp.net",
"dotnet.microsoft.com",
"nuget.org",
"reactnative.dev",
"docs.flutter.dev",
"developer.apple.com",
"developer.android.com",
"keras.io",
"spark.apache.org",
"huggingface.co",
"www.kaggle.com",
"www.mongodb.com",
"redis.io",
"www.postgresql.org",
"dev.mysql.com",
"www.sqlite.org",
"graphql.org",
"prisma.io",
"docs.aws.amazon.com",
"cloud.google.com",
"kubernetes.io",
"www.docker.com",
"www.terraform.io",
"www.ansible.com",
"docs.netlify.com",
"devcenter.heroku.com",
"cypress.io",
"selenium.dev",
"docs.unity.com",
"docs.unrealengine.com",
"git-scm.com",
"nginx.org",
"httpd.apache.org",
};
const Map<String, List<String>> _preapprovedPathPrefixes = {
"github.com": <String>["/anthropics"],
"vercel.com": <String>["/docs"],
};
class WebFetchTool extends BaseTool {
@override
final String name = "WebFetch";
@override
final String description =
"Fetch content from a URL, convert it to readable markdown-like text, and answer a prompt about that page.";
@override
Future<String> execute(Map<String, dynamic> input) async {
final rawUrl = requireString(input, "url").trim();
final prompt = requireString(input, "prompt").trim();
final apiKey = optionalString(input, "_api_key") ?? "";
final model = optionalString(input, "_model") ?? "openrouter/auto";
final permissionMode = optionalString(input, "_permission_mode") ?? "default";
final allowRules = _readStringList(input["_allow_rules"]);
final askRules = _readStringList(input["_ask_rules"]);
final denyRules = _readStringList(input["_deny_rules"]);
if (prompt.isEmpty) {
throw ArgumentError("prompt must not be empty");
}
if (apiKey.isEmpty) {
throw StateError("WebFetch requires an OpenRouter API key");
}
final url = _normalizeUrl(rawUrl);
final uri = Uri.parse(url);
_enforcePermissions(
uri: uri,
permissionMode: permissionMode,
allowRules: allowRules,
askRules: askRules,
denyRules: denyRules,
);
final isPreapproved = _isPreapprovedUrl(url);
final startTime = DateTime.now();
final fetched = await _fetchUrl(url);
final durationMs = DateTime.now().difference(startTime).inMilliseconds;
if (fetched.isRedirectNotice) {
return _formatOutput(
fetched: fetched,
durationMs: durationMs,
result: fetched.content,
);
}
final result = await _summarizeFetchedContent(
apiKey: apiKey,
model: model,
fetched: fetched,
prompt: prompt,
isPreapproved: isPreapproved,
);
return _formatOutput(
fetched: fetched,
durationMs: durationMs,
result: result,
);
}
String _normalizeUrl(String rawUrl) {
if (rawUrl.isEmpty || rawUrl.length > _maxUrlLength) {
throw ArgumentError("Invalid URL");
}
Uri uri;
try {
uri = Uri.parse(rawUrl);
} catch (_) {
throw ArgumentError("Invalid URL: $rawUrl");
}
if (!uri.hasScheme) {
throw ArgumentError("URL must include a scheme");
}
if (uri.userInfo.isNotEmpty || uri.host.isEmpty) {
throw ArgumentError("Invalid URL");
}
if (uri.scheme == "http") {
uri = uri.replace(scheme: "https");
}
if (uri.scheme != "https") {
throw ArgumentError("Only https URLs are supported");
}
final host = uri.host.toLowerCase();
if (_isLocalOrPrivateHost(host)) {
throw ArgumentError("Fetching local or private network URLs is not allowed");
}
return uri.toString();
}
void _enforcePermissions({
required Uri uri,
required String permissionMode,
required List<String> allowRules,
required List<String> askRules,
required List<String> denyRules,
}) {
final bypassModes = <String>{"bypassPermissions", "dontAsk"};
if (bypassModes.contains(permissionMode)) {
return;
}
final domainRule = "domain:${uri.host.toLowerCase()}";
final matchingDeny = denyRules.where((rule) => _matchesRule(rule, uri)).toList();
if (matchingDeny.isNotEmpty) {
throw StateError("WebFetch denied access to $domainRule.");
}
final matchingAllow = allowRules.where((rule) => _matchesRule(rule, uri)).toList();
if (matchingAllow.isNotEmpty) {
return;
}
final matchingAsk = askRules.where((rule) => _matchesRule(rule, uri)).toList();
if (matchingAsk.isNotEmpty) {
throw StateError(
"WebFetch requires permission for $domainRule. Add an allow rule to proceed.",
);
}
}
bool _matchesRule(String rawRule, Uri uri) {
var rule = rawRule.trim();
if (rule.startsWith("WebFetch(") && rule.endsWith(")")) {
rule = rule.substring("WebFetch(".length, rule.length - 1);
}
if (!rule.startsWith("domain:")) {
return false;
}
final pattern = rule.substring("domain:".length).toLowerCase();
final host = uri.host.toLowerCase();
if (pattern.isEmpty) {
return false;
}
if (pattern == host) {
return true;
}
if (pattern.startsWith("*.")) {
final suffix = pattern.substring(1);
return host.endsWith(suffix);
}
if (pattern.endsWith(".*")) {
final prefix = pattern.substring(0, pattern.length - 1);
return host.startsWith(prefix);
}
return false;
}
Future<_FetchedContent> _fetchUrl(String originalUrl) async {
_pruneCache();
final cacheKey = _CacheKey(originalUrl);
final cached = _cache[cacheKey];
if (cached != null && DateTime.now().difference(cached.fetchedAt) < _cacheTtl) {
_touchCacheEntry(cacheKey);
return cached.content;
}
final httpClient = HttpClient()..connectionTimeout = const Duration(seconds: 60);
try {
var currentUrl = Uri.parse(originalUrl);
final originalComparableHost = _stripWww(currentUrl.host);
for (var redirectCount = 0; redirectCount <= _maxRedirects; redirectCount++) {
final request = await httpClient.getUrl(currentUrl);
request.headers.set("Accept", "text/markdown, text/html, text/plain, */*");
request.headers.set("User-Agent", "clawd_code/0.1.0 (WebFetch)");
final response = await request.close().timeout(const Duration(seconds: 60));
final statusCode = response.statusCode;
final statusText = response.reasonPhrase;
final location = response.headers.value(HttpHeaders.locationHeader);
if (_isRedirect(statusCode) && location != null) {
final redirectUrl = currentUrl.resolve(location);
if (_stripWww(redirectUrl.host) != originalComparableHost) {
final redirectNotice = _FetchedContent(
finalUrl: currentUrl.toString(),
statusCode: statusCode,
reasonPhrase: statusText,
bytes: 0,
contentType: "text/plain",
content:
"REDIRECT DETECTED: The URL redirects to a different host.\n\n"
"Original URL: $currentUrl\n"
"Redirect URL: $redirectUrl\n"
"Status: $statusCode $statusText\n\n"
"To complete your request, use WebFetch again with these parameters:\n"
"- url: \"$redirectUrl\"",
isRedirectNotice: true,
);
_storeCacheEntry(cacheKey, redirectNotice);
return redirectNotice;
}
currentUrl = redirectUrl;
continue;
}
final bytes = await _readResponseBytes(response);
final contentType =
response.headers.contentType?.mimeType ?? "application/octet-stream";
final isBinary = _looksBinary(contentType, bytes);
final persistedBinary = isBinary
? await _persistBinaryContent(bytes, contentType)
: null;
final decodedText = _decodeBody(bytes, isBinary: isBinary);
final readableContent = _extractReadableContent(
decodedText,
contentType: contentType,
url: currentUrl.toString(),
);
final fetched = _FetchedContent(
finalUrl: currentUrl.toString(),
statusCode: statusCode,
reasonPhrase: statusText,
bytes: bytes.length,
contentType: contentType,
content: readableContent,
persistedBinaryPath: persistedBinary?.path,
persistedBinarySize: persistedBinary?.size,
);
_storeCacheEntry(cacheKey, fetched);
return fetched;
}
throw StateError("Too many redirects");
} finally {
httpClient.close();
}
}
Future<List<int>> _readResponseBytes(HttpClientResponse response) async {
final builder = BytesBuilder(copy: false);
await for (final chunk in response) {
builder.add(chunk);
if (builder.length > _maxFetchBytes) {
throw StateError("Response exceeded ${_maxFetchBytes} bytes");
}
}
return builder.takeBytes();
}
String _decodeBody(List<int> bytes, {required bool isBinary}) {
if (isBinary) {
return latin1.decode(bytes, allowInvalid: true);
}
try {
return utf8.decode(bytes);
} catch (_) {
return latin1.decode(bytes, allowInvalid: true);
}
}
Future<_PersistedBinary?> _persistBinaryContent(
List<int> bytes,
String contentType,
) async {
try {
final extension = _extensionForMimeType(contentType);
final fileName =
"webfetch-${DateTime.now().millisecondsSinceEpoch}-${_randomSuffix()}$extension";
final file = File(path.join(Directory.systemTemp.path, fileName));
await file.writeAsBytes(bytes, flush: true);
return _PersistedBinary(path: file.path, size: bytes.length);
} catch (_) {
return null;
}
}
String _extractReadableContent(
String rawContent, {
required String contentType,
required String url,
}) {
if (contentType.contains("markdown") || contentType.contains("plain")) {
return _truncateContent(rawContent.trim());
}
final document = html_parser.parse(rawContent);
document.querySelectorAll("script,style,noscript,svg,iframe").forEach((node) {
node.remove();
});
final title = document.querySelector("title")?.text.trim();
final description = document
.querySelector('meta[name="description"], meta[property="og:description"]')
?.attributes["content"]
?.trim();
final root =
document.querySelector("article") ??
document.querySelector("main") ??
document.body ??
document.documentElement;
if (root == null) {
throw StateError("No readable content found at $url");
}
final buffer = StringBuffer();
if (title != null && title.isNotEmpty) {
buffer.writeln("# $title");
buffer.writeln();
}
if (description != null && description.isNotEmpty) {
buffer.writeln(description);
buffer.writeln();
}
for (final node in root.nodes) {
_writeNode(node, buffer, listDepth: 0, inPre: false);
}
var result = buffer.toString();
result = result.replaceAll(RegExp(r"\n{3,}"), "\n\n").trim();
result = _decodeHtmlEntities(result);
if (result.isEmpty) {
throw StateError("No readable content found at $url");
}
return _truncateContent(result);
}
void _writeNode(
dom.Node node,
StringBuffer buffer, {
required int listDepth,
required bool inPre,
}) {
if (node is dom.Text) {
final text = inPre
? node.text
: node.text.replaceAll(RegExp(r"\s+"), " ");
if (text.trim().isNotEmpty) {
buffer.write(text);
}
return;
}
if (node is! dom.Element) {
return;
}
final tag = node.localName?.toLowerCase() ?? "";
switch (tag) {
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6":
final level = int.tryParse(tag.substring(1)) ?? 1;
buffer
..writeln()
..write("${"#" * level} ${node.text.trim()}")
..writeln()
..writeln();
return;
case "p":
_writeChildren(node, buffer, listDepth: listDepth, inPre: false);
buffer.writeln();
buffer.writeln();
return;
case "br":
buffer.writeln();
return;
case "pre":
final code = node.text.trimRight();
if (code.isNotEmpty) {
buffer
..writeln()
..writeln("```")
..writeln(code)
..writeln("```")
..writeln();
}
return;
case "code":
final code = node.text.replaceAll(RegExp(r"\s+"), " ").trim();
if (code.isNotEmpty) {
buffer.write("`$code`");
}
return;
case "ul":
case "ol":
buffer.writeln();
var index = 1;
for (final child in node.children.where((child) => child.localName == "li")) {
final prefix = tag == "ol" ? "${index++}." : "-";
buffer.write("${" " * listDepth}$prefix ");
_writeChildren(child, buffer, listDepth: listDepth + 1, inPre: false);
buffer.writeln();
}
buffer.writeln();
return;
case "li":
_writeChildren(node, buffer, listDepth: listDepth, inPre: false);
return;
case "a":
final label = node.text.replaceAll(RegExp(r"\s+"), " ").trim();
final href = node.attributes["href"]?.trim();
if (label.isNotEmpty && href != null && href.isNotEmpty) {
buffer.write("[$label]($href)");
} else {
_writeChildren(node, buffer, listDepth: listDepth, inPre: false);
}
return;
case "blockquote":
final quote = node.text.trim();
if (quote.isNotEmpty) {
buffer
..writeln()
..writeln("> ${quote.replaceAll("\n", "\n> ")}")
..writeln();
}
return;
case "table":
final tableText = node.text.replaceAll(RegExp(r"\s+"), " ").trim();
if (tableText.isNotEmpty) {
buffer
..writeln()
..writeln(tableText)
..writeln();
}
return;
case "hr":
buffer
..writeln()
..writeln("---")
..writeln();
return;
default:
final blockTags = <String>{
"article",
"section",
"main",
"div",
"header",
"footer",
"nav",
"aside",
};
final wasBlock = blockTags.contains(tag);
if (wasBlock) {
buffer.writeln();
}
_writeChildren(node, buffer, listDepth: listDepth, inPre: inPre);
if (wasBlock) {
buffer.writeln();
}
}
}
void _writeChildren(
dom.Element element,
StringBuffer buffer, {
required int listDepth,
required bool inPre,
}) {
for (final child in element.nodes) {
_writeNode(child, buffer, listDepth: listDepth, inPre: inPre);
}
}
Future<String> _summarizeFetchedContent({
required String apiKey,
required String model,
required _FetchedContent fetched,
required String prompt,
required bool isPreapproved,
}) async {
if (isPreapproved &&
fetched.contentType.contains("markdown") &&
fetched.content.length <= _maxPromptContentChars) {
return fetched.content;
}
final client = await OpenRouterClientFactory.create(apiKey: apiKey);
try {
final response = await client.createMessage(
model: model,
maxTokens: 2048,
messages: <Map<String, dynamic>>[
<String, dynamic>{
"role": "system",
"content": isPreapproved
? "Provide a concise response based on the fetched content. Include relevant details and code examples when present."
: "Provide a concise response based only on the fetched content. Use short quotes only when necessary.",
},
<String, dynamic>{
"role": "user",
"content":
"URL: ${fetched.finalUrl}\n"
"Content-Type: ${fetched.contentType}\n\n"
"Web page content:\n---\n${fetched.content}\n---\n\n"
"$prompt",
},
],
);
final parts = <String>[];
for (final block in response.content) {
if (block is Map<String, dynamic> && block["type"] == "text") {
final text = block["text"];
if (text is String && text.isNotEmpty) {
parts.add(text);
}
}
}
final result = parts.join("\n").trim();
return result.isEmpty ? "No response from model." : result;
} finally {
client.close();
}
}
String _formatOutput({
required _FetchedContent fetched,
required int durationMs,
required String result,
}) {
final lines = <String>[
"URL: ${fetched.finalUrl}",
"Status: ${fetched.statusCode} ${fetched.reasonPhrase}",
"Bytes: ${fetched.bytes}",
"Duration: ${_formatDuration(durationMs)}",
];
if (fetched.persistedBinaryPath != null && fetched.persistedBinarySize != null) {
lines.add(
"Binary content saved: ${fetched.persistedBinaryPath} (${fetched.persistedBinarySize} bytes)",
);
}
lines
..add("")
..add(result.trim());
return lines.join("\n");
}
void _pruneCache() {
final now = DateTime.now();
_cacheOrder.removeWhere((key) {
final entry = _cache[key];
final expired = entry == null || now.difference(entry.fetchedAt) >= _cacheTtl;
if (expired) {
_cache.remove(key);
}
return expired;
});
}
void _storeCacheEntry(_CacheKey key, _FetchedContent content) {
_cache[key] = _CacheEntry(content: content, fetchedAt: DateTime.now());
_touchCacheEntry(key);
while (_cacheOrder.length > _maxCacheEntries) {
final removed = _cacheOrder.removeAt(0);
_cache.remove(removed);
}
}
void _touchCacheEntry(_CacheKey key) {
_cacheOrder.remove(key);
_cacheOrder.add(key);
}
bool _isPreapprovedUrl(String url) {
final uri = Uri.parse(url);
final host = uri.host.toLowerCase();
if (_preapprovedHosts.contains(host)) {
return true;
}
final prefixes = _preapprovedPathPrefixes[host];
if (prefixes == null) {
return false;
}
final pathName = uri.path;
return prefixes.any(
(prefix) => pathName == prefix || pathName.startsWith("$prefix/"),
);
}
bool _isLocalOrPrivateHost(String host) {
final lower = host.toLowerCase();
if (lower == "localhost" || !lower.contains(".")) {
return true;
}
if (lower.endsWith(".local")) {
return true;
}
final ipv4 = RegExp(r"^(\d{1,3}\.){3}\d{1,3}$");
if (ipv4.hasMatch(lower)) {
final parts = lower.split(".").map(int.parse).toList();
if (parts.any((part) => part < 0 || part > 255)) {
return true;
}
return parts[0] == 10 ||
parts[0] == 127 ||
(parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31) ||
(parts[0] == 192 && parts[1] == 168) ||
(parts[0] == 169 && parts[1] == 254);
}
if (lower == "::1" || lower.startsWith("fc") || lower.startsWith("fd")) {
return true;
}
return false;
}
bool _isRedirect(int statusCode) {
return statusCode == 301 ||
statusCode == 302 ||
statusCode == 307 ||
statusCode == 308;
}
bool _looksBinary(String contentType, List<int> bytes) {
if (contentType.startsWith("text/") ||
contentType.contains("json") ||
contentType.contains("xml") ||
contentType.contains("javascript") ||
contentType.contains("xhtml")) {
return false;
}
for (final byte in bytes.take(256)) {
if (byte == 0) {
return true;
}
}
return true;
}
String _truncateContent(String content) {
if (content.length <= _maxPromptContentChars) {
return content;
}
return "${content.substring(0, _maxPromptContentChars)}\n\n[Content truncated due to length...]";
}
String _decodeHtmlEntities(String text) {
return text
.replaceAll("&nbsp;", " ")
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", "\"")
.replaceAll("&#39;", "'")
.replaceAll("&#x27;", "'")
.replaceAll("&apos;", "'");
}
String _extensionForMimeType(String contentType) {
if (contentType.contains("pdf")) return ".pdf";
if (contentType.contains("zip")) return ".zip";
if (contentType.contains("png")) return ".png";
if (contentType.contains("jpeg")) return ".jpg";
if (contentType.contains("gif")) return ".gif";
if (contentType.contains("webp")) return ".webp";
if (contentType.contains("json")) return ".json";
return ".bin";
}
List<String> _readStringList(Object? value) {
if (value is! List) {
return const <String>[];
}
return value
.whereType<String>()
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
}
String _randomSuffix() {
final radix = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
return radix.substring(radix.length - 6);
}
String _stripWww(String host) => host.replaceFirst(RegExp(r"^www\."), "");
String _formatDuration(int durationMs) {
if (durationMs < 1000) {
return "${durationMs}ms";
}
return "${(durationMs / 1000).toStringAsFixed(1)}s";
}
}
class _FetchedContent {
const _FetchedContent({
required this.finalUrl,
required this.statusCode,
required this.reasonPhrase,
required this.bytes,
required this.contentType,
required this.content,
this.persistedBinaryPath,
this.persistedBinarySize,
this.isRedirectNotice = false,
});
final String finalUrl;
final int statusCode;
final String reasonPhrase;
final int bytes;
final String contentType;
final String content;
final String? persistedBinaryPath;
final int? persistedBinarySize;
final bool isRedirectNotice;
}
class _PersistedBinary {
const _PersistedBinary({required this.path, required this.size});
final String path;
final int size;
}
class _CacheEntry {
const _CacheEntry({required this.content, required this.fetchedAt});
final _FetchedContent content;
final DateTime fetchedAt;
}
class _CacheKey {
const _CacheKey(this.url);
final String url;
@override
bool operator ==(Object other) =>
identical(this, other) || other is _CacheKey && other.url == url;
@override
int get hashCode => url.hashCode;
}
+336
View File
@@ -0,0 +1,336 @@
import "dart:convert";
import "dart:io";
import "../api/request_builder.dart";
import "base_tool.dart";
class WebSearchTool extends BaseTool {
@override
final String name = "WebSearch";
@override
final String description =
"Search the web for current information. Supports optional domain allow/block filters and returns a cited summary.";
@override
Future<String> execute(Map<String, dynamic> input) async {
final query = requireString(input, "query").trim();
final allowedDomains = _readStringList(input["allowed_domains"]);
final blockedDomains = _readStringList(input["blocked_domains"]);
final apiKey = optionalString(input, "_api_key") ?? "";
final model = optionalString(input, "_model") ?? "openrouter/auto";
if (query.length < 2) {
throw ArgumentError("query must be at least 2 characters");
}
if (allowedDomains.isNotEmpty && blockedDomains.isNotEmpty) {
throw ArgumentError(
"Cannot specify both allowed_domains and blocked_domains in the same request",
);
}
if (apiKey.isEmpty) {
throw StateError("Web search requires an OpenRouter API key");
}
final startTime = DateTime.now();
final response = await _performSearch(
apiKey: apiKey,
model: model,
query: query,
allowedDomains: allowedDomains,
blockedDomains: blockedDomains,
);
final durationMs = DateTime.now().difference(startTime).inMilliseconds;
return _formatResult(
query: query,
response: response,
durationMs: durationMs,
);
}
Future<Map<String, dynamic>> _performSearch({
required String apiKey,
required String model,
required String query,
required List<String> allowedDomains,
required List<String> blockedDomains,
}) async {
final httpClient = HttpClient()..connectionTimeout = const Duration(seconds: 60);
try {
final request = await httpClient.openUrl(
"POST",
Uri.parse("https://openrouter.ai/api/v1/chat/completions"),
);
final headers = HeaderBuilder();
headers.addAuthHeader(apiKey);
headers.addOpenRouterHeaders();
for (final entry in headers.build().entries) {
request.headers.set(entry.key, entry.value);
}
request.headers.contentType = ContentType.json;
final searchTool = <String, dynamic>{
"type": "openrouter:web_search",
"parameters": <String, dynamic>{
"max_results": 5,
"max_total_results": 20,
"search_context_size": "medium",
},
};
final parameters = searchTool["parameters"] as Map<String, dynamic>;
if (allowedDomains.isNotEmpty) {
parameters["allowed_domains"] = allowedDomains;
}
if (blockedDomains.isNotEmpty) {
parameters["excluded_domains"] = blockedDomains;
}
final requestBody = <String, dynamic>{
"model": model,
"max_tokens": 2048,
"messages": <Map<String, dynamic>>[
<String, dynamic>{
"role": "system",
"content": _buildSearchPrompt(),
},
<String, dynamic>{
"role": "user",
"content": "Perform a web search for the query: $query",
},
],
"tools": <Map<String, dynamic>>[searchTool],
};
request.write(jsonEncode(requestBody));
final response = await request.close();
final responseBody = await response.transform(utf8.decoder).join();
if (response.statusCode >= 400) {
throw StateError(
"OpenRouter web search failed with HTTP ${response.statusCode}: $responseBody",
);
}
final decoded = jsonDecode(responseBody);
if (decoded is! Map<String, dynamic>) {
throw StateError("Unexpected web search response format");
}
return decoded;
} finally {
httpClient.close();
}
}
String _buildSearchPrompt() {
final now = DateTime.now();
final monthYear = "${_monthName(now.month)} ${now.year}";
return [
"You are an assistant for performing a web search tool use.",
"Use the provided web search results to answer the query.",
"After answering, you MUST include a 'Sources:' section with markdown links.",
"Use the current year when searching for recent information.",
"The current month is $monthYear.",
].join(" ");
}
String _formatResult({
required String query,
required Map<String, dynamic> response,
required int durationMs,
}) {
final choices = response["choices"];
Map<String, dynamic>? message;
if (choices is List && choices.isNotEmpty) {
final firstChoice = choices.first;
if (firstChoice is Map<String, dynamic>) {
final rawMessage = firstChoice["message"];
if (rawMessage is Map<String, dynamic>) {
message = rawMessage;
}
}
}
final content = _extractMessageContent(message);
final annotations = _extractAnnotations(message);
final sources = _extractSources(annotations);
final searchesPerformed = _extractSearchCount(response);
final buffer = StringBuffer()
..writeln("Query: $query")
..writeln("Duration: ${_formatDuration(durationMs)}")
..writeln("Searches performed: $searchesPerformed")
..writeln()
..writeln(content.isEmpty ? "No summary returned." : content.trim());
if (!_containsSourcesSection(content) && sources.isNotEmpty) {
buffer
..writeln()
..writeln("Sources:");
for (final source in sources) {
buffer.writeln("- [${source.title}](${source.url})");
}
}
return buffer.toString().trimRight();
}
String _extractMessageContent(Map<String, dynamic>? message) {
if (message == null) {
return "";
}
final content = message["content"];
if (content is String) {
return content;
}
if (content is List) {
final parts = <String>[];
for (final item in content) {
if (item is Map<String, dynamic>) {
final type = item["type"];
if (type == "text" || type == "output_text") {
final text = item["text"];
if (text is String && text.isNotEmpty) {
parts.add(text);
}
}
}
}
return parts.join("\n");
}
return "";
}
List<Map<String, dynamic>> _extractAnnotations(Map<String, dynamic>? message) {
if (message == null) {
return const <Map<String, dynamic>>[];
}
final annotations = <Map<String, dynamic>>[];
final topLevel = message["annotations"];
if (topLevel is List) {
for (final item in topLevel) {
if (item is Map<String, dynamic>) {
annotations.add(item);
}
}
}
final content = message["content"];
if (content is List) {
for (final item in content) {
if (item is! Map<String, dynamic>) {
continue;
}
final nested = item["annotations"];
if (nested is! List) {
continue;
}
for (final annotation in nested) {
if (annotation is Map<String, dynamic>) {
annotations.add(annotation);
}
}
}
}
return annotations;
}
List<_Source> _extractSources(List<Map<String, dynamic>> annotations) {
final seenUrls = <String>{};
final sources = <_Source>[];
for (final annotation in annotations) {
if (annotation["type"] != "url_citation") {
continue;
}
final citation = annotation["url_citation"];
final citationMap = citation is Map<String, dynamic>
? citation
: annotation;
final url = citationMap["url"];
if (url is! String || url.isEmpty || !seenUrls.add(url)) {
continue;
}
final title = citationMap["title"];
sources.add(
_Source(
title: title is String && title.isNotEmpty ? title : _hostForUrl(url),
url: url,
),
);
}
return sources;
}
int _extractSearchCount(Map<String, dynamic> response) {
final usage = response["usage"];
if (usage is! Map<String, dynamic>) {
return 0;
}
final serverToolUse = usage["server_tool_use"];
if (serverToolUse is! Map<String, dynamic>) {
return 0;
}
return (serverToolUse["web_search_requests"] as num?)?.toInt() ?? 0;
}
List<String> _readStringList(Object? value) {
if (value is! List) {
return const <String>[];
}
return value
.whereType<String>()
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
}
bool _containsSourcesSection(String content) {
return RegExp(r"(^|\n)Sources:\s*$", caseSensitive: false).hasMatch(content);
}
String _hostForUrl(String url) {
final uri = Uri.tryParse(url);
return uri?.host.isNotEmpty == true ? uri!.host : url;
}
String _formatDuration(int durationMs) {
if (durationMs < 1000) {
return "${durationMs}ms";
}
return "${(durationMs / 1000).toStringAsFixed(1)}s";
}
String _monthName(int month) {
const names = <String>[
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
return names[month - 1];
}
}
class _Source {
const _Source({required this.title, required this.url});
final String title;
final String url;
}