Implementation
Future<void> sendMessage(
String text, {
List<InputAttachment> attachments = const [],
}) async {
if (text.trim().isEmpty && attachments.isEmpty) return;
if (_engine == null) return;
SintSentinel.logger.d(
'sendMessage: ${text.length} chars, ${attachments.length} attachments',
);
// Rate-limit check before calling the API
if (_rateLimitService != null &&
!_rateLimitService!.isPolicyAllowed('api_call')) {
error.value = 'Rate limited — please wait before sending another message.';
_telemetry.trackError(
message: 'Rate limited',
context: 'sendMessage',
errorCode: 'RATE_LIMITED',
);
_analytics.logEvent(AnalyticsEvents.apiError, {
'reason': 'rate_limited',
});
return;
}
error.value = null;
isLoading.value = true;
isStreaming.value = true;
streamingText.value = '';
currentToolName.value = null;
// Build content blocks: images first, then text
final content = <ContentBlock>[];
for (final att in attachments) {
if (att.isImage) {
content.add(
ImageBlock(mediaType: att.mimeType, base64Data: att.base64Data),
);
} else {
// Non-image files: include as text with filename context
final decoded = _tryDecodeText(att.bytes);
if (decoded != null) {
content.add(TextBlock('[File: ${att.name}]\n$decoded'));
} else {
content.add(
TextBlock(
'[Attached binary file: ${att.name} '
'(${att.bytes.length} bytes)]',
),
);
}
}
}
if (text.trim().isNotEmpty) {
content.add(TextBlock(text));
}
final userMessage = Message(role: MessageRole.user, content: content);
messages.add(userMessage);
// Persist user message to JSONL transcript
_appendToTranscript(userMessage);
// Track message_sent
_telemetry.track(TelemetryEvent(
name: 'message_sent',
type: TelemetryEventType.apiCall,
properties: {
'charCount': text.length,
'attachmentCount': attachments.length,
},
));
_analytics.logEvent('message_sent', {
'charCount': text.length,
'attachmentCount': attachments.length,
});
_telemetry.performance.start('api_call');
// Fire preMessage lifecycle hook
final preMessageResult = await _hookExecutor.executeAsync(
HookType.preMessage,
MessageHookContext(
hookType: HookType.preMessage,
timestamp: DateTime.now(),
sessionId: sessionId.value,
role: 'user',
content: text,
messageTurnIndex: messages.length,
),
);
if (preMessageResult is HookAbort) {
error.value = 'Message blocked by hook: ${preMessageResult.reason}';
isLoading.value = false;
isStreaming.value = false;
return;
}
try {
final response = await _engine!.query(
messages: messages.toList(),
onTextDelta: (delta) {
streamingText.value += delta;
},
onToolUse: (name, input) {
SintSentinel.logger.d('Tool use: $name');
currentToolName.value = name;
_hookExecutor.executeAsync(
HookType.preToolExecution,
ToolHookContext(
hookType: HookType.preToolExecution,
timestamp: DateTime.now(),
sessionId: sessionId.value,
toolName: name,
toolInput: input,
),
).ignore();
_telemetry.performance.start('tool_$name');
_analytics.logEvent(AnalyticsEvents.toolUseGranted, {'tool': name});
},
onToolResult: (name, result) {
SintSentinel.logger.d('Tool result: $name');
_hookExecutor.executeAsync(
HookType.postToolExecution,
ToolHookContext(
hookType: HookType.postToolExecution,
timestamp: DateTime.now(),
sessionId: sessionId.value,
toolName: name,
toolInput: const {},
toolOutput: result.content,
toolIsError: result.isError,
),
).ignore();
final toolDuration = _telemetry.performance.stop('tool_$name');
_telemetry.trackToolUse(
toolName: name,
duration: toolDuration ?? Duration.zero,
);
_telemetry.track(TelemetryEvent(
name: 'tool_executed',
type: TelemetryEventType.toolUse,
properties: {'tool': name},
));
_analytics.logEvent(AnalyticsEvents.toolUseSuccess, {'tool': name});
currentToolName.value = null;
},
onCompaction: (result) {
compactionCount.value++;
SintSentinel.logger.i(
'Auto-compacted: ${result.preCompactTokenCount} → '
'${result.postCompactTokenCount} tokens '
'(strategy: ${result.strategy.name})',
);
// Update messages list with compacted version
messages.assignAll(result.compactedMessages);
_telemetry.track(TelemetryEvent(
name: 'compaction_triggered',
type: TelemetryEventType.compaction,
properties: {
'strategy': result.strategy.name,
'preTokens': result.preCompactTokenCount,
'postTokens': result.postCompactTokenCount,
},
));
_analytics.logEvent(AnalyticsEvents.compactionTriggered, {
'strategy': result.strategy.name,
});
},
);
// Stop API call perf timer and track the call
final apiDuration = _telemetry.performance.stop('api_call');
_telemetry.trackApiCall(
model: _provider?.config.model ?? 'unknown',
latency: apiDuration ?? Duration.zero,
inputTokens: response.usage?.inputTokens,
outputTokens: response.usage?.outputTokens,
);
// Track message_received
_telemetry.track(TelemetryEvent(
name: 'message_received',
type: TelemetryEventType.apiCall,
properties: {
'inputTokens': response.usage?.inputTokens,
'outputTokens': response.usage?.outputTokens,
},
));
_analytics.logEvent(AnalyticsEvents.apiSuccess, {
'inputTokens': response.usage?.inputTokens,
'outputTokens': response.usage?.outputTokens,
});
messages.add(response);
lastUsage.value = response.usage;
streamingText.value = '';
isStreaming.value = false;
// Track token usage
if (response.usage != null) {
totalInputTokens.value += response.usage!.inputTokens;
totalOutputTokens.value += response.usage!.outputTokens;
}
// Persist assistant response to JSONL transcript
_appendToTranscript(response);
// Auto-save session snapshot after each exchange
_autoSaveSession();
// Track session_saved
_telemetry.track(TelemetryEvent(
name: 'session_saved',
type: TelemetryEventType.sessionStart,
properties: {'messageCount': messages.length},
));
// Trigger session memory extraction if thresholds met
_tryExtractSessionMemory();
// Fire postMessage lifecycle hook
_hookExecutor.executeAsync(
HookType.postMessage,
MessageHookContext(
hookType: HookType.postMessage,
timestamp: DateTime.now(),
sessionId: sessionId.value,
role: 'assistant',
content: response.textContent,
messageTurnIndex: messages.length,
),
).ignore();
} catch (e) {
SintSentinel.logger.e('sendMessage error', error: e);
error.value = e.toString();
isStreaming.value = false;
// Stop perf timer on error path
_telemetry.performance.stop('api_call');
// Track error_occurred
_telemetry.trackError(
message: e.toString(),
context: 'sendMessage',
);
_analytics.logEvent(AnalyticsEvents.apiError, {
'error': e.toString(),
});
}
isLoading.value = false;
}