import 'dart:async'; import 'dart:convert'; import 'dart:io'; import '../app/app_store_policy.dart'; import 'go_core.dart'; typedef ArisProcessStarter = Future Function( String executable, List arguments, { Map? environment, String? workingDirectory, }); class ArisLlmChatClient { ArisLlmChatClient({ ArisProcessStarter? processStarter, GoCoreLocator? bridgeLocator, Duration rpcTimeout = const Duration(minutes: 2), }) : _processStarter = processStarter ?? ((executable, arguments, {environment, workingDirectory}) { return Process.start( executable, arguments, environment: environment, workingDirectory: workingDirectory, ); }), _bridgeLocator = bridgeLocator ?? GoCoreLocator(), _rpcTimeout = rpcTimeout; final ArisProcessStarter _processStarter; final GoCoreLocator _bridgeLocator; final Duration _rpcTimeout; Future chat({ required String endpoint, required String apiKey, required String model, required String prompt, String systemPrompt = '', }) { return _callTool( toolName: 'chat', environment: { ...Platform.environment, 'LLM_API_KEY': apiKey, 'LLM_BASE_URL': endpoint, 'LLM_MODEL': model, 'LLM_SERVER_NAME': 'xworkmate-aris-llm-chat', }, arguments: { 'prompt': prompt, 'model': model, if (systemPrompt.trim().isNotEmpty) 'system': systemPrompt.trim(), }, ); } Future claudeReview({ required String prompt, String model = '', String systemPrompt = '', String tools = '', }) { return _callTool( toolName: 'claude_review', environment: { ...Platform.environment, if (model.trim().isNotEmpty) 'CLAUDE_REVIEW_MODEL': model.trim(), if (systemPrompt.trim().isNotEmpty) 'CLAUDE_REVIEW_SYSTEM': systemPrompt.trim(), if (tools.trim().isNotEmpty) 'CLAUDE_REVIEW_TOOLS': tools.trim(), }, arguments: { 'prompt': prompt, if (model.trim().isNotEmpty) 'model': model.trim(), if (systemPrompt.trim().isNotEmpty) 'system': systemPrompt.trim(), if (tools.trim().isNotEmpty) 'tools': tools.trim(), }, ); } Future _callTool({ required String toolName, required Map environment, required Map arguments, }) async { if (blocksAppStoreEmbeddedAgentProcesses( isAppleHost: Platform.isIOS || Platform.isMacOS, )) { throw UnsupportedError( 'App Store builds do not allow launching the bundled Go core process.', ); } final launch = await _bridgeLocator.locate(); if (launch == null) { throw StateError('Go core is unavailable.'); } final process = await _processStarter( launch.executable, launch.arguments, environment: environment, workingDirectory: launch.workingDirectory, ); final responseCompleter = Completer(); final errorBuffer = StringBuffer(); late final StreamSubscription stdoutSubscription; late final StreamSubscription stderrSubscription; late final StreamSubscription exitSubscription; stdoutSubscription = process.stdout .transform(utf8.decoder) .transform(const LineSplitter()) .listen((line) { if (line.trim().isEmpty) { return; } late final Map message; try { message = jsonDecode(line) as Map; } catch (error) { if (!responseCompleter.isCompleted) { responseCompleter.completeError( StateError('Go core returned invalid JSON: $error'), ); } return; } if (message['id'] == 2) { final result = (message['result'] as Map?)?.cast() ?? const {}; final content = (result['content'] as List?) ?.whereType() .map((item) => item['text']?.toString() ?? '') .join('\n') .trim() ?? ''; if (!responseCompleter.isCompleted) { responseCompleter.complete(content); } } else if (message['error'] is Map && !responseCompleter.isCompleted) { final error = (message['error'] as Map).cast(); responseCompleter.completeError( StateError(error['message']?.toString() ?? 'Go core error'), ); } }); stderrSubscription = process.stderr .transform(utf8.decoder) .listen(errorBuffer.write); exitSubscription = process.exitCode.asStream().listen((exitCode) { scheduleMicrotask(() { if (responseCompleter.isCompleted) { return; } final stderrText = errorBuffer.toString().trim(); if (exitCode != 0) { responseCompleter.completeError( StateError( stderrText.isNotEmpty ? stderrText : 'Go core exited with code $exitCode', ), ); return; } responseCompleter.completeError( StateError( stderrText.isNotEmpty ? stderrText : 'Go core closed without returning a tool result.', ), ); }); }); void send(Object payload) { process.stdin.writeln(jsonEncode(payload)); } send({ 'jsonrpc': '2.0', 'id': 1, 'method': 'initialize', 'params': {}, }); send({ 'jsonrpc': '2.0', 'method': 'notifications/initialized', 'params': {}, }); send({ 'jsonrpc': '2.0', 'id': 2, 'method': 'tools/call', 'params': {'name': toolName, 'arguments': arguments}, }); try { return await responseCompleter.future.timeout( _rpcTimeout, onTimeout: () => throw TimeoutException( 'Go core timed out after ${_rpcTimeout.inSeconds}s', _rpcTimeout, ), ); } finally { await stdoutSubscription.cancel(); await stderrSubscription.cancel(); await exitSubscription.cancel(); try { process.kill(); } catch (_) { // Best effort only. } await process.stdin.close(); final stderrText = errorBuffer.toString().trim(); if (stderrText.isNotEmpty && !responseCompleter.isCompleted) { throw StateError(stderrText); } } } }