A Zulip bot agent to sit in our Black Sun. Ever evolving
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