sendMessage method

Future<void> sendMessage(
  1. String text, {
  2. List<InputAttachment> attachments = const [],
})

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;
}