A Zulip bot agent to sit in our Black Sun. Ever evolving

Add multi-turn session tracking with silent context accumulation

This change enables Poe to maintain parallel sessions across channels/DMs
and accumulate conversation context from all participants:

**Session Activation:**
- Sessions activate on first @mention in a channel or DM
- Once active, ALL messages (except from the bot) are accumulated into
the session context, even without @mention
- Bot only responds when explicitly @mentioned, but has full context
- Sessions reset on bot restart (requires new @mention to reactivate)
- Sessions only clear via explicit `clear` command (no timeout expiry)

**zulip_bot Library Changes:**
- Added `?process_all_messages:bool` parameter to `Bot.run`
- When true, handler receives all messages (not just mentions/DMs)
- Handler can return `Response.Silent` to not respond
- Breaking change: `Bot.run` now requires `()` at the end

**Poe Handler Changes:**
- In-memory `Active_sessions` module tracks activated scopes
- `accumulate_message_silently` adds messages to context without Claude
- Messages from other users annotated with sender name for context
- `clear` command now also deactivates the in-memory session

Co-Authored-By: Claude (claude-opus-4-5) <noreply@anthropic.com>

+96 -40
+3
bin/main.ml
··· 60 60 (* Create and run the bot *) 61 61 let handler = Poe.Handler.make_handler handler_env poe_config in 62 62 Logs.info (fun m -> m "Starting Poe bot..."); 63 + (* process_all_messages:true allows the bot to accumulate context from all 64 + messages in active sessions, not just @mentions *) 63 65 Zulip_bot.Bot.run ~sw ~env ~config:zulip_config ~handler 66 + ~process_all_messages:true () 64 67 65 68 let requests_verbose_arg = 66 69 let open Cmdliner in
+87 -21
lib/handler.ml
··· 14 14 fs : Eio.Fs.dir_ty Eio.Path.t; 15 15 } 16 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). *) 21 + module Active_sessions = struct 22 + let sessions : (string, unit) Hashtbl.t = Hashtbl.create 16 23 + 24 + let activate scope = 25 + let key = Session.scope_to_string scope in 26 + if not (Hashtbl.mem sessions key) then begin 27 + Hashtbl.add sessions key (); 28 + Log.info (fun m -> m "Session activated for %s" key) 29 + end 30 + 31 + let is_active scope = 32 + let key = Session.scope_to_string scope in 33 + Hashtbl.mem sessions key 34 + 35 + let deactivate scope = 36 + let key = Session.scope_to_string scope in 37 + Hashtbl.remove sessions key; 38 + Log.info (fun m -> m "Session deactivated for %s" key) 39 + end 40 + 17 41 let run_git_pull ~proc ~cwd = 18 42 Log.info (fun m -> m "Pulling latest changes from remote"); 19 43 Eio.Switch.run @@ fun sw -> ··· 230 254 (Session.scope_to_string scope) (Session.stats session)); 231 255 response 232 256 257 + (** Silently accumulate a message into the session without calling Claude. 258 + Used when the bot is not @mentioned but the session is active. *) 259 + let accumulate_message_silently ~storage msg = 260 + let scope = Session.scope_of_message msg in 261 + let now = Unix.gettimeofday () in 262 + let session = Session.load storage ~scope ~now in 263 + let content = Zulip_bot.Message.content msg in 264 + let sender = Zulip_bot.Message.sender_full_name msg in 265 + (* Include sender name in the accumulated content for context *) 266 + let annotated_content = Printf.sprintf "[%s]: %s" sender content in 267 + let session = Session.add_user_message session ~content:annotated_content ~now in 268 + Session.save storage ~scope session; 269 + Log.debug (fun m -> m "Accumulated message from %s into session for %s" 270 + sender (Session.scope_to_string scope)) 271 + 233 272 let handle_help () = 234 273 Zulip_bot.Response.reply 235 274 {|**Poe Bot Commands:** ··· 407 446 fun ~storage ~identity msg -> 408 447 let bot_email = identity.Zulip_bot.Bot.email in 409 448 let sender_email = Zulip_bot.Message.sender_email msg in 449 + (* Ignore messages from the bot itself *) 410 450 if sender_email = bot_email then Zulip_bot.Response.silent 411 451 else 412 - let client = Zulip_bot.Storage.client storage in 413 - let content = 414 - Zulip_bot.Message.strip_mention msg ~user_email:bot_email 415 - |> String.trim 416 - in 417 - Log.info (fun m -> m "Received message: %s" content); 418 - match Commands.parse content with 419 - | Commands.Help -> handle_help () 420 - | Commands.Status -> handle_status env config 421 - | Commands.Broadcast -> 422 - Broadcast.run ~sw:env.sw ~proc:env.process_mgr ~clock:env.clock 423 - ~fs:env.fs ~client ~storage ~config 424 - | Commands.Refresh -> 425 - handle_refresh env ~client ~storage ~config 426 - | Commands.Admin cmd -> 427 - if is_admin config ~storage msg then 428 - Zulip_bot.Response.reply (Admin.handle ~storage cmd) 429 - else 430 - Zulip_bot.Response.reply "Admin commands require authorization. Contact an admin to be added to the admin_emails list." 431 - | Commands.Clear_session -> handle_clear_session ~storage msg 432 - | Commands.Unknown _ -> handle_claude_query env ~zulip_client:client ~storage msg 452 + let scope = Session.scope_of_message msg in 453 + let is_mentioned = Zulip_bot.Message.is_mentioned msg ~user_email:bot_email in 454 + let is_private = Zulip_bot.Message.is_private msg in 455 + 456 + (* Check if this is a message we should respond to *) 457 + if is_mentioned || is_private then begin 458 + (* Activate the session on first @mention or DM *) 459 + Active_sessions.activate scope; 460 + 461 + let client = Zulip_bot.Storage.client storage in 462 + let content = 463 + Zulip_bot.Message.strip_mention msg ~user_email:bot_email 464 + |> String.trim 465 + in 466 + Log.info (fun m -> m "Received message (mentioned): %s" content); 467 + match Commands.parse content with 468 + | Commands.Help -> handle_help () 469 + | Commands.Status -> handle_status env config 470 + | Commands.Broadcast -> 471 + Broadcast.run ~sw:env.sw ~proc:env.process_mgr ~clock:env.clock 472 + ~fs:env.fs ~client ~storage ~config 473 + | Commands.Refresh -> 474 + handle_refresh env ~client ~storage ~config 475 + | Commands.Admin cmd -> 476 + if is_admin config ~storage msg then 477 + Zulip_bot.Response.reply (Admin.handle ~storage cmd) 478 + else 479 + Zulip_bot.Response.reply "Admin commands require authorization. Contact an admin to be added to the admin_emails list." 480 + | Commands.Clear_session -> 481 + (* Also deactivate the in-memory session *) 482 + Active_sessions.deactivate scope; 483 + handle_clear_session ~storage msg 484 + | Commands.Unknown _ -> handle_claude_query env ~zulip_client:client ~storage msg 485 + end 486 + else if Active_sessions.is_active scope then begin 487 + (* Session is active but bot not mentioned - accumulate silently *) 488 + Log.debug (fun m -> m "Accumulating message in active session for %s" 489 + (Session.scope_to_string scope)); 490 + accumulate_message_silently ~storage msg; 491 + Zulip_bot.Response.silent 492 + end 493 + else begin 494 + (* Session not active and not mentioned - ignore *) 495 + Log.debug (fun m -> m "Ignoring message (session not active for %s)" 496 + (Session.scope_to_string scope)); 497 + Zulip_bot.Response.silent 498 + end
+6 -16
lib/session.ml
··· 64 64 let max_turns = 20 65 65 (** Maximum turns to keep in a session for context window management *) 66 66 67 - let max_age_seconds = 3600.0 68 - (** Sessions expire after 1 hour of inactivity *) 67 + (* Sessions no longer expire automatically - only cleared via explicit command *) 69 68 70 69 (** Extract session scope from a Zulip bot message *) 71 70 let scope_of_message (msg : Zulip_bot.Message.t) : scope = ··· 96 95 (Jsont.Error.to_string err)); 97 96 empty ~now 98 97 | Ok session -> 99 - (* Check if session has expired *) 100 - let age = now -. session.updated_at in 101 - if age > max_age_seconds then begin 102 - Log.info (fun m -> 103 - m "Session for %s expired (%.0fs old)" (scope_to_string scope) 104 - age); 105 - empty ~now 106 - end 107 - else begin 108 - Log.debug (fun m -> 109 - m "Loaded session for %s with %d turns" (scope_to_string scope) 110 - (List.length session.turns)); 111 - session 112 - end) 98 + (* Sessions no longer expire - only cleared via explicit command *) 99 + Log.debug (fun m -> 100 + m "Loaded session for %s with %d turns" (scope_to_string scope) 101 + (List.length session.turns)); 102 + session) 113 103 114 104 (** Save session to storage *) 115 105 let save storage ~scope session =
-3
lib/session.mli
··· 34 34 val max_turns : int 35 35 (** Maximum turns to keep in a session for context window management. *) 36 36 37 - val max_age_seconds : float 38 - (** Sessions expire after this many seconds of inactivity. *) 39 - 40 37 val scope_of_message : Zulip_bot.Message.t -> scope 41 38 (** [scope_of_message msg] extracts the session scope from a Zulip message. 42 39 Channel messages use stream+topic as scope, DMs use sender email. *)