A Zulip bot agent to sit in our Black Sun. Ever evolving
at main 540 lines 22 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6let src = Logs.Src.create "poe" ~doc:"Poe Zulip bot" 7 8module Log = (val Logs.src_log src : Logs.LOG) 9 10type 'a env = { 11 sw : Eio.Switch.t; 12 process_mgr : 'a Eio.Process.mgr; 13 clock : float Eio.Time.clock_ty Eio.Resource.t; 14 fs : Eio.Fs.dir_ty Eio.Path.t; 15} 16 17(** In-memory tracking of active sessions. 18 A session becomes active when the bot is first @mentioned in a channel/DM. 19 Once active, all messages in that scope are accumulated into context. 20 Resets on bot restart (intentional - requires new @mention to reactivate). *) 21module Active_sessions = struct 22 (* Store scope and activation time *) 23 let sessions : (string, Session.scope * float) Hashtbl.t = Hashtbl.create 16 24 25 let activate scope = 26 let key = Session.scope_to_string scope in 27 if not (Hashtbl.mem sessions key) then begin 28 Hashtbl.add sessions key (scope, Unix.gettimeofday ()); 29 Log.info (fun m -> m "Session activated for %s" key) 30 end 31 32 let is_active scope = 33 let key = Session.scope_to_string scope in 34 Hashtbl.mem sessions key 35 36 let deactivate scope = 37 let key = Session.scope_to_string scope in 38 Hashtbl.remove sessions key; 39 Log.info (fun m -> m "Session deactivated for %s" key) 40 41 let list_all () = 42 Hashtbl.fold (fun _key (scope, activated_at) acc -> 43 (scope, activated_at) :: acc 44 ) sessions [] 45end 46 47(** Strip any @**name** mention from the start of content. 48 This handles display names like @**Poe** that don't match email patterns. *) 49let strip_leading_mention content = 50 let s = String.trim content in 51 if String.length s >= 5 && String.sub s 0 3 = "@**" then 52 match String.index_from_opt s 3 '*' with 53 | Some i when i + 1 < String.length s && s.[i+1] = '*' -> 54 (* Found closing **, strip the mention *) 55 String.trim (String.sub s (i + 2) (String.length s - i - 2)) 56 | _ -> s 57 else s 58 59let run_git_pull ~proc ~cwd = 60 Log.info (fun m -> m "Pulling latest changes from remote"); 61 Eio.Switch.run @@ fun sw -> 62 let buf_stdout = Buffer.create 256 in 63 let buf_stderr = Buffer.create 256 in 64 let child = Eio.Process.spawn proc ~sw ~cwd 65 ~stdout:(Eio.Flow.buffer_sink buf_stdout) 66 ~stderr:(Eio.Flow.buffer_sink buf_stderr) 67 ["git"; "pull"; "--ff-only"] 68 in 69 match Eio.Process.await child with 70 | `Exited 0 -> 71 let output = String.trim (Buffer.contents buf_stdout) in 72 if output = "Already up to date." then begin 73 Log.info (fun m -> m "Repository already up to date"); 74 Ok `Up_to_date 75 end else begin 76 Log.info (fun m -> m "Pulled new changes from remote"); 77 Ok (`Updated output) 78 end 79 | `Exited code -> 80 let stderr = String.trim (Buffer.contents buf_stderr) in 81 Log.warn (fun m -> m "git pull exited with code %d: %s" code stderr); 82 Error (Printf.sprintf "git pull failed (code %d): %s" code stderr) 83 | `Signaled sig_ -> 84 Log.warn (fun m -> m "git pull killed by signal %d" sig_); 85 Error (Printf.sprintf "git pull killed by signal %d" sig_) 86 87let get_git_head ~proc ~cwd = 88 Eio.Switch.run @@ fun sw -> 89 let buf = Buffer.create 64 in 90 let child = Eio.Process.spawn proc ~sw ~cwd 91 ~stdout:(Eio.Flow.buffer_sink buf) 92 ["git"; "rev-parse"; "--short"; "HEAD"] 93 in 94 match Eio.Process.await child with 95 | `Exited 0 -> Some (String.trim (Buffer.contents buf)) 96 | _ -> None 97 98let create_claude_client env = 99 let options = 100 Claude.Options.default 101 |> Claude.Options.with_model `Opus_4_5 102 |> Claude.Options.with_permission_mode Claude.Permissions.Mode.Bypass_permissions 103 |> Claude.Options.with_allowed_tools [ "Read"; "Glob"; "Grep" ] 104 |> Claude.Options.with_append_system_prompt 105 {|You are Poe, a helpful Zulip bot that manages a monorepo. 106You have access to your own source code in the poe/ directory. 107When asked to add features to yourself, you can read and understand your implementation. 108Be concise in your responses as they will be posted to Zulip. 109When suggesting code changes, format them clearly with markdown code blocks.|} 110 in 111 Claude.Client.create ~options ~sw:env.sw ~process_mgr:env.process_mgr 112 ~clock:env.clock () 113 114(** Format tool use for Zulip richtext display *) 115let format_tool_use (tool : Claude.Response.Tool_use.t) = 116 let name = Claude.Response.Tool_use.name tool in 117 let input = Claude.Response.Tool_use.input tool in 118 (* Extract key parameters for common tools *) 119 let params = match name with 120 | "Read" -> 121 Claude.Tool_input.get_string input "file_path" 122 |> Option.map (fun p -> Printf.sprintf "`%s`" p) 123 |> Option.value ~default:"" 124 | "Glob" -> 125 Claude.Tool_input.get_string input "pattern" 126 |> Option.map (fun p -> Printf.sprintf "pattern: `%s`" p) 127 |> Option.value ~default:"" 128 | "Grep" -> 129 let pattern = Claude.Tool_input.get_string input "pattern" |> Option.value ~default:"" in 130 let path = Claude.Tool_input.get_string input "path" |> Option.value ~default:"." in 131 Printf.sprintf "`%s` in `%s`" pattern path 132 | "Edit" -> 133 Claude.Tool_input.get_string input "file_path" 134 |> Option.map (fun p -> Printf.sprintf "`%s`" p) 135 |> Option.value ~default:"" 136 | "Write" -> 137 Claude.Tool_input.get_string input "file_path" 138 |> Option.map (fun p -> Printf.sprintf "`%s`" p) 139 |> Option.value ~default:"" 140 | "Bash" -> 141 Claude.Tool_input.get_string input "command" 142 |> Option.map (fun c -> 143 let truncated = if String.length c > 60 then String.sub c 0 57 ^ "..." else c in 144 Printf.sprintf "`%s`" truncated) 145 |> Option.value ~default:"" 146 | _ -> "" 147 in 148 if params = "" then 149 Printf.sprintf "> :gear: **%s**" name 150 else 151 Printf.sprintf "> :gear: **%s** %s" name params 152 153(** Format thinking block for Zulip richtext display *) 154let format_thinking (thinking : Claude.Response.Thinking.t) = 155 let content = Claude.Response.Thinking.content thinking in 156 (* Truncate long thinking and format as quote *) 157 let truncated = 158 if String.length content > 200 then 159 String.sub content 0 197 ^ "..." 160 else content 161 in 162 Printf.sprintf "> :thought_balloon: *%s*" truncated 163 164(** Format error for Zulip richtext display *) 165let format_error (err : Claude.Response.Error.t) = 166 let msg = Claude.Response.Error.message err in 167 Printf.sprintf "> :warning: **Error:** %s" msg 168 169(** Post a message to the appropriate Zulip channel/DM based on scope *) 170let post_to_scope ~client ~(scope : Session.scope) content = 171 let message = match scope with 172 | Session.Channel { stream; topic } -> 173 Zulip.Message.create ~type_:`Channel ~to_:[stream] ~topic ~content () 174 | Session.Direct { user_email; _ } -> 175 Zulip.Message.create ~type_:`Direct ~to_:[user_email] ~content () 176 in 177 let _resp = Zulip.Messages.send client message in 178 () 179 180let ask_claude env prompt = 181 let client = create_claude_client env in 182 Claude.Client.query client prompt; 183 let responses = Claude.Client.receive_all client in 184 let text = 185 List.filter_map 186 (function 187 | Claude.Response.Text t -> Some (Claude.Response.Text.content t) 188 | _ -> None) 189 responses 190 in 191 String.concat "" text 192 193(** Ask Claude with streaming responses posted to Zulip *) 194let ask_claude_with_session_streaming env ~zulip_client ~storage msg user_content = 195 let scope = Session.scope_of_message msg in 196 let now = Unix.gettimeofday () in 197 let session = Session.load storage ~scope ~now in 198 let session = Session.add_user_message session ~content:user_content ~now in 199 200 (* Build prompt with session context *) 201 let context_section = match Session.build_context session with 202 | None -> "" 203 | Some ctx -> ctx ^ "\n\n---\n\n" 204 in 205 let prompt = 206 Printf.sprintf 207 {|%sThe user sent this message to the Poe Zulip bot: 208 209%s 210 211Please help them. If they're asking about adding features to the bot, read the bot's source code in the poe/ directory first. 212If they're asking about the monorepo or daily changes, help them understand the content. 213Keep your response concise and suitable for a Zulip message.|} 214 context_section user_content 215 in 216 217 (* Create Claude client and start query *) 218 let claude_client = create_claude_client env in 219 Claude.Client.query claude_client prompt; 220 221 (* Accumulate text and track agent messages *) 222 let text_buffer = Buffer.create 1024 in 223 let agent_messages = ref [] in 224 225 (* Create streaming handler that posts agent messages to Zulip *) 226 let handler = object 227 inherit Claude.Handler.default 228 229 method! on_text t = 230 Buffer.add_string text_buffer (Claude.Response.Text.content t) 231 232 method! on_tool_use t = 233 let formatted = format_tool_use t in 234 agent_messages := formatted :: !agent_messages; 235 Log.debug (fun m -> m "Tool use: %s" (Claude.Response.Tool_use.name t)) 236 237 method! on_thinking t = 238 let formatted = format_thinking t in 239 agent_messages := formatted :: !agent_messages; 240 Log.debug (fun m -> m "Thinking: %s" (String.sub (Claude.Response.Thinking.content t) 0 (min 50 (String.length (Claude.Response.Thinking.content t))))) 241 242 method! on_error t = 243 let formatted = format_error t in 244 agent_messages := formatted :: !agent_messages; 245 Log.warn (fun m -> m "Claude error: %s" (Claude.Response.Error.message t)) 246 247 method! on_complete c = 248 let cost = Claude.Response.Complete.total_cost_usd c |> Option.value ~default:0.0 in 249 let turns = Claude.Response.Complete.num_turns c in 250 Log.info (fun m -> m "Claude complete: %d turns, $%.4f" turns cost) 251 end in 252 253 (* Run the streaming handler *) 254 Claude.Client.run claude_client ~handler; 255 256 (* Post agent messages summary if any occurred *) 257 let agent_msgs = List.rev !agent_messages in 258 if agent_msgs <> [] then begin 259 let agent_summary = String.concat "\n" agent_msgs in 260 let header = Printf.sprintf "**Agent activity:**\n%s" agent_summary in 261 post_to_scope ~client:zulip_client ~scope header 262 end; 263 264 let response = Buffer.contents text_buffer in 265 266 (* Save the updated session with the response *) 267 let now = Unix.gettimeofday () in 268 let session = Session.add_assistant_message session ~content:response ~now in 269 Session.save storage ~scope session; 270 271 Log.info (fun m -> m "Session for %s: %s" 272 (Session.scope_to_string scope) (Session.stats session)); 273 response 274 275(** Silently accumulate a message into the session without calling Claude. 276 Used when the bot is not @mentioned but the session is active. *) 277let accumulate_message_silently ~storage msg = 278 let scope = Session.scope_of_message msg in 279 let now = Unix.gettimeofday () in 280 let session = Session.load storage ~scope ~now in 281 let content = Zulip_bot.Message.content msg in 282 let sender = Zulip_bot.Message.sender_full_name msg in 283 (* Include sender name in the accumulated content for context *) 284 let annotated_content = Printf.sprintf "[%s]: %s" sender content in 285 let session = Session.add_user_message session ~content:annotated_content ~now in 286 Session.save storage ~scope session; 287 Log.debug (fun m -> m "Accumulated message from %s into session for %s" 288 sender (Session.scope_to_string scope)) 289 290let handle_help () = 291 Zulip_bot.Response.reply 292 {|**Poe Bot Commands:** 293 294**Basic Commands:** 295- `help` or `?` - Show this help message 296- `status` - Show bot configuration and tracked verse users with repo links 297- `broadcast` / `post` / `changes` - Generate and broadcast changelog with Claude 298- `refresh` / `pull` / `sync` / `update` - Pull from remote and broadcast changes 299- `clear` / `new` / `reset` - Clear conversation session and start fresh 300 301**Admin Commands:** (require authorization) 302- `admin last-broadcast` - Show last broadcast time and git HEAD 303- `admin reset-broadcast <ISO-timestamp>` - Reset broadcast time 304- `admin storage keys` - List all storage keys 305- `admin storage get <key>` - Get value for a storage key 306- `admin storage delete <key>` - Delete a storage key 307 308**Conversation Sessions:** 309Poe maintains separate conversation sessions for each channel topic and DM. 310Your conversation history is preserved within each context, allowing multi-turn 311conversations with Claude. Sessions expire after 1 hour of inactivity. 312Use `clear` to start a fresh conversation in the current context. 313 314**Other Messages:** 315Any other message will be interpreted by Claude to help you understand or modify the bot. 316 317**Configuration:** 318The bot reads its configuration from `poe.toml` with the following fields: 319- `channel` - The Zulip channel to broadcast to 320- `topic` - The topic for broadcast messages 321- `verse_path` - Path to verse/ directory containing user monorepos 322- `admin_emails` - List of emails authorized for admin commands|} 323 324(* Load verse registry and get tracked users with their repo URLs *) 325let get_verse_status ~fs ~verse_path = 326 let registry_path = Monopam.Verse_config.registry_path () in 327 let registry_toml = Fpath.(registry_path / "opamverse.toml") in 328 match Monopam.Verse_registry.load ~fs registry_toml with 329 | Error msg -> 330 Log.warn (fun m -> m "Failed to load registry: %s" msg); 331 [] 332 | Ok registry -> 333 (* Scan verse directory for user subdirectories *) 334 let verse_eio = Eio.Path.(fs / verse_path) in 335 let subdirs = try 336 Eio.Path.read_dir verse_eio 337 |> List.filter (fun name -> 338 not (String.starts_with ~prefix:"." name) && 339 not (String.ends_with ~suffix:"-opam" name)) 340 with Eio.Io _ -> [] 341 in 342 (* Match each subdirectory with registry member *) 343 List.filter_map (fun handle -> 344 match Monopam.Verse_registry.find_member registry ~handle with 345 | Some member -> Some (handle, member.monorepo, member.opamrepo) 346 | None -> None 347 ) subdirs 348 349let format_duration seconds = 350 if seconds < 60.0 then Printf.sprintf "%.0fs" seconds 351 else if seconds < 3600.0 then Printf.sprintf "%.0fm" (seconds /. 60.0) 352 else Printf.sprintf "%.1fh" (seconds /. 3600.0) 353 354let handle_status env ~storage config = 355 let admin_list = if config.Config.admin_emails = [] then "none configured" 356 else String.concat ", " config.Config.admin_emails 357 in 358 let verse_path = match config.Config.verse_path with 359 | Some vp -> vp 360 | None -> 361 let mono_dir = Filename.dirname config.Config.monorepo_path in 362 Filename.concat mono_dir "verse" 363 in 364 let verse_users = get_verse_status ~fs:env.fs ~verse_path in 365 let users_section = if verse_users = [] then 366 "- Tracked verse users: none" 367 else 368 "- Tracked verse users:\n" ^ 369 (verse_users 370 |> List.map (fun (handle, mono_url, opam_url) -> 371 Printf.sprintf " - **%s**: [monorepo](%s) | [opam-repo](%s)" 372 handle mono_url opam_url) 373 |> String.concat "\n") 374 in 375 (* Build active sessions section *) 376 let active_sessions = Active_sessions.list_all () in 377 let now = Unix.gettimeofday () in 378 let sessions_section = 379 if active_sessions = [] then 380 "- Active sessions: none" 381 else 382 let session_lines = active_sessions |> List.map (fun (scope, activated_at) -> 383 let session = Session.load storage ~scope ~now in 384 let scope_mention = Session.scope_to_mention scope in 385 let active_for = format_duration (now -. activated_at) in 386 let stats = Session.stats session in 387 Printf.sprintf " - %s: %s (active for %s)" scope_mention stats active_for 388 ) in 389 Printf.sprintf "- Active sessions (%d):\n%s" 390 (List.length active_sessions) 391 (String.concat "\n" session_lines) 392 in 393 Zulip_bot.Response.reply 394 (Printf.sprintf 395 {|**Poe Bot Status:** 396 397- Channel: `%s` 398- Topic: `%s` 399- Verse path: `%s` 400- Admin emails: %s 401%s 402%s|} 403 config.Config.channel config.Config.topic 404 verse_path admin_list users_section sessions_section) 405 406let handle_refresh env ~client ~storage ~config = 407 let monorepo_path = Eio.Path.(env.fs / config.Config.monorepo_path) in 408 409 (* Step 1: Git pull *) 410 let pull_result = run_git_pull ~proc:env.process_mgr ~cwd:monorepo_path in 411 match pull_result with 412 | Error e -> 413 Zulip_bot.Response.reply (Printf.sprintf "**Refresh failed:**\n\n%s" e) 414 | Ok pull_status -> 415 let pull_msg = match pull_status with 416 | `Up_to_date -> "Repository already up to date" 417 | `Updated _ -> "Pulled new changes from remote" 418 in 419 420 (* Step 2: Get commits since last HEAD *) 421 let last_head = Admin.get_last_git_head storage in 422 let commits = match last_head with 423 | Some h -> Changelog.get_git_log ~proc:env.process_mgr ~cwd:monorepo_path ~since_head:h 424 | None -> Changelog.get_recent_commits ~proc:env.process_mgr ~cwd:monorepo_path ~count:10 425 in 426 427 if commits = [] then 428 Zulip_bot.Response.reply 429 (Printf.sprintf "**Refresh completed:**\n\n- %s\n- No new commits to broadcast" pull_msg) 430 else begin 431 (* Get channel members for @mentions *) 432 let members = Changelog.get_channel_members ~client ~channel:config.Config.channel in 433 434 (* Generate narrative changelog with Claude *) 435 match Changelog.generate ~sw:env.sw ~proc:env.process_mgr ~clock:env.clock ~fs:env.fs ~commits ~members () with 436 | None -> 437 Zulip_bot.Response.reply 438 (Printf.sprintf "**Refresh completed:**\n\n- %s\n- Could not generate changelog" pull_msg) 439 | Some content -> 440 (* Update storage *) 441 let now = Ptime_clock.now () in 442 Admin.set_last_broadcast_time storage now; 443 let current_head = get_git_head ~proc:env.process_mgr ~cwd:monorepo_path in 444 Option.iter (Admin.set_last_git_head storage) current_head; 445 Log.info (fun m -> m "Refresh broadcasting: %s" content); 446 447 (* Send to channel *) 448 Zulip_bot.Response.stream 449 ~stream:config.Config.channel 450 ~topic:config.Config.topic 451 ~content:(Printf.sprintf "**Refresh triggered manually**\n\n%s" content) 452 end 453 454let handle_claude_query env ~zulip_client ~storage msg = 455 let content = Zulip_bot.Message.content msg in 456 Log.info (fun m -> m "Asking Claude: %s" content); 457 let response = ask_claude_with_session_streaming env ~zulip_client ~storage msg content in 458 Log.info (fun m -> m "Claude response: %s" response); 459 Zulip_bot.Response.reply response 460 461let handle_clear_session ~storage msg = 462 let scope = Session.scope_of_message msg in 463 Session.clear storage ~scope; 464 Zulip_bot.Response.reply 465 (Printf.sprintf "Session cleared for %s. Starting fresh conversation." 466 (Session.scope_to_string scope)) 467 468let is_admin config ~storage msg = 469 let sender_id = Zulip_bot.Message.sender_id msg in 470 let client = Zulip_bot.Storage.client storage in 471 try 472 let user = Zulip.Users.get_by_id client ~user_id:sender_id () in 473 let delivery_email = Zulip.User.delivery_email user in 474 let email = Zulip.User.email user in 475 (* Check both delivery_email (actual email) and email (Zulip internal) *) 476 let emails_to_check = 477 match delivery_email with 478 | Some de -> [ de; email ] 479 | None -> [ email ] 480 in 481 List.exists (fun e -> List.mem e config.Config.admin_emails) emails_to_check 482 with _ -> 483 (* Fallback to sender_email from message if API call fails *) 484 let sender_email = Zulip_bot.Message.sender_email msg in 485 List.mem sender_email config.Config.admin_emails 486 487let make_handler env config = 488 fun ~storage ~identity ~flags msg -> 489 let bot_email = identity.Zulip_bot.Bot.email in 490 let sender_email = Zulip_bot.Message.sender_email msg in 491 (* Ignore messages from the bot itself *) 492 if sender_email = bot_email then Zulip_bot.Response.silent 493 else 494 let scope = Session.scope_of_message msg in 495 let is_mentioned = List.mem "mentioned" flags in 496 let is_private = Zulip_bot.Message.is_private msg in 497 498 (* Check if this is a message we should respond to *) 499 if is_mentioned || is_private then begin 500 (* Activate the session on first @mention or DM *) 501 Active_sessions.activate scope; 502 503 let client = Zulip_bot.Storage.client storage in 504 let content = 505 Zulip_bot.Message.content msg 506 |> strip_leading_mention 507 in 508 Log.info (fun m -> m "Received message (mentioned): %s" content); 509 match Commands.parse content with 510 | Commands.Help -> handle_help () 511 | Commands.Status -> handle_status env ~storage config 512 | Commands.Broadcast -> 513 Broadcast.run ~sw:env.sw ~proc:env.process_mgr ~clock:env.clock 514 ~fs:env.fs ~client ~storage ~config 515 | Commands.Refresh -> 516 handle_refresh env ~client ~storage ~config 517 | Commands.Admin cmd -> 518 if is_admin config ~storage msg then 519 Zulip_bot.Response.reply (Admin.handle ~storage cmd) 520 else 521 Zulip_bot.Response.reply "Admin commands require authorization. Contact an admin to be added to the admin_emails list." 522 | Commands.Clear_session -> 523 (* Also deactivate the in-memory session *) 524 Active_sessions.deactivate scope; 525 handle_clear_session ~storage msg 526 | Commands.Unknown _ -> handle_claude_query env ~zulip_client:client ~storage msg 527 end 528 else if Active_sessions.is_active scope then begin 529 (* Session is active but bot not mentioned - accumulate silently *) 530 Log.debug (fun m -> m "Accumulating message in active session for %s" 531 (Session.scope_to_string scope)); 532 accumulate_message_silently ~storage msg; 533 Zulip_bot.Response.silent 534 end 535 else begin 536 (* Session not active and not mentioned - ignore *) 537 Log.debug (fun m -> m "Ignoring message (session not active for %s)" 538 (Session.scope_to_string scope)); 539 Zulip_bot.Response.silent 540 end