My aggregated monorepo of OCaml code, automaintained
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

+125 -54
+1 -1
ocaml-zulip/README.md
··· 77 77 Eio.Switch.run @@ fun sw -> 78 78 let fs = Eio.Stdenv.fs env in 79 79 let config = Config.load ~fs "echo-bot" in 80 - Bot.run ~sw ~env ~config ~handler:echo_handler 80 + Bot.run ~sw ~env ~config ~handler:echo_handler () 81 81 ``` 82 82 83 83 ## License
+1 -1
ocaml-zulip/examples/atom_feed_bot.ml
··· 339 339 Log.info (fun m -> m "Feed bot is running! Use !feed help for commands."); 340 340 341 341 Zulip_bot.Bot.run ~sw ~env ~config 342 - ~handler:(Interactive_feed_bot.create_handler bot_state) 342 + ~handler:(Interactive_feed_bot.create_handler bot_state) () 343 343 344 344 (* Run scheduled fetcher mode *) 345 345 let run_scheduled verbosity env =
+1 -1
ocaml-zulip/examples/echo_bot.ml
··· 129 129 Log.app (fun m -> m "Commands: 'help', 'ping', or any message to echo"); 130 130 Log.app (fun m -> m "Press Ctrl+C to stop.\n"); 131 131 132 - try Bot.run ~sw ~env ~config ~handler:echo_handler with 132 + try Bot.run ~sw ~env ~config ~handler:echo_handler () with 133 133 | Sys.Break -> 134 134 Log.info (fun m -> m "Received interrupt signal, shutting down") 135 135 | exn ->
+1 -1
ocaml-zulip/examples/regression_test.ml
··· 330 330 Random.self_init (); 331 331 Eio.Switch.run @@ fun sw -> 332 332 let handler = make_handler ~env ~channel in 333 - Bot.run ~sw ~env ~config ~handler 333 + Bot.run ~sw ~env ~config ~handler () 334 334 335 335 open Cmdliner 336 336
+10 -4
ocaml-zulip/lib/zulip_bot/bot.ml
··· 61 61 m "Stream message sent (id: %d)" (Zulip.Message_response.id resp)) 62 62 | Response.Silent -> Log.debug (fun m -> m "Handler returned silent response") 63 63 64 - let process_event ~client ~storage ~identity ~handler event = 64 + let process_event ?(process_all_messages = false) ~client ~storage ~identity 65 + ~handler event = 65 66 Log.debug (fun m -> 66 67 m "Processing event type: %s" 67 68 (Zulip.Event_type.to_string (Zulip.Event.type_ event))); ··· 104 105 Log.debug (fun m -> 105 106 m "Message check: mentioned=%b, private=%b, from_self=%b" 106 107 is_mentioned is_private is_from_self); 107 - if (is_mentioned || is_private) && not is_from_self then ( 108 + let should_process = 109 + if process_all_messages then not is_from_self 110 + else (is_mentioned || is_private) && not is_from_self 111 + in 112 + if should_process then ( 108 113 Log.info (fun m -> m "Bot should respond to this message"); 109 114 try 110 115 let response = handler ~storage ~identity message in ··· 116 121 m "Not processing (not mentioned and not private)")) 117 122 | _ -> () 118 123 119 - let run ~sw ~env ~config ~handler = 124 + let run ~sw ~env ~config ~handler ?(process_all_messages = false) () = 120 125 Log.info (fun m -> m "Starting bot: %s" config.Config.name); 121 126 let client = create_client ~sw ~env ~config in 122 127 let identity = fetch_identity client in ··· 143 148 Log.debug (fun m -> 144 149 m "Event id=%d, type=%s" (Zulip.Event.id event) 145 150 (Zulip.Event_type.to_string (Zulip.Event.type_ event))); 146 - process_event ~client ~storage ~identity ~handler event) 151 + process_event ~process_all_messages ~client ~storage ~identity 152 + ~handler event) 147 153 events; 148 154 let new_last_id = 149 155 List.fold_left
+14 -5
ocaml-zulip/lib/zulip_bot/bot.mli
··· 19 19 Eio.Switch.run @@ fun sw -> 20 20 let fs = Eio.Stdenv.fs env in 21 21 let config = Config.load ~fs "echo-bot" in 22 - Bot.run ~sw ~env ~config ~handler:echo_handler 22 + Bot.run ~sw ~env ~config ~handler:echo_handler () 23 23 ]} 24 24 25 25 {b Example: Multiple bots} ··· 34 34 (fun () -> 35 35 Bot.run ~sw ~env 36 36 ~config:(Config.load ~fs "echo-bot") 37 - ~handler:echo_handler); 37 + ~handler:echo_handler ()); 38 38 (fun () -> 39 39 Bot.run ~sw ~env 40 40 ~config:(Config.load ~fs "help-bot") 41 - ~handler:help_handler); 41 + ~handler:help_handler ()); 42 42 ] 43 43 ]} *) 44 44 ··· 72 72 ; .. > -> 73 73 config:Config.t -> 74 74 handler:handler -> 75 + ?process_all_messages:bool -> 76 + unit -> 75 77 unit 76 - (** [run ~sw ~env ~config ~handler] runs a bot as a fiber. 78 + (** [run ~sw ~env ~config ~handler ()] runs a bot as a fiber. 77 79 78 80 The bot connects to Zulip's real-time events API and processes incoming 79 81 messages. It runs until the switch is cancelled. ··· 88 90 @param sw Eio switch controlling the bot's lifetime 89 91 @param env Eio environment with clock, net, and fs capabilities 90 92 @param config Bot configuration (credentials and metadata) 91 - @param handler Function to process incoming messages *) 93 + @param handler Function to process incoming messages 94 + @param process_all_messages If true, pass all messages to handler (not just 95 + mentions and DMs). Handler can return [Response.Silent] to not respond. 96 + Default is [false]. *) 92 97 93 98 (** {1 Webhook Mode} *) 94 99 ··· 144 149 @raise Eio.Io on API errors *) 145 150 146 151 val process_event : 152 + ?process_all_messages:bool -> 147 153 client:Zulip.Client.t -> 148 154 storage:Storage.t -> 149 155 identity:identity -> ··· 152 158 unit 153 159 (** [process_event ~client ~storage ~identity ~handler event] processes a single 154 160 Zulip event. 161 + 162 + @param process_all_messages If true, pass all messages to handler (not just 163 + mentions and DMs). Default is [false]. 155 164 156 165 This is useful for custom event loops that need finer control over event 157 166 processing than [run] provides. *)
+1 -1
ocaml-zulip/lib/zulip_bot/cmd.mli
··· 24 24 let () = 25 25 Eio_main.run @@ fun env -> 26 26 Eio.Switch.run @@ fun sw -> 27 - let run config = Bot.run ~sw ~env ~config ~handler:my_handler in 27 + let run config = Bot.run ~sw ~env ~config ~handler:my_handler () in 28 28 let cmd = Cmd.v info Term.(const run $ Zulip_bot.Cmd.config_term "mybot" env) in 29 29 Cmdliner.Cmd.eval cmd 30 30 ]} *)
+3
poe/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
poe/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
poe/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
poe/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. *)