OCaml HTML5 parser/serialiser based on Python's JustHTML

Replace monopam changelog generation with Claude-powered narratives

- Add Changelog module for Claude-based changelog generation
- Generate narrative prose (2-4 sentences) instead of bullet points
- Auto-detect channel members and use @**Name** Zulip mentions
- Focus on user-visible features and API changes
- Remove monopam dependency from poe library

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

+379 -198
+25 -12
poe/bin/main.ml
··· 118 118 Eio_main.run @@ fun env -> 119 119 Eio.Switch.run @@ fun sw -> 120 120 let fs = Eio.Stdenv.fs env in 121 + let proc = Eio.Stdenv.process_mgr env in 122 + let clock = Eio.Stdenv.clock env in 121 123 122 124 (* Load poe config: explicit path > XDG > current dir > defaults *) 123 125 let poe_config = ··· 134 136 135 137 let zulip_config = Zulip_bot.Config.load_or_env ~xdg_app:"poe" ~fs bot_name in 136 138 let client = Zulip_bot.Bot.create_client ~sw ~env ~config:zulip_config in 139 + let monorepo_path = Eio.Path.(fs / poe_config.monorepo_path) in 137 140 138 - match Poe.Handler.read_changes_file ~fs poe_config with 139 - | None -> 140 - Logs.err (fun m -> 141 - m "Could not read changes file: %s" poe_config.changes_file) 142 - | Some content -> 143 - let msg = 144 - Zulip.Message.create ~type_:`Channel ~to_:[ poe_config.channel ] 145 - ~topic:poe_config.topic ~content () 146 - in 147 - let resp = Zulip.Messages.send client msg in 148 - Logs.info (fun m -> 149 - m "Broadcast sent, message ID: %d" (Zulip.Message_response.id resp)) 141 + (* Get recent commits *) 142 + let commits = Poe.Changelog.get_recent_commits ~proc ~cwd:monorepo_path ~count:10 in 143 + 144 + if commits = [] then 145 + Logs.info (fun m -> m "No commits to broadcast") 146 + else begin 147 + (* Get channel members for @mentions *) 148 + let members = Poe.Changelog.get_channel_members ~client ~channel:poe_config.channel in 149 + 150 + (* Generate narrative changelog with Claude *) 151 + match Poe.Changelog.generate ~sw ~proc ~clock ~commits ~members with 152 + | None -> 153 + Logs.err (fun m -> m "Could not generate changelog") 154 + | Some content -> 155 + let msg = 156 + Zulip.Message.create ~type_:`Channel ~to_:[ poe_config.channel ] 157 + ~topic:poe_config.topic ~content () 158 + in 159 + let resp = Zulip.Messages.send client msg in 160 + Logs.info (fun m -> 161 + m "Broadcast sent, message ID: %d" (Zulip.Message_response.id resp)) 162 + end 150 163 in 151 164 let doc = "Broadcast daily changes to Zulip (one-shot)" in 152 165 let info = Cmd.info "broadcast" ~doc in
+38 -49
poe/lib/broadcast.ml
··· 6 6 let src = Logs.Src.create "poe.broadcast" ~doc:"Poe broadcast logic" 7 7 module Log = (val Logs.src_log src : Logs.LOG) 8 8 9 - let run ~fs ~storage ~config = 10 - let changes_dir = Fpath.v (config.Config.monorepo_path ^ "/" ^ config.Config.changes_dir) in 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 11 19 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)); 20 + let run ~sw ~proc ~clock ~fs ~client ~storage ~config = 21 + let monorepo_path = Eio.Path.(fs / config.Config.monorepo_path) in 18 22 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 23 + (* Get last git HEAD from storage *) 24 + let last_head = Admin.get_last_git_head storage in 25 + Log.info (fun m -> m "Last HEAD: %s" 26 + (Option.value ~default:"(none)" last_head)); 27 + 28 + (* Get commits - either since last HEAD or recent commits *) 29 + let commits = match last_head with 30 + | Some h -> Changelog.get_git_log ~proc ~cwd:monorepo_path ~since_head:h 31 + | None -> Changelog.get_recent_commits ~proc ~cwd:monorepo_path ~count:10 27 32 in 28 33 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) 34 + if commits = [] then begin 35 + Log.info (fun m -> m "No commits to broadcast"); 36 + Zulip_bot.Response.reply "No new commits to broadcast." 37 + end 38 + else begin 39 + (* Get channel members for @mentions *) 40 + let members = Changelog.get_channel_members ~client ~channel:config.Config.channel in 41 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 *) 42 + (* Generate narrative changelog with Claude *) 43 + match Changelog.generate ~sw ~proc ~clock ~commits ~members with 44 + | None -> 45 + Zulip_bot.Response.reply "Could not generate changelog." 46 + | Some content -> 47 + (* Update storage *) 59 48 let now = Ptime_clock.now () in 60 49 Admin.set_last_broadcast_time storage now; 50 + let current_head = get_git_head ~proc ~cwd:monorepo_path in 51 + Option.iter (Admin.set_last_git_head storage) current_head; 61 52 Log.info (fun m -> m "Updated broadcast time to %s" (Ptime.to_rfc3339 now)); 62 53 63 54 (* Send as stream message *) 64 - let summary = Monopam.Changes.Query.format_summary ~entries in 65 - Log.info (fun m -> m "Broadcasting: %s" summary); 55 + Log.info (fun m -> m "Broadcasting: %s" content); 66 56 67 - (* Return a compound response: stream message + confirmation reply *) 68 57 Zulip_bot.Response.stream ~stream:config.Config.channel 69 58 ~topic:config.Config.topic ~content 70 - end 59 + end
+13 -8
poe/lib/broadcast.mli
··· 5 5 6 6 (** Smart broadcast logic for Poe bot. 7 7 8 - This module implements intelligent change broadcasting that only sends 9 - new changes since the last broadcast, using Zulip storage to track state. *) 8 + This module implements intelligent change broadcasting that generates 9 + narrative changelogs using Claude, with @mentions for channel members. *) 10 10 11 11 val run : 12 + sw:Eio.Switch.t -> 13 + proc:_ Eio.Process.mgr -> 14 + clock:float Eio.Time.clock_ty Eio.Resource.t -> 12 15 fs:Eio.Fs.dir_ty Eio.Path.t -> 16 + client:Zulip.Client.t -> 13 17 storage:Zulip_bot.Storage.t -> 14 18 config:Config.t -> 15 19 Zulip_bot.Response.t 16 - (** [run ~fs ~storage ~config] checks for new changes and broadcasts them. 20 + (** [run ~sw ~proc ~clock ~client ~storage ~config] generates and broadcasts 21 + a changelog for recent commits. 17 22 18 23 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 24 + 1. Get last git HEAD from storage 25 + 2. Get commits since that HEAD (or last 10 if first run) 26 + 3. Get channel members for @mention matching 27 + 4. Generate narrative changelog with Claude 23 28 5. Send as stream message to configured channel/topic 24 - 6. Update last broadcast time in storage 29 + 6. Update last broadcast time and git HEAD in storage 25 30 7. Return confirmation message *)
+148
poe/lib/changelog.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Claude-powered narrative changelog generation with @mentions. *) 7 + 8 + let src = Logs.Src.create "poe.changelog" ~doc:"Poe changelog generation" 9 + module Log = (val Logs.src_log src : Logs.LOG) 10 + 11 + type commit = { 12 + hash: string; 13 + author: string; 14 + email: string; 15 + subject: string; 16 + } 17 + 18 + type channel_member = { 19 + full_name: string; 20 + email: string; 21 + } 22 + 23 + let get_git_log ~proc ~cwd ~since_head = 24 + Log.info (fun m -> m "Getting commits since %s" since_head); 25 + Eio.Switch.run @@ fun sw -> 26 + let buf = Buffer.create 1024 in 27 + let child = Eio.Process.spawn proc ~sw ~cwd 28 + ~stdout:(Eio.Flow.buffer_sink buf) 29 + ["git"; "log"; "--pretty=format:%h|%an|%ae|%s"; since_head ^ "..HEAD"] 30 + in 31 + match Eio.Process.await child with 32 + | `Exited 0 -> 33 + let output = Buffer.contents buf in 34 + if String.trim output = "" then [] 35 + else 36 + String.split_on_char '\n' output 37 + |> List.filter_map (fun line -> 38 + match String.split_on_char '|' line with 39 + | [hash; author; email; subject] -> 40 + Some { hash; author; email; subject } 41 + | _ -> None) 42 + | _ -> [] 43 + 44 + let get_recent_commits ~proc ~cwd ~count = 45 + Log.info (fun m -> m "Getting last %d commits" count); 46 + Eio.Switch.run @@ fun sw -> 47 + let buf = Buffer.create 1024 in 48 + let child = Eio.Process.spawn proc ~sw ~cwd 49 + ~stdout:(Eio.Flow.buffer_sink buf) 50 + ["git"; "log"; "--pretty=format:%h|%an|%ae|%s"; "-n"; string_of_int count] 51 + in 52 + match Eio.Process.await child with 53 + | `Exited 0 -> 54 + let output = Buffer.contents buf in 55 + if String.trim output = "" then [] 56 + else 57 + String.split_on_char '\n' output 58 + |> List.filter_map (fun line -> 59 + match String.split_on_char '|' line with 60 + | [hash; author; email; subject] -> 61 + Some { hash; author; email; subject } 62 + | _ -> None) 63 + | _ -> [] 64 + 65 + let get_channel_members ~client ~channel = 66 + Log.info (fun m -> m "Getting members of channel %s" channel); 67 + try 68 + let stream_id = Zulip.Channels.get_id client ~name:channel in 69 + let subscriber_ids = Zulip.Channels.get_subscribers client ~stream_id in 70 + Log.info (fun m -> m "Found %d subscribers" (List.length subscriber_ids)); 71 + List.filter_map (fun user_id -> 72 + try 73 + let user = Zulip.Users.get_by_id client ~user_id () in 74 + Some { 75 + full_name = Zulip.User.full_name user; 76 + email = Zulip.User.email user; 77 + } 78 + with _ -> None 79 + ) subscriber_ids 80 + with e -> 81 + Log.warn (fun m -> m "Failed to get channel members: %s" (Printexc.to_string e)); 82 + [] 83 + 84 + let create_claude_client ~sw ~proc ~clock = 85 + let options = 86 + Claude.Options.default 87 + |> Claude.Options.with_model `Opus_4 88 + |> Claude.Options.with_permission_mode Claude.Permissions.Mode.Bypass_permissions 89 + |> Claude.Options.with_allowed_tools [] 90 + in 91 + Claude.Client.create ~options ~sw ~process_mgr:proc ~clock () 92 + 93 + let ask_claude ~sw ~proc ~clock prompt = 94 + let client = create_claude_client ~sw ~proc ~clock in 95 + Claude.Client.query client prompt; 96 + let responses = Claude.Client.receive_all client in 97 + let text = 98 + List.filter_map 99 + (function 100 + | Claude.Response.Text t -> Some (Claude.Response.Text.content t) 101 + | _ -> None) 102 + responses 103 + in 104 + String.concat "" text 105 + 106 + let generate ~sw ~proc ~clock ~commits ~members = 107 + if commits = [] then None 108 + else begin 109 + Log.info (fun m -> m "Generating narrative changelog with Claude for %d commits" (List.length commits)); 110 + 111 + (* Format commits for the prompt *) 112 + let commits_text = commits 113 + |> List.map (fun c -> 114 + Printf.sprintf "- %s by %s <%s>: %s" c.hash c.author c.email c.subject) 115 + |> String.concat "\n" 116 + in 117 + 118 + (* Format members for mention matching *) 119 + let members_text = members 120 + |> List.map (fun m -> 121 + Printf.sprintf "- @**%s** (email: %s)" m.full_name m.email) 122 + |> String.concat "\n" 123 + in 124 + 125 + let prompt = Printf.sprintf 126 + {|You are writing a brief changelog update for a Zulip channel. Given these git commits: 127 + 128 + %s 129 + 130 + And these channel members who can be @mentioned (use the exact @**Name** format): 131 + 132 + %s 133 + 134 + Write a brief, narrative changelog (2-4 sentences) that: 135 + 1. Focuses on user-visible features and API changes 136 + 2. Uses @**Name** mentions when a commit author matches a channel member (by name or email) 137 + 3. Is conversational and not bullet-pointed 138 + 4. Skips internal refactoring or minor fixes unless they're the only changes 139 + 140 + If commits are purely internal/maintenance with no user-visible changes, just write a single sentence noting routine maintenance. 141 + 142 + Write ONLY the changelog text, no preamble or explanation.|} commits_text members_text 143 + in 144 + 145 + let response = ask_claude ~sw ~proc ~clock prompt in 146 + Log.info (fun m -> m "Claude generated: %s" response); 147 + Some (String.trim response) 148 + end
+71
poe/lib/changelog.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Claude-powered narrative changelog generation with @mentions. 7 + 8 + This module generates human-readable changelog narratives from git commits, 9 + using Claude to summarize changes and automatically @mention channel members 10 + who authored commits. *) 11 + 12 + (** {1 Types} *) 13 + 14 + type commit = { 15 + hash: string; 16 + author: string; 17 + email: string; 18 + subject: string; 19 + } 20 + (** A git commit with metadata. *) 21 + 22 + type channel_member = { 23 + full_name: string; 24 + email: string; 25 + } 26 + (** A Zulip channel member for @mention matching. *) 27 + 28 + (** {1 Git Operations} *) 29 + 30 + val get_git_log : 31 + proc:_ Eio.Process.mgr -> 32 + cwd:Eio.Fs.dir_ty Eio.Path.t -> 33 + since_head:string -> 34 + commit list 35 + (** [get_git_log ~proc ~cwd ~since_head] returns commits between [since_head] 36 + and HEAD. *) 37 + 38 + val get_recent_commits : 39 + proc:_ Eio.Process.mgr -> 40 + cwd:Eio.Fs.dir_ty Eio.Path.t -> 41 + count:int -> 42 + commit list 43 + (** [get_recent_commits ~proc ~cwd ~count] returns the last [count] commits. *) 44 + 45 + (** {1 Zulip Operations} *) 46 + 47 + val get_channel_members : 48 + client:Zulip.Client.t -> 49 + channel:string -> 50 + channel_member list 51 + (** [get_channel_members ~client ~channel] returns the members of [channel] 52 + for @mention matching. *) 53 + 54 + (** {1 Changelog Generation} *) 55 + 56 + val generate : 57 + sw:Eio.Switch.t -> 58 + proc:_ Eio.Process.mgr -> 59 + clock:float Eio.Time.clock_ty Eio.Resource.t -> 60 + commits:commit list -> 61 + members:channel_member list -> 62 + string option 63 + (** [generate ~sw ~proc ~clock ~commits ~members] generates a narrative 64 + changelog using Claude. Returns [None] if commits is empty, or 65 + [Some narrative] with the generated text. 66 + 67 + The narrative: 68 + - Focuses on user-visible features and API changes 69 + - Uses @**Name** mentions for authors matching channel members 70 + - Is conversational prose, not bullet points 71 + - Summarizes internal changes briefly *)
+1 -1
poe/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 fpath ptime ptime.clock.os monopam)) 4 + (libraries eio eio_main zulip zulip.bot claude tomlt tomlt.bytesrw xdge logs ptime ptime.clock.os))
+46 -71
poe/lib/handler.ml
··· 14 14 fs : Eio.Fs.dir_ty Eio.Path.t; 15 15 } 16 16 17 - let read_changes_file ~fs config = 18 - let open Eio.Path in 19 - let path = fs / config.Config.monorepo_path / config.Config.changes_file in 20 - try Some (load path) with _ -> None 21 - 22 17 let run_git_pull ~proc ~cwd = 23 18 Log.info (fun m -> m "Pulling latest changes from remote"); 24 19 Eio.Switch.run @@ fun sw -> ··· 47 42 Log.warn (fun m -> m "git pull killed by signal %d" sig_); 48 43 Error (Printf.sprintf "git pull killed by signal %d" sig_) 49 44 50 - let run_monopam_changes ~proc ~cwd = 51 - Log.info (fun m -> m "Running monopam changes --daily --aggregate"); 45 + let get_git_head ~proc ~cwd = 52 46 Eio.Switch.run @@ fun sw -> 53 - let buf_stderr = Buffer.create 256 in 47 + let buf = Buffer.create 64 in 54 48 let child = Eio.Process.spawn proc ~sw ~cwd 55 - ~stderr:(Eio.Flow.buffer_sink buf_stderr) 56 - ["opam"; "exec"; "--"; "dune"; "exec"; "--"; "monopam"; "changes"; "--daily"; "--aggregate"] 49 + ~stdout:(Eio.Flow.buffer_sink buf) 50 + ["git"; "rev-parse"; "--short"; "HEAD"] 57 51 in 58 52 match Eio.Process.await child with 59 - | `Exited 0 -> 60 - Log.info (fun m -> m "monopam changes completed successfully"); 61 - Ok () 62 - | `Exited code -> 63 - let stderr = String.trim (Buffer.contents buf_stderr) in 64 - Log.warn (fun m -> m "monopam changes exited with code %d" code); 65 - Error (Printf.sprintf "monopam changes failed (code %d): %s" code stderr) 66 - | `Signaled sig_ -> 67 - Log.warn (fun m -> m "monopam changes killed by signal %d" sig_); 68 - Error (Printf.sprintf "monopam changes killed by signal %d" sig_) 53 + | `Exited 0 -> Some (String.trim (Buffer.contents buf)) 54 + | _ -> None 69 55 70 56 let create_claude_client env = 71 57 let options = ··· 103 89 **Basic Commands:** 104 90 - `help` or `?` - Show this help message 105 91 - `status` - Show bot configuration status 106 - - `broadcast` / `post` / `changes` - Broadcast new changes to configured channel 107 - - `refresh` / `pull` / `sync` / `update` - Pull from remote, regenerate changes, and broadcast 92 + - `broadcast` / `post` / `changes` - Generate and broadcast changelog with Claude 93 + - `refresh` / `pull` / `sync` / `update` - Pull from remote and broadcast changes 108 94 109 95 **Admin Commands:** (require authorization) 110 96 - `admin last-broadcast` - Show last broadcast time and git HEAD ··· 120 106 The bot reads its configuration from `poe.toml` with the following fields: 121 107 - `channel` - The Zulip channel to broadcast to 122 108 - `topic` - The topic for broadcast messages 123 - - `changes_file` - Path to the daily changes markdown file 124 109 - `monorepo_path` - Path to the monorepo root 125 - - `changes_dir` - Directory for aggregated JSON files (default: .changes) 126 110 - `admin_emails` - List of emails authorized for admin commands|} 127 111 128 112 let handle_status config = ··· 135 119 136 120 - Channel: `%s` 137 121 - Topic: `%s` 138 - - Changes file: `%s` 139 122 - Monorepo path: `%s` 140 - - Changes dir: `%s` 141 123 - Admin emails: %s|} 142 - config.Config.channel config.Config.topic config.Config.changes_file 143 - config.Config.monorepo_path config.Config.changes_dir admin_list) 124 + config.Config.channel config.Config.topic 125 + config.Config.monorepo_path admin_list) 144 126 145 - let handle_refresh env ~fs ~storage ~config = 146 - let monorepo_path = Eio.Path.(fs / config.Config.monorepo_path) in 147 - let changes_dir = Fpath.v (config.Config.monorepo_path ^ "/" ^ config.Config.changes_dir) in 127 + let handle_refresh env ~client ~storage ~config = 128 + let monorepo_path = Eio.Path.(env.fs / config.Config.monorepo_path) in 148 129 149 130 (* Step 1: Git pull *) 150 131 let pull_result = run_git_pull ~proc:env.process_mgr ~cwd:monorepo_path in ··· 157 138 | `Updated _ -> "Pulled new changes from remote" 158 139 in 159 140 160 - (* Step 2: Run monopam changes *) 161 - let changes_result = run_monopam_changes ~proc:env.process_mgr ~cwd:monorepo_path in 162 - match changes_result with 163 - | Error e -> 164 - Zulip_bot.Response.reply 165 - (Printf.sprintf "**Refresh partially failed:**\n\n- %s\n- monopam changes error: %s" pull_msg e) 166 - | Ok () -> 167 - (* Step 3: Load and broadcast changes *) 168 - let last_broadcast = Admin.get_last_broadcast_time storage in 169 - let since = match last_broadcast with 170 - | None -> 171 - let now = Ptime_clock.now () in 172 - let day_ago = Ptime.Span.of_int_s (24 * 60 * 60) in 173 - Option.value ~default:Ptime.epoch (Ptime.sub_span now day_ago) 174 - | Some t -> t 175 - in 141 + (* Step 2: Get commits since last HEAD *) 142 + let last_head = Admin.get_last_git_head storage in 143 + let commits = match last_head with 144 + | Some h -> Changelog.get_git_log ~proc:env.process_mgr ~cwd:monorepo_path ~since_head:h 145 + | None -> Changelog.get_recent_commits ~proc:env.process_mgr ~cwd:monorepo_path ~count:10 146 + in 176 147 177 - match Monopam.Changes.Query.changes_since ~fs ~changes_dir ~since with 178 - | Error e -> 179 - Zulip_bot.Response.reply 180 - (Printf.sprintf "**Refresh completed but failed to load changes:**\n\n- %s\n- Error: %s" pull_msg e) 181 - | Ok entries when entries = [] -> 182 - Zulip_bot.Response.reply 183 - (Printf.sprintf "**Refresh completed:**\n\n- %s\n- No new changes to broadcast" pull_msg) 184 - | Ok entries -> 185 - (* Update broadcast time *) 186 - let now = Ptime_clock.now () in 187 - Admin.set_last_broadcast_time storage now; 148 + if commits = [] then 149 + Zulip_bot.Response.reply 150 + (Printf.sprintf "**Refresh completed:**\n\n- %s\n- No new commits to broadcast" pull_msg) 151 + else begin 152 + (* Get channel members for @mentions *) 153 + let members = Changelog.get_channel_members ~client ~channel:config.Config.channel in 188 154 189 - (* Format and send to channel *) 190 - let content = Monopam.Changes.Query.format_for_zulip 191 - ~entries ~include_date:true ~date:None 192 - in 193 - let summary = Monopam.Changes.Query.format_summary ~entries in 194 - Log.info (fun m -> m "Refresh broadcasting: %s" summary); 155 + (* Generate narrative changelog with Claude *) 156 + match Changelog.generate ~sw:env.sw ~proc:env.process_mgr ~clock:env.clock ~commits ~members with 157 + | None -> 158 + Zulip_bot.Response.reply 159 + (Printf.sprintf "**Refresh completed:**\n\n- %s\n- Could not generate changelog" pull_msg) 160 + | Some content -> 161 + (* Update storage *) 162 + let now = Ptime_clock.now () in 163 + Admin.set_last_broadcast_time storage now; 164 + let current_head = get_git_head ~proc:env.process_mgr ~cwd:monorepo_path in 165 + Option.iter (Admin.set_last_git_head storage) current_head; 166 + Log.info (fun m -> m "Refresh broadcasting: %s" content); 195 167 196 - (* Send to channel - reply will confirm what was sent *) 197 - Zulip_bot.Response.stream 198 - ~stream:config.Config.channel 199 - ~topic:config.Config.topic 200 - ~content:(Printf.sprintf "**Refresh triggered manually**\n\n%s" content) 168 + (* Send to channel *) 169 + Zulip_bot.Response.stream 170 + ~stream:config.Config.channel 171 + ~topic:config.Config.topic 172 + ~content:(Printf.sprintf "**Refresh triggered manually**\n\n%s" content) 173 + end 201 174 202 175 let handle_claude_query env msg = 203 176 let content = Zulip_bot.Message.content msg in ··· 242 215 let sender_email = Zulip_bot.Message.sender_email msg in 243 216 if sender_email = bot_email then Zulip_bot.Response.silent 244 217 else 218 + let client = Zulip_bot.Storage.client storage in 245 219 let content = 246 220 Zulip_bot.Message.strip_mention msg ~user_email:bot_email 247 221 |> String.trim ··· 251 225 | Commands.Help -> handle_help () 252 226 | Commands.Status -> handle_status config 253 227 | Commands.Broadcast -> 254 - Broadcast.run ~fs:env.fs ~storage ~config 228 + Broadcast.run ~sw:env.sw ~proc:env.process_mgr ~clock:env.clock 229 + ~fs:env.fs ~client ~storage ~config 255 230 | Commands.Refresh -> 256 - handle_refresh env ~fs:env.fs ~storage ~config 231 + handle_refresh env ~client ~storage ~config 257 232 | Commands.Admin cmd -> 258 233 if is_admin config ~storage msg then 259 234 Zulip_bot.Response.reply (Admin.handle ~storage cmd)
-3
poe/lib/handler.mli
··· 28 28 29 29 val ask_claude : _ env -> string -> string 30 30 (** [ask_claude env prompt] sends a prompt to Claude and returns the response. *) 31 - 32 - val read_changes_file : fs:_ Eio.Path.t -> Config.t -> string option 33 - (** [read_changes_file ~fs config] reads the daily changes file. *)
+32 -51
poe/lib/loop.ml
··· 34 34 Log.info (fun m -> m "Repository already up to date") 35 35 else begin 36 36 Log.info (fun m -> m "Pulled new changes from remote"); 37 - (* Log the output which shows what was updated *) 38 37 String.split_on_char '\n' output 39 38 |> List.iter (fun line -> 40 39 let line = String.trim line in ··· 49 48 Log.warn (fun m -> m "git pull killed by signal %d" sig_); 50 49 false 51 50 52 - let run_monopam_changes ~proc ~cwd = 53 - Log.info (fun m -> m "Running monopam changes --daily"); 54 - Eio.Switch.run @@ fun sw -> 55 - let child = Eio.Process.spawn proc ~sw ~cwd 56 - ["opam"; "exec"; "--"; "dune"; "exec"; "--"; "monopam"; "changes"; "--daily";] 57 - in 58 - match Eio.Process.await child with 59 - | `Exited 0 -> 60 - Log.info (fun m -> m "monopam changes completed successfully"); 61 - true 62 - | `Exited code -> 63 - Log.warn (fun m -> m "monopam changes exited with code %d" code); 64 - false 65 - | `Signaled sig_ -> 66 - Log.warn (fun m -> m "monopam changes killed by signal %d" sig_); 67 - false 68 - 69 - let send_changes ~client ~stream ~topic ~entries = 70 - let content = Monopam.Changes.Query.format_for_zulip 71 - ~entries ~include_date:true ~date:None 72 - in 51 + let send_message ~client ~stream ~topic ~content = 73 52 let msg = Zulip.Message.create ~type_:`Channel ~to_:[stream] ~topic ~content () in 74 53 let resp = Zulip.Messages.send client msg in 75 54 Log.info (fun m -> m "Broadcast sent, message ID: %d" (Zulip.Message_response.id resp)) ··· 84 63 let storage = Zulip_bot.Storage.create client in 85 64 86 65 let monorepo_path = Eio.Path.(fs / config.Config.monorepo_path) in 87 - let changes_dir = Fpath.v (config.Config.monorepo_path ^ "/" ^ config.Config.changes_dir) in 88 66 89 67 Log.info (fun m -> m "Starting loop with %d second interval" interval); 90 68 ··· 99 77 let current_head = get_git_head ~proc ~cwd:monorepo_path in 100 78 let last_head = Admin.get_last_git_head storage in 101 79 102 - Log.debug (fun m -> m "Current HEAD: %s, Last HEAD: %s" 103 - (Option.value ~default:"unknown" current_head) 104 - (Option.value ~default:"unknown" last_head)); 80 + Log.info (fun m -> m "Current HEAD: %s, Last HEAD: %s" 81 + (Option.value ~default:"(none)" current_head) 82 + (Option.value ~default:"(none)" last_head)); 105 83 106 84 (* Check if HEAD has changed *) 107 85 let head_changed = match (current_head, last_head) with ··· 113 91 if head_changed then begin 114 92 Log.info (fun m -> m "Git HEAD changed, generating changes..."); 115 93 116 - (* Run monopam to generate changes *) 117 - let _success = run_monopam_changes ~proc ~cwd:monorepo_path in 94 + (* Get commits since last HEAD *) 95 + let commits = match last_head with 96 + | Some h -> Changelog.get_git_log ~proc ~cwd:monorepo_path ~since_head:h 97 + | None -> [] (* First run, don't broadcast everything *) 98 + in 99 + 100 + if commits = [] then begin 101 + Log.info (fun m -> m "No commits to broadcast"); 102 + (* Still update HEAD so we don't reprocess *) 103 + Option.iter (Admin.set_last_git_head storage) current_head 104 + end 105 + else begin 106 + (* Get channel members for @mentions *) 107 + let members = Changelog.get_channel_members ~client ~channel:config.Config.channel in 118 108 119 - (* Load changes since last broadcast *) 120 - let last_broadcast = Admin.get_last_broadcast_time storage in 121 - let since = match last_broadcast with 109 + (* Generate narrative changelog with Claude *) 110 + match Changelog.generate ~sw ~proc ~clock ~commits ~members with 122 111 | None -> 112 + Log.info (fun m -> m "No changelog generated"); 113 + Option.iter (Admin.set_last_git_head storage) current_head 114 + | Some content -> 115 + Log.info (fun m -> m "Broadcasting narrative changelog"); 116 + send_message ~client ~stream:config.Config.channel 117 + ~topic:config.Config.topic ~content; 118 + 119 + (* Update storage *) 123 120 let now = Ptime_clock.now () in 124 - let day_ago = Ptime.Span.of_int_s (24 * 60 * 60) in 125 - Option.value ~default:Ptime.epoch (Ptime.sub_span now day_ago) 126 - | Some t -> t 127 - in 128 - 129 - match Monopam.Changes.Query.changes_since ~fs ~changes_dir ~since with 130 - | Error e -> 131 - Log.warn (fun m -> m "Error loading changes: %s" e) 132 - | Ok entries when entries = [] -> 133 - Log.info (fun m -> m "No new changes to broadcast") 134 - | Ok entries -> 135 - Log.info (fun m -> m "Broadcasting %d new entries" (List.length entries)); 136 - send_changes ~client ~stream:config.Config.channel 137 - ~topic:config.Config.topic ~entries; 138 - 139 - (* Update storage *) 140 - let now = Ptime_clock.now () in 141 - Admin.set_last_broadcast_time storage now; 142 - Option.iter (Admin.set_last_git_head storage) current_head; 143 - Log.info (fun m -> m "Updated broadcast time and git HEAD") 121 + Admin.set_last_broadcast_time storage now; 122 + Option.iter (Admin.set_last_git_head storage) current_head; 123 + Log.info (fun m -> m "Updated broadcast time and git HEAD") 124 + end 144 125 end 145 126 else 146 127 Log.debug (fun m -> m "No HEAD change, skipping");
+4 -3
poe/lib/loop.mli
··· 29 29 1. Pull latest changes from remote (git pull --ff-only) 30 30 2. Check if git HEAD has changed (compare with stored last_git_head) 31 31 3. If changed: 32 - - Run [monopam changes --daily --aggregate] via subprocess 33 - - Load new aggregated changes since last_broadcast_time 34 - - If new entries exist, format and send to Zulip channel 32 + - Get commits since last HEAD via git log 33 + - Fetch channel members for @mention matching 34 + - Generate narrative changelog using Claude 35 + - Send to Zulip channel 35 36 - Update last_broadcast_time and last_git_head in storage 36 37 4. Sleep for interval seconds 37 38 5. Repeat
+1
poe/lib/poe.ml
··· 9 9 module Commands = Commands 10 10 module Admin = Admin 11 11 module Broadcast = Broadcast 12 + module Changelog = Changelog 12 13 module Loop = Loop 13 14 module Handler = Handler