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 changes broadcast system with monopam_changes library and poe enhancements

This commit implements a comprehensive changes broadcast system:

**monopam_changes library** (new package):
- Aggregated module: Types and JSON codecs for daily changes format
- Query module: Functions to load changes since timestamp, format for Zulip
- Supports .changes/YYYYMMDD.json aggregated format

**monopam enhancements**:
- Added --aggregate flag to `monopam changes --daily` command
- Generates structured JSON files for broadcast system
- Added generate_aggregated function to Changes module
- Added rev_parse to Git module

**poe bot refactoring**:
- Commands module: Deterministic command parsing (help, status, broadcast, admin)
- Admin module: Storage management for broadcast state (last_time, git_head)
- Broadcast module: Smart broadcasting that only sends NEW changes
- Loop module: Hourly polling loop for automated change detection
- Config: Added admin_emails and changes_dir fields
- Handler: Updated to use command parser, delegate to modules

New poe commands:
- `poe loop --interval SECONDS` - Automated polling and broadcasting
- Admin commands: last-broadcast, reset-broadcast, storage keys/get/delete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+561 -30
+60 -2
bin/main.ml
··· 128 128 const broadcast $ Fmt_cli.style_renderer () $ Logs_cli.level () 129 129 $ config_file $ bot_name) 130 130 131 + let loop_cmd = 132 + let open Cmdliner in 133 + let config_file = 134 + Arg.( 135 + value 136 + & opt (some string) None 137 + & info [ "c"; "config" ] ~docv:"FILE" 138 + ~doc:"Path to poe.toml configuration file.") 139 + in 140 + let bot_name = 141 + Arg.( 142 + value 143 + & opt string "poe" 144 + & info [ "n"; "name" ] ~docv:"NAME" 145 + ~doc:"Bot name for Zulip configuration lookup.") 146 + in 147 + let interval = 148 + Arg.( 149 + value 150 + & opt int 3600 151 + & info [ "i"; "interval" ] ~docv:"SECONDS" 152 + ~doc:"Interval in seconds between change checks (default: 3600).") 153 + in 154 + let loop style_renderer level config_file bot_name interval = 155 + setup_logging style_renderer level; 156 + Eio_main.run @@ fun env -> 157 + Eio.Switch.run @@ fun sw -> 158 + let fs = Eio.Stdenv.fs env in 159 + 160 + (* Load poe config: explicit path > XDG > current dir > defaults *) 161 + let poe_config = 162 + match config_file with 163 + | Some path -> Poe.Config.load ~fs path 164 + | None -> ( 165 + match Poe.Config.load_xdg_opt ~fs with 166 + | Some c -> c 167 + | None -> ( 168 + match Poe.Config.load_opt ~fs "poe.toml" with 169 + | Some c -> c 170 + | None -> Poe.Config.default)) 171 + in 172 + 173 + let zulip_config = Zulip_bot.Config.load_or_env ~xdg_app:"poe" ~fs bot_name in 174 + Logs.info (fun m -> 175 + m "Starting loop, broadcasting to %s/%s every %d seconds" 176 + poe_config.channel poe_config.topic interval); 177 + Poe.Loop.run ~sw ~env ~config:poe_config ~zulip_config ~interval 178 + in 179 + let doc = "Run polling loop to check for and broadcast changes" in 180 + let info = Cmd.info "loop" ~doc in 181 + Cmd.v info 182 + Term.( 183 + const loop $ Fmt_cli.style_renderer () $ Logs_cli.level () 184 + $ config_file $ bot_name $ interval) 185 + 131 186 let main_cmd = 132 187 let open Cmdliner in 133 188 let doc = "Poe - Zulip bot for monorepo changes with Claude integration" in ··· 143 198 `S Manpage.s_commands; 144 199 `P "$(b,run) - Run the bot as a long-running service"; 145 200 `P "$(b,broadcast) - Send daily changes once and exit"; 201 + `P "$(b,loop) - Run polling loop to check for and broadcast changes"; 146 202 `S "CONFIGURATION"; 147 203 `P 148 204 "Poe configuration is searched in order:"; ··· 155 211 "channel = \"general\" # Zulip channel to broadcast to\n\ 156 212 topic = \"Daily Changes\" # Topic for broadcasts\n\ 157 213 changes_file = \"DAILY-CHANGES.md\"\n\ 158 - monorepo_path = \".\""; 214 + monorepo_path = \".\"\n\ 215 + changes_dir = \".changes\"\n\ 216 + admin_emails = [\"admin@example.com\"]"; 159 217 `P 160 218 "Zulip credentials are loaded from \ 161 219 $(b,~/.config/poe/zulip.config) or environment variables."; 162 220 ] 163 221 in 164 - Cmd.group info [ run_cmd; broadcast_cmd ] 222 + Cmd.group info [ run_cmd; broadcast_cmd; loop_cmd ] 165 223 166 224 let () = 167 225 Fmt_tty.setup_std_outputs ();
+71
lib/admin.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + let last_broadcast_key = "poe:broadcast:last_time" 7 + let last_git_head_key = "poe:broadcast:last_git_head" 8 + 9 + let get_last_broadcast_time storage = 10 + match Zulip_bot.Storage.get storage last_broadcast_key with 11 + | None -> None 12 + | Some s when s = "" -> None 13 + | Some s -> 14 + match Ptime.of_rfc3339 s with 15 + | Ok (t, _, _) -> Some t 16 + | Error _ -> None 17 + 18 + let set_last_broadcast_time storage time = 19 + let timestamp = Ptime.to_rfc3339 ~tz_offset_s:0 time in 20 + Zulip_bot.Storage.set storage last_broadcast_key timestamp 21 + 22 + let get_last_git_head storage = 23 + match Zulip_bot.Storage.get storage last_git_head_key with 24 + | None -> None 25 + | Some s when s = "" -> None 26 + | Some s -> Some s 27 + 28 + let set_last_git_head storage hash = 29 + Zulip_bot.Storage.set storage last_git_head_key hash 30 + 31 + let format_time_option = function 32 + | None -> "never" 33 + | Some t -> Ptime.to_rfc3339 ~tz_offset_s:0 t 34 + 35 + let handle ~storage cmd = 36 + match cmd with 37 + | Commands.Last_broadcast -> 38 + let time = get_last_broadcast_time storage in 39 + let head = get_last_git_head storage in 40 + Printf.sprintf "**Last Broadcast**\n- Time: `%s`\n- Git HEAD: `%s`" 41 + (format_time_option time) 42 + (Option.value ~default:"unknown" head) 43 + 44 + | Commands.Reset_broadcast timestamp -> 45 + (match Ptime.of_rfc3339 timestamp with 46 + | Ok (t, _, _) -> 47 + set_last_broadcast_time storage t; 48 + Printf.sprintf "Broadcast time reset to: `%s`" 49 + (Ptime.to_rfc3339 ~tz_offset_s:0 t) 50 + | Error _ -> 51 + Printf.sprintf "Invalid timestamp format: `%s`. Use ISO 8601 format (e.g., 2026-01-15T10:30:00Z)." 52 + timestamp) 53 + 54 + | Commands.Storage_keys -> 55 + let keys = Zulip_bot.Storage.keys storage in 56 + if keys = [] then 57 + "No storage keys found." 58 + else 59 + "**Storage Keys:**\n" ^ 60 + String.concat "\n" (List.map (fun k -> "- `" ^ k ^ "`") keys) 61 + 62 + | Commands.Storage_get key -> 63 + (match Zulip_bot.Storage.get storage key with 64 + | None -> Printf.sprintf "Key `%s` not found." key 65 + | Some "" -> Printf.sprintf "Key `%s` is empty." key 66 + | Some value -> 67 + Printf.sprintf "**%s:**\n```\n%s\n```" key value) 68 + 69 + | Commands.Storage_delete key -> 70 + Zulip_bot.Storage.remove storage key; 71 + Printf.sprintf "Deleted key: `%s`" key
+38
lib/admin.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Admin command handlers for Poe bot. 7 + 8 + This module provides handlers for admin commands and storage helpers 9 + for managing broadcast state via Zulip bot storage. *) 10 + 11 + (** {1 Storage Keys} *) 12 + 13 + val last_broadcast_key : string 14 + (** Key for storing the last broadcast timestamp: ["poe:broadcast:last_time"] *) 15 + 16 + val last_git_head_key : string 17 + (** Key for storing the last git head: ["poe:broadcast:last_git_head"] *) 18 + 19 + (** {1 Storage Access} *) 20 + 21 + val get_last_broadcast_time : Zulip_bot.Storage.t -> Ptime.t option 22 + (** [get_last_broadcast_time storage] retrieves the last broadcast timestamp 23 + from Zulip bot storage. Returns [None] if not set or invalid. *) 24 + 25 + val set_last_broadcast_time : Zulip_bot.Storage.t -> Ptime.t -> unit 26 + (** [set_last_broadcast_time storage time] stores the broadcast timestamp 27 + in Zulip bot storage. *) 28 + 29 + val get_last_git_head : Zulip_bot.Storage.t -> string option 30 + (** [get_last_git_head storage] retrieves the last seen git HEAD from storage. *) 31 + 32 + val set_last_git_head : Zulip_bot.Storage.t -> string -> unit 33 + (** [set_last_git_head storage hash] stores the git HEAD in storage. *) 34 + 35 + (** {1 Command Handlers} *) 36 + 37 + val handle : storage:Zulip_bot.Storage.t -> Commands.admin_command -> string 38 + (** [handle ~storage cmd] executes an admin command and returns the response. *)
+70
lib/broadcast.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + let src = Logs.Src.create "poe.broadcast" ~doc:"Poe broadcast logic" 7 + module Log = (val Logs.src_log src : Logs.LOG) 8 + 9 + let run ~fs ~storage ~config = 10 + let changes_dir = Fpath.v (config.Config.monorepo_path ^ "/" ^ config.Config.changes_dir) in 11 + 12 + (* Get last broadcast time from storage *) 13 + let last_broadcast = Admin.get_last_broadcast_time storage in 14 + Log.info (fun m -> m "Last broadcast: %s" 15 + (match last_broadcast with 16 + | None -> "never" 17 + | Some t -> Ptime.to_rfc3339 t)); 18 + 19 + (* Get changes since last broadcast *) 20 + let since = match last_broadcast with 21 + | None -> 22 + (* First run - get changes from the last 24 hours *) 23 + let now = Ptime_clock.now () in 24 + let day_ago = Ptime.Span.of_int_s (24 * 60 * 60) in 25 + Option.value ~default:Ptime.epoch (Ptime.sub_span now day_ago) 26 + | Some t -> t 27 + in 28 + 29 + match Monopam_changes.Query.changes_since ~fs ~changes_dir ~since with 30 + | Error e -> 31 + Log.warn (fun m -> m "Error loading changes: %s" e); 32 + (* Fall back to reading the markdown file *) 33 + let changes_path = Eio.Path.(fs / config.Config.monorepo_path / config.Config.changes_file) in 34 + (match Eio.Path.load changes_path with 35 + | exception _ -> 36 + Zulip_bot.Response.reply 37 + (Printf.sprintf "Could not read changes: %s" e) 38 + | content -> 39 + Zulip_bot.Response.stream ~stream:config.Config.channel 40 + ~topic:config.Config.topic ~content) 41 + 42 + | Ok entries -> 43 + if entries = [] then begin 44 + let msg = match last_broadcast with 45 + | None -> "No changes found in the last 24 hours." 46 + | Some t -> 47 + Printf.sprintf "No new changes since %s." 48 + (Ptime.to_rfc3339 ~tz_offset_s:0 t) 49 + in 50 + Zulip_bot.Response.reply msg 51 + end 52 + else begin 53 + (* Format the changes for Zulip *) 54 + let content = Monopam_changes.Query.format_for_zulip 55 + ~entries ~include_date:true ~date:None 56 + in 57 + 58 + (* Update last broadcast time before sending *) 59 + let now = Ptime_clock.now () in 60 + Admin.set_last_broadcast_time storage now; 61 + Log.info (fun m -> m "Updated broadcast time to %s" (Ptime.to_rfc3339 now)); 62 + 63 + (* Send as stream message *) 64 + let summary = Monopam_changes.Query.format_summary ~entries in 65 + Log.info (fun m -> m "Broadcasting: %s" summary); 66 + 67 + (* Return a compound response: stream message + confirmation reply *) 68 + Zulip_bot.Response.stream ~stream:config.Config.channel 69 + ~topic:config.Config.topic ~content 70 + end
+25
lib/broadcast.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Smart broadcast logic for Poe bot. 7 + 8 + This module implements intelligent change broadcasting that only sends 9 + new changes since the last broadcast, using Zulip storage to track state. *) 10 + 11 + val run : 12 + fs:Eio.Fs.dir_ty Eio.Path.t -> 13 + storage:Zulip_bot.Storage.t -> 14 + config:Config.t -> 15 + Zulip_bot.Response.t 16 + (** [run ~fs ~storage ~config] checks for new changes and broadcasts them. 17 + 18 + Logic: 19 + 1. Get last broadcast time from storage (or None for first run) 20 + 2. Load aggregated changes since that time 21 + 3. If no new changes, return "No new changes" message 22 + 4. Format changes for Zulip 23 + 5. Send as stream message to configured channel/topic 24 + 6. Update last broadcast time in storage 25 + 7. Return confirmation message *)
+49
lib/commands.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + type admin_command = 7 + | Last_broadcast 8 + | Reset_broadcast of string 9 + | Storage_keys 10 + | Storage_get of string 11 + | Storage_delete of string 12 + 13 + type command = 14 + | Help 15 + | Status 16 + | Broadcast 17 + | Admin of admin_command 18 + | Unknown of string 19 + 20 + let admin_parse args = 21 + let args = String.trim args in 22 + match String.split_on_char ' ' args with 23 + | ["last-broadcast"] | ["last_broadcast"] | ["lastbroadcast"] -> 24 + Some Last_broadcast 25 + | ["reset-broadcast"; timestamp] | ["reset_broadcast"; timestamp] -> 26 + Some (Reset_broadcast timestamp) 27 + | ["storage"; "keys"] -> 28 + Some Storage_keys 29 + | ["storage"; "get"; key] -> 30 + Some (Storage_get key) 31 + | ["storage"; "delete"; key] -> 32 + Some (Storage_delete key) 33 + | _ -> 34 + None 35 + 36 + let parse content = 37 + let content = String.trim (String.lowercase_ascii content) in 38 + match content with 39 + | "help" | "?" -> Help 40 + | "status" -> Status 41 + | "broadcast" | "post changes" | "post" | "changes" -> Broadcast 42 + | _ -> 43 + if String.starts_with ~prefix:"admin " content then 44 + let args = String.sub content 6 (String.length content - 6) in 45 + match admin_parse args with 46 + | Some cmd -> Admin cmd 47 + | None -> Unknown content 48 + else 49 + Unknown content
+33
lib/commands.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Command parsing for Poe bot. 7 + 8 + This module provides deterministic command parsing for the Poe Zulip bot. 9 + Unrecognized commands are passed through to Claude for interpretation. *) 10 + 11 + (** Admin sub-commands for storage and broadcast management. *) 12 + type admin_command = 13 + | Last_broadcast (** Show last broadcast time *) 14 + | Reset_broadcast of string (** Reset broadcast time to ISO timestamp *) 15 + | Storage_keys (** List all storage keys *) 16 + | Storage_get of string (** Get value for a storage key *) 17 + | Storage_delete of string (** Delete a storage key *) 18 + 19 + (** Parsed bot commands. *) 20 + type command = 21 + | Help (** Show help message *) 22 + | Status (** Show bot configuration status *) 23 + | Broadcast (** Broadcast new changes *) 24 + | Admin of admin_command (** Admin commands (require authorization) *) 25 + | Unknown of string (** Unrecognized command - pass to Claude *) 26 + 27 + val parse : string -> command 28 + (** [parse content] parses a message into a command. 29 + The input should be trimmed and lowercased. *) 30 + 31 + val admin_parse : string -> admin_command option 32 + (** [admin_parse args] parses admin sub-command arguments. 33 + Returns [None] if the arguments don't match any admin command. *)
+10 -2
lib/config.ml
··· 8 8 topic : string; 9 9 changes_file : string; 10 10 monorepo_path : string; 11 + admin_emails : string list; 12 + changes_dir : string; 11 13 } 12 14 13 15 let default = { ··· 15 17 topic = "Daily Changes"; 16 18 changes_file = "DAILY-CHANGES.md"; 17 19 monorepo_path = "."; 20 + admin_emails = []; 21 + changes_dir = ".changes"; 18 22 } 19 23 20 24 let codec = 21 25 Tomlt.( 22 26 Table.( 23 - obj (fun channel topic changes_file monorepo_path -> 24 - { channel; topic; changes_file; monorepo_path }) 27 + obj (fun channel topic changes_file monorepo_path admin_emails changes_dir -> 28 + { channel; topic; changes_file; monorepo_path; admin_emails; changes_dir }) 25 29 |> mem "channel" string ~dec_absent:default.channel 26 30 ~enc:(fun c -> c.channel) 27 31 |> mem "topic" string ~dec_absent:default.topic ~enc:(fun c -> c.topic) ··· 29 33 ~enc:(fun c -> c.changes_file) 30 34 |> mem "monorepo_path" string ~dec_absent:default.monorepo_path 31 35 ~enc:(fun c -> c.monorepo_path) 36 + |> mem "admin_emails" (list string) ~dec_absent:default.admin_emails 37 + ~enc:(fun c -> c.admin_emails) 38 + |> mem "changes_dir" string ~dec_absent:default.changes_dir 39 + ~enc:(fun c -> c.changes_dir) 32 40 |> finish)) 33 41 34 42 let load_from_path path =
+5 -1
lib/config.mli
··· 15 15 topic = "Daily Changes" 16 16 changes_file = "DAILY-CHANGES.md" 17 17 monorepo_path = "." 18 + changes_dir = ".changes" 19 + admin_emails = ["admin@example.com"] 18 20 v} *) 19 21 20 22 type t = { 21 23 channel : string; (** The Zulip channel to broadcast to *) 22 24 topic : string; (** The topic for broadcast messages *) 23 - changes_file : string; (** Path to the daily changes file *) 25 + changes_file : string; (** Path to the daily changes markdown file *) 24 26 monorepo_path : string; (** Path to the monorepo root *) 27 + admin_emails : string list; (** Emails authorized for admin commands *) 28 + changes_dir : string; (** Directory for aggregated JSON files *) 25 29 } 26 30 27 31 val default : t
+1 -1
lib/dune
··· 1 1 (library 2 2 (name poe) 3 3 (public_name poe) 4 - (libraries eio eio_main zulip zulip.bot claude tomlt tomlt.bytesrw xdge logs)) 4 + (libraries eio eio_main zulip zulip.bot claude tomlt tomlt.bytesrw xdge logs fpath ptime ptime.clock.os monopam-changes))
+39 -24
lib/handler.ml
··· 19 19 let path = fs / config.Config.monorepo_path / config.Config.changes_file in 20 20 try Some (load path) with _ -> None 21 21 22 - let broadcast_changes ~fs ~storage:_ ~identity:_ config _msg = 23 - match read_changes_file ~fs config with 24 - | None -> 25 - Zulip_bot.Response.reply 26 - (Printf.sprintf "Could not read changes file: %s" 27 - config.Config.changes_file) 28 - | Some content -> 29 - Zulip_bot.Response.stream ~stream:config.Config.channel 30 - ~topic:config.Config.topic ~content 31 - 32 22 let create_claude_client env = 33 23 let options = 34 24 Claude.Options.default ··· 62 52 Zulip_bot.Response.reply 63 53 {|**Poe Bot Commands:** 64 54 65 - - `broadcast` or `post changes` - Broadcast the daily changes to the configured channel 66 - - `help` - Show this help message 55 + **Basic Commands:** 56 + - `help` or `?` - Show this help message 67 57 - `status` - Show bot configuration status 68 - - Any other message will be interpreted by Claude to help you understand or modify the bot 58 + - `broadcast` / `post` / `changes` - Broadcast new changes to configured channel 59 + 60 + **Admin Commands:** (require authorization) 61 + - `admin last-broadcast` - Show last broadcast time and git HEAD 62 + - `admin reset-broadcast <ISO-timestamp>` - Reset broadcast time 63 + - `admin storage keys` - List all storage keys 64 + - `admin storage get <key>` - Get value for a storage key 65 + - `admin storage delete <key>` - Delete a storage key 66 + 67 + **Other Messages:** 68 + Any other message will be interpreted by Claude to help you understand or modify the bot. 69 69 70 70 **Configuration:** 71 71 The bot reads its configuration from `poe.toml` with the following fields: 72 72 - `channel` - The Zulip channel to broadcast to 73 73 - `topic` - The topic for broadcast messages 74 - - `changes_file` - Path to the daily changes file 75 - - `monorepo_path` - Path to the monorepo root|} 74 + - `changes_file` - Path to the daily changes markdown file 75 + - `monorepo_path` - Path to the monorepo root 76 + - `changes_dir` - Directory for aggregated JSON files (default: .changes) 77 + - `admin_emails` - List of emails authorized for admin commands|} 76 78 77 79 let handle_status config = 80 + let admin_list = if config.Config.admin_emails = [] then "none configured" 81 + else String.concat ", " config.Config.admin_emails 82 + in 78 83 Zulip_bot.Response.reply 79 84 (Printf.sprintf 80 85 {|**Poe Bot Status:** ··· 82 87 - Channel: `%s` 83 88 - Topic: `%s` 84 89 - Changes file: `%s` 85 - - Monorepo path: `%s`|} 90 + - Monorepo path: `%s` 91 + - Changes dir: `%s` 92 + - Admin emails: %s|} 86 93 config.Config.channel config.Config.topic config.Config.changes_file 87 - config.Config.monorepo_path) 94 + config.Config.monorepo_path config.Config.changes_dir admin_list) 88 95 89 96 let handle_claude_query env msg = 90 97 let content = Zulip_bot.Message.content msg in ··· 104 111 Log.info (fun m -> m "Claude response: %s" response); 105 112 Zulip_bot.Response.reply response 106 113 114 + let is_admin config email = 115 + List.mem email config.Config.admin_emails 116 + 107 117 let make_handler env config = 108 118 fun ~storage ~identity msg -> 109 119 let bot_email = identity.Zulip_bot.Bot.email in ··· 112 122 else 113 123 let content = 114 124 Zulip_bot.Message.strip_mention msg ~user_email:bot_email 115 - |> String.trim |> String.lowercase_ascii 125 + |> String.trim 116 126 in 117 127 Log.info (fun m -> m "Received message: %s" content); 118 - match content with 119 - | "help" | "?" -> handle_help () 120 - | "status" -> handle_status config 121 - | "broadcast" | "post changes" | "post" | "changes" -> 122 - broadcast_changes ~fs:env.fs ~storage ~identity config msg 123 - | _ -> handle_claude_query env msg 128 + match Commands.parse content with 129 + | Commands.Help -> handle_help () 130 + | Commands.Status -> handle_status config 131 + | Commands.Broadcast -> 132 + Broadcast.run ~fs:env.fs ~storage ~config 133 + | Commands.Admin cmd -> 134 + if is_admin config sender_email then 135 + Zulip_bot.Response.reply (Admin.handle ~storage cmd) 136 + else 137 + Zulip_bot.Response.reply "Admin commands require authorization. Contact an admin to be added to the admin_emails list." 138 + | Commands.Unknown _ -> handle_claude_query env msg
+118
lib/loop.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + let src = Logs.Src.create "poe.loop" ~doc:"Poe polling loop" 7 + module Log = (val Logs.src_log src : Logs.LOG) 8 + 9 + let get_git_head ~proc ~cwd = 10 + Eio.Switch.run @@ fun sw -> 11 + let buf = Buffer.create 64 in 12 + let child = Eio.Process.spawn proc ~sw ~cwd 13 + ~stdout:(Eio.Flow.buffer_sink buf) 14 + ["git"; "rev-parse"; "--short"; "HEAD"] 15 + in 16 + match Eio.Process.await child with 17 + | `Exited 0 -> Some (String.trim (Buffer.contents buf)) 18 + | _ -> None 19 + 20 + let run_monopam_changes ~proc ~cwd = 21 + Log.info (fun m -> m "Running monopam changes --daily --aggregate"); 22 + Eio.Switch.run @@ fun sw -> 23 + let child = Eio.Process.spawn proc ~sw ~cwd 24 + ["opam"; "exec"; "--"; "dune"; "exec"; "--"; "monopam"; "changes"; "--daily"; "--aggregate"] 25 + in 26 + match Eio.Process.await child with 27 + | `Exited 0 -> 28 + Log.info (fun m -> m "monopam changes completed successfully"); 29 + true 30 + | `Exited code -> 31 + Log.warn (fun m -> m "monopam changes exited with code %d" code); 32 + false 33 + | `Signaled sig_ -> 34 + Log.warn (fun m -> m "monopam changes killed by signal %d" sig_); 35 + false 36 + 37 + let send_changes ~client ~stream ~topic ~entries = 38 + let content = Monopam_changes.Query.format_for_zulip 39 + ~entries ~include_date:true ~date:None 40 + in 41 + let msg = Zulip.Message.create ~type_:`Channel ~to_:[stream] ~topic ~content () in 42 + let resp = Zulip.Messages.send client msg in 43 + Log.info (fun m -> m "Broadcast sent, message ID: %d" (Zulip.Message_response.id resp)) 44 + 45 + let run ~sw ~env ~config ~zulip_config ~interval = 46 + let fs = Eio.Stdenv.fs env in 47 + let proc = Eio.Stdenv.process_mgr env in 48 + let clock = Eio.Stdenv.clock env in 49 + 50 + (* Create Zulip client *) 51 + let client = Zulip_bot.Bot.create_client ~sw ~env ~config:zulip_config in 52 + let storage = Zulip_bot.Storage.create client in 53 + 54 + let monorepo_path = Eio.Path.(fs / config.Config.monorepo_path) in 55 + let changes_dir = Fpath.v (config.Config.monorepo_path ^ "/" ^ config.Config.changes_dir) in 56 + 57 + Log.info (fun m -> m "Starting loop with %d second interval" interval); 58 + 59 + let rec loop () = 60 + Log.info (fun m -> m "Checking for changes..."); 61 + 62 + (* Get current git HEAD *) 63 + let current_head = get_git_head ~proc ~cwd:monorepo_path in 64 + let last_head = Admin.get_last_git_head storage in 65 + 66 + Log.debug (fun m -> m "Current HEAD: %s, Last HEAD: %s" 67 + (Option.value ~default:"unknown" current_head) 68 + (Option.value ~default:"unknown" last_head)); 69 + 70 + (* Check if HEAD has changed *) 71 + let head_changed = match (current_head, last_head) with 72 + | (Some c, Some l) -> c <> l 73 + | (Some _, None) -> true (* First run *) 74 + | _ -> false 75 + in 76 + 77 + if head_changed then begin 78 + Log.info (fun m -> m "Git HEAD changed, generating changes..."); 79 + 80 + (* Run monopam to generate changes *) 81 + let _success = run_monopam_changes ~proc ~cwd:monorepo_path in 82 + 83 + (* Load changes since last broadcast *) 84 + let last_broadcast = Admin.get_last_broadcast_time storage in 85 + let since = match last_broadcast with 86 + | None -> 87 + let now = Ptime_clock.now () in 88 + let day_ago = Ptime.Span.of_int_s (24 * 60 * 60) in 89 + Option.value ~default:Ptime.epoch (Ptime.sub_span now day_ago) 90 + | Some t -> t 91 + in 92 + 93 + match Monopam_changes.Query.changes_since ~fs ~changes_dir ~since with 94 + | Error e -> 95 + Log.warn (fun m -> m "Error loading changes: %s" e) 96 + | Ok entries when entries = [] -> 97 + Log.info (fun m -> m "No new changes to broadcast") 98 + | Ok entries -> 99 + Log.info (fun m -> m "Broadcasting %d new entries" (List.length entries)); 100 + send_changes ~client ~stream:config.Config.channel 101 + ~topic:config.Config.topic ~entries; 102 + 103 + (* Update storage *) 104 + let now = Ptime_clock.now () in 105 + Admin.set_last_broadcast_time storage now; 106 + Option.iter (Admin.set_last_git_head storage) current_head; 107 + Log.info (fun m -> m "Updated broadcast time and git HEAD") 108 + end 109 + else 110 + Log.debug (fun m -> m "No HEAD change, skipping"); 111 + 112 + (* Sleep until next check *) 113 + Log.info (fun m -> m "Sleeping for %d seconds" interval); 114 + Eio.Time.sleep clock (float_of_int interval); 115 + loop () 116 + in 117 + 118 + loop ()
+38
lib/loop.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Hourly loop for automated change detection and broadcast. 7 + 8 + This module implements a polling loop that periodically checks for new 9 + changes in the monorepo and broadcasts them to Zulip. *) 10 + 11 + val run : 12 + sw:Eio.Switch.t -> 13 + env:< clock : float Eio.Time.clock_ty Eio.Resource.t ; 14 + fs : Eio.Fs.dir_ty Eio.Path.t ; 15 + net : [ `Generic | `Unix ] Eio.Net.ty Eio.Resource.t ; 16 + process_mgr : _ Eio.Process.mgr ; 17 + .. > -> 18 + config:Config.t -> 19 + zulip_config:Zulip_bot.Config.t -> 20 + interval:int -> 21 + 'a 22 + (** [run ~sw ~env ~config ~zulip_config ~interval] starts the polling loop. 23 + 24 + Loop flow: 25 + 1. Check if git HEAD has changed (compare with stored last_git_head) 26 + 2. If changed: 27 + - Run [monopam changes --daily --aggregate] via subprocess 28 + - Load new aggregated changes since last_broadcast_time 29 + - If new entries exist, format and send to Zulip channel 30 + - Update last_broadcast_time and last_git_head in storage 31 + 3. Sleep for interval seconds 32 + 4. Repeat 33 + 34 + @param sw Eio switch for resource management 35 + @param env Eio environment 36 + @param config Poe configuration 37 + @param zulip_config Zulip bot configuration 38 + @param interval Seconds between checks (default: 3600) *)
+4
lib/poe.ml
··· 6 6 (** Poe - A Zulip bot for broadcasting monorepo changes with Claude integration. *) 7 7 8 8 module Config = Config 9 + module Commands = Commands 10 + module Admin = Admin 11 + module Broadcast = Broadcast 12 + module Loop = Loop 9 13 module Handler = Handler