From 75e59e6d8c6d046dce13eb10f1c0f70e2aaa7c2c Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Mon, 9 Mar 2026 23:15:02 +0700 Subject: [PATCH] feat(tri-bot): Replace claude CLI with direct Anthropic API + SSE streaming (Issue #62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: tri-bot no longer depends on claude CLI (Node.js/TypeScript). Full pipeline: Telegram → Zig → api.anthropic.com (pure std.http.Client). Changes: - claude_stream.zig: REWRITE — POST with stream:true, parse SSE events inline - handlers.zig: /status uses git commands directly (no claude), /sessions stubbed - bot_loop.zig: /continue, /resume deferred to Phase 5 (sessions) - telegram_api.zig: add api_key to BotConfig - main.zig: read ANTHROPIC_API_KEY from env - /stop: cancel flag instead of SIGTERM (no child process) Zero references to "claude" binary remain in bot/. Net -88 lines. Closes #62 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tools/mcp/trinity_mcp/bot/bot_loop.zig | 44 +--- tools/mcp/trinity_mcp/bot/claude_stream.zig | 255 ++++++++++++-------- tools/mcp/trinity_mcp/bot/handlers.zig | 213 ++++------------ tools/mcp/trinity_mcp/bot/main.zig | 7 +- tools/mcp/trinity_mcp/bot/telegram_api.zig | 1 + 5 files changed, 216 insertions(+), 304 deletions(-) diff --git a/tools/mcp/trinity_mcp/bot/bot_loop.zig b/tools/mcp/trinity_mcp/bot/bot_loop.zig index 14dd5f74c8..5b8f80c7ae 100644 --- a/tools/mcp/trinity_mcp/bot/bot_loop.zig +++ b/tools/mcp/trinity_mcp/bot/bot_loop.zig @@ -1,5 +1,5 @@ // bot_loop.zig — Main poll → parse → dispatch → repeat loop -// Phase 2.5: Two-thread arch + session management (/model, /resume, /sessions) +// v2.0: Direct Anthropic API (no claude CLI) const std = @import("std"); const telegram_api = @import("telegram_api.zig"); const json_utils = @import("json_utils.zig"); @@ -96,7 +96,6 @@ fn spawnStreaming(allocator: std.mem.Allocator, config: BotConfig, opts: claude_ _ = std.Thread.spawn(.{}, claude_stream.runStreaming, .{ allocator, config, opts, &stream_state }) catch { stream_state.is_busy.store(false, .release); allocator.free(opts.args); - if (opts.resume_id) |rid| allocator.free(rid); telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9d\x8c Failed to spawn worker thread"); return; }; @@ -120,42 +119,9 @@ fn dispatch(allocator: std.mem.Allocator, config: BotConfig, cmd: command_parser .args = args_owned, .model = bot_state.getModel(), }); - } else if (std.mem.eql(u8, cmd.name, "continue")) { - // Streaming /continue via worker thread - if (stream_state.is_busy.load(.acquire)) { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x8f\xb3 Already processing. Send /stop first."); - return; - } - const args_owned = allocator.dupe(u8, cmd.args) catch return; - spawnStreaming(allocator, config, .{ - .args = args_owned, - .use_continue = true, - .model = bot_state.getModel(), - }); - } else if (std.mem.eql(u8, cmd.name, "resume")) { - // Streaming /resume via worker thread - if (cmd.args.len == 0) { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Usage: /resume "); - return; - } - if (stream_state.is_busy.load(.acquire)) { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x8f\xb3 Already processing. Send /stop first."); - return; - } - // Extract session ID (first word of args) - const id_end = std.mem.indexOf(u8, cmd.args, " ") orelse cmd.args.len; - const resume_id = allocator.dupe(u8, cmd.args[0..id_end]) catch return; - // Remaining text after ID is the prompt (if any) - const prompt_start = if (id_end < cmd.args.len) id_end + 1 else id_end; - const prompt = allocator.dupe(u8, cmd.args[prompt_start..]) catch { - allocator.free(resume_id); - return; - }; - spawnStreaming(allocator, config, .{ - .args = prompt, - .resume_id = resume_id, - .model = bot_state.getModel(), - }); + } else if (std.mem.eql(u8, cmd.name, "continue") or std.mem.eql(u8, cmd.name, "resume")) { + // Deferred to Phase 5 (sessions) + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\x93\x8b Coming in Phase 5 (sessions). Use /ask for new queries."); } else if (std.mem.eql(u8, cmd.name, "model")) { // Blocking: set/show model handlers.handleModel(allocator, config, cmd.args, &bot_state); @@ -166,7 +132,7 @@ fn dispatch(allocator: std.mem.Allocator, config: BotConfig, cmd: command_parser // Blocking: project status handlers.handleStatus(allocator, config); } else if (std.mem.eql(u8, cmd.name, "stop")) { - // Kill active Claude process + // Cancel active streaming request claude_stream.stopProcess(allocator, config, &stream_state); } else if (cmd.name.len > 0) { var buf: [256]u8 = undefined; diff --git a/tools/mcp/trinity_mcp/bot/claude_stream.zig b/tools/mcp/trinity_mcp/bot/claude_stream.zig index e1047c9e5f..1f48aa923a 100644 --- a/tools/mcp/trinity_mcp/bot/claude_stream.zig +++ b/tools/mcp/trinity_mcp/bot/claude_stream.zig @@ -1,29 +1,29 @@ -// claude_stream.zig — Streaming Claude CLI execution with sendMessageDraft -// Spawns claude with --output-format stream-json, pipes stdout, -// reads NDJSON line-by-line, sends drafts every 500ms, final message when done. +// claude_stream.zig — Streaming via direct Anthropic API (SSE) +// POST to api.anthropic.com/v1/messages with stream:true, parse SSE events, +// send Telegram drafts every 500ms, final message when done. +// No claude CLI dependency. Pure Zig std.http.Client. const std = @import("std"); const telegram_api = @import("telegram_api.zig"); -const json_utils = @import("json_utils.zig"); const BotConfig = telegram_api.BotConfig; +const api_url = "https://api.anthropic.com/v1/messages"; +const api_version = "2023-06-01"; +const default_model = "claude-sonnet-4-20250514"; + /// Shared state between main thread (polling) and worker thread (streaming). -/// Atomics for lock-free is_busy check, PID for /stop. pub const StreamState = struct { is_busy: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), - active_pid: std.atomic.Value(i32) = std.atomic.Value(i32).init(0), + cancel_requested: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), }; -/// Options for streaming — covers /ask, /continue, and /resume. +/// Options for streaming /ask. pub const StreamOpts = struct { args: []const u8, // prompt text (caller-owned, freed by worker) - use_continue: bool = false, - resume_id: ?[]const u8 = null, // session ID for --resume - model: ?[]const u8 = null, // model override (pointer to BotState buffer) + model: ?[]const u8 = null, // model override }; -/// Worker thread entry point: spawn claude, stream output, send drafts. -/// Called via std.Thread.spawn() from bot_loop dispatch. +/// Worker thread entry point: POST to Anthropic API, stream SSE, send drafts. pub fn runStreaming( allocator: std.mem.Allocator, config: BotConfig, @@ -31,100 +31,100 @@ pub fn runStreaming( state: *StreamState, ) void { defer { - state.active_pid.store(0, .release); + state.cancel_requested.store(false, .release); state.is_busy.store(false, .release); allocator.free(opts.args); - if (opts.resume_id) |rid| allocator.free(rid); } - // Notify user - if (opts.resume_id != null) { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\x94\x84 TRI resuming session..."); - } else if (opts.use_continue) { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\x94\x84 TRI streaming..."); - } else { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\xa7\xa0 TRI streaming..."); - } + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\xa7\xa0 TRI streaming..."); - // Build turns string - var turns_buf: [16]u8 = undefined; - const turns_str = std.fmt.bufPrint(&turns_buf, "{d}", .{config.max_turns}) catch "10"; - - // Build argv dynamically based on opts - var argv_buf: [16][]const u8 = undefined; - var argc: usize = 0; - - argv_buf[argc] = "claude"; - argc += 1; - if (opts.args.len > 0) { - argv_buf[argc] = "-p"; - argc += 1; - argv_buf[argc] = opts.args; - argc += 1; - } - if (opts.resume_id) |rid| { - argv_buf[argc] = "--resume"; - argc += 1; - argv_buf[argc] = rid; - argc += 1; - } else if (opts.use_continue) { - argv_buf[argc] = "--continue"; - argc += 1; - } - if (opts.model) |model| { - argv_buf[argc] = "--model"; - argc += 1; - argv_buf[argc] = model; - argc += 1; - } - argv_buf[argc] = "--output-format"; - argc += 1; - argv_buf[argc] = "stream-json"; - argc += 1; - argv_buf[argc] = "--max-turns"; - argc += 1; - argv_buf[argc] = turns_str; - argc += 1; - - // Init child with piped stdout - var child = std.process.Child.init(argv_buf[0..argc], allocator); - child.stdout_behavior = .Pipe; - child.stderr_behavior = .Pipe; - child.cwd = config.project_root; - - child.spawn() catch |err| { - var err_buf: [256]u8 = undefined; - telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &err_buf, "\xe2\x9d\x8c Spawn error: {s}", .{@errorName(err)}); + const model = opts.model orelse default_model; + + // Build request body + var body_buf: std.ArrayList(u8) = .empty; + defer body_buf.deinit(allocator); + + buildRequestBody(allocator, &body_buf, model, opts.args) catch { + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9d\x8c Failed to build request"); + return; + }; + + std.debug.print("[tri-bot] SSE request: {d} bytes, model={s}\n", .{ body_buf.items.len, model }); + + // HTTP POST to Anthropic API + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + const uri = std.Uri.parse(api_url) catch unreachable; + + var req = client.request(.POST, uri, .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "application/json" }, + .{ .name = "x-api-key", .value = config.api_key }, + .{ .name = "anthropic-version", .value = api_version }, + }, + }) catch { + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9d\x8c Connection failed"); + return; + }; + defer req.deinit(); + + // Send body + req.transfer_encoding = .{ .content_length = body_buf.items.len }; + var bw = req.sendBodyUnflushed(&.{}) catch { + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9d\x8c Send failed"); + return; + }; + bw.writer.writeAll(body_buf.items) catch { + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9d\x8c Write failed"); + return; + }; + bw.end() catch {}; + if (req.connection) |conn| conn.flush() catch {}; + + // Receive response head + var redirect_buf: [0]u8 = .{}; + var response = req.receiveHead(&redirect_buf) catch { + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9d\x8c No response from API"); return; }; - // Store PID for /stop - state.active_pid.store(@intCast(child.id), .release); + // Check for HTTP errors + const status = @intFromEnum(response.head.status); + if (status >= 400) { + handleApiError(allocator, config, &response, status); + return; + } - std.debug.print("[tri-bot] Streaming started (PID {d})\n", .{child.id}); + std.debug.print("[tri-bot] SSE stream started (status {d})\n", .{status}); + + // Read SSE stream line-by-line + var transfer_buf: [8192]u8 = undefined; + var reader = response.reader(&transfer_buf); - // Read stdout line-by-line, accumulate text deltas, send drafts var text_buf: std.ArrayList(u8) = .empty; defer text_buf.deinit(allocator); var last_draft_ns: i128 = std.time.nanoTimestamp(); const draft_interval: i128 = 500_000_000; // 500ms - const stdout_file = child.stdout.?; var line_buf: [65536]u8 = undefined; var line_len: usize = 0; var read_chunk: [4096]u8 = undefined; while (true) { - const n = stdout_file.read(&read_chunk) catch break; - if (n == 0) break; // EOF + if (state.cancel_requested.load(.acquire)) break; + + const n = reader.readSliceShort(&read_chunk) catch break; for (read_chunk[0..n]) |byte| { if (byte == '\n') { - // Process complete NDJSON line const line = line_buf[0..line_len]; - if (std.mem.indexOf(u8, line, "\"text_delta\"") != null) { - if (json_utils.extractString(line, "text")) |text_val| { + + // Parse SSE: "data: {json}" + if (line.len > 6 and std.mem.eql(u8, line[0..6], "data: ")) { + const json = line[6..]; + if (extractTextDelta(json)) |text_val| { text_buf.appendSlice(allocator, text_val) catch {}; } } @@ -144,31 +144,92 @@ pub fn runStreaming( } } - // Wait for child to finish - _ = child.wait() catch {}; - - std.debug.print("[tri-bot] Streaming done ({d} bytes)\n", .{text_buf.items.len}); + std.debug.print("[tri-bot] SSE done ({d} bytes text)\n", .{text_buf.items.len}); // Send final message if (text_buf.items.len > 0) { telegram_api.sendLongMessage(allocator, config, text_buf.items); } else { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Claude returned empty response"); + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Empty response from API"); } } -/// Stop the active Claude process via SIGTERM. -pub fn stopProcess(allocator: std.mem.Allocator, config: BotConfig, state: *StreamState) void { - const pid = state.active_pid.load(.acquire); - if (pid > 0) { - const posix_pid: std.posix.pid_t = @intCast(pid); - std.posix.kill(posix_pid, 15) catch |err| { // 15 = SIGTERM - var err_buf: [256]u8 = undefined; - telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &err_buf, "\xe2\x9d\x8c Kill error: {s}", .{@errorName(err)}); +/// Build JSON request body for Anthropic Messages API (streaming). +fn buildRequestBody(allocator: std.mem.Allocator, body: *std.ArrayList(u8), model: []const u8, prompt: []const u8) !void { + try body.appendSlice(allocator, "{\"model\":\""); + try body.appendSlice(allocator, model); + try body.appendSlice(allocator, "\",\"max_tokens\":8192,\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\""); + // JSON-escape prompt + for (prompt) |c| { + switch (c) { + '"' => try body.appendSlice(allocator, "\\\""), + '\\' => try body.appendSlice(allocator, "\\\\"), + '\n' => try body.appendSlice(allocator, "\\n"), + '\r' => try body.appendSlice(allocator, "\\r"), + '\t' => try body.appendSlice(allocator, "\\t"), + else => try body.append(allocator, c), + } + } + try body.appendSlice(allocator, "\"}]}"); +} + +/// Extract text from SSE content_block_delta event. +/// Input: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} +/// Returns: "Hello" +fn extractTextDelta(json: []const u8) ?[]const u8 { + // Find "text_delta","text":" — the text field after the delta type + const needle = "\"text_delta\",\"text\":\""; + const idx = std.mem.indexOf(u8, json, needle) orelse return null; + const start = idx + needle.len; + if (start >= json.len) return null; + var end = start; + while (end < json.len) : (end += 1) { + if (json[end] == '"' and (end == start or json[end - 1] != '\\')) break; + } + if (end == start) return null; + return json[start..end]; +} + +/// Handle API error response — read body and send error to Telegram. +fn handleApiError(allocator: std.mem.Allocator, config: BotConfig, response: *std.http.Client.Response, status: u16) void { + var transfer_buf: [8192]u8 = undefined; + var reader = response.reader(&transfer_buf); + const err_body = reader.allocRemaining(allocator, std.Io.Limit.limited(4096)) catch { + var buf: [256]u8 = undefined; + telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &buf, "\xe2\x9d\x8c API error: HTTP {d}", .{status}); + return; + }; + defer allocator.free(err_body); + + // Try to extract error message from JSON + var needle_buf: [128]u8 = undefined; + const needle = std.fmt.bufPrint(&needle_buf, "\"message\":\"", .{}) catch { + var buf: [256]u8 = undefined; + telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &buf, "\xe2\x9d\x8c API error: HTTP {d}", .{status}); + return; + }; + if (std.mem.indexOf(u8, err_body, needle)) |idx| { + const msg_start = idx + needle.len; + if (msg_start < err_body.len) { + var msg_end = msg_start; + while (msg_end < err_body.len and err_body[msg_end] != '"') : (msg_end += 1) {} + const msg = err_body[msg_start..msg_end]; + var buf: [512]u8 = undefined; + telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &buf, "\xe2\x9d\x8c API {d}: {s}", .{ status, msg }); return; - }; - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9b\x94 Claude process stopped"); + } + } + + var buf: [256]u8 = undefined; + telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &buf, "\xe2\x9d\x8c API error: HTTP {d}", .{status}); +} + +/// Cancel the active streaming request. +pub fn stopProcess(allocator: std.mem.Allocator, config: BotConfig, state: *StreamState) void { + if (state.is_busy.load(.acquire)) { + state.cancel_requested.store(true, .release); + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9b\x94 Cancelling request..."); } else { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 No active Claude process"); + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 No active request"); } } diff --git a/tools/mcp/trinity_mcp/bot/handlers.zig b/tools/mcp/trinity_mcp/bot/handlers.zig index 096376fc6c..5c9eef9147 100644 --- a/tools/mcp/trinity_mcp/bot/handlers.zig +++ b/tools/mcp/trinity_mcp/bot/handlers.zig @@ -1,8 +1,7 @@ // handlers.zig — Command handlers for tri-bot -// Each handler: receives args + config, runs claude CLI, sends result to Telegram +// No claude CLI dependency. /status uses git directly. const std = @import("std"); const telegram_api = @import("telegram_api.zig"); -const json_utils = @import("json_utils.zig"); const BotConfig = telegram_api.BotConfig; @@ -26,119 +25,49 @@ pub const BotState = struct { /// /help — Send list of available commands pub fn handleHelp(allocator: std.mem.Allocator, config: BotConfig) void { const help_text = - "\xf0\x9f\xa4\x96 TRI BOT \xe2\x80\x94 Claude Code Remote Control\n" ++ + "\xf0\x9f\xa4\x96 TRI BOT v2.0 \xe2\x80\x94 Direct Anthropic API\n" ++ "\n" ++ - "/ask \xe2\x80\x94 Ask Claude (streaming)\n" ++ - "/continue [msg] \xe2\x80\x94 Continue session\n" ++ - "/resume \xe2\x80\x94 Resume session by ID\n" ++ - "/sessions \xe2\x80\x94 List recent sessions\n" ++ + "/ask \xe2\x80\x94 Ask Claude (streaming SSE)\n" ++ "/model \xe2\x80\x94 Set Claude model\n" ++ - "/status \xe2\x80\x94 Project status\n" ++ - "/stop \xe2\x80\x94 Stop running process\n" ++ + "/status \xe2\x80\x94 Git project status\n" ++ + "/stop \xe2\x80\x94 Cancel active request\n" ++ "/help \xe2\x80\x94 This message\n" ++ "\n" ++ - "Phase 3: /board, /pr, /worktree"; + "Phase 5: /continue, /resume, /sessions"; telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, help_text); } -/// /ask — Run claude -p "" and send result -pub fn handleAsk(allocator: std.mem.Allocator, config: BotConfig, args: []const u8) void { - if (args.len == 0) { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Usage: /ask "); - return; - } - - // Notify user - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\xa7\xa0 TRI thinking..."); - - // Build turns string - var turns_buf: [16]u8 = undefined; - const turns_str = std.fmt.bufPrint(&turns_buf, "{d}", .{config.max_turns}) catch "10"; - - // Spawn claude -p "" --output-format text --max-turns N - const result = std.process.Child.run(.{ - .allocator = allocator, - .argv = &.{ "claude", "-p", args, "--output-format", "text", "--max-turns", turns_str }, - .cwd = config.project_root, - .max_output_bytes = 512 * 1024, - }) catch |err| { - var err_buf: [256]u8 = undefined; - telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &err_buf, "\xe2\x9d\x8c Claude error: {s}", .{@errorName(err)}); - return; - }; - defer allocator.free(result.stdout); - defer allocator.free(result.stderr); - - if (result.stdout.len == 0) { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Claude returned empty response"); - return; - } - - // Telegram message limit: 4096 chars. Split if needed. - telegram_api.sendLongMessage(allocator, config, result.stdout); -} +/// /status — Show git project status (branch, commits, changes). +/// Uses git commands directly — no claude CLI. +pub fn handleStatus(allocator: std.mem.Allocator, config: BotConfig) void { + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\x93\x8a Checking status..."); -/// /continue — Run claude -p "" --continue -pub fn handleContinue(allocator: std.mem.Allocator, config: BotConfig, args: []const u8) void { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\x94\x84 TRI continuing..."); + var out: std.ArrayList(u8) = .empty; + defer out.deinit(allocator); - var turns_buf: [16]u8 = undefined; - const turns_str = std.fmt.bufPrint(&turns_buf, "{d}", .{config.max_turns}) catch "10"; + out.appendSlice(allocator, "\xf0\x9f\x93\x8a Project Status\n\n") catch return; - const argv: []const []const u8 = if (args.len > 0) - &.{ "claude", "-p", args, "--continue", "--output-format", "text", "--max-turns", turns_str } - else - &.{ "claude", "--continue", "--output-format", "text", "--max-turns", turns_str }; + // Branch + out.appendSlice(allocator, "Branch: ") catch return; + appendCommandOutput(allocator, &out, config.project_root, &.{ "git", "branch", "--show-current" }); - const result = std.process.Child.run(.{ - .allocator = allocator, - .argv = argv, - .cwd = config.project_root, - .max_output_bytes = 512 * 1024, - }) catch |err| { - var err_buf: [256]u8 = undefined; - telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &err_buf, "\xe2\x9d\x8c Claude error: {s}", .{@errorName(err)}); - return; - }; - defer allocator.free(result.stdout); - defer allocator.free(result.stderr); + // Recent commits + out.appendSlice(allocator, "\nRecent commits:\n") catch return; + appendCommandOutput(allocator, &out, config.project_root, &.{ "git", "log", "-5", "--oneline" }); - if (result.stdout.len == 0) { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Claude returned empty response"); - return; + // Working tree changes + out.appendSlice(allocator, "\nChanges:\n") catch return; + const before_changes = out.items.len; + appendCommandOutput(allocator, &out, config.project_root, &.{ "git", "status", "--short" }); + if (out.items.len == before_changes) { + out.appendSlice(allocator, "(clean)\n") catch {}; } - telegram_api.sendLongMessage(allocator, config, result.stdout); -} - -/// /status — Run claude -p "Summarize project status in 5 lines" -pub fn handleStatus(allocator: std.mem.Allocator, config: BotConfig) void { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\x93\x8a TRI checking status..."); - - const result = std.process.Child.run(.{ - .allocator = allocator, - .argv = &.{ - "claude", "-p", - "Summarize the project status in 5 short lines: open issues count, last commit, test status, current branch, any blockers. Be concise.", "--output-format", - "text", "--max-turns", - "3", - }, - .cwd = config.project_root, - .max_output_bytes = 64 * 1024, - }) catch |err| { - var err_buf: [256]u8 = undefined; - telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &err_buf, "\xe2\x9d\x8c Status error: {s}", .{@errorName(err)}); - return; - }; - defer allocator.free(result.stdout); - defer allocator.free(result.stderr); - - if (result.stdout.len == 0) { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 No status available"); - return; + if (out.items.len > 20) { + telegram_api.sendLongMessage(allocator, config, out.items); + } else { + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Could not get status"); } - - telegram_api.sendLongMessage(allocator, config, result.stdout); } /// /model — Set Claude model for subsequent requests. @@ -149,7 +78,7 @@ pub fn handleModel(allocator: std.mem.Allocator, config: BotConfig, args: []cons var buf: [256]u8 = undefined; telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &buf, "\xf0\x9f\xa4\x96 Current model: {s}", .{model}); } else { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\xa4\x96 Model: default (no override)"); + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\xa4\x96 Model: default (claude-sonnet-4-20250514)"); } return; } @@ -164,80 +93,30 @@ pub fn handleModel(allocator: std.mem.Allocator, config: BotConfig, args: []cons telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &buf, "\xe2\x9c\x85 Model set: {s}", .{args}); } -/// /sessions — List recent Claude sessions. +/// /sessions — Stubbed for Phase 5. pub fn handleSessions(allocator: std.mem.Allocator, config: BotConfig) void { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\x93\x8b Fetching sessions..."); + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\x93\x8b Sessions: coming in Phase 5. Use /ask for new conversations."); +} +/// Run a command and append its stdout to the output buffer. +fn appendCommandOutput(allocator: std.mem.Allocator, out: *std.ArrayList(u8), cwd: []const u8, argv: []const []const u8) void { const result = std.process.Child.run(.{ .allocator = allocator, - .argv = &.{ "claude", "sessions", "list", "--json" }, - .cwd = config.project_root, - .max_output_bytes = 128 * 1024, - }) catch |err| { - var err_buf: [256]u8 = undefined; - telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &err_buf, "\xe2\x9d\x8c Sessions error: {s}", .{@errorName(err)}); + .argv = argv, + .cwd = cwd, + .max_output_bytes = 64 * 1024, + }) catch { + out.appendSlice(allocator, "(error)\n") catch {}; return; }; defer allocator.free(result.stdout); defer allocator.free(result.stderr); - if (result.stdout.len == 0) { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 No sessions found"); - return; - } - - // Format sessions: extract id + summary from JSON array - var out_buf: std.ArrayList(u8) = .empty; - defer out_buf.deinit(allocator); - - out_buf.appendSlice(allocator, "\xf0\x9f\x93\x8b Recent sessions:\n") catch return; - formatSessions(allocator, &out_buf, result.stdout); - - if (out_buf.items.len > 20) { - telegram_api.sendLongMessage(allocator, config, out_buf.items); - } else { - telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Could not parse sessions"); - } -} - -/// Parse JSON session list and append formatted entries to output buffer. -fn formatSessions(allocator: std.mem.Allocator, out: *std.ArrayList(u8), json: []const u8) void { - var count: usize = 0; - var pos: usize = 0; - const id_needle = "\"session_id\":\""; - - while (pos < json.len and count < 10) { - const idx = std.mem.indexOfPos(u8, json, pos, id_needle) orelse break; - const id_start = idx + id_needle.len; - const id_end = std.mem.indexOfPos(u8, json, id_start, "\"") orelse break; - const session_id = json[id_start..id_end]; - - // Try to find summary near this session entry - const block_end = std.mem.indexOfPos(u8, json, id_end, id_needle) orelse json.len; - const block = json[idx..block_end]; - const summary = json_utils.extractString(block, "summary") orelse - json_utils.extractString(block, "name") orelse "(no summary)"; - - count += 1; - var num_buf: [4]u8 = undefined; - const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{count}) catch "?"; - out.appendSlice(allocator, num_str) catch {}; - out.appendSlice(allocator, ". ") catch {}; - // Show short ID (first 8 chars or full if shorter) - const short_id = if (session_id.len > 8) session_id[0..8] else session_id; - out.appendSlice(allocator, short_id) catch {}; - out.appendSlice(allocator, " \xe2\x80\x94 ") catch {}; - // Truncate summary to 60 chars - const max_sum: usize = 60; - const sum_display = if (summary.len > max_sum) summary[0..max_sum] else summary; - out.appendSlice(allocator, sum_display) catch {}; - if (summary.len > max_sum) out.appendSlice(allocator, "...") catch {}; - out.appendSlice(allocator, "\n") catch {}; - - pos = block_end; - } - - if (count == 0) { - out.appendSlice(allocator, "(no sessions found)\n") catch {}; + if (result.stdout.len > 0) { + out.appendSlice(allocator, result.stdout) catch {}; + // Ensure trailing newline + if (result.stdout[result.stdout.len - 1] != '\n') { + out.appendSlice(allocator, "\n") catch {}; + } } } diff --git a/tools/mcp/trinity_mcp/bot/main.zig b/tools/mcp/trinity_mcp/bot/main.zig index f9115ec45d..ddffc45f20 100644 --- a/tools/mcp/trinity_mcp/bot/main.zig +++ b/tools/mcp/trinity_mcp/bot/main.zig @@ -20,6 +20,10 @@ pub fn main() !void { }; const project_root = std.posix.getenv("PROJECT_ROOT") orelse std.posix.getenv("TRINITY_PROJECT_ROOT") orelse "."; + const api_key = std.posix.getenv("ANTHROPIC_API_KEY") orelse { + std.debug.print("[tri-bot] ERROR: ANTHROPIC_API_KEY not set\n", .{}); + return error.MissingConfig; + }; const max_turns_str = std.posix.getenv("MAX_TURNS") orelse "10"; const max_turns = std.fmt.parseInt(u32, max_turns_str, 10) catch 10; @@ -28,11 +32,12 @@ pub fn main() !void { .bot_token = bot_token, .chat_id = chat_id, .project_root = project_root, + .api_key = api_key, .max_turns = max_turns, }; std.debug.print( - \\[tri-bot] TRI BOT v1.0.0 + \\[tri-bot] TRI BOT v2.0.0 (Direct API) \\[tri-bot] Chat ID: {s} \\[tri-bot] Project: {s} \\[tri-bot] Max turns: {d} diff --git a/tools/mcp/trinity_mcp/bot/telegram_api.zig b/tools/mcp/trinity_mcp/bot/telegram_api.zig index 66d861d905..666c030170 100644 --- a/tools/mcp/trinity_mcp/bot/telegram_api.zig +++ b/tools/mcp/trinity_mcp/bot/telegram_api.zig @@ -6,6 +6,7 @@ pub const BotConfig = struct { bot_token: []const u8, chat_id: []const u8, project_root: []const u8, + api_key: []const u8, max_turns: u32, };