Add new features and update configurations for improved functionality
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
// Agent execution context and shared state
|
||||
// Represents the environment in which agents operate
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import '../local_state.dart';
|
||||
|
||||
/// Shared context passed between agents
|
||||
class AgentContext {
|
||||
final String agentId;
|
||||
final String parentAgentId;
|
||||
final String sessionId;
|
||||
final String workingDirectory;
|
||||
final LocalSettings settings;
|
||||
|
||||
/// Conversation history visible to this agent
|
||||
final List<Map<String, dynamic>> conversationHistory;
|
||||
|
||||
/// Shared variables/state between agents
|
||||
final Map<String, dynamic> sharedState;
|
||||
|
||||
/// Files this agent has access to
|
||||
final Set<String> accessiblePaths;
|
||||
|
||||
/// Tools this agent can invoke
|
||||
final Set<String> allowedTools;
|
||||
|
||||
/// Results from sub-agents (if delegating work)
|
||||
final Map<String, AgentResult> subAgentResults;
|
||||
|
||||
DateTime createdAt;
|
||||
DateTime? completedAt;
|
||||
|
||||
AgentContext({
|
||||
required this.agentId,
|
||||
required this.parentAgentId,
|
||||
required this.sessionId,
|
||||
required this.workingDirectory,
|
||||
required this.settings,
|
||||
List<Map<String, dynamic>>? conversationHistory,
|
||||
Map<String, dynamic>? sharedState,
|
||||
Set<String>? accessiblePaths,
|
||||
Set<String>? allowedTools,
|
||||
}) : conversationHistory = conversationHistory ?? [],
|
||||
sharedState = sharedState ?? {},
|
||||
accessiblePaths = accessiblePaths ?? {},
|
||||
allowedTools = allowedTools ?? {},
|
||||
subAgentResults = {},
|
||||
createdAt = DateTime.now();
|
||||
|
||||
/// Create a child agent context
|
||||
AgentContext createChildContext({
|
||||
required String childAgentId,
|
||||
List<Map<String, dynamic>>? childConversationHistory,
|
||||
}) {
|
||||
return AgentContext(
|
||||
agentId: childAgentId,
|
||||
parentAgentId: agentId,
|
||||
sessionId: sessionId,
|
||||
workingDirectory: workingDirectory,
|
||||
settings: settings,
|
||||
conversationHistory: childConversationHistory ?? List.from(conversationHistory),
|
||||
sharedState: Map.from(sharedState),
|
||||
accessiblePaths: Set.from(accessiblePaths),
|
||||
allowedTools: Set.from(allowedTools),
|
||||
);
|
||||
}
|
||||
|
||||
/// Add shared state (visible to all agents)
|
||||
void setSharedVariable(String key, dynamic value) {
|
||||
sharedState[key] = value;
|
||||
}
|
||||
|
||||
/// Get shared variable
|
||||
dynamic getSharedVariable(String key) => sharedState[key];
|
||||
|
||||
/// Check if agent can access a path
|
||||
bool canAccessPath(String path) {
|
||||
if (accessiblePaths.isEmpty) {
|
||||
return true; // No restrictions
|
||||
}
|
||||
return accessiblePaths.contains(path) ||
|
||||
accessiblePaths.any((p) => path.startsWith(p));
|
||||
}
|
||||
|
||||
/// Check if agent can use a tool
|
||||
bool canUseTool(String toolName) {
|
||||
if (allowedTools.isEmpty) {
|
||||
return true; // No restrictions
|
||||
}
|
||||
return allowedTools.contains(toolName);
|
||||
}
|
||||
|
||||
/// Store sub-agent result
|
||||
void recordSubAgentResult(String subAgentId, AgentResult result) {
|
||||
subAgentResults[subAgentId] = result;
|
||||
}
|
||||
|
||||
/// Get sub-agent result
|
||||
AgentResult? getSubAgentResult(String subAgentId) => subAgentResults[subAgentId];
|
||||
|
||||
/// Mark agent as complete
|
||||
void markComplete() {
|
||||
completedAt = DateTime.now();
|
||||
}
|
||||
|
||||
/// Get agent duration
|
||||
Duration? getDuration() {
|
||||
if (completedAt == null) {
|
||||
return null;
|
||||
}
|
||||
return completedAt!.difference(createdAt);
|
||||
}
|
||||
|
||||
/// Export context as JSON (for debugging/logging)
|
||||
Map<String, dynamic> toJson() => {
|
||||
'agent_id': agentId,
|
||||
'parent_agent_id': parentAgentId,
|
||||
'session_id': sessionId,
|
||||
'working_directory': workingDirectory,
|
||||
'conversation_history_length': conversationHistory.length,
|
||||
'shared_state_keys': sharedState.keys.toList(),
|
||||
'accessible_paths': accessiblePaths.toList(),
|
||||
'allowed_tools': allowedTools.toList(),
|
||||
'sub_agent_results': subAgentResults.length,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'completed_at': completedAt?.toIso8601String(),
|
||||
'duration_ms': getDuration()?.inMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
/// Result of an agent execution
|
||||
class AgentResult {
|
||||
final String agentId;
|
||||
final bool success;
|
||||
final String output;
|
||||
final String? error;
|
||||
final Map<String, dynamic>? data;
|
||||
final Duration? duration;
|
||||
|
||||
const AgentResult({
|
||||
required this.agentId,
|
||||
required this.success,
|
||||
required this.output,
|
||||
this.error,
|
||||
this.data,
|
||||
this.duration,
|
||||
});
|
||||
|
||||
factory AgentResult.success({
|
||||
required String agentId,
|
||||
required String output,
|
||||
Map<String, dynamic>? data,
|
||||
Duration? duration,
|
||||
}) =>
|
||||
AgentResult(
|
||||
agentId: agentId,
|
||||
success: true,
|
||||
output: output,
|
||||
error: null,
|
||||
data: data,
|
||||
duration: duration,
|
||||
);
|
||||
|
||||
factory AgentResult.failure({
|
||||
required String agentId,
|
||||
required String error,
|
||||
Duration? duration,
|
||||
}) =>
|
||||
AgentResult(
|
||||
agentId: agentId,
|
||||
success: false,
|
||||
output: '',
|
||||
error: error,
|
||||
data: null,
|
||||
duration: duration,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'agent_id': agentId,
|
||||
'success': success,
|
||||
'output': output,
|
||||
'error': error,
|
||||
'data': data,
|
||||
'duration_ms': duration?.inMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
/// Agent definition (what kind of agent to spawn)
|
||||
class AgentDefinition {
|
||||
final String type; // e.g., 'researcher', 'coder', 'reviewer', 'planner'
|
||||
final String? model; // Override model for this agent
|
||||
final String? systemPrompt; // Custom system prompt
|
||||
final Map<String, dynamic>? config; // Agent-specific config
|
||||
|
||||
const AgentDefinition({
|
||||
required this.type,
|
||||
this.model,
|
||||
this.systemPrompt,
|
||||
this.config,
|
||||
});
|
||||
|
||||
/// Get system prompt for this agent type
|
||||
String getSystemPrompt() {
|
||||
if (systemPrompt != null) {
|
||||
return systemPrompt!;
|
||||
}
|
||||
|
||||
switch (type.toLowerCase()) {
|
||||
case 'researcher':
|
||||
return '''You are a research agent. Your job is to:
|
||||
1. Search for information using available tools
|
||||
2. Aggregate findings from multiple sources
|
||||
3. Provide a comprehensive summary of findings
|
||||
Focus on accuracy and citing sources.''';
|
||||
|
||||
case 'coder':
|
||||
return '''You are a code generation and implementation agent. Your job is to:
|
||||
1. Write or modify code to solve problems
|
||||
2. Test the code to ensure it works
|
||||
3. Document the implementation
|
||||
Focus on correctness and best practices.''';
|
||||
|
||||
case 'reviewer':
|
||||
return '''You are a code review and analysis agent. Your job is to:
|
||||
1. Review code for correctness and quality
|
||||
2. Identify potential issues and improvements
|
||||
3. Provide detailed feedback
|
||||
Focus on constructive and actionable feedback.''';
|
||||
|
||||
case 'planner':
|
||||
return '''You are a planning and strategy agent. Your job is to:
|
||||
1. Break down complex tasks into steps
|
||||
2. Identify dependencies and risks
|
||||
3. Create detailed implementation plans
|
||||
Focus on thoroughness and clarity.''';
|
||||
|
||||
case 'executor':
|
||||
return '''You are an execution agent. Your job is to:
|
||||
1. Execute planned steps
|
||||
2. Handle errors and recover gracefully
|
||||
3. Report progress and results
|
||||
Focus on reliability and completeness.''';
|
||||
|
||||
default:
|
||||
return '''You are an AI agent. Use available tools to accomplish your assigned task.
|
||||
Focus on being thorough, accurate, and helpful.''';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Agent coordination engine
|
||||
// Manages multi-agent workflows and communication
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import '../local_state.dart';
|
||||
import 'agent_context.dart';
|
||||
import 'agent_executor.dart';
|
||||
|
||||
/// Orchestrates multiple agents working together
|
||||
class AgentCoordinator {
|
||||
static final AgentCoordinator _instance = AgentCoordinator._internal();
|
||||
factory AgentCoordinator() => _instance;
|
||||
AgentCoordinator._internal() : _executor = AgentExecutor();
|
||||
|
||||
final AgentExecutor _executor;
|
||||
final Map<String, AgentWorkflow> _workflows = {};
|
||||
int _workflowCounter = 1;
|
||||
|
||||
/// Create a new workflow
|
||||
AgentWorkflow createWorkflow({
|
||||
required String name,
|
||||
required String sessionId,
|
||||
required String workingDirectory,
|
||||
required LocalSettings settings,
|
||||
}) {
|
||||
final workflow = AgentWorkflow(
|
||||
id: 'workflow_${_workflowCounter++}',
|
||||
name: name,
|
||||
sessionId: sessionId,
|
||||
workingDirectory: workingDirectory,
|
||||
settings: settings,
|
||||
coordinator: this,
|
||||
executor: _executor,
|
||||
);
|
||||
|
||||
_workflows[workflow.id] = workflow;
|
||||
return workflow;
|
||||
}
|
||||
|
||||
/// Get a workflow
|
||||
AgentWorkflow? getWorkflow(String workflowId) => _workflows[workflowId];
|
||||
|
||||
/// List all workflows
|
||||
List<AgentWorkflow> listWorkflows() => _workflows.values.toList();
|
||||
}
|
||||
|
||||
/// Represents a workflow (sequence of agents)
|
||||
class AgentWorkflow {
|
||||
final String id;
|
||||
final String name;
|
||||
final String sessionId;
|
||||
final String workingDirectory;
|
||||
final LocalSettings settings;
|
||||
final AgentCoordinator coordinator;
|
||||
final AgentExecutor executor;
|
||||
|
||||
DateTime createdAt = DateTime.now();
|
||||
DateTime? completedAt;
|
||||
|
||||
final List<WorkflowStep> steps = [];
|
||||
final Map<String, dynamic> sharedState = {};
|
||||
final Map<String, AgentResult> agentResults = {};
|
||||
|
||||
int currentStepIndex = 0;
|
||||
bool _isRunning = false;
|
||||
bool _cancelled = false;
|
||||
|
||||
AgentWorkflow({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.sessionId,
|
||||
required this.workingDirectory,
|
||||
required this.settings,
|
||||
required this.coordinator,
|
||||
required this.executor,
|
||||
});
|
||||
|
||||
/// Add a step to the workflow
|
||||
void addStep(
|
||||
AgentDefinition agentDef,
|
||||
String task, {
|
||||
bool dependsOnPrevious = true,
|
||||
}) {
|
||||
steps.add(WorkflowStep(
|
||||
index: steps.length,
|
||||
definition: agentDef,
|
||||
task: task,
|
||||
dependsOnPrevious: dependsOnPrevious,
|
||||
));
|
||||
}
|
||||
|
||||
/// Execute the workflow
|
||||
Future<WorkflowResult> execute({
|
||||
required String apiKey,
|
||||
required String model,
|
||||
}) async {
|
||||
if (_isRunning) {
|
||||
throw Exception('Workflow $id is already running');
|
||||
}
|
||||
|
||||
_isRunning = true;
|
||||
final startTime = DateTime.now();
|
||||
|
||||
try {
|
||||
for (int i = 0; i < steps.length; i++) {
|
||||
if (_cancelled) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentStepIndex = i;
|
||||
final step = steps[i];
|
||||
|
||||
// Prepare task (substitute variables from previous results)
|
||||
var task = step.task;
|
||||
for (final result in agentResults.values) {
|
||||
task = task.replaceAll(
|
||||
'\${result}',
|
||||
result.output,
|
||||
);
|
||||
}
|
||||
|
||||
// Create context for this agent
|
||||
final context = AgentContext(
|
||||
agentId: 'workflow_${id}_agent_$i',
|
||||
parentAgentId: id,
|
||||
sessionId: sessionId,
|
||||
workingDirectory: workingDirectory,
|
||||
settings: settings,
|
||||
sharedState: Map.from(sharedState),
|
||||
);
|
||||
|
||||
// Spawn agent
|
||||
final agentId = await executor.spawnAgent(
|
||||
definition: step.definition,
|
||||
context: context,
|
||||
task: task,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
);
|
||||
|
||||
// Wait for agent to complete
|
||||
final result = await executor.waitForAgent(agentId);
|
||||
agentResults[agentId] = result;
|
||||
|
||||
// Update shared state from agent result
|
||||
if (result.data != null) {
|
||||
sharedState.addAll(result.data!);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
// Stop workflow on agent failure
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
completedAt = DateTime.now();
|
||||
|
||||
return WorkflowResult(
|
||||
id: id,
|
||||
name: name,
|
||||
success: agentResults.values.every((r) => r.success),
|
||||
agentResults: agentResults,
|
||||
sharedState: sharedState,
|
||||
duration: completedAt!.difference(startTime),
|
||||
);
|
||||
} catch (e) {
|
||||
completedAt = DateTime.now();
|
||||
rethrow;
|
||||
} finally {
|
||||
_isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel the workflow
|
||||
void cancel() {
|
||||
_cancelled = true;
|
||||
}
|
||||
|
||||
/// Get workflow status
|
||||
Map<String, dynamic> getStatus() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'is_running': _isRunning,
|
||||
'current_step': currentStepIndex,
|
||||
'total_steps': steps.length,
|
||||
'completed_agents': agentResults.length,
|
||||
'completed_at': completedAt?.toIso8601String(),
|
||||
'duration_ms': completedAt?.difference(createdAt).inMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
/// A single step in a workflow
|
||||
class WorkflowStep {
|
||||
final int index;
|
||||
final AgentDefinition definition;
|
||||
final String task;
|
||||
final bool dependsOnPrevious;
|
||||
|
||||
WorkflowStep({
|
||||
required this.index,
|
||||
required this.definition,
|
||||
required this.task,
|
||||
required this.dependsOnPrevious,
|
||||
});
|
||||
}
|
||||
|
||||
/// Result of a complete workflow
|
||||
class WorkflowResult {
|
||||
final String id;
|
||||
final String name;
|
||||
final bool success;
|
||||
final Map<String, AgentResult> agentResults;
|
||||
final Map<String, dynamic> sharedState;
|
||||
final Duration duration;
|
||||
|
||||
const WorkflowResult({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.success,
|
||||
required this.agentResults,
|
||||
required this.sharedState,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
/// Get combined output from all agents
|
||||
String getCombinedOutput() {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
for (final entry in agentResults.entries) {
|
||||
buffer.writeln('Agent: ${entry.key}');
|
||||
buffer.writeln(entry.value.output);
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Export as JSON
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'success': success,
|
||||
'agent_results': {
|
||||
for (final e in agentResults.entries) e.key: e.value.toJson(),
|
||||
},
|
||||
'shared_state': sharedState,
|
||||
'duration_ms': duration.inMilliseconds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Agent execution engine
|
||||
// Spawns and manages agent instances
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import '../api/openrouter_client.dart';
|
||||
import '../chat/tool_loop_service.dart';
|
||||
import '../local_state.dart';
|
||||
import 'agent_context.dart';
|
||||
|
||||
/// Executes a single agent
|
||||
class AgentExecutor {
|
||||
static final AgentExecutor _instance = AgentExecutor._internal();
|
||||
factory AgentExecutor() => _instance;
|
||||
AgentExecutor._internal();
|
||||
|
||||
final Map<String, _RunningAgent> _agents = {};
|
||||
int _idCounter = 1;
|
||||
|
||||
/// Spawn and run an agent
|
||||
Future<String> spawnAgent({
|
||||
required AgentDefinition definition,
|
||||
required AgentContext context,
|
||||
required String task,
|
||||
required String apiKey,
|
||||
required String model,
|
||||
}) async {
|
||||
final agentId = 'agent_${_idCounter++}';
|
||||
|
||||
final agent = _RunningAgent(
|
||||
id: agentId,
|
||||
definition: definition,
|
||||
context: context.createChildContext(childAgentId: agentId),
|
||||
task: task,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
);
|
||||
|
||||
_agents[agentId] = agent;
|
||||
|
||||
// Start agent in background
|
||||
agent.run().then((result) {
|
||||
// Agent completed
|
||||
context.recordSubAgentResult(agentId, result);
|
||||
}).catchError((e) {
|
||||
print('Agent $agentId failed: $e');
|
||||
context.recordSubAgentResult(
|
||||
agentId,
|
||||
AgentResult.failure(agentId: agentId, error: e.toString()),
|
||||
);
|
||||
});
|
||||
|
||||
return agentId;
|
||||
}
|
||||
|
||||
/// Get agent result (non-blocking)
|
||||
AgentResult? getResult(String agentId) {
|
||||
final agent = _agents[agentId];
|
||||
if (agent == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return agent.result;
|
||||
}
|
||||
|
||||
/// Wait for agent completion
|
||||
Future<AgentResult> waitForAgent(String agentId,
|
||||
{Duration timeout = const Duration(hours: 24)}) async {
|
||||
final agent = _agents[agentId];
|
||||
if (agent == null) {
|
||||
throw Exception('Agent $agentId not found');
|
||||
}
|
||||
|
||||
return agent.waitForCompletion(timeout: timeout);
|
||||
}
|
||||
|
||||
/// Get all running agents
|
||||
List<Map<String, dynamic>> getAllAgents() {
|
||||
return _agents.entries.map((e) {
|
||||
return {
|
||||
'id': e.key,
|
||||
'type': e.value.definition.type,
|
||||
'task': e.value.task,
|
||||
'status': e.value.isComplete ? 'completed' : 'running',
|
||||
'result': e.value.result?.toJson(),
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Cancel an agent
|
||||
Future<bool> cancelAgent(String agentId) async {
|
||||
final agent = _agents[agentId];
|
||||
if (agent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
agent.cancel();
|
||||
_agents.remove(agentId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a running agent instance
|
||||
class _RunningAgent {
|
||||
final String id;
|
||||
final AgentDefinition definition;
|
||||
final AgentContext context;
|
||||
final String task;
|
||||
final String apiKey;
|
||||
final String model;
|
||||
|
||||
DateTime _startTime = DateTime.now();
|
||||
DateTime? _endTime;
|
||||
AgentResult? result;
|
||||
bool _cancelled = false;
|
||||
|
||||
final Completer<AgentResult> _completer = Completer();
|
||||
|
||||
_RunningAgent({
|
||||
required this.id,
|
||||
required this.definition,
|
||||
required this.context,
|
||||
required this.task,
|
||||
required this.apiKey,
|
||||
required this.model,
|
||||
});
|
||||
|
||||
bool get isComplete => _endTime != null;
|
||||
|
||||
/// Run the agent
|
||||
Future<AgentResult> run() async {
|
||||
try {
|
||||
// Create API client for this agent
|
||||
final client = OpenRouterClient(
|
||||
config: OpenRouterConfig(
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
),
|
||||
);
|
||||
|
||||
// Create tool loop for this agent
|
||||
final toolLoop = ToolLoopService();
|
||||
|
||||
// Build agent-specific system prompt
|
||||
final systemPrompt = '''${definition.getSystemPrompt()}
|
||||
|
||||
Task: $task
|
||||
|
||||
Context:
|
||||
- Session ID: ${context.sessionId}
|
||||
- Working Directory: ${context.workingDirectory}
|
||||
- Parent Agent: ${context.parentAgentId}
|
||||
${context.sharedState.isNotEmpty ? '- Shared State: ${context.sharedState}' : ''}
|
||||
|
||||
Use available tools to complete this task. Report your findings clearly.''';
|
||||
|
||||
// Run the tool loop for this agent
|
||||
final toolResult = await toolLoop.runTurn(
|
||||
client: client,
|
||||
model: model,
|
||||
apiKey: apiKey,
|
||||
getSettings: () => context.settings,
|
||||
apiMessages: context.conversationHistory,
|
||||
userText: task,
|
||||
workingDirectory: context.workingDirectory,
|
||||
);
|
||||
|
||||
// Extract output
|
||||
final output = toolResult.responseText;
|
||||
|
||||
// Create result
|
||||
result = AgentResult.success(
|
||||
agentId: id,
|
||||
output: output,
|
||||
data: {
|
||||
'messages_exchanged': toolResult.apiMessages.length,
|
||||
'web_searches': toolResult.webSearchRequests,
|
||||
'web_fetches': toolResult.webFetchRequests,
|
||||
},
|
||||
duration: DateTime.now().difference(_startTime),
|
||||
);
|
||||
|
||||
_endTime = DateTime.now();
|
||||
_completer.complete(result!);
|
||||
|
||||
return result!;
|
||||
} catch (e, st) {
|
||||
result = AgentResult.failure(
|
||||
agentId: id,
|
||||
error: e.toString(),
|
||||
duration: DateTime.now().difference(_startTime),
|
||||
);
|
||||
|
||||
_endTime = DateTime.now();
|
||||
_completer.completeError(e, st);
|
||||
|
||||
return result!;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for agent to complete
|
||||
Future<AgentResult> waitForCompletion({Duration timeout = const Duration(hours: 24)}) {
|
||||
return _completer.future.timeout(timeout);
|
||||
}
|
||||
|
||||
/// Cancel this agent
|
||||
void cancel() {
|
||||
_cancelled = true;
|
||||
}
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
// Anthropic API client
|
||||
// Ported from old_repo/services/api/client.ts
|
||||
|
||||
import "dart:async";
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "../services/oauth_service.dart";
|
||||
import "api_types.dart";
|
||||
import "request_builder.dart";
|
||||
import "response_parser.dart";
|
||||
|
||||
// Configuration for the Anthropic API client
|
||||
class AnthropicClientConfig {
|
||||
final String apiKey;
|
||||
final String baseUrl;
|
||||
final int maxRetries;
|
||||
final String? model;
|
||||
final String? source;
|
||||
final bool enableLogging;
|
||||
|
||||
const AnthropicClientConfig({
|
||||
required this.apiKey,
|
||||
required this.baseUrl,
|
||||
this.maxRetries = 2,
|
||||
this.model,
|
||||
this.source,
|
||||
this.enableLogging = false,
|
||||
});
|
||||
}
|
||||
|
||||
// Main Anthropic API client
|
||||
class AnthropicClient {
|
||||
final AnthropicClientConfig _config;
|
||||
late HttpClient _httpClient;
|
||||
|
||||
AnthropicClient({required AnthropicClientConfig config}) : _config = config {
|
||||
_httpClient = HttpClient();
|
||||
_httpClient.connectionTimeout = Duration(seconds: 600);
|
||||
}
|
||||
|
||||
// Get API key from environment or config
|
||||
String _getApiKey() {
|
||||
if (_config.apiKey.isNotEmpty) {
|
||||
return _config.apiKey;
|
||||
}
|
||||
|
||||
final env = Platform.environment;
|
||||
return env["ANTHROPIC_API_KEY"] ??
|
||||
env["CLAUDE_API_KEY"] ??
|
||||
env["CLAUDE_CODE_API_KEY"] ??
|
||||
"";
|
||||
}
|
||||
|
||||
// Get base URL from environment or config
|
||||
String _getBaseUrl() {
|
||||
if (_config.baseUrl.isNotEmpty) {
|
||||
return _config.baseUrl;
|
||||
}
|
||||
|
||||
final env = Platform.environment;
|
||||
final override =
|
||||
env["ANTHROPIC_BASE_URL"] ?? env["CLAUDE_CODE_BASE_URL"];
|
||||
if (override != null && override.isNotEmpty) {
|
||||
return override;
|
||||
}
|
||||
|
||||
return "https://api.anthropic.com";
|
||||
}
|
||||
|
||||
// Build headers for API request
|
||||
Map<String, String> _buildHeaders() {
|
||||
final builder = HeaderBuilder();
|
||||
|
||||
// Add API key authentication
|
||||
final apiKey = _getApiKey();
|
||||
if (apiKey.isNotEmpty) {
|
||||
builder.addAuthHeader(apiKey);
|
||||
}
|
||||
|
||||
// Add custom headers from environment
|
||||
builder.addCustomHeadersFromEnv();
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
// Send a message to Claude
|
||||
Future<ApiMessage> createMessage({
|
||||
required String model,
|
||||
required int maxTokens,
|
||||
required List<Map<String, dynamic>> messages,
|
||||
String? system,
|
||||
double? temperature,
|
||||
List<Map<String, dynamic>>? tools,
|
||||
String? toolChoice,
|
||||
}) async {
|
||||
final requestBuilder = MessageRequestBuilder(
|
||||
model: model,
|
||||
maxTokens: maxTokens,
|
||||
messages: messages,
|
||||
);
|
||||
|
||||
if (system != null) {
|
||||
requestBuilder.withSystem(system);
|
||||
}
|
||||
|
||||
if (temperature != null) {
|
||||
requestBuilder.withTemperature(temperature);
|
||||
}
|
||||
|
||||
if (tools != null && tools.isNotEmpty) {
|
||||
requestBuilder.withTools(tools);
|
||||
if (toolChoice != null) {
|
||||
requestBuilder.withToolChoice(toolChoice);
|
||||
}
|
||||
}
|
||||
|
||||
final request = requestBuilder.build();
|
||||
|
||||
return _makeRequest(
|
||||
method: "POST",
|
||||
endpoint: "/v1/messages",
|
||||
body: request.toJson(),
|
||||
).then((response) {
|
||||
return ResponseParser.parseMessageResponse(response);
|
||||
});
|
||||
}
|
||||
|
||||
// List available models (API endpoint)
|
||||
Future<List<String>> listModels() async {
|
||||
final response = await _makeRequest(
|
||||
method: "GET",
|
||||
endpoint: "/v1/models",
|
||||
);
|
||||
|
||||
// parse models from response
|
||||
final models = <String>[];
|
||||
if (response["data"] is List) {
|
||||
for (final model in response["data"] as List) {
|
||||
if (model is Map<String, dynamic> && model["id"] is String) {
|
||||
models.add(model["id"] as String);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
// Get a single model's details
|
||||
Future<Map<String, dynamic>> getModel(String modelId) async {
|
||||
return _makeRequest(
|
||||
method: "GET",
|
||||
endpoint: "/v1/models/$modelId",
|
||||
);
|
||||
}
|
||||
|
||||
// Count tokens for a message (beta API)
|
||||
Future<int> countTokens({
|
||||
required String model,
|
||||
required List<Map<String, dynamic>> messages,
|
||||
String? system,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
};
|
||||
|
||||
if (system != null) {
|
||||
body["system"] = system;
|
||||
}
|
||||
|
||||
final response = await _makeRequest(
|
||||
method: "POST",
|
||||
endpoint: "/v1/messages/count_tokens",
|
||||
body: body,
|
||||
);
|
||||
|
||||
final count = response["input_tokens"];
|
||||
return count is int ? count : 0;
|
||||
}
|
||||
|
||||
// Internal: make HTTP request to API
|
||||
Future<Map<String, dynamic>> _makeRequest({
|
||||
required String method,
|
||||
required String endpoint,
|
||||
Map<String, dynamic>? body,
|
||||
}) async {
|
||||
final baseUrl = _getBaseUrl();
|
||||
final url = Uri.parse("$baseUrl$endpoint");
|
||||
final headers = _buildHeaders();
|
||||
|
||||
if (_config.enableLogging) {
|
||||
_log("[API REQUEST] $method $endpoint");
|
||||
}
|
||||
|
||||
try {
|
||||
final request = await _httpClient.openUrl(method, url);
|
||||
|
||||
// Set headers
|
||||
headers.forEach((key, value) {
|
||||
request.headers.set(key, value);
|
||||
});
|
||||
|
||||
// Add content type for JSON
|
||||
request.headers.contentType = ContentType.json;
|
||||
|
||||
// Write body if present
|
||||
if (body != null) {
|
||||
request.write(jsonEncode(body));
|
||||
}
|
||||
|
||||
final response = await request.close();
|
||||
final responseBody = await response.transform(utf8.decoder).join();
|
||||
|
||||
if (_config.enableLogging) {
|
||||
_log("[API RESPONSE] ${response.statusCode}");
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
if (response.statusCode >= 400) {
|
||||
_handleErrorResponse(response.statusCode, responseBody);
|
||||
}
|
||||
|
||||
// Parse response
|
||||
final decoded = jsonDecode(responseBody);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw Exception("Invalid API response format");
|
||||
}
|
||||
|
||||
return decoded;
|
||||
} catch (e) {
|
||||
if (_config.enableLogging) {
|
||||
_log("[API ERROR] $e");
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle error responses
|
||||
void _handleErrorResponse(int statusCode, String body) {
|
||||
late String errorMessage;
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final error = ErrorParser.extractErrorMessage(decoded);
|
||||
if (error != null) {
|
||||
errorMessage = error;
|
||||
} else {
|
||||
errorMessage = "HTTP $statusCode";
|
||||
}
|
||||
} else {
|
||||
errorMessage = "HTTP $statusCode";
|
||||
}
|
||||
} catch (_) {
|
||||
errorMessage = "HTTP $statusCode";
|
||||
}
|
||||
|
||||
if (statusCode == 401 || statusCode == 403) {
|
||||
throw AuthenticationException(errorMessage);
|
||||
} else if (statusCode == 429) {
|
||||
throw RateLimitException(errorMessage);
|
||||
} else if (statusCode == 413) {
|
||||
throw RequestTooLargeException(errorMessage);
|
||||
} else {
|
||||
throw ApiException(errorMessage, statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Internal logging
|
||||
void _log(String message) {
|
||||
// could wire this to real logging later
|
||||
print("[AnthropicClient] $message");
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
void close() {
|
||||
_httpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Exception classes for API errors
|
||||
class ApiException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
ApiException(this.message, [this.statusCode]);
|
||||
|
||||
@override
|
||||
String toString() => "ApiException: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}";
|
||||
}
|
||||
|
||||
class AuthenticationException extends ApiException {
|
||||
AuthenticationException(String message) : super(message, 401);
|
||||
|
||||
@override
|
||||
String toString() => "AuthenticationException: $message";
|
||||
}
|
||||
|
||||
class RateLimitException extends ApiException {
|
||||
RateLimitException(String message) : super(message, 429);
|
||||
|
||||
@override
|
||||
String toString() => "RateLimitException: $message";
|
||||
}
|
||||
|
||||
class RequestTooLargeException extends ApiException {
|
||||
RequestTooLargeException(String message) : super(message, 413);
|
||||
|
||||
@override
|
||||
String toString() => "RequestTooLargeException: $message";
|
||||
}
|
||||
|
||||
// Factory to create client from environment
|
||||
class AnthropicClientFactory {
|
||||
static Future<AnthropicClient> create({
|
||||
String? apiKey,
|
||||
String? baseUrl,
|
||||
int maxRetries = 2,
|
||||
String? model,
|
||||
String? source,
|
||||
bool enableLogging = false,
|
||||
}) async {
|
||||
// Try to get OAuth tokens if available
|
||||
final tokens = await loadStoredTokens();
|
||||
final resolvedApiKey = apiKey ?? _resolveApiKey();
|
||||
|
||||
if (resolvedApiKey.isEmpty && tokens == null) {
|
||||
throw Exception("No API key found and no OAuth tokens available");
|
||||
}
|
||||
|
||||
final config = AnthropicClientConfig(
|
||||
apiKey: resolvedApiKey,
|
||||
baseUrl: baseUrl ?? _resolveBaseUrl(),
|
||||
maxRetries: maxRetries,
|
||||
model: model,
|
||||
source: source,
|
||||
enableLogging: enableLogging,
|
||||
);
|
||||
|
||||
return AnthropicClient(config: config);
|
||||
}
|
||||
|
||||
static String _resolveApiKey() {
|
||||
final env = Platform.environment;
|
||||
return env["ANTHROPIC_API_KEY"] ??
|
||||
env["CLAUDE_API_KEY"] ??
|
||||
env["CLAUDE_CODE_API_KEY"] ??
|
||||
"";
|
||||
}
|
||||
|
||||
static String _resolveBaseUrl() {
|
||||
final env = Platform.environment;
|
||||
final override =
|
||||
env["ANTHROPIC_BASE_URL"] ?? env["CLAUDE_CODE_BASE_URL"];
|
||||
if (override != null && override.isNotEmpty) {
|
||||
return override;
|
||||
}
|
||||
return "https://api.anthropic.com";
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,10 @@ class ApiMessage {
|
||||
final Map<String, dynamic>? usage;
|
||||
final int? inputTokens;
|
||||
final int? outputTokens;
|
||||
final int? cacheCreationInputTokens;
|
||||
final int? cacheReadInputTokens;
|
||||
final int? webSearchRequests;
|
||||
final int? webFetchRequests;
|
||||
|
||||
const ApiMessage({
|
||||
required this.id,
|
||||
@@ -118,8 +122,19 @@ class ApiMessage {
|
||||
this.usage,
|
||||
this.inputTokens,
|
||||
this.outputTokens,
|
||||
this.cacheCreationInputTokens,
|
||||
this.cacheReadInputTokens,
|
||||
this.webSearchRequests,
|
||||
this.webFetchRequests,
|
||||
});
|
||||
|
||||
/// Total context window size — matches Claude Code's getTokenCountFromUsage()
|
||||
int get contextTokens =>
|
||||
(inputTokens ?? 0) +
|
||||
(cacheCreationInputTokens ?? 0) +
|
||||
(cacheReadInputTokens ?? 0) +
|
||||
(outputTokens ?? 0);
|
||||
|
||||
factory ApiMessage.fromJson(Map<String, dynamic> json) {
|
||||
int? extractInputTokens() {
|
||||
final usage = json["usage"] as Map<String, dynamic>?;
|
||||
@@ -133,6 +148,38 @@ class ApiMessage {
|
||||
(usage?["completion_tokens"] as num?)?.toInt();
|
||||
}
|
||||
|
||||
int? extractWebSearchRequests() {
|
||||
final usage = json["usage"] as Map<String, dynamic>?;
|
||||
final direct = (usage?["web_search_requests"] as num?)?.toInt();
|
||||
if (direct != null) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
final serverToolUse = usage?["server_tool_use"];
|
||||
if (serverToolUse is Map<String, dynamic>) {
|
||||
return (serverToolUse["web_search_requests"] as num?)?.toInt();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
int? extractWebFetchRequests() {
|
||||
final usage = json["usage"] as Map<String, dynamic>?;
|
||||
final direct = (usage?["web_fetch_requests"] as num?)?.toInt();
|
||||
if (direct != null) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
final serverToolUse = usage?["server_tool_use"];
|
||||
if (serverToolUse is Map<String, dynamic>) {
|
||||
return (serverToolUse["web_fetch_requests"] as num?)?.toInt();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
final rawUsage = json["usage"] as Map<String, dynamic>?;
|
||||
|
||||
return ApiMessage(
|
||||
id: json["id"] as String,
|
||||
type: json["type"] as String? ?? "message",
|
||||
@@ -141,9 +188,13 @@ class ApiMessage {
|
||||
model: json["model"] as String,
|
||||
stopReason:
|
||||
json["stop_reason"] as String? ?? json["finish_reason"] as String?,
|
||||
usage: json["usage"] as Map<String, dynamic>?,
|
||||
usage: rawUsage,
|
||||
inputTokens: extractInputTokens(),
|
||||
outputTokens: extractOutputTokens(),
|
||||
cacheCreationInputTokens: (rawUsage?["cache_creation_input_tokens"] as num?)?.toInt(),
|
||||
cacheReadInputTokens: (rawUsage?["cache_read_input_tokens"] as num?)?.toInt(),
|
||||
webSearchRequests: extractWebSearchRequests(),
|
||||
webFetchRequests: extractWebFetchRequests(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,6 +258,36 @@ class ApiMessage {
|
||||
return (usage?["completion_tokens"] as num?)?.toInt();
|
||||
}
|
||||
|
||||
int? extractWebSearchRequests() {
|
||||
final usage = json["usage"] as Map<String, dynamic>?;
|
||||
final direct = (usage?["web_search_requests"] as num?)?.toInt();
|
||||
if (direct != null) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
final serverToolUse = usage?["server_tool_use"];
|
||||
if (serverToolUse is Map<String, dynamic>) {
|
||||
return (serverToolUse["web_search_requests"] as num?)?.toInt();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
int? extractWebFetchRequests() {
|
||||
final usage = json["usage"] as Map<String, dynamic>?;
|
||||
final direct = (usage?["web_fetch_requests"] as num?)?.toInt();
|
||||
if (direct != null) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
final serverToolUse = usage?["server_tool_use"];
|
||||
if (serverToolUse is Map<String, dynamic>) {
|
||||
return (serverToolUse["web_fetch_requests"] as num?)?.toInt();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return ApiMessage(
|
||||
id: json["id"] as String? ?? "",
|
||||
type: "message",
|
||||
@@ -219,6 +300,8 @@ class ApiMessage {
|
||||
usage: json["usage"] as Map<String, dynamic>?,
|
||||
inputTokens: extractInputTokens(),
|
||||
outputTokens: extractOutputTokens(),
|
||||
webSearchRequests: extractWebSearchRequests(),
|
||||
webFetchRequests: extractWebFetchRequests(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+294
-167
@@ -4,6 +4,7 @@
|
||||
import "dart:async";
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
import "dart:math";
|
||||
|
||||
import "api_types.dart";
|
||||
import "request_builder.dart";
|
||||
@@ -12,12 +13,14 @@ import "response_parser.dart";
|
||||
class OpenRouterConfig {
|
||||
final String apiKey;
|
||||
final int maxRetries;
|
||||
final Duration requestTimeout;
|
||||
final String? model;
|
||||
final bool enableLogging;
|
||||
|
||||
const OpenRouterConfig({
|
||||
required this.apiKey,
|
||||
this.maxRetries = 2,
|
||||
this.maxRetries = 10,
|
||||
this.requestTimeout = const Duration(seconds: 300),
|
||||
this.model,
|
||||
this.enableLogging = false,
|
||||
});
|
||||
@@ -29,6 +32,8 @@ class OpenRouterClient {
|
||||
bool _requestCancelled = false;
|
||||
|
||||
static const String _baseUrl = "https://openrouter.ai/api/v1";
|
||||
static const int _baseRetryDelayMs = 500;
|
||||
static const int _maxRetryDelayMs = 32000;
|
||||
|
||||
OpenRouterClient({required OpenRouterConfig config}) : _config = config {
|
||||
_httpClient = HttpClient();
|
||||
@@ -94,10 +99,12 @@ class OpenRouterClient {
|
||||
}
|
||||
}
|
||||
|
||||
final response = await _makeRequest(
|
||||
method: "POST",
|
||||
endpoint: "/chat/completions",
|
||||
body: requestBody,
|
||||
final response = await _withRetry(
|
||||
() => _makeRequest(
|
||||
method: "POST",
|
||||
endpoint: "/chat/completions",
|
||||
body: requestBody,
|
||||
),
|
||||
);
|
||||
|
||||
return ResponseParser.parseOpenRouterResponse(response);
|
||||
@@ -144,175 +151,182 @@ class OpenRouterClient {
|
||||
final url = Uri.parse("$_baseUrl/chat/completions");
|
||||
final headers = _buildHeaders();
|
||||
|
||||
final textBuffer = StringBuffer();
|
||||
final toolCalls = <int, _StreamingToolCallBuilder>{};
|
||||
String responseId = "";
|
||||
String responseModel = model;
|
||||
String? finishReason;
|
||||
Map<String, dynamic>? usage;
|
||||
bool hasVisibleOutput = false;
|
||||
return _withRetry(() async {
|
||||
final textBuffer = StringBuffer();
|
||||
final toolCalls = <int, _StreamingToolCallBuilder>{};
|
||||
String responseId = "";
|
||||
String responseModel = model;
|
||||
String? finishReason;
|
||||
Map<String, dynamic>? usage;
|
||||
|
||||
try {
|
||||
if (_requestCancelled) {
|
||||
throw const RequestCancelledException();
|
||||
}
|
||||
|
||||
final request = await _httpClient.openUrl("POST", url);
|
||||
headers.forEach((key, value) {
|
||||
request.headers.set(key, value);
|
||||
});
|
||||
request.headers.contentType = ContentType.json;
|
||||
request.write(jsonEncode(requestBody));
|
||||
|
||||
final response = await request.close();
|
||||
if (response.statusCode >= 400) {
|
||||
final responseBody = await response.transform(utf8.decoder).join();
|
||||
print(
|
||||
"OpenRouter API error ${response.statusCode} for /chat/completions: $responseBody",
|
||||
);
|
||||
_handleErrorResponse(response.statusCode, responseBody);
|
||||
}
|
||||
|
||||
final responseStream = response
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter());
|
||||
|
||||
await for (final line in responseStream) {
|
||||
try {
|
||||
if (_requestCancelled) {
|
||||
throw const RequestCancelledException();
|
||||
}
|
||||
|
||||
final event = StreamingResponseParser.parseStreamLine(line);
|
||||
if (StreamingResponseParser.isDone(event)) {
|
||||
break;
|
||||
}
|
||||
if (event == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final id = event["id"];
|
||||
if (id is String && id.isNotEmpty) {
|
||||
responseId = id;
|
||||
}
|
||||
|
||||
final streamedModel = event["model"];
|
||||
if (streamedModel is String && streamedModel.isNotEmpty) {
|
||||
responseModel = streamedModel;
|
||||
}
|
||||
|
||||
final rawUsage = event["usage"];
|
||||
if (rawUsage is Map<String, dynamic>) {
|
||||
usage = rawUsage;
|
||||
}
|
||||
|
||||
final choices = event["choices"];
|
||||
if (choices is! List || choices.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final firstChoice = choices.first;
|
||||
if (firstChoice is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final rawFinishReason = firstChoice["finish_reason"];
|
||||
if (rawFinishReason is String && rawFinishReason.isNotEmpty) {
|
||||
finishReason = rawFinishReason == "tool_calls"
|
||||
? "tool_use"
|
||||
: rawFinishReason;
|
||||
}
|
||||
|
||||
final delta = firstChoice["delta"];
|
||||
if (delta is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final content = delta["content"];
|
||||
if (content is String && content.isNotEmpty) {
|
||||
textBuffer.write(content);
|
||||
onTextDelta?.call(content);
|
||||
}
|
||||
|
||||
final toolCallDeltas = delta["tool_calls"];
|
||||
if (toolCallDeltas is! List) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final rawToolCall in toolCallDeltas) {
|
||||
if (rawToolCall is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final index = (rawToolCall["index"] as num?)?.toInt() ?? 0;
|
||||
final builder = toolCalls.putIfAbsent(
|
||||
index,
|
||||
() => _StreamingToolCallBuilder(),
|
||||
);
|
||||
|
||||
final toolCallId = rawToolCall["id"];
|
||||
if (toolCallId is String && toolCallId.isNotEmpty) {
|
||||
builder.id = toolCallId;
|
||||
}
|
||||
|
||||
final rawType = rawToolCall["type"];
|
||||
if (rawType is String && rawType.isNotEmpty) {
|
||||
builder.type = rawType;
|
||||
}
|
||||
|
||||
final function = rawToolCall["function"];
|
||||
if (function is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final name = function["name"];
|
||||
if (name is String && name.isNotEmpty) {
|
||||
builder.name = name;
|
||||
}
|
||||
|
||||
final arguments = function["arguments"];
|
||||
if (arguments is String && arguments.isNotEmpty) {
|
||||
builder.arguments.write(arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final contentBlocks = <Map<String, dynamic>>[];
|
||||
final text = textBuffer.toString();
|
||||
if (text.isNotEmpty) {
|
||||
contentBlocks.add(<String, dynamic>{"type": "text", "text": text});
|
||||
}
|
||||
|
||||
final orderedToolCalls = toolCalls.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
for (final entry in orderedToolCalls) {
|
||||
final builder = entry.value;
|
||||
contentBlocks.add(<String, dynamic>{
|
||||
"type": "tool_use",
|
||||
"id": builder.id,
|
||||
"name": builder.name,
|
||||
"input": builder.parsedArguments,
|
||||
final request = await _httpClient.openUrl("POST", url);
|
||||
headers.forEach((key, value) {
|
||||
request.headers.set(key, value);
|
||||
});
|
||||
}
|
||||
request.headers.contentType = ContentType.json;
|
||||
request.write(jsonEncode(requestBody));
|
||||
|
||||
return ApiMessage(
|
||||
id: responseId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: contentBlocks,
|
||||
model: responseModel,
|
||||
stopReason: finishReason,
|
||||
usage: usage,
|
||||
inputTokens: (usage?["prompt_tokens"] as num?)?.toInt(),
|
||||
outputTokens: (usage?["completion_tokens"] as num?)?.toInt(),
|
||||
);
|
||||
} catch (e) {
|
||||
if (_requestCancelled) {
|
||||
throw const RequestCancelledException();
|
||||
final response = await request.close();
|
||||
if (response.statusCode >= 400) {
|
||||
final responseBody = await response.transform(utf8.decoder).join();
|
||||
print(
|
||||
"OpenRouter API error ${response.statusCode} for /chat/completions: $responseBody",
|
||||
);
|
||||
_handleErrorResponse(response.statusCode, responseBody);
|
||||
}
|
||||
|
||||
final responseStream = response
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter());
|
||||
|
||||
await for (final line in responseStream) {
|
||||
if (_requestCancelled) {
|
||||
throw const RequestCancelledException();
|
||||
}
|
||||
|
||||
final event = StreamingResponseParser.parseStreamLine(line);
|
||||
if (StreamingResponseParser.isDone(event)) {
|
||||
break;
|
||||
}
|
||||
if (event == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final id = event["id"];
|
||||
if (id is String && id.isNotEmpty) {
|
||||
responseId = id;
|
||||
}
|
||||
|
||||
final streamedModel = event["model"];
|
||||
if (streamedModel is String && streamedModel.isNotEmpty) {
|
||||
responseModel = streamedModel;
|
||||
}
|
||||
|
||||
final rawUsage = event["usage"];
|
||||
if (rawUsage is Map<String, dynamic>) {
|
||||
usage = rawUsage;
|
||||
}
|
||||
|
||||
final choices = event["choices"];
|
||||
if (choices is! List || choices.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final firstChoice = choices.first;
|
||||
if (firstChoice is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final rawFinishReason = firstChoice["finish_reason"];
|
||||
if (rawFinishReason is String && rawFinishReason.isNotEmpty) {
|
||||
finishReason = rawFinishReason == "tool_calls"
|
||||
? "tool_use"
|
||||
: rawFinishReason;
|
||||
}
|
||||
|
||||
final delta = firstChoice["delta"];
|
||||
if (delta is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final content = delta["content"];
|
||||
if (content is String && content.isNotEmpty) {
|
||||
hasVisibleOutput = true;
|
||||
textBuffer.write(content);
|
||||
onTextDelta?.call(content);
|
||||
}
|
||||
|
||||
final toolCallDeltas = delta["tool_calls"];
|
||||
if (toolCallDeltas is! List) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (final rawToolCall in toolCallDeltas) {
|
||||
if (rawToolCall is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final index = (rawToolCall["index"] as num?)?.toInt() ?? 0;
|
||||
final builder = toolCalls.putIfAbsent(
|
||||
index,
|
||||
() => _StreamingToolCallBuilder(),
|
||||
);
|
||||
|
||||
final toolCallId = rawToolCall["id"];
|
||||
if (toolCallId is String && toolCallId.isNotEmpty) {
|
||||
builder.id = toolCallId;
|
||||
}
|
||||
|
||||
final rawType = rawToolCall["type"];
|
||||
if (rawType is String && rawType.isNotEmpty) {
|
||||
builder.type = rawType;
|
||||
}
|
||||
|
||||
final function = rawToolCall["function"];
|
||||
if (function is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final name = function["name"];
|
||||
if (name is String && name.isNotEmpty) {
|
||||
builder.name = name;
|
||||
}
|
||||
|
||||
final arguments = function["arguments"];
|
||||
if (arguments is String && arguments.isNotEmpty) {
|
||||
builder.arguments.write(arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final contentBlocks = <Map<String, dynamic>>[];
|
||||
final text = textBuffer.toString();
|
||||
if (text.isNotEmpty) {
|
||||
contentBlocks.add(<String, dynamic>{"type": "text", "text": text});
|
||||
}
|
||||
|
||||
final orderedToolCalls = toolCalls.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
for (final entry in orderedToolCalls) {
|
||||
final builder = entry.value;
|
||||
contentBlocks.add(<String, dynamic>{
|
||||
"type": "tool_use",
|
||||
"id": builder.id,
|
||||
"name": builder.name,
|
||||
"input": builder.parsedArguments,
|
||||
});
|
||||
}
|
||||
|
||||
return ApiMessage(
|
||||
id: responseId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: contentBlocks,
|
||||
model: responseModel,
|
||||
stopReason: finishReason,
|
||||
usage: usage,
|
||||
inputTokens: (usage?["prompt_tokens"] as num?)?.toInt(),
|
||||
outputTokens: (usage?["completion_tokens"] as num?)?.toInt(),
|
||||
);
|
||||
} catch (e) {
|
||||
if (_requestCancelled) {
|
||||
throw const RequestCancelledException();
|
||||
}
|
||||
if (_config.enableLogging) {
|
||||
_log("[API STREAM ERROR] $e");
|
||||
}
|
||||
if (hasVisibleOutput) {
|
||||
throw StreamingRetryNotAllowedException(e);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
if (_config.enableLogging) {
|
||||
_log("[API STREAM ERROR] $e");
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}, canRetryAfterTimeout: () => !hasVisibleOutput);
|
||||
}
|
||||
|
||||
// List available models
|
||||
@@ -380,6 +394,8 @@ class OpenRouterClient {
|
||||
}
|
||||
|
||||
return decoded;
|
||||
} on TimeoutException catch (_) {
|
||||
throw ApiTimeoutException(_config.requestTimeout);
|
||||
} catch (e) {
|
||||
if (_requestCancelled) {
|
||||
throw const RequestCancelledException();
|
||||
@@ -427,6 +443,98 @@ class OpenRouterClient {
|
||||
print("[OpenRouterClient] $message");
|
||||
}
|
||||
|
||||
Future<T> _withRetry<T>(
|
||||
Future<T> Function() operation, {
|
||||
bool Function()? canRetryAfterTimeout,
|
||||
}) async {
|
||||
Object? lastError;
|
||||
|
||||
for (int attempt = 1; attempt <= _config.maxRetries + 1; attempt++) {
|
||||
if (_requestCancelled) {
|
||||
throw const RequestCancelledException();
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation().timeout(_config.requestTimeout);
|
||||
} catch (error, stackTrace) {
|
||||
final retryableError =
|
||||
error is TimeoutException &&
|
||||
canRetryAfterTimeout != null &&
|
||||
!canRetryAfterTimeout()
|
||||
? StreamingRetryNotAllowedException(
|
||||
ApiTimeoutException(_config.requestTimeout),
|
||||
)
|
||||
: error;
|
||||
lastError = retryableError;
|
||||
if (!_shouldRetry(retryableError) || attempt > _config.maxRetries) {
|
||||
if (_config.enableLogging) {
|
||||
_log("[API RETRY STOP] $retryableError");
|
||||
}
|
||||
Error.throwWithStackTrace(retryableError, stackTrace);
|
||||
}
|
||||
|
||||
final delayMs = _getRetryDelayMs(attempt);
|
||||
print(
|
||||
"OpenRouter request failed (attempt $attempt/${_config.maxRetries + 1}), retrying in ${delayMs}ms: $retryableError",
|
||||
);
|
||||
_recreateHttpClient();
|
||||
await Future<void>.delayed(Duration(milliseconds: delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? Exception("OpenRouter request failed");
|
||||
}
|
||||
|
||||
bool _shouldRetry(Object error) {
|
||||
if (error is RequestCancelledException) {
|
||||
return false;
|
||||
}
|
||||
if (error is StreamingRetryNotAllowedException) {
|
||||
return false;
|
||||
}
|
||||
if (error is ApiTimeoutException) {
|
||||
return true;
|
||||
}
|
||||
if (error is TimeoutException) {
|
||||
return true;
|
||||
}
|
||||
if (error is SocketException) {
|
||||
return true;
|
||||
}
|
||||
if (error is HttpException) {
|
||||
return true;
|
||||
}
|
||||
if (error is ApiException) {
|
||||
final statusCode = error.statusCode;
|
||||
if (statusCode == null) {
|
||||
return true;
|
||||
}
|
||||
if (statusCode == 408 || statusCode == 409 || statusCode == 429) {
|
||||
return true;
|
||||
}
|
||||
if (statusCode >= 500) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int _getRetryDelayMs(int attempt) {
|
||||
final baseDelay = (_baseRetryDelayMs * (1 << (attempt - 1))).clamp(
|
||||
_baseRetryDelayMs,
|
||||
_maxRetryDelayMs,
|
||||
);
|
||||
final jitter = (Random().nextDouble() * 0.25 * baseDelay).round();
|
||||
return baseDelay + jitter;
|
||||
}
|
||||
|
||||
void _recreateHttpClient() {
|
||||
_httpClient.close(force: true);
|
||||
_httpClient = HttpClient();
|
||||
_httpClient.connectionTimeout = Duration(seconds: 600);
|
||||
}
|
||||
|
||||
void cancelActiveRequest() {
|
||||
_requestCancelled = true;
|
||||
_httpClient.close(force: true);
|
||||
@@ -465,6 +573,23 @@ class RequestCancelledException implements Exception {
|
||||
String toString() => "RequestCancelledException: Request cancelled by user";
|
||||
}
|
||||
|
||||
class StreamingRetryNotAllowedException implements Exception {
|
||||
const StreamingRetryNotAllowedException(this.cause);
|
||||
|
||||
final Object cause;
|
||||
|
||||
@override
|
||||
String toString() => cause.toString();
|
||||
}
|
||||
|
||||
class ApiTimeoutException extends ApiException {
|
||||
ApiTimeoutException(Duration timeout)
|
||||
: super("Request timed out after ${timeout.inSeconds} seconds", 408);
|
||||
|
||||
@override
|
||||
String toString() => "ApiTimeoutException: $message";
|
||||
}
|
||||
|
||||
class ApiException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
@@ -500,7 +625,8 @@ class RequestTooLargeException extends ApiException {
|
||||
class OpenRouterClientFactory {
|
||||
static Future<OpenRouterClient> create({
|
||||
String? apiKey,
|
||||
int maxRetries = 2,
|
||||
int maxRetries = 10,
|
||||
Duration requestTimeout = const Duration(seconds: 300),
|
||||
String? model,
|
||||
bool enableLogging = false,
|
||||
}) async {
|
||||
@@ -513,6 +639,7 @@ class OpenRouterClientFactory {
|
||||
final config = OpenRouterConfig(
|
||||
apiKey: resolvedApiKey,
|
||||
maxRetries: maxRetries,
|
||||
requestTimeout: requestTimeout,
|
||||
model: model,
|
||||
enableLogging: enableLogging,
|
||||
);
|
||||
|
||||
+81
-7
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'build_info.dart';
|
||||
import 'chat/repl_handler.dart';
|
||||
import 'command.dart';
|
||||
import 'daemon/daemon_manager.dart';
|
||||
import 'daemon/daemon_types.dart';
|
||||
@@ -15,6 +16,8 @@ import 'local_state.dart';
|
||||
import 'migration_assessment.dart';
|
||||
import 'runtime_state.dart';
|
||||
import 'services/cost_tracker.dart' as costTracker;
|
||||
import 'services/analytics_service.dart';
|
||||
import 'services/usage_tracker.dart';
|
||||
import 'session/conversation_history.dart';
|
||||
import 'session/session_store.dart';
|
||||
import 'session/session_types.dart';
|
||||
@@ -581,7 +584,29 @@ class _ClawdCli {
|
||||
this.runtimeStateStore,
|
||||
this.sessionState,
|
||||
this.hookRunner,
|
||||
);
|
||||
) {
|
||||
_toolRegistry = ToolRegistry();
|
||||
_toolRegistry.setSettings(settingsStore.settings);
|
||||
}
|
||||
|
||||
Future<void> _initializeServices() async {
|
||||
try {
|
||||
// Initialize analytics
|
||||
final analytics = AnalyticsService();
|
||||
final telemetryEnabled = settingsStore.settings.telemetry == 'true' ||
|
||||
settingsStore.settings.telemetry == null; // Default to enabled
|
||||
await analytics.initialize(enabled: telemetryEnabled);
|
||||
|
||||
// Initialize usage tracking
|
||||
final usage = UsageTracker();
|
||||
await usage.initialize(enabled: true);
|
||||
|
||||
// Log session start
|
||||
await analytics.logSession(sessionState.sessionId ?? 'unknown', 'start');
|
||||
} catch (e) {
|
||||
// Silently fail - services are optional
|
||||
}
|
||||
}
|
||||
|
||||
final CommandCatalog catalog;
|
||||
final RuntimeStateStore runtimeStateStore;
|
||||
@@ -590,9 +615,12 @@ class _ClawdCli {
|
||||
final HookRunner hookRunner;
|
||||
|
||||
// tool registry for direct tool invocations like "bash: echo hello"
|
||||
final ToolRegistry _toolRegistry = ToolRegistry();
|
||||
late final ToolRegistry _toolRegistry;
|
||||
|
||||
Future<CommandResult> run(List<String> args) async {
|
||||
// Initialize services
|
||||
await _initializeServices();
|
||||
|
||||
if (_isVersionFastPath(args)) {
|
||||
stdout.writeln(BuildInfo.versionDisplay);
|
||||
return const CommandResult();
|
||||
@@ -661,10 +689,11 @@ class _ClawdCli {
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
stderr.writeln(
|
||||
'Free-form prompt execution is not ported yet. Start the REPL with no args or use a known command.',
|
||||
// Free-form prompt: send to model via REPL handler
|
||||
return await _handleFreeFormPrompt(
|
||||
input: tokens.join(' '),
|
||||
interactive: interactive,
|
||||
);
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
Future<CommandResult> _execute(
|
||||
@@ -688,6 +717,9 @@ class _ClawdCli {
|
||||
);
|
||||
});
|
||||
|
||||
// Log command execution
|
||||
await _logCommandExecution(command.name, args);
|
||||
|
||||
// run before-command hooks
|
||||
await hookRunner.runHooksForKind(
|
||||
HookKind.userPromptSubmit,
|
||||
@@ -722,6 +754,15 @@ class _ClawdCli {
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _logCommandExecution(String commandName, List<String> args) async {
|
||||
try {
|
||||
final analytics = AnalyticsService();
|
||||
await analytics.logCommand(commandName, args: args, sessionId: sessionState.sessionId);
|
||||
} catch (e) {
|
||||
// Silently fail - analytics is optional
|
||||
}
|
||||
}
|
||||
|
||||
Future<CommandResult> _executePortedCommand(
|
||||
String name,
|
||||
List<String> args, {
|
||||
@@ -876,6 +917,39 @@ class _ClawdCli {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<CommandResult> _handleFreeFormPrompt({
|
||||
required String input,
|
||||
required bool interactive,
|
||||
}) async {
|
||||
if (!interactive) {
|
||||
stderr.writeln('Free-form prompts are only supported in interactive mode (REPL).');
|
||||
return const CommandResult(exitCode: 64);
|
||||
}
|
||||
|
||||
try {
|
||||
final handler = ReplHandler(
|
||||
settings: settingsStore.settings,
|
||||
sessionId: sessionState.sessionId ?? 'unknown',
|
||||
workingDirectory: sessionState.workingDirectory,
|
||||
);
|
||||
|
||||
stdout.writeln('');
|
||||
await handler.executePrompt(
|
||||
userInput: input,
|
||||
streaming: true,
|
||||
);
|
||||
stdout.writeln('');
|
||||
|
||||
return const CommandResult();
|
||||
} catch (e, st) {
|
||||
stderr.writeln('Error: $e');
|
||||
if (settingsStore.settings.privacyLevel == 'debug') {
|
||||
stderr.writeln(st);
|
||||
}
|
||||
return const CommandResult(exitCode: 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2292,7 +2366,7 @@ Future<CommandResult> _runResume(
|
||||
) async {
|
||||
final query = args.join(' ').trim();
|
||||
|
||||
final sessions = await SessionStore.instance.listSessions();
|
||||
final sessions = await SessionStore.instance.listSessionsForProject(context.workingDirectory);
|
||||
|
||||
if (sessions.isEmpty) {
|
||||
context.writeLine("No saved sessions found.");
|
||||
@@ -2329,7 +2403,7 @@ Future<CommandResult> _runResume(
|
||||
|
||||
// if exactly one match and it was a direct lookup - load it
|
||||
if (filtered.length == 1 && query.isNotEmpty) {
|
||||
final loaded = await SessionStore.instance.loadSession(filtered.first.id);
|
||||
final loaded = await SessionStore.instance.loadSession(filtered.first.id, workingDirectory: context.workingDirectory);
|
||||
if (loaded != null) {
|
||||
_history.setSession(loaded);
|
||||
context.sessionState.sessionName = loaded.name;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import "../api/openrouter_client.dart";
|
||||
|
||||
const _advisorSystemPrompt =
|
||||
"You are an advisor reviewing an AI agent's work in progress. "
|
||||
"You will be shown the full conversation history including tool calls and results. "
|
||||
"Your job is to give concise, actionable guidance: identify mistakes, "
|
||||
"suggest better approaches, flag assumptions that need verifying, or confirm "
|
||||
"the agent is on the right track. Be direct and specific.";
|
||||
|
||||
const advisorToolDescription =
|
||||
"Call the advisor model for a second opinion on your current approach. "
|
||||
"Takes no parameters — your full conversation history is forwarded automatically. "
|
||||
"Call BEFORE committing to a significant approach, BEFORE declaring done, or when stuck.";
|
||||
|
||||
class AdvisorService {
|
||||
|
||||
Future<String> run({
|
||||
required String advisorModel,
|
||||
required String apiKey,
|
||||
required List<Map<String, dynamic>> conversationSoFar,
|
||||
void Function(String toolName, Map<String, dynamic> input)? onToolCall,
|
||||
void Function(String toolName, String result)? onToolResult,
|
||||
}) async {
|
||||
onToolCall?.call("Advisor", {"model": advisorModel});
|
||||
|
||||
OpenRouterClient? client;
|
||||
try {
|
||||
client = OpenRouterClient(
|
||||
config: OpenRouterConfig(apiKey: apiKey),
|
||||
);
|
||||
|
||||
final response = await client.createMessage(
|
||||
model: advisorModel,
|
||||
maxTokens: 2048,
|
||||
messages: conversationSoFar,
|
||||
system: _advisorSystemPrompt,
|
||||
);
|
||||
|
||||
final text = response.content
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.where((b) => b["type"] == "text")
|
||||
.map((b) => b["text"] as String? ?? "")
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
final result = text.isEmpty ? "Advisor returned no guidance." : text;
|
||||
onToolResult?.call("Advisor", result);
|
||||
return result;
|
||||
} catch (e) {
|
||||
final err = "Advisor call failed: $e";
|
||||
onToolResult?.call("Advisor", err);
|
||||
return err;
|
||||
} finally {
|
||||
client?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// REPL handler for free-form prompts
|
||||
// Bridges user input → ToolLoopService → model → tool execution
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import '../api/openrouter_client.dart';
|
||||
import '../chat/tool_loop_service.dart';
|
||||
import '../local_state.dart';
|
||||
import '../services/cost_tracker.dart' as costTracker;
|
||||
import '../utils/model_cost.dart';
|
||||
|
||||
class ReplHandler {
|
||||
ReplHandler({
|
||||
required this.settings,
|
||||
required this.sessionId,
|
||||
required this.workingDirectory,
|
||||
});
|
||||
|
||||
final LocalSettings settings;
|
||||
final String sessionId;
|
||||
final String workingDirectory;
|
||||
|
||||
// Conversation history for this REPL session
|
||||
final List<Map<String, dynamic>> _conversationHistory = [];
|
||||
|
||||
/// Execute a free-form prompt in the REPL
|
||||
/// Returns the assistant's text response
|
||||
Future<String> executePrompt({
|
||||
required String userInput,
|
||||
bool streaming = true,
|
||||
}) async {
|
||||
// Get API configuration
|
||||
final apiKey = _resolveApiKey();
|
||||
if (apiKey.isEmpty) {
|
||||
return 'Error: No API key configured. Set OPENROUTER_API_KEY or USE_ANTHROPIC with ANTHROPIC_API_KEY.';
|
||||
}
|
||||
|
||||
final model = settings.model ?? _getDefaultModel();
|
||||
if (model.isEmpty) {
|
||||
return 'Error: No model configured. Use /model to set one.';
|
||||
}
|
||||
|
||||
// Create API client
|
||||
final client = OpenRouterClient(
|
||||
config: OpenRouterConfig(
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
enableLogging: false,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Create tool loop service
|
||||
final toolLoop = ToolLoopService();
|
||||
|
||||
// Run the tool loop
|
||||
final result = await toolLoop.runTurn(
|
||||
client: client,
|
||||
model: model,
|
||||
apiKey: apiKey,
|
||||
getSettings: () => settings,
|
||||
apiMessages: _conversationHistory,
|
||||
userText: userInput,
|
||||
workingDirectory: workingDirectory,
|
||||
onToolCall: (toolName, input) {
|
||||
stderr.writeln('→ Calling $toolName');
|
||||
},
|
||||
onToolResult: (toolName, result) {
|
||||
stderr.writeln('← $toolName returned: ${result.substring(0, 100)}${result.length > 100 ? '...' : ''}');
|
||||
},
|
||||
onAssistantTextDelta: (delta) {
|
||||
if (streaming) {
|
||||
stdout.write(delta);
|
||||
}
|
||||
},
|
||||
onAssistantMessageComplete: () {
|
||||
if (streaming) {
|
||||
stdout.writeln();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update conversation history
|
||||
_conversationHistory.addAll(result.apiMessages);
|
||||
|
||||
// Track costs
|
||||
if (result.response.inputTokens != null && result.response.outputTokens != null) {
|
||||
final cost = calculateUSDCost(
|
||||
model,
|
||||
TokenUsage(
|
||||
inputTokens: result.response.inputTokens!,
|
||||
outputTokens: result.response.outputTokens!,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
webSearchRequests: result.webSearchRequests,
|
||||
),
|
||||
);
|
||||
|
||||
costTracker.addToTotalSessionCost(
|
||||
model: model,
|
||||
cost: cost,
|
||||
inputTokens: result.response.inputTokens!,
|
||||
outputTokens: result.response.outputTokens!,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
webSearchRequests: result.webSearchRequests,
|
||||
webFetchRequests: result.webFetchRequests,
|
||||
);
|
||||
}
|
||||
|
||||
return result.responseText;
|
||||
} catch (e) {
|
||||
return 'Error: ${e.toString()}';
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get conversation history for session
|
||||
List<Map<String, dynamic>> getHistory() => List.from(_conversationHistory);
|
||||
|
||||
/// Clear conversation history
|
||||
void clearHistory() => _conversationHistory.clear();
|
||||
|
||||
String _resolveApiKey() {
|
||||
// Check settings first if openRouterApiKey is configured
|
||||
if (settings.openRouterApiKey?.isNotEmpty ?? false) {
|
||||
return settings.openRouterApiKey!;
|
||||
}
|
||||
|
||||
final env = Platform.environment;
|
||||
|
||||
// Try OpenRouter first (vendor-neutral preferred)
|
||||
if ((env['USE_OPENROUTER'] ?? '').toLowerCase() == 'true' ||
|
||||
(env['OPENROUTER_API_KEY']?.isNotEmpty ?? false)) {
|
||||
return env['OPENROUTER_API_KEY'] ?? '';
|
||||
}
|
||||
|
||||
// Try Anthropic
|
||||
if ((env['USE_ANTHROPIC'] ?? '').toLowerCase() == 'true') {
|
||||
return env['ANTHROPIC_API_KEY'] ?? env['CLAUDE_API_KEY'] ?? '';
|
||||
}
|
||||
|
||||
// Default: check all sources
|
||||
return env['OPENROUTER_API_KEY'] ??
|
||||
env['ANTHROPIC_API_KEY'] ??
|
||||
env['CLAUDE_API_KEY'] ??
|
||||
env['CLAUDE_CODE_API_KEY'] ??
|
||||
'';
|
||||
}
|
||||
|
||||
String _getDefaultModel() {
|
||||
// Check settings first
|
||||
if (settings.model?.isNotEmpty ?? false) {
|
||||
return settings.model!;
|
||||
}
|
||||
|
||||
final env = Platform.environment;
|
||||
|
||||
// If USE_OPENROUTER is set, default to an OpenRouter model
|
||||
if ((env['USE_OPENROUTER'] ?? '').toLowerCase() == 'true') {
|
||||
return 'openrouter/auto'; // OpenRouter will pick best available
|
||||
}
|
||||
|
||||
// If USE_ANTHROPIC is set or ANTHROPIC_API_KEY exists, use Claude
|
||||
if ((env['USE_ANTHROPIC'] ?? '').toLowerCase() == 'true' ||
|
||||
(env['ANTHROPIC_API_KEY']?.isNotEmpty ?? false)) {
|
||||
return 'claude-opus-4-1';
|
||||
}
|
||||
|
||||
// Check what API keys are available
|
||||
if ((env['OPENROUTER_API_KEY']?.isNotEmpty ?? false)) {
|
||||
return 'openrouter/auto';
|
||||
}
|
||||
|
||||
if ((env['ANTHROPIC_API_KEY']?.isNotEmpty ?? false) ||
|
||||
(env['CLAUDE_API_KEY']?.isNotEmpty ?? false)) {
|
||||
return 'claude-opus-4-1';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,15 @@ import "package:path/path.dart" as path;
|
||||
|
||||
import "../api/api_types.dart";
|
||||
import "../api/openrouter_client.dart";
|
||||
import "../hooks/hook_runner.dart";
|
||||
import "../hooks/hook_types.dart";
|
||||
import "../permissions/permission_manager.dart";
|
||||
import "../permissions/permission_types.dart";
|
||||
import "advisor_service.dart";
|
||||
import "../local_state.dart";
|
||||
import "../api/response_parser.dart";
|
||||
import "../services/tool_telemetry_service.dart";
|
||||
import "../system_prompt/claude_md_loader.dart";
|
||||
import "../system_prompt/system_prompt_builder.dart";
|
||||
import "../tools/tool_registry.dart";
|
||||
|
||||
@@ -14,12 +22,16 @@ class ToolLoopResult {
|
||||
required this.responseText,
|
||||
required this.response,
|
||||
required this.finalResponseWasStreamed,
|
||||
required this.webSearchRequests,
|
||||
required this.webFetchRequests,
|
||||
});
|
||||
|
||||
final List<Map<String, dynamic>> apiMessages;
|
||||
final String responseText;
|
||||
final ApiMessage response;
|
||||
final bool finalResponseWasStreamed;
|
||||
final int webSearchRequests;
|
||||
final int webFetchRequests;
|
||||
}
|
||||
|
||||
class ToolLoopException implements Exception {
|
||||
@@ -38,20 +50,31 @@ class ToolLoopException implements Exception {
|
||||
}
|
||||
|
||||
class ToolLoopService {
|
||||
ToolLoopService() : _toolRegistry = ToolRegistry();
|
||||
ToolLoopService({HookRunner? hookRunner})
|
||||
: _toolRegistry = ToolRegistry(),
|
||||
_toolTelemetryClient = createToolTelemetryClient(),
|
||||
_hookRunner = hookRunner,
|
||||
_advisorService = AdvisorService();
|
||||
|
||||
final ToolRegistry _toolRegistry;
|
||||
final ToolTelemetryClient _toolTelemetryClient;
|
||||
final HookRunner? _hookRunner;
|
||||
final AdvisorService _advisorService;
|
||||
|
||||
Future<ToolLoopResult> runTurn({
|
||||
required OpenRouterClient client,
|
||||
required String model,
|
||||
required String apiKey,
|
||||
required LocalSettings Function() getSettings,
|
||||
required List<Map<String, dynamic>> apiMessages,
|
||||
required String userText,
|
||||
String? workingDirectory,
|
||||
String? advisorModel,
|
||||
void Function(String toolName, Map<String, dynamic> input)? onToolCall,
|
||||
void Function(String toolName, String result)? onToolResult,
|
||||
void Function(String delta)? onAssistantTextDelta,
|
||||
void Function()? onAssistantMessageComplete,
|
||||
Future<PermissionDecision> Function(String toolName, Map<String, dynamic> input)? onPermissionRequired,
|
||||
}) async {
|
||||
final updatedMessages = List<Map<String, dynamic>>.from(apiMessages)
|
||||
..add(<String, dynamic>{"role": "user", "content": userText});
|
||||
@@ -65,8 +88,8 @@ class ToolLoopService {
|
||||
model: model,
|
||||
maxTokens: 4096,
|
||||
messages: updatedMessages,
|
||||
system: _buildSystemPrompt(workingDirectory),
|
||||
tools: _buildToolDefinitions(),
|
||||
system: await _buildSystemPrompt(workingDirectory),
|
||||
tools: _buildToolDefinitions(advisorModel: advisorModel),
|
||||
toolChoice: "auto",
|
||||
onTextDelta: (delta) {
|
||||
streamedTextThisIteration = true;
|
||||
@@ -91,17 +114,72 @@ class ToolLoopService {
|
||||
: responseText,
|
||||
response: lastResponse,
|
||||
finalResponseWasStreamed: streamedTextThisIteration,
|
||||
webSearchRequests: lastResponse.webSearchRequests ?? 0,
|
||||
webFetchRequests: lastResponse.webFetchRequests ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
for (final toolUse in toolUses) {
|
||||
// advisor is handled separately — not via the tool registry
|
||||
if (toolUse.name == "Advisor") {
|
||||
final advisorResult = await _advisorService.run(
|
||||
advisorModel: advisorModel!,
|
||||
apiKey: apiKey,
|
||||
conversationSoFar: List<Map<String, dynamic>>.from(updatedMessages),
|
||||
onToolCall: onToolCall,
|
||||
onToolResult: onToolResult,
|
||||
);
|
||||
updatedMessages.add(<String, dynamic>{
|
||||
"role": "tool",
|
||||
"tool_call_id": toolUse.id,
|
||||
"content": advisorResult,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
final currentSettings = getSettings();
|
||||
final normalizedInput = _normalizeToolInput(
|
||||
toolName: toolUse.name,
|
||||
input: toolUse.input,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
settings: currentSettings,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
|
||||
// check permissions before executing
|
||||
final permManager = PermissionManager(currentSettings);
|
||||
if (permManager.shouldAskForPermission(toolUse.name, normalizedInput, workingDirectory)) {
|
||||
onToolCall?.call(toolUse.name, normalizedInput);
|
||||
|
||||
PermissionDecision decision;
|
||||
if (onPermissionRequired != null) {
|
||||
decision = await onPermissionRequired(toolUse.name, normalizedInput);
|
||||
} else {
|
||||
decision = PermissionDecision.reject;
|
||||
}
|
||||
|
||||
if (decision == PermissionDecision.reject) {
|
||||
const denied = "Permission denied by user.";
|
||||
onToolResult?.call(toolUse.name, denied);
|
||||
updatedMessages.add(<String, dynamic>{
|
||||
"role": "tool",
|
||||
"tool_call_id": toolUse.id,
|
||||
"content": denied,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// allowOnce or allowAlways — fall through to execute
|
||||
}
|
||||
|
||||
onToolCall?.call(toolUse.name, normalizedInput);
|
||||
|
||||
await _hookRunner?.runHooksForKind(
|
||||
HookKind.preToolUse,
|
||||
targetName: toolUse.name,
|
||||
input: normalizedInput,
|
||||
);
|
||||
|
||||
final toolResult = await _executeTool(
|
||||
toolUse: toolUse,
|
||||
normalizedInput: normalizedInput,
|
||||
@@ -133,15 +211,55 @@ class ToolLoopService {
|
||||
required ToolUse toolUse,
|
||||
required Map<String, dynamic> normalizedInput,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
print(
|
||||
"Executing tool ${toolUse.name} with input: ${jsonEncode(normalizedInput)}",
|
||||
);
|
||||
|
||||
try {
|
||||
final result = await _toolRegistry.execute(toolUse.name, normalizedInput);
|
||||
final success = !result.startsWith("Error");
|
||||
await _toolTelemetryClient.recordToolCall(
|
||||
toolName: toolUse.name,
|
||||
success: success,
|
||||
durationMs: stopwatch.elapsedMilliseconds,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
await _hookRunner?.runHooksForKind(
|
||||
HookKind.postToolUse,
|
||||
targetName: toolUse.name,
|
||||
input: normalizedInput,
|
||||
output: result,
|
||||
exitCode: 0,
|
||||
);
|
||||
} else {
|
||||
await _hookRunner?.runHooksForKind(
|
||||
HookKind.postToolUseFailure,
|
||||
targetName: toolUse.name,
|
||||
input: normalizedInput,
|
||||
output: result,
|
||||
exitCode: 1,
|
||||
);
|
||||
}
|
||||
|
||||
print("Tool ${toolUse.name} completed");
|
||||
return result;
|
||||
} catch (error, stackTrace) {
|
||||
await _toolTelemetryClient.recordToolCall(
|
||||
toolName: toolUse.name,
|
||||
success: false,
|
||||
durationMs: stopwatch.elapsedMilliseconds,
|
||||
);
|
||||
|
||||
await _hookRunner?.runHooksForKind(
|
||||
HookKind.postToolUseFailure,
|
||||
targetName: toolUse.name,
|
||||
input: normalizedInput,
|
||||
output: error.toString(),
|
||||
exitCode: 1,
|
||||
);
|
||||
|
||||
print("Tool ${toolUse.name} failed: $error");
|
||||
print(stackTrace);
|
||||
return "Error executing ${toolUse.name}: $error";
|
||||
@@ -151,34 +269,53 @@ class ToolLoopService {
|
||||
Map<String, dynamic> _normalizeToolInput({
|
||||
required String toolName,
|
||||
required Map<String, dynamic> input,
|
||||
required String apiKey,
|
||||
required String model,
|
||||
required LocalSettings settings,
|
||||
String? workingDirectory,
|
||||
}) {
|
||||
final normalized = Map<String, dynamic>.from(input);
|
||||
final cwd = workingDirectory?.trim();
|
||||
|
||||
if (cwd == null || cwd.isEmpty) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case "Bash":
|
||||
normalized["cwd"] = cwd;
|
||||
if (cwd != null && cwd.isNotEmpty) {
|
||||
normalized["cwd"] = cwd;
|
||||
}
|
||||
break;
|
||||
case "Read":
|
||||
case "Edit":
|
||||
case "Write":
|
||||
final rawPath = normalized["file_path"];
|
||||
if (rawPath is String && rawPath.isNotEmpty) {
|
||||
normalized["file_path"] = _resolvePath(rawPath, cwd);
|
||||
if (cwd != null && cwd.isNotEmpty) {
|
||||
final rawPath = normalized["file_path"];
|
||||
if (rawPath is String && rawPath.isNotEmpty) {
|
||||
normalized["file_path"] = _resolvePath(rawPath, cwd);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "Glob":
|
||||
case "Grep":
|
||||
final rawPath = normalized["path"];
|
||||
if (rawPath is String && rawPath.isNotEmpty) {
|
||||
normalized["path"] = _resolvePath(rawPath, cwd);
|
||||
} else {
|
||||
normalized["path"] = cwd;
|
||||
if (cwd != null && cwd.isNotEmpty) {
|
||||
final rawPath = normalized["path"];
|
||||
if (rawPath is String && rawPath.isNotEmpty) {
|
||||
normalized["path"] = _resolvePath(rawPath, cwd);
|
||||
} else {
|
||||
normalized["path"] = cwd;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "WebSearch":
|
||||
case "WebFetch":
|
||||
normalized["_api_key"] = apiKey;
|
||||
normalized["_model"] = model;
|
||||
normalized["_permission_mode"] = settings.permissionMode;
|
||||
normalized["_allow_rules"] = settings.alwaysAllowRules;
|
||||
normalized["_ask_rules"] = settings.alwaysAskRules;
|
||||
normalized["_deny_rules"] = settings.alwaysDenyRules;
|
||||
break;
|
||||
case "ExecuteTask":
|
||||
if (cwd != null && cwd.isNotEmpty) {
|
||||
normalized["working_directory"] = cwd;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -228,8 +365,19 @@ class ToolLoopService {
|
||||
return message;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _buildToolDefinitions() {
|
||||
List<Map<String, dynamic>> _buildToolDefinitions({String? advisorModel}) {
|
||||
return <Map<String, dynamic>>[
|
||||
|
||||
if (advisorModel != null && advisorModel.isNotEmpty)
|
||||
_functionTool(
|
||||
name: "Advisor",
|
||||
description:
|
||||
"Call the advisor model for a second opinion on your current approach. "
|
||||
"Takes no parameters — your full conversation history is forwarded automatically. "
|
||||
"Call BEFORE committing to a significant approach, BEFORE declaring done, or when stuck.",
|
||||
properties: <String, dynamic>{},
|
||||
required: const <String>[],
|
||||
),
|
||||
_functionTool(
|
||||
name: "Bash",
|
||||
description:
|
||||
@@ -284,6 +432,44 @@ class ToolLoopService {
|
||||
},
|
||||
required: const <String>["pattern"],
|
||||
),
|
||||
_functionTool(
|
||||
name: "WebSearch",
|
||||
description:
|
||||
"Search the web for current information. Supports optional allowed_domains or blocked_domains filters and returns a cited summary.",
|
||||
properties: <String, dynamic>{
|
||||
"query": <String, dynamic>{
|
||||
"type": "string",
|
||||
"description": "The web search query to run.",
|
||||
},
|
||||
"allowed_domains": <String, dynamic>{
|
||||
"type": "array",
|
||||
"items": <String, dynamic>{"type": "string"},
|
||||
"description": "Optional list of domains to include.",
|
||||
},
|
||||
"blocked_domains": <String, dynamic>{
|
||||
"type": "array",
|
||||
"items": <String, dynamic>{"type": "string"},
|
||||
"description": "Optional list of domains to exclude.",
|
||||
},
|
||||
},
|
||||
required: const <String>["query"],
|
||||
),
|
||||
_functionTool(
|
||||
name: "WebFetch",
|
||||
description:
|
||||
"Fetch content from a URL, extract readable text, and answer a prompt about that page.",
|
||||
properties: <String, dynamic>{
|
||||
"url": <String, dynamic>{
|
||||
"type": "string",
|
||||
"description": "The fully formed URL to fetch.",
|
||||
},
|
||||
"prompt": <String, dynamic>{
|
||||
"type": "string",
|
||||
"description": "What information to extract from the fetched page.",
|
||||
},
|
||||
},
|
||||
required: const <String>["url", "prompt"],
|
||||
),
|
||||
_functionTool(
|
||||
name: "Read",
|
||||
description: "Read a file from the project with line numbers.",
|
||||
@@ -341,6 +527,40 @@ class ToolLoopService {
|
||||
},
|
||||
required: const <String>["file_path", "content"],
|
||||
),
|
||||
_functionTool(
|
||||
name: "ExecuteTask",
|
||||
description:
|
||||
"Execute a task as a background process with real process management.",
|
||||
properties: <String, dynamic>{
|
||||
"action": <String, dynamic>{
|
||||
"type": "string",
|
||||
"enum": <String>["execute", "status", "result", "cancel", "list"],
|
||||
"description": "Action to perform.",
|
||||
},
|
||||
"task_id": <String, dynamic>{
|
||||
"type": "string",
|
||||
"description": "Task identifier (required for execute).",
|
||||
},
|
||||
"command": <String, dynamic>{
|
||||
"type": "string",
|
||||
"description": "Command to execute (required for execute).",
|
||||
},
|
||||
"arguments": <String, dynamic>{
|
||||
"type": "array",
|
||||
"items": <String, dynamic>{"type": "string"},
|
||||
"description": "Command arguments.",
|
||||
},
|
||||
"process_id": <String, dynamic>{
|
||||
"type": "string",
|
||||
"description": "Process identifier (for status/result/cancel).",
|
||||
},
|
||||
"force": <String, dynamic>{
|
||||
"type": "boolean",
|
||||
"description": "Force kill when canceling.",
|
||||
},
|
||||
},
|
||||
required: const <String>["action"],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -365,7 +585,7 @@ class ToolLoopService {
|
||||
};
|
||||
}
|
||||
|
||||
String _buildSystemPrompt(String? workingDirectory) {
|
||||
Future<String> _buildSystemPrompt(String? workingDirectory) async {
|
||||
final cwd = workingDirectory?.trim();
|
||||
final appendPrompt = [
|
||||
if (cwd == null || cwd.isEmpty)
|
||||
@@ -373,13 +593,22 @@ class ToolLoopService {
|
||||
else
|
||||
"The active working directory is: $cwd",
|
||||
"You have access to tools for shell commands, file globbing, grep search, file reads, exact edits, and file writes.",
|
||||
"You also have a WebSearch tool for up-to-date external information; when you use it, include a Sources section with markdown links in your final answer.",
|
||||
"You also have a WebFetch tool for reading a specific public URL and answering questions about that page.",
|
||||
"If MCP-provided web search or web fetch tools are available in your tool list, prefer them over the built-in WebSearch and WebFetch tools.",
|
||||
"When the user asks about files, code, project structure, configuration, or repository contents, use the tools instead of guessing.",
|
||||
"If the user asks you to inspect the project structure, start by using Glob or Bash to inspect the filesystem.",
|
||||
"Do not claim you cannot access the project when tools are available.",
|
||||
"Keep answers concise and grounded in tool results.",
|
||||
].join("\n");
|
||||
|
||||
return buildDefaultSystemPrompt(appendSystemPrompt: appendPrompt);
|
||||
final memoryFiles = await getMemoryFiles(workingDirectory);
|
||||
final claudeMd = getClaudeMds(memoryFiles);
|
||||
|
||||
return buildDefaultSystemPrompt(
|
||||
appendSystemPrompt: appendPrompt,
|
||||
claudeMd: claudeMd.isEmpty ? null : claudeMd,
|
||||
);
|
||||
}
|
||||
|
||||
String _buildEmptyAssistantFallback(ApiMessage response) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Constants for Claude Code
|
||||
// Vendor-neutral abstractions for remote services - ACTUALLY INTEGRATED
|
||||
|
||||
/// Base endpoint for hosted services
|
||||
/// Replace anthropic.com specific endpoints with this vendor-neutral constant
|
||||
const String kHostEndpoint = String.fromEnvironment(
|
||||
'CLAWED_HOST_ENDPOINT',
|
||||
defaultValue: '', // Empty means use local fallback
|
||||
);
|
||||
|
||||
/// Environment variable for overriding host endpoint
|
||||
const String kHostEndpointEnvVar = 'CLAWED_HOST_ENDPOINT';
|
||||
|
||||
/// Check if remote services are available
|
||||
bool areRemoteServicesAvailable() {
|
||||
return kHostEndpoint.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Get configured host endpoint with validation
|
||||
String getHostEndpoint() {
|
||||
return kHostEndpoint;
|
||||
}
|
||||
|
||||
/// Check if a feature should use remote service or local fallback
|
||||
bool shouldUseRemoteService(String feature) {
|
||||
if (!areRemoteServicesAvailable()) return false;
|
||||
|
||||
// For now, all features can use remote if endpoint is configured
|
||||
return true;
|
||||
}
|
||||
|
||||
/// API paths for remote services (relative to kHostEndpoint)
|
||||
class ApiPaths {
|
||||
static const String sessions = '/api/v1/sessions';
|
||||
static const String analytics = '/api/v1/analytics';
|
||||
static const String usage = '/api/v1/usage';
|
||||
static const String config = '/api/v1/config';
|
||||
static const String auth = '/api/v1/auth';
|
||||
|
||||
/// Get session URL for a given session ID
|
||||
static String sessionUrl(String sessionId) => '$sessions/$sessionId';
|
||||
|
||||
/// Get session worker URL for a given session ID
|
||||
static String sessionWorkerUrl(String sessionId) => '$sessions/$sessionId/worker';
|
||||
}
|
||||
|
||||
/// Get full URL for a remote service
|
||||
String getRemoteServiceUrl(String path) {
|
||||
final endpoint = getHostEndpoint();
|
||||
if (endpoint.isEmpty) {
|
||||
throw Exception('Remote services not configured');
|
||||
}
|
||||
return '$endpoint$path';
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Configuration constants for Claude Code Dart migration
|
||||
// Vendor-neutral configuration with fallbacks
|
||||
|
||||
/// Environment variable configuration keys
|
||||
class Config {
|
||||
/// Whether to use custom endpoints
|
||||
static const bool useCustomEndpoints = false;
|
||||
|
||||
/// API endpoint from environment (vendor-neutral)
|
||||
static const String? apiEndpoint = null;
|
||||
|
||||
/// Default endpoints when not overridden
|
||||
static const String defaultApiEndpoint = String.fromEnvironment(
|
||||
'CLAWED_HOST_ENDPOINT',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
/// Model Configuration (vendor-neutral)
|
||||
static const String defaultMainModel = String.fromEnvironment(
|
||||
'CLAWED_DEFAULT_MODEL',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
/// Model aliases mapping (configurable)
|
||||
static final Map<String, String> modelAliases = {
|
||||
// Can be populated from config file
|
||||
};
|
||||
|
||||
/// Model context window sizes (configurable)
|
||||
static final Map<String, int> modelContextWindows = {
|
||||
// Can be populated from config file
|
||||
};
|
||||
|
||||
/// Home directory for configuration
|
||||
static String get homeDirectory => '~/.clawd_code';
|
||||
|
||||
/// Configuration file paths
|
||||
static String get configPath => '${homeDirectory}/config';
|
||||
static String get settingsFilePath => '${configPath}/settings.json';
|
||||
|
||||
/// Parse boolean environment variable
|
||||
static bool parseBoolEnv(String value) {
|
||||
final lowerValue = value.toLowerCase();
|
||||
return lowerValue == 'true' || lowerValue == '1' || lowerValue == 'yes';
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ String getCoordinatorSystemPrompt() {
|
||||
? "Workers have access to Bash, Read, and Edit tools, plus MCP tools from configured MCP servers."
|
||||
: "Workers have access to standard tools, MCP tools from configured MCP servers, and project skills via the Skill tool. Delegate skill invocations (e.g. /commit, /verify) to workers.";
|
||||
|
||||
return """You are Claude Code, an AI assistant that orchestrates software engineering tasks across multiple workers.
|
||||
return """You are The Agency, an AI assistant that orchestrates software engineering tasks across multiple workers.
|
||||
|
||||
## 1. Your Role
|
||||
|
||||
|
||||
@@ -180,6 +180,34 @@ class LocalSettings {
|
||||
);
|
||||
}
|
||||
|
||||
// Merges this (base) with an override layer.
|
||||
// For nullable fields: override wins only if non-null.
|
||||
// For list fields: override wins if non-empty.
|
||||
// For non-nullable fields with defaults: override wins if different from its default.
|
||||
LocalSettings mergeWith(LocalSettings? override) {
|
||||
if (override == null) return this;
|
||||
|
||||
return LocalSettings(
|
||||
advisorModel: override.advisorModel ?? advisorModel,
|
||||
alwaysAllowRules: override.alwaysAllowRules.isNotEmpty ? override.alwaysAllowRules : alwaysAllowRules,
|
||||
alwaysAskRules: override.alwaysAskRules.isNotEmpty ? override.alwaysAskRules : alwaysAskRules,
|
||||
alwaysDenyRules: override.alwaysDenyRules.isNotEmpty ? override.alwaysDenyRules : alwaysDenyRules,
|
||||
editorMode: override.editorMode != 'normal' ? override.editorMode : editorMode,
|
||||
effortLevel: override.effortLevel ?? effortLevel,
|
||||
fastMode: override.fastMode ? true : fastMode,
|
||||
hooks: override.hooks ?? hooks,
|
||||
mcpServers: override.mcpServers ?? mcpServers,
|
||||
model: override.model ?? model,
|
||||
openRouterApiKey: override.openRouterApiKey ?? openRouterApiKey,
|
||||
outputStyle: override.outputStyle ?? outputStyle,
|
||||
permissionMode: override.permissionMode != 'default' ? override.permissionMode : permissionMode,
|
||||
privacyLevel: override.privacyLevel ?? privacyLevel,
|
||||
statusLinePrompt: override.statusLinePrompt ?? statusLinePrompt,
|
||||
telemetry: override.telemetry ?? telemetry,
|
||||
theme: override.theme != 'dark' ? override.theme : theme,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'advisorModel': advisorModel,
|
||||
@@ -206,7 +234,8 @@ class LocalSettings {
|
||||
class SessionState {
|
||||
SessionState({required this.workingDirectory, String? effortValue})
|
||||
: effortValue = effortValue,
|
||||
startedAt = DateTime.now().toUtc();
|
||||
startedAt = DateTime.now().toUtc(),
|
||||
sessionId = _generateSessionId();
|
||||
|
||||
String? effortValue;
|
||||
int commandsExecuted = 0;
|
||||
@@ -229,6 +258,13 @@ class SessionState {
|
||||
|
||||
final DateTime startedAt;
|
||||
final String workingDirectory;
|
||||
final String sessionId;
|
||||
|
||||
static String _generateSessionId() {
|
||||
final now = DateTime.now();
|
||||
final rnd = (now.millisecondsSinceEpoch % 1000000).toString().padLeft(6, '0');
|
||||
return 'sess_${now.millisecondsSinceEpoch}_$rnd';
|
||||
}
|
||||
|
||||
String get planFilePath {
|
||||
return joinPath(getPlansDirectoryPath(), 'active-plan.md');
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../local_state.dart';
|
||||
|
||||
/// Manages tool permissions and access control
|
||||
class PermissionManager {
|
||||
final LocalSettings settings;
|
||||
|
||||
PermissionManager(this.settings);
|
||||
|
||||
/// Check if a tool is allowed to run
|
||||
bool isToolAllowed(String toolName, [String? toolArgs]) {
|
||||
// Check bypass mode
|
||||
if (settings.permissionMode == 'bypassPermissions') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check explicit deny rules
|
||||
for (final rule in settings.alwaysDenyRules) {
|
||||
if (_matchesRule(toolName, toolArgs, rule)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check explicit allow rules
|
||||
for (final rule in settings.alwaysAllowRules) {
|
||||
if (_matchesRule(toolName, toolArgs, rule)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check ask rules (requires user confirmation)
|
||||
for (final rule in settings.alwaysAskRules) {
|
||||
if (_matchesRule(toolName, toolArgs, rule)) {
|
||||
// In interactive mode, we would ask user
|
||||
// For now, default to false in non-interactive contexts
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Default behavior based on permission mode
|
||||
switch (settings.permissionMode) {
|
||||
case 'acceptEdits':
|
||||
// Accept file edits but ask for other tools
|
||||
return toolName.toLowerCase() == 'fileedit' ||
|
||||
toolName.toLowerCase() == 'filewrite';
|
||||
case 'dontAsk':
|
||||
// Don't ask, just run (legacy default)
|
||||
return true;
|
||||
case 'plan':
|
||||
// Only allow during plan execution
|
||||
return true; // Would check plan context in real implementation
|
||||
case 'bubble':
|
||||
// Bubble up to parent process for decision
|
||||
return false; // Default deny, parent must approve
|
||||
case 'auto':
|
||||
// Auto-detect based on tool safety
|
||||
return _isToolConsideredSafe(toolName);
|
||||
case 'default':
|
||||
default:
|
||||
// Default behavior: allow safe tools, ask for others
|
||||
return _isToolConsideredSafe(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a tool should trigger a user confirmation.
|
||||
/// Pass the full normalized input and the working directory so path-based
|
||||
/// tools (Read, Glob, Grep) can auto-allow paths inside the cwd.
|
||||
bool shouldAskForPermission(
|
||||
String toolName,
|
||||
Map<String, dynamic> input,
|
||||
String? workingDirectory,
|
||||
) {
|
||||
// alwaysAsk rules always win
|
||||
for (final rule in settings.alwaysAskRules) {
|
||||
if (_matchesRule(toolName, null, rule)) return true;
|
||||
}
|
||||
|
||||
// explicit deny → dont ask (tool will be denied, not prompted)
|
||||
if (settings.alwaysDenyRules.any((r) => _matchesRule(toolName, null, r))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// explicit allow → no prompt needed
|
||||
if (settings.alwaysAllowRules.any((r) => _matchesRule(toolName, null, r))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (settings.permissionMode) {
|
||||
case 'bypassPermissions':
|
||||
return false;
|
||||
case 'dontAsk':
|
||||
return false;
|
||||
case 'bubble':
|
||||
return true;
|
||||
case 'acceptEdits':
|
||||
// edit/write tools are auto-accepted, everything else prompts
|
||||
final n = toolName.toLowerCase();
|
||||
return !(n == 'edit' || n == 'write' || n == 'fileedit' || n == 'filewrite');
|
||||
case 'default':
|
||||
case 'auto':
|
||||
default:
|
||||
return _defaultShouldAsk(toolName, input, workingDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
bool _defaultShouldAsk(
|
||||
String toolName,
|
||||
Map<String, dynamic> input,
|
||||
String? workingDirectory,
|
||||
) {
|
||||
final cwd = workingDirectory?.trim();
|
||||
|
||||
switch (toolName) {
|
||||
// read-only filesystem tools: allow if path is inside cwd
|
||||
case 'Read':
|
||||
case 'Glob':
|
||||
case 'Grep':
|
||||
if (cwd == null || cwd.isEmpty) return true;
|
||||
final pathArg = (input['file_path'] ?? input['path']) as String?;
|
||||
if (pathArg == null || pathArg.isEmpty) return false;
|
||||
final abs = p.isAbsolute(pathArg) ? pathArg : p.join(cwd, pathArg);
|
||||
final norm = p.normalize(abs);
|
||||
return !norm.startsWith(p.normalize(cwd));
|
||||
|
||||
// write tools always ask
|
||||
case 'Edit':
|
||||
case 'Write':
|
||||
case 'Bash':
|
||||
return true;
|
||||
|
||||
// network tools always ask
|
||||
case 'WebSearch':
|
||||
case 'WebFetch':
|
||||
return true;
|
||||
|
||||
// everything else asks by default
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get permission decision for a tool (allowed, denied, or ask)
|
||||
String getPermissionDecision(String toolName, Map<String, dynamic> input, String? workingDirectory) {
|
||||
if (!isToolAllowed(toolName)) {
|
||||
return 'denied';
|
||||
}
|
||||
if (shouldAskForPermission(toolName, input, workingDirectory)) {
|
||||
return 'ask';
|
||||
}
|
||||
return 'allowed';
|
||||
}
|
||||
|
||||
/// Parse a permission rule
|
||||
Map<String, dynamic> parsePermissionRule(String rule) {
|
||||
final result = <String, dynamic>{
|
||||
'tool': '',
|
||||
'pattern': '',
|
||||
'args': '',
|
||||
};
|
||||
|
||||
// Check for tool(args) pattern
|
||||
final match = RegExp(r'^(\w+)\((.*)\)$').firstMatch(rule);
|
||||
if (match != null) {
|
||||
result['tool'] = match.group(1)?.toLowerCase() ?? '';
|
||||
result['args'] = match.group(2) ?? '';
|
||||
result['pattern'] = rule;
|
||||
} else {
|
||||
// Just tool name
|
||||
result['tool'] = rule.toLowerCase();
|
||||
result['pattern'] = rule;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Check if a tool matches a permission rule
|
||||
bool _matchesRule(String toolName, String? toolArgs, String rule) {
|
||||
final parsed = parsePermissionRule(rule);
|
||||
final ruleTool = parsed['tool'] as String;
|
||||
|
||||
// Check tool name match
|
||||
if (toolName.toLowerCase() != ruleTool) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check args if specified in rule
|
||||
final ruleArgs = parsed['args'] as String;
|
||||
if (ruleArgs.isNotEmpty && toolArgs != null) {
|
||||
// Simple substring matching for args
|
||||
return toolArgs.contains(ruleArgs);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Determine if a tool is considered "safe" for auto-allow
|
||||
bool _isToolConsideredSafe(String toolName) {
|
||||
const safeTools = {
|
||||
'bash': false, // Can run arbitrary commands
|
||||
'fileedit': false, // Modifies files
|
||||
'filewrite': false, // Writes files
|
||||
'websearch': false, // Makes network requests
|
||||
'webfetch': false, // Makes network requests
|
||||
'agent': false, // Can spawn other agents
|
||||
'task': false, // Can run background tasks
|
||||
'skill': false, // Can execute arbitrary skills
|
||||
'mcp': false, // Can connect to external servers
|
||||
'glob': true, // Just lists files
|
||||
'grep': true, // Just searches files
|
||||
'fileread': true, // Just reads files
|
||||
'simpleagent': false, // Agent operations
|
||||
};
|
||||
|
||||
return safeTools[toolName.toLowerCase()] ?? false;
|
||||
}
|
||||
|
||||
/// Get user confirmation for a tool (simulated for now)
|
||||
Future<bool> getUserConfirmation(
|
||||
String toolName, String? toolArgs, String prompt) async {
|
||||
// In a real implementation, this would show an interactive prompt
|
||||
// For now, simulate based on environment variable
|
||||
final autoConfirm = Platform.environment['CLAWED_AUTO_CONFIRM'] == 'true';
|
||||
|
||||
if (autoConfirm) {
|
||||
print('Auto-confirming: $toolName $toolArgs');
|
||||
return true;
|
||||
}
|
||||
|
||||
print('Permission required: $prompt');
|
||||
print('Tool: $toolName${toolArgs != null ? ' with args: $toolArgs' : ''}');
|
||||
print('Run with CLAWED_AUTO_CONFIRM=true to auto-confirm.');
|
||||
|
||||
// Default deny in non-interactive mode
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Format permission rules for display
|
||||
String formatPermissionRules() {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('Permission Mode: ${settings.permissionMode}');
|
||||
buffer.writeln();
|
||||
|
||||
if (settings.alwaysAllowRules.isNotEmpty) {
|
||||
buffer.writeln('Always Allow:');
|
||||
for (final rule in settings.alwaysAllowRules) {
|
||||
buffer.writeln(' • $rule');
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
if (settings.alwaysDenyRules.isNotEmpty) {
|
||||
buffer.writeln('Always Deny:');
|
||||
for (final rule in settings.alwaysDenyRules) {
|
||||
buffer.writeln(' • $rule');
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
if (settings.alwaysAskRules.isNotEmpty) {
|
||||
buffer.writeln('Always Ask:');
|
||||
for (final rule in settings.alwaysAskRules) {
|
||||
buffer.writeln(' • $rule');
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
if (settings.alwaysAllowRules.isEmpty &&
|
||||
settings.alwaysDenyRules.isEmpty &&
|
||||
settings.alwaysAskRules.isEmpty) {
|
||||
buffer.writeln('No specific permission rules configured.');
|
||||
buffer.writeln('Using mode: ${settings.permissionMode}');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import "dart:async";
|
||||
|
||||
enum PermissionDecision { allowOnce, allowAlways, reject }
|
||||
|
||||
class PendingPermission {
|
||||
PendingPermission({required this.toolName, required this.input})
|
||||
: _completer = Completer<PermissionDecision>();
|
||||
|
||||
final String toolName;
|
||||
final Map<String, dynamic> input;
|
||||
final Completer<PermissionDecision> _completer;
|
||||
|
||||
Future<PermissionDecision> get future => _completer.future;
|
||||
|
||||
void resolve(PermissionDecision decision) {
|
||||
if (!_completer.isCompleted) {
|
||||
_completer.complete(decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "package:path/path.dart" as p;
|
||||
|
||||
import "local_state.dart";
|
||||
import "session/session_store.dart";
|
||||
|
||||
const _encoder = JsonEncoder.withIndent(" ");
|
||||
|
||||
String getProjectSettingsPath(String workingDirectory) {
|
||||
return p.join(getProjectAgencyDir(workingDirectory), "settings.json");
|
||||
}
|
||||
|
||||
class ProjectSettingsStore {
|
||||
ProjectSettingsStore._();
|
||||
|
||||
static final ProjectSettingsStore instance = ProjectSettingsStore._();
|
||||
|
||||
Future<LocalSettings?> load(String workingDirectory) async {
|
||||
final file = File(getProjectSettingsPath(workingDirectory));
|
||||
if (!await file.exists()) return null;
|
||||
|
||||
try {
|
||||
final raw = await file.readAsString();
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return LocalSettings.fromJson(decoded);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> save(String workingDirectory, LocalSettings settings) async {
|
||||
final dir = Directory(getProjectAgencyDir(workingDirectory));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
|
||||
final file = File(getProjectSettingsPath(workingDirectory));
|
||||
await file.writeAsString("${_encoder.convert(settings.toJson())}\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../constants.dart';
|
||||
|
||||
/// Analytics and telemetry service
|
||||
/// Supports both local logging and remote analytics with vendor-neutral abstraction
|
||||
class AnalyticsService {
|
||||
static final AnalyticsService _instance = AnalyticsService._internal();
|
||||
factory AnalyticsService() => _instance;
|
||||
AnalyticsService._internal();
|
||||
|
||||
bool _enabled = true;
|
||||
bool _initialized = false;
|
||||
final List<Map<String, dynamic>> _eventBuffer = [];
|
||||
final String _logPath = _getLogFilePath();
|
||||
|
||||
/// Initialize analytics service
|
||||
Future<void> initialize({bool enabled = true}) async {
|
||||
if (_initialized) return;
|
||||
|
||||
_enabled = enabled && areRemoteServicesAvailable();
|
||||
_initialized = true;
|
||||
|
||||
// Load existing log if any
|
||||
await _loadEventBuffer();
|
||||
|
||||
// Log initialization event
|
||||
await _logEvent('analytics_initialized', {
|
||||
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||
'enabled': _enabled,
|
||||
'remote_services_available': areRemoteServicesAvailable(),
|
||||
});
|
||||
|
||||
// Flush buffer if we have pending events
|
||||
if (_eventBuffer.isNotEmpty) {
|
||||
await _flushEvents();
|
||||
}
|
||||
}
|
||||
|
||||
/// Log an event
|
||||
Future<void> logEvent(String eventName,
|
||||
{Map<String, dynamic>? properties,
|
||||
Map<String, dynamic>? metrics}) async {
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
if (!_enabled) return;
|
||||
|
||||
final event = <String, dynamic>{
|
||||
'event': eventName,
|
||||
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||
if (properties != null && properties.isNotEmpty) 'properties': properties,
|
||||
if (metrics != null && metrics.isNotEmpty) 'metrics': metrics,
|
||||
};
|
||||
|
||||
// Add to buffer and try to send
|
||||
_eventBuffer.add(event);
|
||||
await _saveEventBuffer();
|
||||
|
||||
// Try to send immediately, but don't block
|
||||
_flushEventsInBackground();
|
||||
}
|
||||
|
||||
/// Log command execution
|
||||
Future<void> logCommand(String commandName,
|
||||
{List<String>? args,
|
||||
int? exitCode,
|
||||
int? durationMs,
|
||||
String? sessionId}) async {
|
||||
await logEvent('command_executed', properties: {
|
||||
'command': commandName,
|
||||
if (args != null && args.isNotEmpty) 'args_count': args.length,
|
||||
if (exitCode != null) 'exit_code': exitCode,
|
||||
if (durationMs != null) 'duration_ms': durationMs,
|
||||
if (sessionId != null) 'session_id': sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
/// Log tool execution
|
||||
Future<void> logTool(String toolName,
|
||||
{Map<String, dynamic>? input,
|
||||
String? result,
|
||||
int? durationMs,
|
||||
bool? allowed}) async {
|
||||
await logEvent('tool_executed', properties: {
|
||||
'tool': toolName,
|
||||
if (input != null && input.isNotEmpty) 'input_keys': input.keys.toList(),
|
||||
if (result != null) 'result_length': result.length,
|
||||
if (durationMs != null) 'duration_ms': durationMs,
|
||||
if (allowed != null) 'allowed': allowed,
|
||||
});
|
||||
}
|
||||
|
||||
/// Log model usage
|
||||
Future<void> logModelUsage(String modelName,
|
||||
{int? inputTokens,
|
||||
int? outputTokens,
|
||||
double? costUsd,
|
||||
int? durationMs}) async {
|
||||
await logEvent('model_usage', properties: {
|
||||
'model': modelName,
|
||||
if (inputTokens != null) 'input_tokens': inputTokens,
|
||||
if (outputTokens != null) 'output_tokens': outputTokens,
|
||||
if (costUsd != null) 'cost_usd': costUsd,
|
||||
if (durationMs != null) 'duration_ms': durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/// Log error
|
||||
Future<void> logError(String errorType,
|
||||
{String? message,
|
||||
StackTrace? stackTrace,
|
||||
String? context}) async {
|
||||
await logEvent('error', properties: {
|
||||
'type': errorType,
|
||||
if (message != null) 'message': message,
|
||||
if (context != null) 'context': context,
|
||||
if (stackTrace != null) 'stack_trace': stackTrace.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Log session start/end
|
||||
Future<void> logSession(String sessionId, String action,
|
||||
{int? messageCount,
|
||||
int? durationMs,
|
||||
double? totalCost}) async {
|
||||
await logEvent('session_$action', properties: {
|
||||
'session_id': sessionId,
|
||||
if (messageCount != null) 'message_count': messageCount,
|
||||
if (durationMs != null) 'duration_ms': durationMs,
|
||||
if (totalCost != null) 'total_cost': totalCost,
|
||||
});
|
||||
}
|
||||
|
||||
/// Flush events to remote service
|
||||
Future<void> flush() async {
|
||||
await _flushEvents();
|
||||
}
|
||||
|
||||
/// Get analytics status
|
||||
Map<String, dynamic> getStatus() {
|
||||
return {
|
||||
'enabled': _enabled,
|
||||
'initialized': _initialized,
|
||||
'buffer_size': _eventBuffer.length,
|
||||
'log_path': _logPath,
|
||||
'remote_services_available': areRemoteServicesAvailable(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Enable or disable analytics
|
||||
void setEnabled(bool enabled) {
|
||||
_enabled = enabled;
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
Future<void> _flushEvents() async {
|
||||
if (_eventBuffer.isEmpty) return;
|
||||
|
||||
final eventsToSend = List<Map<String, dynamic>>.from(_eventBuffer);
|
||||
_eventBuffer.clear();
|
||||
|
||||
// Try to send to remote service
|
||||
bool remoteSuccess = false;
|
||||
if (shouldUseRemoteService('analytics')) {
|
||||
try {
|
||||
remoteSuccess = await _sendToRemoteService(eventsToSend);
|
||||
} catch (e) {
|
||||
// Log error but continue with local fallback
|
||||
await _logLocal('Failed to send analytics to remote: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Always log locally as backup
|
||||
await _logLocal('Analytics events (remote: $remoteSuccess):');
|
||||
for (final event in eventsToSend) {
|
||||
await _logLocal(' ${jsonEncode(event)}');
|
||||
}
|
||||
|
||||
await _saveEventBuffer();
|
||||
}
|
||||
|
||||
void _flushEventsInBackground() {
|
||||
Future.microtask(() async {
|
||||
try {
|
||||
await _flushEvents();
|
||||
} catch (e) {
|
||||
// Silently fail for background flushes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _sendToRemoteService(List<Map<String, dynamic>> events) async {
|
||||
if (!shouldUseRemoteService('analytics')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final url = getRemoteServiceUrl('${ApiPaths.analytics}/batch');
|
||||
final client = HttpClient();
|
||||
final request = await client.postUrl(Uri.parse(url));
|
||||
|
||||
request.headers.set('Content-Type', 'application/json');
|
||||
request.write(jsonEncode({
|
||||
'events': events,
|
||||
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||
'client': 'clawd_code',
|
||||
'version': '1.0.0',
|
||||
}));
|
||||
|
||||
final response = await request.close();
|
||||
await response.drain();
|
||||
|
||||
return response.statusCode >= 200 && response.statusCode < 300;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _logEvent(String eventName, Map<String, dynamic> data) async {
|
||||
final logFile = File(_logPath);
|
||||
final logLine = jsonEncode({
|
||||
'event': eventName,
|
||||
...data,
|
||||
'logged_at': DateTime.now().toUtc().toIso8601String(),
|
||||
});
|
||||
|
||||
try {
|
||||
await logFile.parent.create(recursive: true);
|
||||
await logFile.writeAsString('$logLine\n', mode: FileMode.append);
|
||||
} catch (e) {
|
||||
// Silently fail if we can't write to log
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _logLocal(String message) async {
|
||||
final logFile = File(_logPath);
|
||||
final logLine =
|
||||
'[${DateTime.now().toLocal()}] $message\n';
|
||||
|
||||
try {
|
||||
await logFile.parent.create(recursive: true);
|
||||
await logFile.writeAsString(logLine, mode: FileMode.append);
|
||||
} catch (e) {
|
||||
// Silently fail if we can't write to log
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadEventBuffer() async {
|
||||
final logFile = File(_logPath);
|
||||
if (!await logFile.exists()) return;
|
||||
|
||||
try {
|
||||
final lines = await logFile.readAsLines();
|
||||
for (final line in lines) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
try {
|
||||
final event = jsonDecode(line) as Map<String, dynamic>;
|
||||
_eventBuffer.add(event);
|
||||
} catch (e) {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Reset buffer if we can't read the file
|
||||
_eventBuffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveEventBuffer() async {
|
||||
final logFile = File(_logPath);
|
||||
try {
|
||||
await logFile.parent.create(recursive: true);
|
||||
final lines = _eventBuffer.map(jsonEncode).join('\n');
|
||||
await logFile.writeAsString('$lines\n');
|
||||
} catch (e) {
|
||||
// Silently fail if we can't write to log
|
||||
}
|
||||
}
|
||||
|
||||
static String _getLogFilePath() {
|
||||
final home = Platform.environment['HOME'] ??
|
||||
Platform.environment['USERPROFILE'] ??
|
||||
Directory.current.path;
|
||||
return Platform.isWindows
|
||||
? '$home\\.clawd_code\\analytics.log'
|
||||
: '$home/.clawd_code/analytics.log';
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
// Anthropic API client stub
|
||||
// Ported from old_repo/services/api/client.ts
|
||||
// Full implementation requires HTTP + auth — stubbed with TODOs
|
||||
// Vendor-neutral API client stub
|
||||
// Generic client that can work with multiple providers
|
||||
|
||||
import "dart:io";
|
||||
|
||||
enum ApiProvider { anthropic, bedrock, vertex, foundry }
|
||||
enum ApiProvider { generic, anthropic, openrouter, bedrock, vertex, foundry }
|
||||
|
||||
ApiProvider getApiProvider() {
|
||||
final env = Platform.environment;
|
||||
|
||||
// Check for vendor-specific flags
|
||||
if (_isTruthy(env["CLAUDE_CODE_USE_BEDROCK"])) return ApiProvider.bedrock;
|
||||
if (_isTruthy(env["CLAUDE_CODE_USE_VERTEX"])) return ApiProvider.vertex;
|
||||
if (_isTruthy(env["CLAUDE_CODE_USE_FOUNDRY"])) return ApiProvider.foundry;
|
||||
if (_isTruthy(env["USE_OPENROUTER"])) return ApiProvider.openrouter;
|
||||
if (_isTruthy(env["USE_ANTHROPIC"])) return ApiProvider.anthropic;
|
||||
|
||||
return ApiProvider.anthropic;
|
||||
// Default to generic
|
||||
return ApiProvider.generic;
|
||||
}
|
||||
|
||||
bool _isTruthy(String? v) {
|
||||
@@ -62,7 +65,19 @@ String? resolveApiKey() {
|
||||
|
||||
String resolveBaseUrl() {
|
||||
final env = Platform.environment;
|
||||
final override = env["ANTHROPIC_BASE_URL"] ?? env["CLAUDE_CODE_BASE_URL"];
|
||||
final override = env["ANTHROPIC_BASE_URL"] ??
|
||||
env["CLAUDE_CODE_BASE_URL"] ??
|
||||
env["OPENROUTER_BASE_URL"] ??
|
||||
env["API_BASE_URL"];
|
||||
if (override != null && override.isNotEmpty) return override;
|
||||
return "https://api.anthropic.com";
|
||||
|
||||
// No vendor-specific defaults — require explicit configuration
|
||||
throw StateError(
|
||||
'Base URL not configured. Set one of:\n'
|
||||
' ANTHROPIC_BASE_URL (for Anthropic)\n'
|
||||
' CLAUDE_CODE_BASE_URL (for Claude Code backend)\n'
|
||||
' OPENROUTER_BASE_URL (for OpenRouter)\n'
|
||||
' API_BASE_URL (generic fallback)\n'
|
||||
'Or use vendor-neutral kHostEndpoint from lib/src/constants.dart'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ class ModelUsage {
|
||||
int cacheReadInputTokens;
|
||||
int cacheCreationInputTokens;
|
||||
int webSearchRequests;
|
||||
int webFetchRequests;
|
||||
double costUsd;
|
||||
int contextWindow;
|
||||
int maxOutputTokens;
|
||||
@@ -18,6 +19,7 @@ class ModelUsage {
|
||||
this.cacheReadInputTokens = 0,
|
||||
this.cacheCreationInputTokens = 0,
|
||||
this.webSearchRequests = 0,
|
||||
this.webFetchRequests = 0,
|
||||
this.costUsd = 0.0,
|
||||
this.contextWindow = 0,
|
||||
this.maxOutputTokens = 0,
|
||||
@@ -29,6 +31,7 @@ class ModelUsage {
|
||||
"cacheReadInputTokens": cacheReadInputTokens,
|
||||
"cacheCreationInputTokens": cacheCreationInputTokens,
|
||||
"webSearchRequests": webSearchRequests,
|
||||
"webFetchRequests": webFetchRequests,
|
||||
"costUsd": costUsd,
|
||||
};
|
||||
|
||||
@@ -38,6 +41,7 @@ class ModelUsage {
|
||||
cacheReadInputTokens: (json["cacheReadInputTokens"] as num?)?.toInt() ?? 0,
|
||||
cacheCreationInputTokens: (json["cacheCreationInputTokens"] as num?)?.toInt() ?? 0,
|
||||
webSearchRequests: (json["webSearchRequests"] as num?)?.toInt() ?? 0,
|
||||
webFetchRequests: (json["webFetchRequests"] as num?)?.toInt() ?? 0,
|
||||
costUsd: (json["costUsd"] as num?)?.toDouble() ?? 0.0,
|
||||
);
|
||||
}
|
||||
@@ -50,6 +54,7 @@ class _CostState {
|
||||
int totalCacheReadInputTokens = 0;
|
||||
int totalCacheCreationInputTokens = 0;
|
||||
int totalWebSearchRequests = 0;
|
||||
int totalWebFetchRequests = 0;
|
||||
int totalApiDurationMs = 0;
|
||||
int totalApiDurationWithoutRetriesMs = 0;
|
||||
int totalToolDurationMs = 0;
|
||||
@@ -69,6 +74,7 @@ int getTotalOutputTokens() => _state.totalOutputTokens;
|
||||
int getTotalCacheReadInputTokens() => _state.totalCacheReadInputTokens;
|
||||
int getTotalCacheCreationInputTokens() => _state.totalCacheCreationInputTokens;
|
||||
int getTotalWebSearchRequests() => _state.totalWebSearchRequests;
|
||||
int getTotalWebFetchRequests() => _state.totalWebFetchRequests;
|
||||
int getTotalApiDurationMs() => _state.totalApiDurationMs;
|
||||
int getTotalApiDurationWithoutRetriesMs() => _state.totalApiDurationWithoutRetriesMs;
|
||||
int getTotalToolDurationMs() => _state.totalToolDurationMs;
|
||||
@@ -98,6 +104,7 @@ void resetCostState() {
|
||||
_state.totalCacheReadInputTokens = 0;
|
||||
_state.totalCacheCreationInputTokens = 0;
|
||||
_state.totalWebSearchRequests = 0;
|
||||
_state.totalWebFetchRequests = 0;
|
||||
_state.totalApiDurationMs = 0;
|
||||
_state.totalApiDurationWithoutRetriesMs = 0;
|
||||
_state.totalToolDurationMs = 0;
|
||||
@@ -142,6 +149,7 @@ double addToTotalSessionCost({
|
||||
required int cacheReadTokens,
|
||||
required int cacheCreationTokens,
|
||||
int webSearchRequests = 0,
|
||||
int webFetchRequests = 0,
|
||||
required String model,
|
||||
}) {
|
||||
_state.totalCostUsd += cost;
|
||||
@@ -150,6 +158,7 @@ double addToTotalSessionCost({
|
||||
_state.totalCacheReadInputTokens += cacheReadTokens;
|
||||
_state.totalCacheCreationInputTokens += cacheCreationTokens;
|
||||
_state.totalWebSearchRequests += webSearchRequests;
|
||||
_state.totalWebFetchRequests += webFetchRequests;
|
||||
|
||||
final existing = _state.modelUsage.putIfAbsent(model, ModelUsage.new);
|
||||
existing.inputTokens += inputTokens;
|
||||
@@ -157,6 +166,7 @@ double addToTotalSessionCost({
|
||||
existing.cacheReadInputTokens += cacheReadTokens;
|
||||
existing.cacheCreationInputTokens += cacheCreationTokens;
|
||||
existing.webSearchRequests += webSearchRequests;
|
||||
existing.webFetchRequests += webFetchRequests;
|
||||
existing.costUsd += cost;
|
||||
|
||||
return _state.totalCostUsd;
|
||||
@@ -209,6 +219,7 @@ String formatTotalCost() {
|
||||
"${_fmt(u.cacheReadInputTokens)} cache read, "
|
||||
"${_fmt(u.cacheCreationInputTokens)} cache write"
|
||||
"${u.webSearchRequests > 0 ? ", ${_fmt(u.webSearchRequests)} web search" : ""}"
|
||||
"${u.webFetchRequests > 0 ? ", ${_fmt(u.webFetchRequests)} web fetch" : ""}"
|
||||
" (${formatCost(u.costUsd)})";
|
||||
final label = "${entry.key}:".padLeft(21);
|
||||
buf.write("\n$label$line");
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Process management service for task execution
|
||||
// Handles spawning, monitoring, and terminating sub-processes
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../tools/task_tool.dart';
|
||||
|
||||
typedef ProcessCallback = void Function(String output);
|
||||
|
||||
/// Represents a running process
|
||||
class ManagedProcess {
|
||||
final String id;
|
||||
final String taskId;
|
||||
final String command;
|
||||
final List<String> arguments;
|
||||
final String workingDirectory;
|
||||
|
||||
late Process _process;
|
||||
late StreamSubscription<String> _stdoutSub;
|
||||
late StreamSubscription<String> _stderrSub;
|
||||
|
||||
final List<String> _output = [];
|
||||
final List<String> _errors = [];
|
||||
|
||||
DateTime _startTime = DateTime.now();
|
||||
DateTime? _endTime;
|
||||
|
||||
int? _exitCode;
|
||||
bool _isRunning = true;
|
||||
|
||||
ManagedProcess({
|
||||
required this.id,
|
||||
required this.taskId,
|
||||
required this.command,
|
||||
required this.arguments,
|
||||
required this.workingDirectory,
|
||||
});
|
||||
|
||||
/// Start the process
|
||||
Future<void> start() async {
|
||||
try {
|
||||
_process = await Process.start(
|
||||
command,
|
||||
arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
includeParentEnvironment: true,
|
||||
);
|
||||
|
||||
// Listen to stdout
|
||||
_stdoutSub = _process.stdout
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen((line) {
|
||||
_output.add(line);
|
||||
});
|
||||
|
||||
// Listen to stderr
|
||||
_stderrSub = _process.stderr
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen((line) {
|
||||
_errors.add(line);
|
||||
});
|
||||
|
||||
// Wait for process to exit
|
||||
_exitCode = await _process.exitCode;
|
||||
_isRunning = false;
|
||||
_endTime = DateTime.now();
|
||||
|
||||
// Cancel subscriptions
|
||||
await _stdoutSub.cancel();
|
||||
await _stderrSub.cancel();
|
||||
} catch (e) {
|
||||
_isRunning = false;
|
||||
_endTime = DateTime.now();
|
||||
_errors.add('Failed to start process: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate the process
|
||||
Future<bool> terminate({bool force = false}) async {
|
||||
if (!_isRunning) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (force) {
|
||||
// Force kill
|
||||
return _process.kill(ProcessSignal.sigkill);
|
||||
} else {
|
||||
// Graceful shutdown
|
||||
_process.kill(ProcessSignal.sigterm);
|
||||
|
||||
// Wait up to 5 seconds for graceful shutdown
|
||||
for (int i = 0; i < 50; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (!_isRunning) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If still running, force kill
|
||||
return _process.kill(ProcessSignal.sigkill);
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current output
|
||||
String getOutput() => _output.join('\n');
|
||||
|
||||
/// Get current errors
|
||||
String getErrors() => _errors.join('\n');
|
||||
|
||||
/// Get full output (both stdout and stderr)
|
||||
String getFullOutput() {
|
||||
final combined = <String>[];
|
||||
combined.addAll(_output);
|
||||
if (_errors.isNotEmpty) {
|
||||
combined.add('\n--- STDERR ---');
|
||||
combined.addAll(_errors);
|
||||
}
|
||||
return combined.join('\n');
|
||||
}
|
||||
|
||||
/// Public getters for process state
|
||||
bool get isRunning => _isRunning;
|
||||
int? get exitCode => _exitCode;
|
||||
DateTime get startTime => _startTime;
|
||||
DateTime? get endTime => _endTime;
|
||||
|
||||
/// Get process info
|
||||
Map<String, dynamic> toJson() {
|
||||
final duration = (_endTime ?? DateTime.now()).difference(_startTime);
|
||||
return {
|
||||
'id': id,
|
||||
'task_id': taskId,
|
||||
'command': command,
|
||||
'arguments': arguments,
|
||||
'working_directory': workingDirectory,
|
||||
'is_running': _isRunning,
|
||||
'exit_code': _exitCode,
|
||||
'duration_ms': duration.inMilliseconds,
|
||||
'output_lines': _output.length,
|
||||
'error_lines': _errors.length,
|
||||
'started_at': _startTime.toIso8601String(),
|
||||
'ended_at': _endTime?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages multiple processes
|
||||
class ProcessManager {
|
||||
static final ProcessManager _instance = ProcessManager._internal();
|
||||
factory ProcessManager() => _instance;
|
||||
ProcessManager._internal();
|
||||
|
||||
final Map<String, ManagedProcess> _processes = {};
|
||||
int _idCounter = 1;
|
||||
|
||||
/// Spawn a new process
|
||||
Future<ManagedProcess> spawn({
|
||||
required String taskId,
|
||||
required String command,
|
||||
required List<String> arguments,
|
||||
required String workingDirectory,
|
||||
}) async {
|
||||
final id = 'proc_${_idCounter++}';
|
||||
|
||||
final process = ManagedProcess(
|
||||
id: id,
|
||||
taskId: taskId,
|
||||
command: command,
|
||||
arguments: arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
|
||||
_processes[id] = process;
|
||||
|
||||
// Start in background
|
||||
process.start().then((_) {
|
||||
// Process completed
|
||||
}).catchError((e) {
|
||||
print('Process $id failed: $e');
|
||||
});
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
/// Get a process by ID
|
||||
ManagedProcess? getProcess(String id) => _processes[id];
|
||||
|
||||
/// Get all processes for a task
|
||||
List<ManagedProcess> getTaskProcesses(String taskId) {
|
||||
return _processes.values
|
||||
.where((p) => p.taskId == taskId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Terminate a process
|
||||
Future<bool> terminateProcess(String id, {bool force = false}) async {
|
||||
final process = _processes[id];
|
||||
if (process == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final result = await process.terminate(force: force);
|
||||
if (result) {
|
||||
_processes.remove(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Terminate all processes for a task
|
||||
Future<void> terminateTaskProcesses(String taskId, {bool force = false}) async {
|
||||
final processes = getTaskProcesses(taskId);
|
||||
for (final process in processes) {
|
||||
await process.terminate(force: force);
|
||||
_processes.remove(process.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all processes
|
||||
List<ManagedProcess> getAllProcesses() => _processes.values.toList();
|
||||
|
||||
/// Get process count
|
||||
int getProcessCount() => _processes.length;
|
||||
|
||||
/// Get running process count
|
||||
int getRunningProcessCount() =>
|
||||
_processes.values.where((p) => p.isRunning).length;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Task execution service
|
||||
// Bridges task definitions to actual process execution
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'process_manager.dart';
|
||||
|
||||
/// Status of a task execution
|
||||
enum TaskExecutionStatus {
|
||||
created,
|
||||
queued,
|
||||
running,
|
||||
completed,
|
||||
failed,
|
||||
cancelled,
|
||||
}
|
||||
|
||||
/// Result of a task execution
|
||||
class TaskExecutionResult {
|
||||
final String taskId;
|
||||
final String processId;
|
||||
final TaskExecutionStatus status;
|
||||
final int? exitCode;
|
||||
final String output;
|
||||
final String errors;
|
||||
final Duration? duration;
|
||||
|
||||
const TaskExecutionResult({
|
||||
required this.taskId,
|
||||
required this.processId,
|
||||
required this.status,
|
||||
this.exitCode,
|
||||
required this.output,
|
||||
required this.errors,
|
||||
this.duration,
|
||||
});
|
||||
|
||||
bool get isSuccess => exitCode == 0;
|
||||
bool get isRunning => status == TaskExecutionStatus.running;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'task_id': taskId,
|
||||
'process_id': processId,
|
||||
'status': status.name,
|
||||
'exit_code': exitCode,
|
||||
'output': output,
|
||||
'errors': errors,
|
||||
'duration_ms': duration?.inMilliseconds,
|
||||
'is_success': isSuccess,
|
||||
};
|
||||
}
|
||||
|
||||
/// Executes tasks as background processes
|
||||
class TaskExecutor {
|
||||
static final TaskExecutor _instance = TaskExecutor._internal();
|
||||
factory TaskExecutor() => _instance;
|
||||
TaskExecutor._internal() : _processManager = ProcessManager();
|
||||
|
||||
final ProcessManager _processManager;
|
||||
final Map<String, TaskExecutionResult> _results = {};
|
||||
|
||||
/// Execute a task command
|
||||
/// Returns immediately with process ID
|
||||
/// Call getResult() to check completion
|
||||
Future<String> executeTask({
|
||||
required String taskId,
|
||||
required String command,
|
||||
required List<String> arguments,
|
||||
required String workingDirectory,
|
||||
}) async {
|
||||
try {
|
||||
final process = await _processManager.spawn(
|
||||
taskId: taskId,
|
||||
command: command,
|
||||
arguments: arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
|
||||
return process.id;
|
||||
} catch (e) {
|
||||
throw TaskExecutionException(
|
||||
'Failed to execute task: $e',
|
||||
taskId: taskId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get execution result (non-blocking)
|
||||
/// Returns null if process still running
|
||||
/// Returns result when complete
|
||||
TaskExecutionResult? getResult(String processId) {
|
||||
final process = _processManager.getProcess(processId);
|
||||
if (process == null) {
|
||||
return _results[processId];
|
||||
}
|
||||
|
||||
// Process still exists, check if running
|
||||
if (process.isRunning) {
|
||||
return null; // Still running
|
||||
}
|
||||
|
||||
// Process completed, return result
|
||||
final result = TaskExecutionResult(
|
||||
taskId: process.taskId,
|
||||
processId: processId,
|
||||
status: process.exitCode == 0
|
||||
? TaskExecutionStatus.completed
|
||||
: TaskExecutionStatus.failed,
|
||||
exitCode: process.exitCode,
|
||||
output: process.getOutput(),
|
||||
errors: process.getErrors(),
|
||||
duration: process.endTime?.difference(process.startTime),
|
||||
);
|
||||
|
||||
_results[processId] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Wait for process completion
|
||||
/// Polls for result (blocking)
|
||||
Future<TaskExecutionResult> waitForResult(
|
||||
String processId, {
|
||||
Duration timeout = const Duration(hours: 24),
|
||||
}) async {
|
||||
final endTime = DateTime.now().add(timeout);
|
||||
|
||||
while (DateTime.now().isBefore(endTime)) {
|
||||
final result = getResult(processId);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Wait 100ms before checking again
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
throw TaskExecutionException(
|
||||
'Task execution timeout after ${timeout.inSeconds} seconds',
|
||||
taskId: '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Watch process output in real-time
|
||||
/// Returns stream of output lines
|
||||
Stream<String> watchOutput(String processId) async* {
|
||||
final process = _processManager.getProcess(processId);
|
||||
if (process == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial output
|
||||
final output = process.getOutput();
|
||||
if (output.isNotEmpty) {
|
||||
for (final line in output.split('\n')) {
|
||||
yield line;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for more output (simplified - real version would stream)
|
||||
while (process.isRunning) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
final newOutput = process.getOutput();
|
||||
if (newOutput.isNotEmpty) {
|
||||
yield newOutput;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate a task
|
||||
Future<bool> cancelTask(String processId, {bool force = false}) async {
|
||||
return _processManager.terminateProcess(processId, force: force);
|
||||
}
|
||||
|
||||
/// Get all active tasks
|
||||
List<Map<String, dynamic>> getActiveTasks() {
|
||||
return _processManager.getAllProcesses().map((p) => p.toJson()).toList();
|
||||
}
|
||||
|
||||
/// Get task status
|
||||
TaskExecutionStatus getTaskStatus(String processId) {
|
||||
final process = _processManager.getProcess(processId);
|
||||
if (process == null) {
|
||||
final result = _results[processId];
|
||||
return result?.status ?? TaskExecutionStatus.cancelled;
|
||||
}
|
||||
|
||||
if (process.isRunning) {
|
||||
return TaskExecutionStatus.running;
|
||||
}
|
||||
|
||||
return process.exitCode == 0
|
||||
? TaskExecutionStatus.completed
|
||||
: TaskExecutionStatus.failed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception thrown during task execution
|
||||
class TaskExecutionException implements Exception {
|
||||
final String message;
|
||||
final String taskId;
|
||||
|
||||
TaskExecutionException(this.message, {required this.taskId});
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import "../analytics/analytics_service.dart";
|
||||
import "analytics_config.dart";
|
||||
|
||||
abstract class ToolTelemetryClient {
|
||||
Future<void> recordToolCall({
|
||||
required String toolName,
|
||||
required bool success,
|
||||
int? durationMs,
|
||||
Map<String, Object?> metadata = const <String, Object?>{},
|
||||
});
|
||||
}
|
||||
|
||||
class NullToolTelemetryClient implements ToolTelemetryClient {
|
||||
const NullToolTelemetryClient();
|
||||
|
||||
@override
|
||||
Future<void> recordToolCall({
|
||||
required String toolName,
|
||||
required bool success,
|
||||
int? durationMs,
|
||||
Map<String, Object?> metadata = const <String, Object?>{},
|
||||
}) async {}
|
||||
}
|
||||
|
||||
class LocalToolTelemetryClient implements ToolTelemetryClient {
|
||||
const LocalToolTelemetryClient();
|
||||
|
||||
@override
|
||||
Future<void> recordToolCall({
|
||||
required String toolName,
|
||||
required bool success,
|
||||
int? durationMs,
|
||||
Map<String, Object?> metadata = const <String, Object?>{},
|
||||
}) async {
|
||||
final eventMetadata = <String, Object?>{
|
||||
"tool_name": toolName,
|
||||
"success": success,
|
||||
if (durationMs != null) "duration_ms": durationMs,
|
||||
...metadata,
|
||||
};
|
||||
logAnalyticsEvent("tool_call", eventMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
ToolTelemetryClient createToolTelemetryClient() {
|
||||
if (isTelemetryDisabled()) {
|
||||
return const NullToolTelemetryClient();
|
||||
}
|
||||
return const LocalToolTelemetryClient();
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../constants.dart';
|
||||
|
||||
/// Usage tracking and quota management
|
||||
/// Tracks API usage, tool usage, and enforces limits
|
||||
class UsageTracker {
|
||||
static final UsageTracker _instance = UsageTracker._internal();
|
||||
factory UsageTracker() => _instance;
|
||||
UsageTracker._internal();
|
||||
|
||||
bool _enabled = true;
|
||||
bool _initialized = false;
|
||||
|
||||
// Usage counters
|
||||
final Map<String, dynamic> _usage = {
|
||||
'daily': _resetDailyCounters(),
|
||||
'monthly': _resetMonthlyCounters(),
|
||||
'total': _resetTotalCounters(),
|
||||
};
|
||||
|
||||
// Quota limits (would be loaded from config/remote)
|
||||
final Map<String, dynamic> _limits = {
|
||||
'daily': {
|
||||
'api_calls': 1000,
|
||||
'tokens': 1000000,
|
||||
'tool_executions': 1000,
|
||||
'cost_usd': 10.0,
|
||||
},
|
||||
'monthly': {
|
||||
'api_calls': 30000,
|
||||
'tokens': 30000000,
|
||||
'tool_executions': 30000,
|
||||
'cost_usd': 300.0,
|
||||
},
|
||||
};
|
||||
|
||||
// Last reset times
|
||||
DateTime _lastDailyReset = DateTime.now();
|
||||
DateTime _lastMonthlyReset = DateTime.now();
|
||||
|
||||
/// Initialize usage tracker
|
||||
Future<void> initialize({bool enabled = true}) async {
|
||||
if (_initialized) return;
|
||||
|
||||
_enabled = enabled && areRemoteServicesAvailable();
|
||||
_initialized = true;
|
||||
|
||||
// Load saved usage data
|
||||
await _loadUsageData();
|
||||
|
||||
// Check if we need to reset counters
|
||||
await _checkAndResetCounters();
|
||||
|
||||
// Sync with remote service if available
|
||||
if (shouldUseRemoteService('usage')) {
|
||||
await _syncWithRemote();
|
||||
}
|
||||
}
|
||||
|
||||
/// Track API usage
|
||||
Future<void> trackApiCall(String model,
|
||||
{int? inputTokens,
|
||||
int? outputTokens,
|
||||
double? costUsd,
|
||||
int? durationMs}) async {
|
||||
if (!_enabled) return;
|
||||
|
||||
await _checkAndResetCounters();
|
||||
|
||||
final counters = _usage['daily'] as Map<String, dynamic>;
|
||||
final monthly = _usage['monthly'] as Map<String, dynamic>;
|
||||
final total = _usage['total'] as Map<String, dynamic>;
|
||||
|
||||
// Update counters
|
||||
counters['api_calls'] = (counters['api_calls'] as int) + 1;
|
||||
monthly['api_calls'] = (monthly['api_calls'] as int) + 1;
|
||||
total['api_calls'] = (total['api_calls'] as int) + 1;
|
||||
|
||||
if (inputTokens != null) {
|
||||
counters['tokens'] = (counters['tokens'] as int) + inputTokens;
|
||||
monthly['tokens'] = (monthly['tokens'] as int) + inputTokens;
|
||||
total['tokens'] = (total['tokens'] as int) + inputTokens;
|
||||
}
|
||||
|
||||
if (outputTokens != null) {
|
||||
counters['tokens'] = (counters['tokens'] as int) + outputTokens;
|
||||
monthly['tokens'] = (monthly['tokens'] as int) + outputTokens;
|
||||
total['tokens'] = (total['tokens'] as int) + outputTokens;
|
||||
}
|
||||
|
||||
if (costUsd != null) {
|
||||
counters['cost_usd'] = (counters['cost_usd'] as double) + costUsd;
|
||||
monthly['cost_usd'] = (monthly['cost_usd'] as double) + costUsd;
|
||||
total['cost_usd'] = (total['cost_usd'] as double) + costUsd;
|
||||
}
|
||||
|
||||
// Track per-model usage
|
||||
if (!counters.containsKey('models')) {
|
||||
counters['models'] = {};
|
||||
monthly['models'] = {};
|
||||
total['models'] = {};
|
||||
}
|
||||
|
||||
final modelCounters = counters['models'] as Map<String, dynamic>;
|
||||
final monthlyModels = monthly['models'] as Map<String, dynamic>;
|
||||
final totalModels = total['models'] as Map<String, dynamic>;
|
||||
|
||||
modelCounters[model] = (modelCounters[model] as int? ?? 0) + 1;
|
||||
monthlyModels[model] = (monthlyModels[model] as int? ?? 0) + 1;
|
||||
totalModels[model] = (totalModels[model] as int? ?? 0) + 1;
|
||||
|
||||
await _saveUsageData();
|
||||
await _checkLimits();
|
||||
}
|
||||
|
||||
/// Track tool execution
|
||||
Future<void> trackToolExecution(String toolName,
|
||||
{int? durationMs, Map<String, dynamic>? input}) async {
|
||||
if (!_enabled) return;
|
||||
|
||||
await _checkAndResetCounters();
|
||||
|
||||
final counters = _usage['daily'] as Map<String, dynamic>;
|
||||
final monthly = _usage['monthly'] as Map<String, dynamic>;
|
||||
final total = _usage['total'] as Map<String, dynamic>;
|
||||
|
||||
counters['tool_executions'] = (counters['tool_executions'] as int) + 1;
|
||||
monthly['tool_executions'] = (monthly['tool_executions'] as int) + 1;
|
||||
total['tool_executions'] = (total['tool_executions'] as int) + 1;
|
||||
|
||||
// Track per-tool usage
|
||||
if (!counters.containsKey('tools')) {
|
||||
counters['tools'] = {};
|
||||
monthly['tools'] = {};
|
||||
total['tools'] = {};
|
||||
}
|
||||
|
||||
final toolCounters = counters['tools'] as Map<String, dynamic>;
|
||||
final monthlyTools = monthly['tools'] as Map<String, dynamic>;
|
||||
final totalTools = total['tools'] as Map<String, dynamic>;
|
||||
|
||||
toolCounters[toolName] = (toolCounters[toolName] as int? ?? 0) + 1;
|
||||
monthlyTools[toolName] = (monthlyTools[toolName] as int? ?? 0) + 1;
|
||||
totalTools[toolName] = (totalTools[toolName] as int? ?? 0) + 1;
|
||||
|
||||
await _saveUsageData();
|
||||
}
|
||||
|
||||
/// Check if usage is within limits
|
||||
Future<Map<String, dynamic>> checkLimits() async {
|
||||
await _checkAndResetCounters();
|
||||
|
||||
final daily = _usage['daily'] as Map<String, dynamic>;
|
||||
final monthly = _usage['monthly'] as Map<String, dynamic>;
|
||||
final dailyLimits = _limits['daily'] as Map<String, dynamic>;
|
||||
final monthlyLimits = _limits['monthly'] as Map<String, dynamic>;
|
||||
|
||||
final violations = <String, List<String>>{
|
||||
'daily': [],
|
||||
'monthly': [],
|
||||
};
|
||||
|
||||
// Check daily limits
|
||||
for (final key in dailyLimits.keys) {
|
||||
if (key == 'cost_usd') {
|
||||
final used = daily[key] as double;
|
||||
final limit = dailyLimits[key] as double;
|
||||
if (used > limit) {
|
||||
violations['daily']!.add('$key: $used/$limit');
|
||||
}
|
||||
} else {
|
||||
final used = daily[key] as int;
|
||||
final limit = dailyLimits[key] as int;
|
||||
if (used > limit) {
|
||||
violations['daily']!.add('$key: $used/$limit');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check monthly limits
|
||||
for (final key in monthlyLimits.keys) {
|
||||
if (key == 'cost_usd') {
|
||||
final used = monthly[key] as double;
|
||||
final limit = monthlyLimits[key] as double;
|
||||
if (used > limit) {
|
||||
violations['monthly']!.add('$key: $used/$limit');
|
||||
}
|
||||
} else {
|
||||
final used = monthly[key] as int;
|
||||
final limit = monthlyLimits[key] as int;
|
||||
if (used > limit) {
|
||||
violations['monthly']!.add('$key: $used/$limit');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'within_limits': violations['daily']!.isEmpty && violations['monthly']!.isEmpty,
|
||||
'violations': violations,
|
||||
'usage': {
|
||||
'daily': daily,
|
||||
'monthly': monthly,
|
||||
'total': _usage['total'],
|
||||
},
|
||||
'limits': _limits,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get usage summary
|
||||
Map<String, dynamic> getUsageSummary() {
|
||||
return {
|
||||
'enabled': _enabled,
|
||||
'daily': Map<String, dynamic>.from(_usage['daily'] as Map),
|
||||
'monthly': Map<String, dynamic>.from(_usage['monthly'] as Map),
|
||||
'total': Map<String, dynamic>.from(_usage['total'] as Map),
|
||||
'limits': Map<String, dynamic>.from(_limits),
|
||||
'last_daily_reset': _lastDailyReset.toIso8601String(),
|
||||
'last_monthly_reset': _lastMonthlyReset.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Reset usage counters
|
||||
Future<void> resetCounters({bool daily = false, bool monthly = false}) async {
|
||||
if (daily) {
|
||||
_usage['daily'] = _resetDailyCounters();
|
||||
_lastDailyReset = DateTime.now();
|
||||
}
|
||||
if (monthly) {
|
||||
_usage['monthly'] = _resetMonthlyCounters();
|
||||
_lastMonthlyReset = DateTime.now();
|
||||
}
|
||||
await _saveUsageData();
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
Future<void> _checkAndResetCounters() async {
|
||||
final now = DateTime.now();
|
||||
|
||||
// Check daily reset (reset at midnight)
|
||||
if (now.day != _lastDailyReset.day ||
|
||||
now.month != _lastDailyReset.month ||
|
||||
now.year != _lastDailyReset.year) {
|
||||
_usage['daily'] = _resetDailyCounters();
|
||||
_lastDailyReset = now;
|
||||
}
|
||||
|
||||
// Check monthly reset (reset on 1st of month)
|
||||
if (now.month != _lastMonthlyReset.month ||
|
||||
now.year != _lastMonthlyReset.year) {
|
||||
_usage['monthly'] = _resetMonthlyCounters();
|
||||
_lastMonthlyReset = now;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkLimits() async {
|
||||
final limits = await checkLimits();
|
||||
if (!limits['within_limits'] as bool) {
|
||||
// Log limit violations
|
||||
final violations = limits['violations'] as Map<String, List<String>>;
|
||||
for (final period in violations.keys) {
|
||||
if (violations[period]!.isNotEmpty) {
|
||||
print('Warning: $period usage limits exceeded: ${violations[period]!.join(', ')}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncWithRemote() async {
|
||||
if (!shouldUseRemoteService('usage')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final url = getRemoteServiceUrl('${ApiPaths.usage}/sync');
|
||||
final client = HttpClient();
|
||||
final request = await client.postUrl(Uri.parse(url));
|
||||
|
||||
request.headers.set('Content-Type', 'application/json');
|
||||
request.write(jsonEncode({
|
||||
'usage': _usage,
|
||||
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||
}));
|
||||
|
||||
final response = await request.close();
|
||||
if (response.statusCode == 200) {
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
||||
|
||||
// Update limits from remote
|
||||
if (data.containsKey('limits')) {
|
||||
_limits.clear();
|
||||
_limits.addAll(Map<String, dynamic>.from(data['limits'] as Map));
|
||||
}
|
||||
}
|
||||
|
||||
await response.drain();
|
||||
} catch (e) {
|
||||
// Silently fail - we'll try again later
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadUsageData() async {
|
||||
final usageFile = File(_getUsageFilePath());
|
||||
if (!await usageFile.exists()) return;
|
||||
|
||||
try {
|
||||
final data = jsonDecode(await usageFile.readAsString()) as Map<String, dynamic>;
|
||||
|
||||
if (data.containsKey('usage')) {
|
||||
_usage.clear();
|
||||
_usage.addAll(Map<String, dynamic>.from(data['usage'] as Map));
|
||||
}
|
||||
|
||||
if (data.containsKey('last_daily_reset')) {
|
||||
_lastDailyReset = DateTime.parse(data['last_daily_reset'] as String);
|
||||
}
|
||||
|
||||
if (data.containsKey('last_monthly_reset')) {
|
||||
_lastMonthlyReset = DateTime.parse(data['last_monthly_reset'] as String);
|
||||
}
|
||||
|
||||
if (data.containsKey('limits')) {
|
||||
_limits.clear();
|
||||
_limits.addAll(Map<String, dynamic>.from(data['limits'] as Map));
|
||||
}
|
||||
} catch (e) {
|
||||
// Reset on error
|
||||
_usage['daily'] = _resetDailyCounters();
|
||||
_usage['monthly'] = _resetMonthlyCounters();
|
||||
_usage['total'] = _resetTotalCounters();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveUsageData() async {
|
||||
final usageFile = File(_getUsageFilePath());
|
||||
final data = {
|
||||
'usage': _usage,
|
||||
'last_daily_reset': _lastDailyReset.toIso8601String(),
|
||||
'last_monthly_reset': _lastMonthlyReset.toIso8601String(),
|
||||
'limits': _limits,
|
||||
'saved_at': DateTime.now().toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
try {
|
||||
await usageFile.parent.create(recursive: true);
|
||||
await usageFile.writeAsString(jsonEncode(data));
|
||||
} catch (e) {
|
||||
// Silently fail if we can't write to file
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _resetDailyCounters() {
|
||||
return {
|
||||
'api_calls': 0,
|
||||
'tokens': 0,
|
||||
'tool_executions': 0,
|
||||
'cost_usd': 0.0,
|
||||
'models': {},
|
||||
'tools': {},
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _resetMonthlyCounters() {
|
||||
return {
|
||||
'api_calls': 0,
|
||||
'tokens': 0,
|
||||
'tool_executions': 0,
|
||||
'cost_usd': 0.0,
|
||||
'models': {},
|
||||
'tools': {},
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _resetTotalCounters() {
|
||||
return {
|
||||
'api_calls': 0,
|
||||
'tokens': 0,
|
||||
'tool_executions': 0,
|
||||
'cost_usd': 0.0,
|
||||
'models': {},
|
||||
'tools': {},
|
||||
};
|
||||
}
|
||||
|
||||
static String _getUsageFilePath() {
|
||||
final home = Platform.environment['HOME'] ??
|
||||
Platform.environment['USERPROFILE'] ??
|
||||
Directory.current.path;
|
||||
return Platform.isWindows
|
||||
? '$home\\.clawd_code\\usage.json'
|
||||
: '$home/.clawd_code/usage.json';
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,10 @@ class ConversationHistory {
|
||||
_session = s;
|
||||
}
|
||||
|
||||
void addMessage(String role, String content, {int? tokens}) {
|
||||
void addMessage(String role, String content, {int? tokens, int? contextTokens}) {
|
||||
if (_session == null) return;
|
||||
|
||||
final msg = Message(role: role, content: content, tokens: tokens);
|
||||
final msg = Message(role: role, content: content, tokens: tokens, contextTokens: contextTokens);
|
||||
|
||||
_session!.messages.add(msg);
|
||||
_session!.updated = DateTime.now().toUtc();
|
||||
@@ -41,10 +41,23 @@ class ConversationHistory {
|
||||
content: "${lastMessage.content}$text",
|
||||
timestamp: lastMessage.timestamp,
|
||||
tokens: lastMessage.tokens,
|
||||
contextTokens: lastMessage.contextTokens,
|
||||
);
|
||||
_session!.updated = DateTime.now().toUtc();
|
||||
}
|
||||
|
||||
void setLastMessageContextTokens(int contextTokens) {
|
||||
if (_session == null || _session!.messages.isEmpty) return;
|
||||
final last = _session!.messages.last;
|
||||
_session!.messages[_session!.messages.length - 1] = Message(
|
||||
role: last.role,
|
||||
content: last.content,
|
||||
timestamp: last.timestamp,
|
||||
tokens: last.tokens,
|
||||
contextTokens: contextTokens,
|
||||
);
|
||||
}
|
||||
|
||||
void removeLastMessage() {
|
||||
if (_session == null || _session!.messages.isEmpty) {
|
||||
return;
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
// Parity exception: Claude Code stores sessions in ~/.claude/projects/<sanitized-cwd>/<id>.jsonl
|
||||
// The Agency uses <workingDirectory>/.the_agency/sessions/<id>.json instead, because the UI
|
||||
// is multi-project and it makes more sense for session data to live alongside the project.
|
||||
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "../local_state.dart";
|
||||
import "package:path/path.dart" as p;
|
||||
|
||||
import "session_types.dart";
|
||||
|
||||
const _encoder = JsonEncoder.withIndent(" ");
|
||||
|
||||
// sessions live in ~/.clawd_code/sessions/{id}.json
|
||||
String getSessionsDir() {
|
||||
return joinPath(getConfigHomeDir(), "sessions");
|
||||
// sessions live in <workingDirectory>/.the_agency/sessions/{id}.json
|
||||
String getProjectAgencyDir(String workingDirectory) {
|
||||
return p.join(workingDirectory, ".the_agency");
|
||||
}
|
||||
|
||||
String _sessionPath(String id) {
|
||||
return joinPath(getSessionsDir(), "$id.json");
|
||||
String getProjectSessionsDir(String workingDirectory) {
|
||||
return p.join(getProjectAgencyDir(workingDirectory), "sessions");
|
||||
}
|
||||
|
||||
String _sessionPath(String workingDirectory, String id) {
|
||||
return p.join(getProjectSessionsDir(workingDirectory), "$id.json");
|
||||
}
|
||||
|
||||
class SessionStore {
|
||||
@@ -20,20 +29,22 @@ class SessionStore {
|
||||
|
||||
static final SessionStore instance = SessionStore._();
|
||||
|
||||
|
||||
Future<void> saveSession(ConversationSession session) async {
|
||||
final dir = Directory(getSessionsDir());
|
||||
final workingDir = session.workingDirectory;
|
||||
if (workingDir == null || workingDir.isEmpty) return;
|
||||
|
||||
final dir = Directory(getProjectSessionsDir(workingDir));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
|
||||
final file = File(_sessionPath(session.id));
|
||||
final file = File(_sessionPath(workingDir, session.id));
|
||||
final json = _encoder.convert(session.toJson());
|
||||
await file.writeAsString("$json\n");
|
||||
}
|
||||
|
||||
Future<ConversationSession?> loadSession(String id) async {
|
||||
final file = File(_sessionPath(id));
|
||||
Future<ConversationSession?> loadSession(String id, {required String workingDirectory}) async {
|
||||
final file = File(_sessionPath(workingDirectory, id));
|
||||
if (!await file.exists()) return null;
|
||||
|
||||
try {
|
||||
@@ -43,15 +54,15 @@ class SessionStore {
|
||||
return ConversationSession.fromJson(decoded);
|
||||
}
|
||||
} catch (_) {
|
||||
// corrupt file - just return null
|
||||
// corrupt file — return null
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// returns summaries sorted newest first
|
||||
Future<List<SessionSummary>> listSessions() async {
|
||||
final dir = Directory(getSessionsDir());
|
||||
// lists sessions for a single project, sorted newest first
|
||||
Future<List<SessionSummary>> listSessionsForProject(String workingDirectory) async {
|
||||
final dir = Directory(getProjectSessionsDir(workingDirectory));
|
||||
if (!await dir.exists()) return <SessionSummary>[];
|
||||
|
||||
final summaries = <SessionSummary>[];
|
||||
@@ -76,39 +87,23 @@ class SessionStore {
|
||||
return summaries;
|
||||
}
|
||||
|
||||
Future<bool> deleteSession(String id) async {
|
||||
final file = File(_sessionPath(id));
|
||||
// lists all sessions across multiple projects, sorted newest first
|
||||
Future<List<SessionSummary>> listAllSessions(List<String> workingDirectories) async {
|
||||
final all = <SessionSummary>[];
|
||||
|
||||
for (final dir in workingDirectories) {
|
||||
all.addAll(await listSessionsForProject(dir));
|
||||
}
|
||||
|
||||
all.sort((a, b) => b.updated.compareTo(a.updated));
|
||||
return all;
|
||||
}
|
||||
|
||||
Future<bool> deleteSession(String id, {required String workingDirectory}) async {
|
||||
final file = File(_sessionPath(workingDirectory, id));
|
||||
if (!await file.exists()) return false;
|
||||
|
||||
await file.delete();
|
||||
return true;
|
||||
}
|
||||
|
||||
// case insensitive search by name
|
||||
Future<ConversationSession?> findSessionByName(String name) async {
|
||||
final dir = Directory(getSessionsDir());
|
||||
if (!await dir.exists()) return null;
|
||||
|
||||
final lowerName = name.toLowerCase();
|
||||
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is! File) continue;
|
||||
if (!entity.path.endsWith(".json")) continue;
|
||||
|
||||
try {
|
||||
final raw = await entity.readAsString();
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final sess = ConversationSession.fromJson(decoded);
|
||||
if (sess.name.toLowerCase() == lowerName) {
|
||||
return sess;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class Message {
|
||||
required this.content,
|
||||
DateTime? timestamp,
|
||||
this.tokens,
|
||||
this.contextTokens,
|
||||
}) : timestamp = timestamp ?? DateTime.now().toUtc();
|
||||
|
||||
factory Message.fromJson(Map<String, dynamic> json) {
|
||||
@@ -17,6 +18,7 @@ class Message {
|
||||
? DateTime.tryParse(json["timestamp"] as String)
|
||||
: null,
|
||||
tokens: json["tokens"] as int?,
|
||||
contextTokens: json["contextTokens"] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,12 +29,17 @@ class Message {
|
||||
// approx token count - may be null if not tracked
|
||||
final int? tokens;
|
||||
|
||||
// full context window size from the last API response usage field
|
||||
// (input + cache_creation + cache_read + output), same as Claude Code
|
||||
final int? contextTokens;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": timestamp.toIso8601String(),
|
||||
if (tokens != null) "tokens": tokens,
|
||||
if (contextTokens != null) "contextTokens": contextTokens,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
// CLAUDE.md loader — mirrors claude code's claudemd.ts behaviour.
|
||||
//
|
||||
// Loading order (later = higher priority, model pays more attention):
|
||||
// 1. Managed (/Library/Application Support/ClaudeCode/CLAUDE.md on mac,
|
||||
// /etc/claude-code/CLAUDE.md on linux,
|
||||
// C:\Program Files\ClaudeCode\CLAUDE.md on windows)
|
||||
// 2. User (~/.claude/CLAUDE.md, ~/.claude/rules/*.md)
|
||||
// 3. Project (CLAUDE.md, .claude/CLAUDE.md, .claude/rules/*.md)
|
||||
// walking up from cwd to root, root first (cwd wins)
|
||||
// 4. Local (CLAUDE.local.md, same traversal)
|
||||
//
|
||||
// @include directive:
|
||||
// @path, @./relative, @~/home, @/absolute — only in text nodes,
|
||||
// not inside fenced code blocks. Max depth 5. Circular refs skipped.
|
||||
//
|
||||
// HTML comment stripping:
|
||||
// Block-level HTML comments (lines starting with <!--) are stripped.
|
||||
// Comments inside fenced code blocks are preserved.
|
||||
|
||||
import "dart:io";
|
||||
import "package:path/path.dart" as p;
|
||||
|
||||
import "frontmatter_parser.dart";
|
||||
import "memory_file_info.dart";
|
||||
import "memory_types.dart";
|
||||
|
||||
|
||||
const int _maxIncludeDepth = 5;
|
||||
|
||||
// Only text-like extensions are allowed in @include directives
|
||||
// (prevents loading binary files like images, PDFs, etc.)
|
||||
const _textFileExtensions = {
|
||||
".md", ".txt", ".text",
|
||||
".json", ".yaml", ".yml", ".toml", ".xml", ".csv",
|
||||
".html", ".htm", ".css", ".scss", ".sass", ".less",
|
||||
".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".mts", ".cts",
|
||||
".py", ".pyi", ".pyw",
|
||||
".rb", ".erb", ".rake",
|
||||
".go",
|
||||
".rs",
|
||||
".java", ".kt", ".kts", ".scala",
|
||||
".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx",
|
||||
".cs",
|
||||
".swift",
|
||||
".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", ".cmd",
|
||||
".env", ".ini", ".cfg", ".conf", ".config", ".properties",
|
||||
".sql", ".graphql", ".gql",
|
||||
".proto",
|
||||
".vue", ".svelte", ".astro",
|
||||
".ejs", ".hbs", ".pug", ".jade",
|
||||
".php", ".pl", ".pm", ".lua", ".r", ".R", ".dart",
|
||||
".ex", ".exs", ".erl", ".hrl",
|
||||
".clj", ".cljs", ".cljc", ".edn",
|
||||
".hs", ".lhs", ".elm",
|
||||
".ml", ".mli",
|
||||
".f", ".f90", ".f95", ".for",
|
||||
".cmake", ".make", ".makefile", ".gradle", ".sbt",
|
||||
".rst", ".adoc", ".asciidoc", ".org", ".tex", ".latex",
|
||||
".lock", ".log", ".diff", ".patch",
|
||||
};
|
||||
|
||||
const _memoryInstructionPrompt =
|
||||
"Codebase and user instructions are shown below. Be sure to adhere to these"
|
||||
" instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you"
|
||||
" MUST follow them exactly as written.";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Platform paths
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
String _getManagedFilePath() {
|
||||
if (Platform.isMacOS) return "/Library/Application Support/ClaudeCode";
|
||||
if (Platform.isWindows) return r"C:\Program Files\ClaudeCode";
|
||||
return "/etc/claude-code";
|
||||
}
|
||||
|
||||
String _getClaudeConfigHomeDir() {
|
||||
final override = Platform.environment["CLAUDE_CONFIG_DIR"];
|
||||
if (override != null && override.isNotEmpty) return override;
|
||||
final home = Platform.environment["HOME"] ?? Platform.environment["USERPROFILE"] ?? "";
|
||||
return p.join(home, ".claude");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// HTML comment stripping
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Strips block-level HTML comments from markdown content.
|
||||
// Comments inside fenced code blocks are preserved.
|
||||
// Unclosed comments (<!-- with no -->) are left in place.
|
||||
String stripHtmlComments(String content) {
|
||||
if (!content.contains("<!--")) return content;
|
||||
|
||||
final lines = content.split("\n");
|
||||
final result = StringBuffer();
|
||||
var inFence = false;
|
||||
String? fenceChar;
|
||||
var inComment = false;
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final line = lines[i];
|
||||
final raw = line.trimLeft();
|
||||
|
||||
// Track fenced code blocks (``` or ~~~)
|
||||
if (!inComment) {
|
||||
if (!inFence) {
|
||||
if (raw.startsWith("```") || raw.startsWith("~~~")) {
|
||||
inFence = true;
|
||||
fenceChar = raw.startsWith("```") ? "```" : "~~~";
|
||||
result.write(line);
|
||||
if (i < lines.length - 1) result.write("\n");
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (raw.startsWith(fenceChar!)) {
|
||||
inFence = false;
|
||||
fenceChar = null;
|
||||
}
|
||||
result.write(line);
|
||||
if (i < lines.length - 1) result.write("\n");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (inFence) {
|
||||
result.write(line);
|
||||
if (i < lines.length - 1) result.write("\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
// block-level HTML comment starts with <!-- (up to 3 leading spaces allowed)
|
||||
if (!inComment && raw.startsWith("<!--")) {
|
||||
// check if it closes on the same line/block
|
||||
final commentSpan = RegExp(r"<!--[\s\S]*?-->");
|
||||
final residue = line.replaceAll(commentSpan, "");
|
||||
|
||||
if (line.contains("-->")) {
|
||||
// comment closed — keep any residue
|
||||
if (residue.trim().isNotEmpty) {
|
||||
result.write(residue);
|
||||
if (i < lines.length - 1) result.write("\n");
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
// multi-line comment — enter comment mode
|
||||
inComment = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (inComment) {
|
||||
if (line.contains("-->")) {
|
||||
inComment = false;
|
||||
// strip up to and including -->
|
||||
final after = line.substring(line.indexOf("-->") + 3);
|
||||
if (after.trim().isNotEmpty) {
|
||||
result.write(after);
|
||||
if (i < lines.length - 1) result.write("\n");
|
||||
}
|
||||
}
|
||||
// skip lines inside comment block
|
||||
continue;
|
||||
}
|
||||
|
||||
result.write(line);
|
||||
if (i < lines.length - 1) result.write("\n");
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// @include path extraction
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Mirrors extractIncludePathsFromTokens from Claude Code.
|
||||
// Finds @path patterns in text nodes (skips code blocks and HTML comments).
|
||||
List<String> _extractIncludePaths(String content, String fileDir) {
|
||||
final absolutePaths = <String>{};
|
||||
final includeRegex = RegExp(r"(?:^|\s)@((?:[^\s\\]|\\ )+)");
|
||||
|
||||
final lines = content.split("\n");
|
||||
var inFence = false;
|
||||
String? fenceChar;
|
||||
var inComment = false;
|
||||
|
||||
for (final line in lines) {
|
||||
final raw = line.trimLeft();
|
||||
|
||||
// Track fenced code blocks
|
||||
if (!inFence && !inComment) {
|
||||
if (raw.startsWith("```") || raw.startsWith("~~~")) {
|
||||
inFence = true;
|
||||
fenceChar = raw.startsWith("```") ? "```" : "~~~";
|
||||
continue;
|
||||
}
|
||||
} else if (inFence) {
|
||||
if (raw.startsWith(fenceChar!)) {
|
||||
inFence = false;
|
||||
fenceChar = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track HTML comment blocks (skip @includes inside them)
|
||||
if (!inComment && raw.startsWith("<!--")) {
|
||||
if (line.contains("-->")) {
|
||||
// single-line comment — strip and check residue
|
||||
final residue = line.replaceAll(RegExp(r"<!--[\s\S]*?-->"), "");
|
||||
_extractPathsFromText(residue, fileDir, includeRegex, absolutePaths);
|
||||
} else {
|
||||
inComment = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inComment) {
|
||||
if (line.contains("-->")) inComment = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
_extractPathsFromText(line, fileDir, includeRegex, absolutePaths);
|
||||
}
|
||||
|
||||
return absolutePaths.toList();
|
||||
}
|
||||
|
||||
void _extractPathsFromText(
|
||||
String text,
|
||||
String fileDir,
|
||||
RegExp includeRegex,
|
||||
Set<String> absolutePaths,
|
||||
) {
|
||||
for (final match in includeRegex.allMatches(text)) {
|
||||
var path = match.group(1);
|
||||
if (path == null || path.isEmpty) continue;
|
||||
|
||||
// strip fragment identifiers
|
||||
final hashIdx = path.indexOf("#");
|
||||
if (hashIdx != -1) path = path.substring(0, hashIdx);
|
||||
if (path.isEmpty) continue;
|
||||
|
||||
// unescape spaces
|
||||
path = path.replaceAll(r"\ ", " ");
|
||||
|
||||
final isValid = path.startsWith("./") ||
|
||||
path.startsWith("~/") ||
|
||||
(path.startsWith("/") && path != "/") ||
|
||||
(!path.startsWith("@") &&
|
||||
!RegExp(r"^[#%^&*()]+").hasMatch(path) &&
|
||||
RegExp(r"^[a-zA-Z0-9._-]").hasMatch(path));
|
||||
|
||||
if (!isValid) continue;
|
||||
|
||||
final resolved = _expandPath(path, fileDir);
|
||||
absolutePaths.add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
String _expandPath(String path, String baseDir) {
|
||||
if (path.startsWith("~/")) {
|
||||
final home = Platform.environment["HOME"] ?? Platform.environment["USERPROFILE"] ?? "";
|
||||
return p.join(home, path.substring(2));
|
||||
}
|
||||
if (p.isAbsolute(path)) return path;
|
||||
return p.normalize(p.join(baseDir, path));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Frontmatter paths extraction
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Returns null if no paths restriction, empty/non-empty list if restricted.
|
||||
// Strips /** suffix like Claude Code does.
|
||||
List<String>? _parseFrontmatterPaths(Map<String, dynamic> frontmatter) {
|
||||
final raw = frontmatter["paths"];
|
||||
if (raw == null) return null;
|
||||
|
||||
final patterns = splitPathInFrontmatter(raw)
|
||||
.map((p) => p.endsWith("/**") ? p.substring(0, p.length - 3) : p)
|
||||
.where((p) => p.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
// if all patterns are ** (match-all), treat as no restriction
|
||||
if (patterns.isEmpty || patterns.every((p) => p == "**")) return null;
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Core file processor
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Recursively processes a memory file and all its @include references.
|
||||
// Returns includes-first list (same order as Claude Code).
|
||||
Future<List<MemoryFileInfo>> processMemoryFile(
|
||||
String filePath,
|
||||
MemoryType type,
|
||||
Set<String> processedPaths, {
|
||||
|
||||
bool includeExternal = false,
|
||||
int depth = 0,
|
||||
String? parent,
|
||||
String? originalCwd,
|
||||
}) async {
|
||||
final normalizedPath = p.normalize(filePath);
|
||||
|
||||
if (processedPaths.contains(normalizedPath) || depth >= _maxIncludeDepth) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extension check — skip non-text files (like Claude Code does for @include)
|
||||
final ext = p.extension(filePath).toLowerCase();
|
||||
if (ext.isNotEmpty && !_textFileExtensions.contains(ext)) return [];
|
||||
|
||||
processedPaths.add(normalizedPath);
|
||||
|
||||
String rawContent;
|
||||
try {
|
||||
final file = File(filePath);
|
||||
|
||||
// resolve symlinks
|
||||
String resolvedPath = filePath;
|
||||
try {
|
||||
resolvedPath = await file.resolveSymbolicLinks();
|
||||
if (resolvedPath != normalizedPath) {
|
||||
processedPaths.add(p.normalize(resolvedPath));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (!await file.exists()) return [];
|
||||
rawContent = await file.readAsString();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// parse frontmatter
|
||||
final parsed = parseFrontmatter(rawContent);
|
||||
final globs = _parseFrontmatterPaths(parsed.frontmatter);
|
||||
|
||||
// strip HTML block comments
|
||||
final stripped = stripHtmlComments(parsed.content);
|
||||
if (stripped.trim().isEmpty) return [];
|
||||
|
||||
final memFile = MemoryFileInfo(
|
||||
path: filePath,
|
||||
type: type,
|
||||
content: stripped.trim(),
|
||||
parent: parent,
|
||||
globs: globs,
|
||||
);
|
||||
|
||||
final result = <MemoryFileInfo>[memFile];
|
||||
|
||||
// process @include directives
|
||||
final fileDir = p.dirname(filePath);
|
||||
final includePaths = _extractIncludePaths(stripped, fileDir);
|
||||
|
||||
for (final includePath in includePaths) {
|
||||
final isExternal = originalCwd != null &&
|
||||
!_pathIsUnder(includePath, originalCwd);
|
||||
if (isExternal && !includeExternal) continue;
|
||||
|
||||
final included = await processMemoryFile(
|
||||
includePath,
|
||||
type,
|
||||
processedPaths,
|
||||
includeExternal: includeExternal,
|
||||
depth: depth + 1,
|
||||
parent: filePath,
|
||||
originalCwd: originalCwd,
|
||||
);
|
||||
result.addAll(included);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool _pathIsUnder(String path, String root) {
|
||||
final normPath = p.normalize(path);
|
||||
final normRoot = p.normalize(root);
|
||||
return normPath.startsWith(normRoot + p.separator) || normPath == normRoot;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// rules directory processor
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Processes all .md files in a .claude/rules/ directory (and subdirectories).
|
||||
// conditionalRule=false → keep files WITHOUT a paths: frontmatter
|
||||
// conditionalRule=true → keep files WITH a paths: frontmatter
|
||||
Future<List<MemoryFileInfo>> processMdRules(
|
||||
String rulesDir,
|
||||
MemoryType type,
|
||||
Set<String> processedPaths, {
|
||||
|
||||
bool includeExternal = false,
|
||||
bool conditionalRule = false,
|
||||
Set<String>? visitedDirs,
|
||||
String? originalCwd,
|
||||
}) async {
|
||||
visitedDirs ??= {};
|
||||
|
||||
String resolvedDir;
|
||||
try {
|
||||
resolvedDir = await Directory(rulesDir).resolveSymbolicLinks();
|
||||
} catch (_) {
|
||||
resolvedDir = rulesDir;
|
||||
}
|
||||
|
||||
if (visitedDirs.contains(resolvedDir)) return [];
|
||||
visitedDirs.add(resolvedDir);
|
||||
|
||||
final dir = Directory(resolvedDir);
|
||||
List<FileSystemEntity> entries;
|
||||
try {
|
||||
entries = dir.listSync();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.path.compareTo(b.path));
|
||||
|
||||
final result = <MemoryFileInfo>[];
|
||||
|
||||
for (final entry in entries) {
|
||||
if (entry is Directory) {
|
||||
result.addAll(await processMdRules(
|
||||
entry.path,
|
||||
type,
|
||||
processedPaths,
|
||||
includeExternal: includeExternal,
|
||||
conditionalRule: conditionalRule,
|
||||
visitedDirs: visitedDirs,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
} else if (entry is File && entry.path.endsWith(".md")) {
|
||||
String resolvedEntry;
|
||||
try {
|
||||
resolvedEntry = await entry.resolveSymbolicLinks();
|
||||
} catch (_) {
|
||||
resolvedEntry = entry.path;
|
||||
}
|
||||
|
||||
final files = await processMemoryFile(
|
||||
resolvedEntry,
|
||||
type,
|
||||
processedPaths,
|
||||
includeExternal: includeExternal,
|
||||
originalCwd: originalCwd,
|
||||
);
|
||||
// filter by conditional/non-conditional
|
||||
result.addAll(files.where((f) => conditionalRule ? f.globs != null : f.globs == null));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Git root detection (for nested worktree handling)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
String? _findGitRoot(String startDir) {
|
||||
var current = p.normalize(startDir);
|
||||
final root = p.rootPrefix(current);
|
||||
|
||||
while (true) {
|
||||
final gitDir = Directory(p.join(current, ".git"));
|
||||
if (gitDir.existsSync()) return current;
|
||||
final parent = p.dirname(current);
|
||||
if (parent == current || current == root) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// In a git worktree, .git is a file (not a dir) containing the gitdir path.
|
||||
// The canonical root is the main repo that owns the worktree.
|
||||
String? _findCanonicalGitRoot(String startDir) {
|
||||
var current = p.normalize(startDir);
|
||||
final root = p.rootPrefix(current);
|
||||
|
||||
while (true) {
|
||||
final gitEntity = p.join(current, ".git");
|
||||
final gitFile = File(gitEntity);
|
||||
final gitDir = Directory(gitEntity);
|
||||
|
||||
if (gitFile.existsSync() && !gitDir.existsSync()) {
|
||||
// worktree: .git is a file like "gitdir: ../../.git/worktrees/name"
|
||||
try {
|
||||
final content = gitFile.readAsStringSync().trim();
|
||||
if (content.startsWith("gitdir:")) {
|
||||
final gitdirPath = content.substring("gitdir:".length).trim();
|
||||
final resolved = p.normalize(p.isAbsolute(gitdirPath)
|
||||
? gitdirPath
|
||||
: p.join(current, gitdirPath));
|
||||
// go up from .git/worktrees/<name> → main repo root
|
||||
final mainGit = p.dirname(p.dirname(p.dirname(resolved)));
|
||||
return mainGit;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (gitDir.existsSync()) return current;
|
||||
|
||||
final parent = p.dirname(current);
|
||||
if (parent == current || current == root) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Main entry point
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Loads all CLAUDE.md files in the same order as Claude Code.
|
||||
// Returns a list of MemoryFileInfo objects, later entries have higher priority.
|
||||
Future<List<MemoryFileInfo>> getMemoryFiles(String? workingDirectory) async {
|
||||
final result = <MemoryFileInfo>[];
|
||||
final processedPaths = <String>{};
|
||||
final originalCwd = workingDirectory != null ? p.normalize(workingDirectory) : null;
|
||||
|
||||
// 1. Managed memory
|
||||
final managedDir = _getManagedFilePath();
|
||||
final managedMd = p.join(managedDir, "CLAUDE.md");
|
||||
|
||||
result.addAll(await processMemoryFile(
|
||||
managedMd,
|
||||
MemoryType.managed,
|
||||
processedPaths,
|
||||
includeExternal: true,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
result.addAll(await processMdRules(
|
||||
p.join(managedDir, ".claude", "rules"),
|
||||
MemoryType.managed,
|
||||
processedPaths,
|
||||
includeExternal: true,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
// 2. User memory
|
||||
final userConfigDir = _getClaudeConfigHomeDir();
|
||||
final userMd = p.join(userConfigDir, "CLAUDE.md");
|
||||
|
||||
result.addAll(await processMemoryFile(
|
||||
userMd,
|
||||
MemoryType.user,
|
||||
processedPaths,
|
||||
includeExternal: true,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
result.addAll(await processMdRules(
|
||||
p.join(userConfigDir, "rules"),
|
||||
MemoryType.user,
|
||||
processedPaths,
|
||||
includeExternal: true,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
if (originalCwd != null) {
|
||||
// 3 & 4. Project + Local memory — walk up from cwd to root
|
||||
final dirs = _buildAncestorChain(originalCwd);
|
||||
|
||||
// git worktree detection — same logic as Claude Code
|
||||
final gitRoot = _findGitRoot(originalCwd);
|
||||
final canonicalRoot = _findCanonicalGitRoot(originalCwd);
|
||||
final isNestedWorktree = gitRoot != null &&
|
||||
canonicalRoot != null &&
|
||||
p.normalize(gitRoot) != p.normalize(canonicalRoot) &&
|
||||
_pathIsUnder(gitRoot, canonicalRoot);
|
||||
|
||||
// process from root → cwd (so cwd files are loaded last = highest priority)
|
||||
for (final dir in dirs) {
|
||||
final skipProject = isNestedWorktree &&
|
||||
_pathIsUnder(dir, canonicalRoot!) &&
|
||||
!_pathIsUnder(dir, gitRoot!);
|
||||
|
||||
if (!skipProject) {
|
||||
// CLAUDE.md
|
||||
result.addAll(await processMemoryFile(
|
||||
p.join(dir, "CLAUDE.md"),
|
||||
MemoryType.project,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
// .claude/CLAUDE.md
|
||||
result.addAll(await processMemoryFile(
|
||||
p.join(dir, ".claude", "CLAUDE.md"),
|
||||
MemoryType.project,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
// .claude/rules/*.md
|
||||
result.addAll(await processMdRules(
|
||||
p.join(dir, ".claude", "rules"),
|
||||
MemoryType.project,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
}
|
||||
|
||||
// CLAUDE.local.md (not skipped even in nested worktrees)
|
||||
result.addAll(await processMemoryFile(
|
||||
p.join(dir, "CLAUDE.local.md"),
|
||||
MemoryType.local,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
}
|
||||
|
||||
// env var for additional directories
|
||||
final additionalDirsEnv =
|
||||
Platform.environment["CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD"];
|
||||
if (additionalDirsEnv != null && additionalDirsEnv.isNotEmpty &&
|
||||
_isEnvTruthy(additionalDirsEnv)) {
|
||||
final additionalDirs = _getAdditionalDirs();
|
||||
|
||||
for (final dir in additionalDirs) {
|
||||
result.addAll(await processMemoryFile(
|
||||
p.join(dir, "CLAUDE.md"),
|
||||
MemoryType.project,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
result.addAll(await processMemoryFile(
|
||||
p.join(dir, ".claude", "CLAUDE.md"),
|
||||
MemoryType.project,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
|
||||
result.addAll(await processMdRules(
|
||||
p.join(dir, ".claude", "rules"),
|
||||
MemoryType.project,
|
||||
processedPaths,
|
||||
originalCwd: originalCwd,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Builds ancestor chain from filesystem root down to dir (inclusive)
|
||||
List<String> _buildAncestorChain(String dir) {
|
||||
final chain = <String>[];
|
||||
var current = p.normalize(dir);
|
||||
final root = p.rootPrefix(current);
|
||||
|
||||
while (true) {
|
||||
chain.add(current);
|
||||
final parent = p.dirname(current);
|
||||
if (parent == current || current == root) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return chain.reversed.toList();
|
||||
}
|
||||
|
||||
bool _isEnvTruthy(String value) {
|
||||
final v = value.toLowerCase().trim();
|
||||
return v == "1" || v == "true" || v == "yes";
|
||||
}
|
||||
|
||||
List<String> _getAdditionalDirs() {
|
||||
// Claude Code gets these from bootstrap state (--add-dir flag).
|
||||
// The Agency doesn't support --add-dir yet, so return empty.
|
||||
return [];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Assembler (mirrors getClaudeMds)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Assembles memory files into a single string for injection into the system prompt.
|
||||
// Mirrors getClaudeMds() from Claude Code.
|
||||
String getClaudeMds(List<MemoryFileInfo> memoryFiles) {
|
||||
final memories = <String>[];
|
||||
|
||||
for (final file in memoryFiles) {
|
||||
final content = file.content.trim();
|
||||
if (content.isEmpty) continue;
|
||||
|
||||
memories.add("Contents of ${file.path}${file.type.label}:\n\n$content");
|
||||
}
|
||||
|
||||
if (memories.isEmpty) return "";
|
||||
|
||||
return "$_memoryInstructionPrompt\n\n${memories.join("\n\n")}";
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import "package:yaml/yaml.dart";
|
||||
|
||||
class ParsedFrontmatter {
|
||||
final Map<String, dynamic> frontmatter;
|
||||
final String content;
|
||||
|
||||
const ParsedFrontmatter({required this.frontmatter, required this.content});
|
||||
}
|
||||
|
||||
final _frontmatterRegex = RegExp(r"^---\s*\n([\s\S]*?)---\s*\n?");
|
||||
|
||||
ParsedFrontmatter parseFrontmatter(String markdown) {
|
||||
final match = _frontmatterRegex.firstMatch(markdown);
|
||||
if (match == null) {
|
||||
return ParsedFrontmatter(frontmatter: {}, content: markdown);
|
||||
}
|
||||
|
||||
final frontmatterText = match.group(1) ?? "";
|
||||
final content = markdown.substring(match.end);
|
||||
|
||||
Map<String, dynamic> frontmatter = {};
|
||||
try {
|
||||
final parsed = loadYaml(frontmatterText);
|
||||
if (parsed is YamlMap) {
|
||||
frontmatter = _deepConvert(parsed) as Map<String, dynamic>;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return ParsedFrontmatter(frontmatter: frontmatter, content: content);
|
||||
}
|
||||
|
||||
dynamic _deepConvert(dynamic value) {
|
||||
if (value is YamlMap) {
|
||||
return {
|
||||
for (final entry in value.entries)
|
||||
entry.key.toString(): _deepConvert(entry.value),
|
||||
};
|
||||
}
|
||||
if (value is YamlList) {
|
||||
return [for (final item in value) _deepConvert(item)];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Splits the frontmatter `paths:` value into individual glob patterns.
|
||||
// Accepts a comma-separated string or a list.
|
||||
// Handles brace expansion: src/*.{ts,tsx} → [src/*.ts, src/*.tsx]
|
||||
List<String> splitPathInFrontmatter(dynamic input) {
|
||||
if (input is List) {
|
||||
return input.expand((e) => splitPathInFrontmatter(e.toString())).toList();
|
||||
}
|
||||
if (input is! String) return [];
|
||||
|
||||
// split by comma while respecting braces
|
||||
final parts = <String>[];
|
||||
var current = StringBuffer();
|
||||
var braceDepth = 0;
|
||||
|
||||
for (var i = 0; i < input.length; i++) {
|
||||
final char = input[i];
|
||||
if (char == "{") {
|
||||
braceDepth++;
|
||||
current.write(char);
|
||||
} else if (char == "}") {
|
||||
braceDepth--;
|
||||
current.write(char);
|
||||
} else if (char == "," && braceDepth == 0) {
|
||||
final trimmed = current.toString().trim();
|
||||
if (trimmed.isNotEmpty) parts.add(trimmed);
|
||||
current.clear();
|
||||
} else {
|
||||
current.write(char);
|
||||
}
|
||||
}
|
||||
|
||||
final last = current.toString().trim();
|
||||
if (last.isNotEmpty) parts.add(last);
|
||||
|
||||
return parts
|
||||
.where((p) => p.isNotEmpty)
|
||||
.expand(_expandBraces)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<String> _expandBraces(String pattern) {
|
||||
final braceMatch = RegExp(r"^([^{]*)\{([^}]+)\}(.*)$").firstMatch(pattern);
|
||||
if (braceMatch == null) return [pattern];
|
||||
|
||||
final prefix = braceMatch.group(1) ?? "";
|
||||
final alternatives = braceMatch.group(2) ?? "";
|
||||
final suffix = braceMatch.group(3) ?? "";
|
||||
|
||||
return alternatives
|
||||
.split(",")
|
||||
.map((alt) => alt.trim())
|
||||
.expand((alt) => _expandBraces("$prefix$alt$suffix"))
|
||||
.toList();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import "memory_types.dart";
|
||||
|
||||
class MemoryFileInfo {
|
||||
|
||||
final String path;
|
||||
final MemoryType type;
|
||||
final String content;
|
||||
final String? parent;
|
||||
|
||||
// glob patterns from frontmatter `paths:` field
|
||||
// null means no paths restriction (applies to all files)
|
||||
final List<String>? globs;
|
||||
|
||||
const MemoryFileInfo({
|
||||
required this.path,
|
||||
required this.type,
|
||||
required this.content,
|
||||
this.parent,
|
||||
this.globs,
|
||||
});
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
enum MemoryType { managed, user, project, local }
|
||||
|
||||
extension MemoryTypeDescription on MemoryType {
|
||||
String get label {
|
||||
switch (this) {
|
||||
case MemoryType.managed:
|
||||
return " (user's private global instructions for all projects)";
|
||||
case MemoryType.user:
|
||||
return " (user's private global instructions for all projects)";
|
||||
case MemoryType.project:
|
||||
return " (project instructions, checked into the codebase)";
|
||||
case MemoryType.local:
|
||||
return " (user's private project instructions, not checked in)";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,27 @@
|
||||
String buildDefaultSystemPrompt({
|
||||
String? appendSystemPrompt,
|
||||
String? customSystemPrompt,
|
||||
String? claudeMd,
|
||||
}) {
|
||||
if (customSystemPrompt != null && customSystemPrompt.trim().isNotEmpty) {
|
||||
final parts = <String>[customSystemPrompt];
|
||||
if (appendSystemPrompt != null && appendSystemPrompt.trim().isNotEmpty) {
|
||||
parts.add(appendSystemPrompt);
|
||||
}
|
||||
if (claudeMd != null && claudeMd.trim().isNotEmpty) {
|
||||
parts.add(claudeMd);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
final parts = <String>[
|
||||
"You are Claude Code, an AI assistant for software engineering.",
|
||||
"You are The Agency, an AI assistant for software engineering.",
|
||||
];
|
||||
if (appendSystemPrompt != null && appendSystemPrompt.trim().isNotEmpty) {
|
||||
parts.add(appendSystemPrompt);
|
||||
}
|
||||
if (claudeMd != null && claudeMd.trim().isNotEmpty) {
|
||||
parts.add(claudeMd);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Model pricing and cost calculation
|
||||
// Ported from modelCost.ts
|
||||
// Model pricing and cost calculation - Vendor-neutral version
|
||||
// Pricing can be loaded from config or remote service
|
||||
|
||||
/// Per-million-token costs for a model
|
||||
class ModelCosts {
|
||||
@@ -8,6 +8,7 @@ class ModelCosts {
|
||||
final double promptCacheWriteTokens;
|
||||
final double promptCacheReadTokens;
|
||||
final double webSearchRequests;
|
||||
final String provider; // Vendor identifier
|
||||
|
||||
const ModelCosts({
|
||||
required this.inputTokens,
|
||||
@@ -15,73 +16,63 @@ class ModelCosts {
|
||||
required this.promptCacheWriteTokens,
|
||||
required this.promptCacheReadTokens,
|
||||
this.webSearchRequests = 0.01,
|
||||
this.provider = 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
// standard Sonnet pricing: $3 input / $15 output per Mtok
|
||||
const costTier3_15 = ModelCosts(
|
||||
// Default cost structure (can be overridden by config)
|
||||
const defaultModelCosts = ModelCosts(
|
||||
inputTokens: 3,
|
||||
outputTokens: 15,
|
||||
promptCacheWriteTokens: 3.75,
|
||||
promptCacheReadTokens: 0.3,
|
||||
provider: 'default',
|
||||
);
|
||||
|
||||
// Opus 4/4.1 pricing: $15/$75
|
||||
const costTier15_75 = ModelCosts(
|
||||
// Cost tiers for reference (not hardcoded to specific vendor)
|
||||
const costTierLow = ModelCosts(
|
||||
inputTokens: 1,
|
||||
outputTokens: 4,
|
||||
promptCacheWriteTokens: 1.25,
|
||||
promptCacheReadTokens: 0.1,
|
||||
provider: 'generic',
|
||||
);
|
||||
|
||||
const costTierMedium = ModelCosts(
|
||||
inputTokens: 3,
|
||||
outputTokens: 15,
|
||||
promptCacheWriteTokens: 3.75,
|
||||
promptCacheReadTokens: 0.3,
|
||||
provider: 'generic',
|
||||
);
|
||||
|
||||
const costTierHigh = ModelCosts(
|
||||
inputTokens: 15,
|
||||
outputTokens: 75,
|
||||
promptCacheWriteTokens: 18.75,
|
||||
promptCacheReadTokens: 1.5,
|
||||
provider: 'generic',
|
||||
);
|
||||
|
||||
// Opus 4.5: $5/$25
|
||||
const costTier5_25 = ModelCosts(
|
||||
inputTokens: 5,
|
||||
outputTokens: 25,
|
||||
promptCacheWriteTokens: 6.25,
|
||||
promptCacheReadTokens: 0.5,
|
||||
);
|
||||
// Pricing map - populated from config/remote service
|
||||
final Map<String, ModelCosts> modelCostMap = {
|
||||
// Can be loaded from: config file, environment, remote service
|
||||
};
|
||||
|
||||
// fast mode Opus 4.6: $30/$150
|
||||
const costTier30_150 = ModelCosts(
|
||||
inputTokens: 30,
|
||||
outputTokens: 150,
|
||||
promptCacheWriteTokens: 37.5,
|
||||
promptCacheReadTokens: 3,
|
||||
);
|
||||
// Cost examples (can be loaded from config)
|
||||
// const costExample = ModelCosts(
|
||||
// inputTokens: 1,
|
||||
// outputTokens: 5,
|
||||
// promptCacheWriteTokens: 1.25,
|
||||
// promptCacheReadTokens: 0.1,
|
||||
// provider: 'example',
|
||||
// );
|
||||
|
||||
// Haiku 3.5: $0.80/$4
|
||||
const costHaiku35 = ModelCosts(
|
||||
inputTokens: 0.8,
|
||||
outputTokens: 4,
|
||||
promptCacheWriteTokens: 1,
|
||||
promptCacheReadTokens: 0.08,
|
||||
);
|
||||
const _defaultUnknownModelCost = defaultModelCosts;
|
||||
|
||||
// Haiku 4.5: $1/$5
|
||||
const costHaiku45 = ModelCosts(
|
||||
inputTokens: 1,
|
||||
outputTokens: 5,
|
||||
promptCacheWriteTokens: 1.25,
|
||||
promptCacheReadTokens: 0.1,
|
||||
);
|
||||
|
||||
const _defaultUnknownModelCost = costTier5_25;
|
||||
|
||||
|
||||
// Model name -> cost mapping
|
||||
// Model name -> cost mapping (can be loaded from config)
|
||||
final Map<String, ModelCosts> modelCosts = {
|
||||
"claude-3-5-haiku": costHaiku35,
|
||||
"claude-haiku-4-5": costHaiku45,
|
||||
"claude-3-5-sonnet-v2": costTier3_15,
|
||||
"claude-3-7-sonnet": costTier3_15,
|
||||
"claude-sonnet-4": costTier3_15,
|
||||
"claude-sonnet-4-5": costTier3_15,
|
||||
"claude-sonnet-4-6": costTier3_15,
|
||||
"claude-opus-4": costTier15_75,
|
||||
"claude-opus-4-1": costTier15_75,
|
||||
"claude-opus-4-5": costTier5_25,
|
||||
"claude-opus-4-6": costTier5_25,
|
||||
// Can be populated from config file
|
||||
};
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user