Add new features and update configurations for improved functionality
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(" ", " ")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", "\"")
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user