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

Configure Feed

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

Add per-channel and per-DM session tracking for multi-turn Claude conversations

Poe now maintains independent conversation sessions for each Zulip channel
topic and DM thread. This enables natural multi-turn conversations with
Claude where context is preserved within each scope.

Key changes:
- New Session module for managing conversation history in Zulip bot storage
- Sessions track up to 20 turns and expire after 1 hour of inactivity
- Added 'clear'/'new'/'reset' commands to start fresh conversations
- Updated handler to include session context in Claude prompts
- Updated help text to explain session behavior

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

+305 -14
+2
lib/commands.ml
··· 15 15 | Status 16 16 | Broadcast 17 17 | Refresh 18 + | Clear_session 18 19 | Admin of admin_command 19 20 | Unknown of string 20 21 ··· 41 42 | "status" -> Status 42 43 | "broadcast" | "post changes" | "post" | "changes" -> Broadcast 43 44 | "refresh" | "pull" | "sync" | "update" -> Refresh 45 + | "clear" | "new" | "reset" | "clear session" | "new session" -> Clear_session 44 46 | _ -> 45 47 if String.starts_with ~prefix:"admin " content then 46 48 let args = String.sub content 6 (String.length content - 6) in
+1
lib/commands.mli
··· 22 22 | Status (** Show bot configuration status *) 23 23 | Broadcast (** Broadcast new changes *) 24 24 | Refresh (** Pull from remote, regenerate changes, and broadcast *) 25 + | Clear_session (** Clear conversation session for this channel/DM *) 25 26 | Admin of admin_command (** Admin commands (require authorization) *) 26 27 | Unknown of string (** Unrecognized command - pass to Claude *) 27 28
+52 -14
lib/handler.ml
··· 82 82 in 83 83 String.concat "" text 84 84 85 + let ask_claude_with_session env ~storage msg user_content = 86 + let scope = Session.scope_of_message msg in 87 + let now = Unix.gettimeofday () in 88 + let session = Session.load storage ~scope ~now in 89 + let session = Session.add_user_message session ~content:user_content ~now in 90 + 91 + (* Build prompt with session context *) 92 + let context_section = match Session.build_context session with 93 + | None -> "" 94 + | Some ctx -> ctx ^ "\n\n---\n\n" 95 + in 96 + let prompt = 97 + Printf.sprintf 98 + {|%sThe user sent this message to the Poe Zulip bot: 99 + 100 + %s 101 + 102 + Please help them. If they're asking about adding features to the bot, read the bot's source code in the poe/ directory first. 103 + If they're asking about the monorepo or daily changes, help them understand the content. 104 + Keep your response concise and suitable for a Zulip message.|} 105 + context_section user_content 106 + in 107 + 108 + let response = ask_claude env prompt in 109 + 110 + (* Save the updated session with the response *) 111 + let now = Unix.gettimeofday () in 112 + let session = Session.add_assistant_message session ~content:response ~now in 113 + Session.save storage ~scope session; 114 + 115 + Log.info (fun m -> m "Session for %s: %s" 116 + (Session.scope_to_string scope) (Session.stats session)); 117 + response 118 + 85 119 let handle_help () = 86 120 Zulip_bot.Response.reply 87 121 {|**Poe Bot Commands:** ··· 91 125 - `status` - Show bot configuration and tracked verse users with repo links 92 126 - `broadcast` / `post` / `changes` - Generate and broadcast changelog with Claude 93 127 - `refresh` / `pull` / `sync` / `update` - Pull from remote and broadcast changes 128 + - `clear` / `new` / `reset` - Clear conversation session and start fresh 94 129 95 130 **Admin Commands:** (require authorization) 96 131 - `admin last-broadcast` - Show last broadcast time and git HEAD ··· 98 133 - `admin storage keys` - List all storage keys 99 134 - `admin storage get <key>` - Get value for a storage key 100 135 - `admin storage delete <key>` - Delete a storage key 136 + 137 + **Conversation Sessions:** 138 + Poe maintains separate conversation sessions for each channel topic and DM. 139 + Your conversation history is preserved within each context, allowing multi-turn 140 + conversations with Claude. Sessions expire after 1 hour of inactivity. 141 + Use `clear` to start a fresh conversation in the current context. 101 142 102 143 **Other Messages:** 103 144 Any other message will be interpreted by Claude to help you understand or modify the bot. ··· 215 256 ~content:(Printf.sprintf "**Refresh triggered manually**\n\n%s" content) 216 257 end 217 258 218 - let handle_claude_query env msg = 259 + let handle_claude_query env ~storage msg = 219 260 let content = Zulip_bot.Message.content msg in 220 261 Log.info (fun m -> m "Asking Claude: %s" content); 221 - let prompt = 222 - Printf.sprintf 223 - {|The user sent this message to the Poe Zulip bot: 224 - 225 - %s 226 - 227 - Please help them. If they're asking about adding features to the bot, read the bot's source code in the poe/ directory first. 228 - If they're asking about the monorepo or daily changes, help them understand the content. 229 - Keep your response concise and suitable for a Zulip message.|} 230 - content 231 - in 232 - let response = ask_claude env prompt in 262 + let response = ask_claude_with_session env ~storage msg content in 233 263 Log.info (fun m -> m "Claude response: %s" response); 234 264 Zulip_bot.Response.reply response 265 + 266 + let handle_clear_session ~storage msg = 267 + let scope = Session.scope_of_message msg in 268 + Session.clear storage ~scope; 269 + Zulip_bot.Response.reply 270 + (Printf.sprintf "Session cleared for %s. Starting fresh conversation." 271 + (Session.scope_to_string scope)) 235 272 236 273 let is_admin config ~storage msg = 237 274 let sender_id = Zulip_bot.Message.sender_id msg in ··· 277 314 Zulip_bot.Response.reply (Admin.handle ~storage cmd) 278 315 else 279 316 Zulip_bot.Response.reply "Admin commands require authorization. Contact an admin to be added to the admin_emails list." 280 - | Commands.Unknown _ -> handle_claude_query env msg 317 + | Commands.Clear_session -> handle_clear_session ~storage msg 318 + | Commands.Unknown _ -> handle_claude_query env ~storage msg
+1
lib/poe.ml
··· 12 12 module Changelog = Changelog 13 13 module Loop = Loop 14 14 module Handler = Handler 15 + module Session = Session
+184
lib/session.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Per-channel and per-DM Claude session management. 7 + 8 + Sessions maintain conversation context independently for each Zulip 9 + channel or DM thread, enabling multi-turn conversations with Claude. *) 10 + 11 + let src = Logs.Src.create "poe.session" ~doc:"Poe session management" 12 + 13 + module Log = (val Logs.src_log src : Logs.LOG) 14 + 15 + (** A conversation turn in a session *) 16 + type role = User | Assistant 17 + 18 + type turn = { role : role; content : string; timestamp : float } 19 + 20 + let role_of_string = function 21 + | "user" -> User 22 + | "assistant" -> Assistant 23 + | _ -> User 24 + 25 + let role_to_string = function 26 + | User -> "user" 27 + | Assistant -> "assistant" 28 + 29 + let turn_jsont : turn Jsont.t = 30 + Jsont.Object.map ~kind:"Turn" (fun role_str content timestamp -> 31 + { role = role_of_string role_str; content; timestamp }) 32 + |> Jsont.Object.mem "role" Jsont.string ~enc:(fun t -> role_to_string t.role) 33 + |> Jsont.Object.mem "content" Jsont.string ~enc:(fun t -> t.content) 34 + |> Jsont.Object.mem "timestamp" Jsont.number ~enc:(fun t -> t.timestamp) 35 + |> Jsont.Object.finish 36 + 37 + (** Session scope - either a channel+topic or a DM with a user *) 38 + type scope = 39 + | Channel of { stream : string; topic : string } 40 + | Direct of { user_email : string } 41 + 42 + let scope_to_key = function 43 + | Channel { stream; topic } -> 44 + Printf.sprintf "poe:session:channel:%s:%s" stream topic 45 + | Direct { user_email } -> Printf.sprintf "poe:session:dm:%s" user_email 46 + 47 + let scope_to_string = function 48 + | Channel { stream; topic } -> Printf.sprintf "channel %s > %s" stream topic 49 + | Direct { user_email } -> Printf.sprintf "DM with %s" user_email 50 + 51 + (** Session data stored in Zulip bot storage *) 52 + type t = { turns : turn list; created_at : float; updated_at : float } 53 + 54 + let session_jsont : t Jsont.t = 55 + Jsont.Object.map ~kind:"Session" (fun turns created_at updated_at -> 56 + { turns; created_at; updated_at }) 57 + |> Jsont.Object.mem "turns" (Jsont.list turn_jsont) ~enc:(fun t -> t.turns) 58 + |> Jsont.Object.mem "created_at" Jsont.number ~enc:(fun t -> t.created_at) 59 + |> Jsont.Object.mem "updated_at" Jsont.number ~enc:(fun t -> t.updated_at) 60 + |> Jsont.Object.finish 61 + 62 + let empty ~now = { turns = []; created_at = now; updated_at = now } 63 + 64 + let max_turns = 20 65 + (** Maximum turns to keep in a session for context window management *) 66 + 67 + let max_age_seconds = 3600.0 68 + (** Sessions expire after 1 hour of inactivity *) 69 + 70 + (** Extract session scope from a Zulip bot message *) 71 + let scope_of_message (msg : Zulip_bot.Message.t) : scope = 72 + match msg with 73 + | Zulip_bot.Message.Private { common; _ } -> 74 + Direct { user_email = common.sender_email } 75 + | Zulip_bot.Message.Stream { common = _; display_recipient; subject; _ } -> 76 + Channel { stream = display_recipient; topic = subject } 77 + | Zulip_bot.Message.Unknown { common; _ } -> 78 + (* Fall back to treating as DM *) 79 + Direct { user_email = common.sender_email } 80 + 81 + (** Load session from storage *) 82 + let load storage ~scope ~now : t = 83 + let key = scope_to_key scope in 84 + match Zulip_bot.Storage.get storage key with 85 + | None -> 86 + Log.debug (fun m -> m "No existing session for %s" (scope_to_string scope)); 87 + empty ~now 88 + | Some "" -> 89 + Log.debug (fun m -> m "Empty session for %s" (scope_to_string scope)); 90 + empty ~now 91 + | Some json_str -> ( 92 + match Jsont_bytesrw.decode_string' session_jsont json_str with 93 + | Error err -> 94 + Log.warn (fun m -> 95 + m "Failed to parse session for %s: %s" (scope_to_string scope) 96 + (Jsont.Error.to_string err)); 97 + empty ~now 98 + | 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) 113 + 114 + (** Save session to storage *) 115 + let save storage ~scope session = 116 + let key = scope_to_key scope in 117 + match Jsont_bytesrw.encode_string' session_jsont session with 118 + | Error err -> 119 + Log.err (fun m -> 120 + m "Failed to encode session: %s" (Jsont.Error.to_string err)) 121 + | Ok json_str -> 122 + Zulip_bot.Storage.set storage key json_str; 123 + Log.debug (fun m -> 124 + m "Saved session for %s with %d turns" (scope_to_string scope) 125 + (List.length session.turns)) 126 + 127 + (** Add a user message to the session *) 128 + let add_user_message session ~content ~now = 129 + let turn = { role = User; content; timestamp = now } in 130 + let turns = session.turns @ [ turn ] in 131 + (* Trim to max_turns, keeping most recent *) 132 + let turns = 133 + if List.length turns > max_turns then 134 + let drop = List.length turns - max_turns in 135 + List.filteri (fun i _ -> i >= drop) turns 136 + else turns 137 + in 138 + { session with turns; updated_at = now } 139 + 140 + (** Add an assistant response to the session *) 141 + let add_assistant_message session ~content ~now = 142 + let turn = { role = Assistant; content; timestamp = now } in 143 + let turns = session.turns @ [ turn ] in 144 + let turns = 145 + if List.length turns > max_turns then 146 + let drop = List.length turns - max_turns in 147 + List.filteri (fun i _ -> i >= drop) turns 148 + else turns 149 + in 150 + { session with turns; updated_at = now } 151 + 152 + (** Clear a session *) 153 + let clear storage ~scope = 154 + let key = scope_to_key scope in 155 + Zulip_bot.Storage.remove storage key; 156 + Log.info (fun m -> m "Cleared session for %s" (scope_to_string scope)) 157 + 158 + (** Build conversation context for Claude from session history *) 159 + let build_context session = 160 + if session.turns = [] then None 161 + else 162 + let history = 163 + session.turns 164 + |> List.map (fun turn -> 165 + let role_str = 166 + match turn.role with User -> "User" | Assistant -> "Assistant" 167 + in 168 + Printf.sprintf "%s: %s" role_str turn.content) 169 + |> String.concat "\n\n" 170 + in 171 + Some 172 + (Printf.sprintf 173 + {|Previous conversation in this session: 174 + 175 + %s 176 + 177 + Continue the conversation naturally, taking into account the context above.|} 178 + history) 179 + 180 + (** Get session statistics *) 181 + let stats session = 182 + Printf.sprintf "%d turns, last updated %.0fs ago" 183 + (List.length session.turns) 184 + (Unix.gettimeofday () -. session.updated_at)
+65
lib/session.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Per-channel and per-DM Claude session management. 7 + 8 + Sessions maintain conversation context independently for each Zulip 9 + channel or DM thread, enabling multi-turn conversations with Claude. *) 10 + 11 + val src : Logs.Src.t 12 + (** Log source for session management. *) 13 + 14 + (** Role in a conversation turn *) 15 + type role = User | Assistant 16 + 17 + (** A conversation turn in a session *) 18 + type turn = { role : role; content : string; timestamp : float } 19 + 20 + (** Session scope - either a channel+topic or a DM with a user *) 21 + type scope = 22 + | Channel of { stream : string; topic : string } 23 + | Direct of { user_email : string } 24 + 25 + val scope_to_string : scope -> string 26 + (** [scope_to_string scope] returns a human-readable description of the scope. *) 27 + 28 + (** Session data *) 29 + type t 30 + 31 + val empty : now:float -> t 32 + (** [empty ~now] creates an empty session. *) 33 + 34 + val max_turns : int 35 + (** Maximum turns to keep in a session for context window management. *) 36 + 37 + val max_age_seconds : float 38 + (** Sessions expire after this many seconds of inactivity. *) 39 + 40 + val scope_of_message : Zulip_bot.Message.t -> scope 41 + (** [scope_of_message msg] extracts the session scope from a Zulip message. 42 + Channel messages use stream+topic as scope, DMs use sender email. *) 43 + 44 + val load : Zulip_bot.Storage.t -> scope:scope -> now:float -> t 45 + (** [load storage ~scope ~now] loads a session from storage. 46 + Returns an empty session if none exists or if the session has expired. *) 47 + 48 + val save : Zulip_bot.Storage.t -> scope:scope -> t -> unit 49 + (** [save storage ~scope session] persists the session to storage. *) 50 + 51 + val add_user_message : t -> content:string -> now:float -> t 52 + (** [add_user_message session ~content ~now] adds a user message to the session. *) 53 + 54 + val add_assistant_message : t -> content:string -> now:float -> t 55 + (** [add_assistant_message session ~content ~now] adds an assistant response. *) 56 + 57 + val clear : Zulip_bot.Storage.t -> scope:scope -> unit 58 + (** [clear storage ~scope] removes the session from storage. *) 59 + 60 + val build_context : t -> string option 61 + (** [build_context session] builds a context string from the session history 62 + for inclusion in Claude prompts. Returns [None] if the session is empty. *) 63 + 64 + val stats : t -> string 65 + (** [stats session] returns human-readable session statistics. *)