My aggregated monorepo of OCaml code, automaintained

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>

+1216 -37
+1
monopam/bin/dune
··· 1 1 (executable 2 2 (name main) 3 3 (public_name monopam) 4 + (package monopam) 4 5 (libraries monopam eio_main cmdliner fmt.tty fmt.cli logs.fmt logs.cli))
+11 -3
monopam/bin/main.ml
··· 409 409 "Changes are stored in the .changes directory at the monorepo root:"; 410 410 `I (".changes/<repo>.json", "Weekly changelog entries"); 411 411 `I (".changes/<repo>-daily.json", "Daily changelog entries"); 412 + `I (".changes/YYYYMMDD.json", "Aggregated daily entries (with --aggregate)"); 412 413 `P 413 414 "Also generates aggregated markdown files at the monorepo root:"; 414 415 `I ("CHANGES.md", "Aggregated weekly changelog"); ··· 424 425 `P 425 426 "Repositories with no user-facing changes will have blank entries \ 426 427 (empty summary and changes) rather than 'no changes' text."; 428 + `P 429 + "With --aggregate, also generates a structured JSON file suitable for \ 430 + the poe Zulip bot broadcasting system."; 427 431 ] 428 432 in 429 433 let info = Cmd.info "changes" ~doc ~man in ··· 447 451 let doc = "Preview changes without writing files" in 448 452 Arg.(value & flag & info [ "dry-run"; "n" ] ~doc) 449 453 in 450 - let run config_file package daily weeks days history dry_run () = 454 + let aggregate = 455 + let doc = "Also generate .changes/YYYYMMDD.json aggregated file (only with --daily)" in 456 + Arg.(value & flag & info [ "aggregate"; "a" ] ~doc) 457 + in 458 + let run config_file package daily weeks days history dry_run aggregate () = 451 459 Eio_main.run @@ fun env -> 452 460 with_config env config_file @@ fun config -> 453 461 let fs = Eio.Stdenv.fs env in ··· 457 465 if daily then begin 458 466 (* Use 30 as default history for daily if not explicitly set *) 459 467 let history = if history = 12 then 30 else history in 460 - Monopam.changes_daily ~proc ~fs ~config ~clock ?package ~days ~history ~dry_run () 468 + Monopam.changes_daily ~proc ~fs ~config ~clock ?package ~days ~history ~dry_run ~aggregate () 461 469 end 462 470 else 463 471 Monopam.changes ~proc ~fs ~config ~clock ?package ~weeks ~history ~dry_run () ··· 476 484 Term.( 477 485 ret 478 486 (const run $ config_file_arg $ package_arg $ daily $ weeks $ days $ history $ dry_run 479 - $ logging_term)) 487 + $ aggregate $ logging_term)) 480 488 481 489 (* Main command group *) 482 490
+13
monopam/dune-project
··· 32 32 (jsont (>= 0.2.0)) 33 33 (ptime (>= 1.0.0)) 34 34 (odoc :with-doc))) 35 + 36 + (package 37 + (name monopam-changes) 38 + (synopsis "Parse and query aggregated daily changes from monopam") 39 + (description "Library for parsing and querying the aggregated daily changes format produced by monopam, suitable for broadcasting via Zulip or other channels.") 40 + (depends 41 + (ocaml (>= 5.2.0)) 42 + (dune (>= 3.20)) 43 + (eio (>= 1.2)) 44 + (jsont (>= 0.2.0)) 45 + (ptime (>= 1.0.0)) 46 + (fpath (>= 0.7.0)) 47 + (odoc :with-doc)))
+106
monopam/lib/changes.ml
··· 684 684 match !result with 685 685 | Some r -> r 686 686 | None -> Ok markdown (* fallback to original *) 687 + 688 + (* Simple string containment check *) 689 + let string_contains_s haystack needle = 690 + let hlen = String.length haystack in 691 + let nlen = String.length needle in 692 + if nlen > hlen then false 693 + else begin 694 + let rec check i = 695 + if i > hlen - nlen then false 696 + else if String.sub haystack i nlen = needle then true 697 + else check (i + 1) 698 + in 699 + check 0 700 + end 701 + 702 + (* Infer change type from summary text *) 703 + let infer_change_type summary = 704 + let summary_lower = String.lowercase_ascii summary in 705 + if String.starts_with ~prefix:"initial import" summary_lower || 706 + String.starts_with ~prefix:"added as subtree" summary_lower || 707 + String.starts_with ~prefix:"added" summary_lower && String.ends_with ~suffix:"library" summary_lower then 708 + Monopam_changes.Aggregated.New_library 709 + else if List.exists (fun kw -> string_contains_s summary_lower kw) 710 + ["fix"; "bugfix"; "bug fix"; "repair"; "patch"; "resolve"; "correct"] then 711 + Monopam_changes.Aggregated.Bugfix 712 + else if List.exists (fun kw -> string_contains_s summary_lower kw) 713 + ["refactor"; "cleanup"; "clean up"; "reorganize"; "restructure"; "simplify"] then 714 + Monopam_changes.Aggregated.Refactor 715 + else if List.exists (fun kw -> string_contains_s summary_lower kw) 716 + ["doc"; "documentation"; "readme"; "comment"; "tutorial"; "guide"] then 717 + Monopam_changes.Aggregated.Documentation 718 + else if List.exists (fun kw -> string_contains_s summary_lower kw) 719 + ["add"; "new"; "feature"; "implement"; "support"; "introduce"; "enable"] then 720 + Monopam_changes.Aggregated.Feature 721 + else 722 + Monopam_changes.Aggregated.Unknown 723 + 724 + (** Generate an aggregated daily file from individual daily json files. 725 + This creates a YYYYMMDD.json file in the .changes directory. *) 726 + let generate_aggregated ~fs ~monorepo ~date ~git_head = 727 + let changes_dir = Eio.Path.(fs / Fpath.to_string monorepo / ".changes") in 728 + 729 + (* List all *-daily.json files *) 730 + let files = 731 + try Eio.Path.read_dir changes_dir 732 + with Eio.Io _ -> [] 733 + in 734 + let daily_files = List.filter (fun f -> 735 + String.ends_with ~suffix:"-daily.json" f) files 736 + in 737 + 738 + (* Load all daily files and collect entries for the target date *) 739 + let entries = List.concat_map (fun filename -> 740 + let repo_name = String.sub filename 0 (String.length filename - 11) in 741 + let path = Eio.Path.(changes_dir / filename) in 742 + try 743 + let content = Eio.Path.load path in 744 + match Jsont_bytesrw.decode_string daily_changes_file_jsont content with 745 + | Ok dcf -> 746 + List.filter_map (fun (e : daily_entry) -> 747 + if e.date = date && e.changes <> [] then 748 + Some (repo_name, e) 749 + else 750 + None) dcf.entries 751 + | Error _ -> [] 752 + with Eio.Io _ -> [] 753 + ) daily_files in 754 + 755 + (* Convert to aggregated format *) 756 + let now = Ptime_clock.now () in 757 + let agg_entries = List.map (fun (repo_name, (e : daily_entry)) -> 758 + let change_type = infer_change_type e.summary in 759 + Monopam_changes.Aggregated.{ 760 + repository = repo_name; 761 + summary = e.summary; 762 + changes = e.changes; 763 + commit_range = { 764 + from_hash = e.commit_range.from_hash; 765 + to_hash = e.commit_range.to_hash; 766 + count = e.commit_range.count; 767 + }; 768 + contributors = e.contributors; 769 + repo_url = e.repo_url; 770 + change_type; 771 + }) entries 772 + in 773 + 774 + (* Collect all unique authors *) 775 + let authors = 776 + entries 777 + |> List.concat_map (fun (_, (e : daily_entry)) -> e.contributors) 778 + |> List.sort_uniq String.compare 779 + in 780 + 781 + (* Create the aggregated structure *) 782 + let aggregated : Monopam_changes.Aggregated.t = { 783 + date; 784 + generated_at = now; 785 + git_head; 786 + entries = agg_entries; 787 + authors; 788 + } in 789 + 790 + (* Save to YYYYMMDD.json *) 791 + let changes_dir_fpath = Fpath.(v (Fpath.to_string monorepo) / ".changes") in 792 + Monopam_changes.Aggregated.save ~fs ~changes_dir:changes_dir_fpath aggregated
+19
monopam/lib/changes.mli
··· 195 195 by significance. Ensures all repository names are formatted as markdown 196 196 links using the pattern [\[repo-name\](https://tangled.org/@anil.recoil.org/repo-name.git)]. 197 197 Returns the refined markdown or the original on error. *) 198 + 199 + (** {1 Aggregated Files} *) 200 + 201 + val generate_aggregated : 202 + fs:_ Eio.Path.t -> 203 + monorepo:Fpath.t -> 204 + date:string -> 205 + git_head:string -> 206 + (unit, string) result 207 + (** [generate_aggregated ~fs ~monorepo ~date ~git_head] generates an aggregated 208 + JSON file from all daily JSON files. 209 + 210 + This creates a .changes/YYYYMMDD.json file containing all repository entries 211 + for the specified date, with change type classification and author aggregation. 212 + 213 + @param fs Filesystem path 214 + @param monorepo Path to the monorepo root 215 + @param date Date in YYYY-MM-DD format 216 + @param git_head Short git hash of the monorepo HEAD at generation time *)
+1 -1
monopam/lib/dune
··· 1 1 (library 2 2 (name monopam) 3 3 (public_name monopam) 4 - (libraries eio tomlt tomlt.eio xdge opam-file-format fmt logs uri fpath claude jsont jsont.bytesrw ptime)) 4 + (libraries eio tomlt tomlt.eio xdge opam-file-format fmt logs uri fpath claude jsont jsont.bytesrw ptime ptime.clock.os monopam_changes))
+4
monopam/lib/git.ml
··· 74 74 let cwd = path_to_eio ~fs path in 75 75 run_git_ok ~proc ~cwd [ "rev-parse"; "HEAD" ] 76 76 77 + let rev_parse ~proc ~fs ~rev path = 78 + let cwd = path_to_eio ~fs path in 79 + run_git_ok ~proc ~cwd [ "rev-parse"; rev ] 80 + 77 81 let clone ~proc ~fs ~url ~branch target = 78 82 let parent = Fpath.parent target in 79 83 let cwd = Eio.Path.(fs / Fpath.to_string parent) in
+9
monopam/lib/git.mli
··· 51 51 (string, error) result 52 52 (** [head_commit ~proc ~fs path] returns the current HEAD commit hash. *) 53 53 54 + val rev_parse : 55 + proc:_ Eio.Process.mgr -> 56 + fs:Eio.Fs.dir_ty Eio.Path.t -> 57 + rev:string -> 58 + Fpath.t -> 59 + (string, error) result 60 + (** [rev_parse ~proc ~fs ~rev path] resolves a revision to a commit hash. 61 + @param rev The revision to resolve (e.g., "HEAD", "main", "abc123") *) 62 + 54 63 (** {1 Basic Operations} *) 55 64 56 65 val clone :
+14 -1
monopam/lib/monopam.ml
··· 1008 1008 1009 1009 (* Daily changes command - generate daily changelogs using Claude *) 1010 1010 1011 - let changes_daily ~proc ~fs ~config ~clock ?package ?(days = 1) ?(history = 30) ?(dry_run = false) () = 1011 + let changes_daily ~proc ~fs ~config ~clock ?package ?(days = 1) ?(history = 30) ?(dry_run = false) ?(aggregate = false) () = 1012 1012 let fs_t = fs_typed fs in 1013 1013 let monorepo = Config.Paths.monorepo config in 1014 1014 ··· 1175 1175 let changes_md_path = Eio.Path.(fs_t / Fpath.to_string monorepo / "DAILY-CHANGES.md") in 1176 1176 Eio.Path.save ~create:(`Or_truncate 0o644) changes_md_path markdown; 1177 1177 Log.app (fun m -> m "Generated DAILY-CHANGES.md at monorepo root") 1178 + end; 1179 + (* Generate aggregated JSON file if requested *) 1180 + if not dry_run && aggregate then begin 1181 + let today = Changes.date_of_ptime now_ptime in 1182 + let git_head = 1183 + match Git.rev_parse ~proc ~fs:fs_t ~rev:"HEAD" monorepo with 1184 + | Ok hash -> String.sub hash 0 (min 7 (String.length hash)) 1185 + | Error _ -> "unknown" 1186 + in 1187 + match Changes.generate_aggregated ~fs:fs_t ~monorepo ~date:today ~git_head with 1188 + | Ok () -> Log.app (fun m -> m "Generated aggregated file .changes/%s.json" 1189 + (String.concat "" (String.split_on_char '-' today))) 1190 + | Error e -> Log.warn (fun m -> m "Failed to generate aggregated file: %s" e) 1178 1191 end; 1179 1192 Ok () 1180 1193 end
+7 -2
monopam/lib/monopam.mli
··· 208 208 ?days:int -> 209 209 ?history:int -> 210 210 ?dry_run:bool -> 211 + ?aggregate:bool -> 211 212 unit -> 212 213 (unit, error) result 213 - (** [changes_daily ~proc ~fs ~config ~clock ?package ?days ?history ?dry_run ()] 214 + (** [changes_daily ~proc ~fs ~config ~clock ?package ?days ?history ?dry_run ?aggregate ()] 214 215 generates daily changelog entries using Claude AI. 215 216 216 217 For each repository (or the specified package's repository): ··· 222 223 Also generates an aggregated DAILY-CHANGES.md at the monorepo root. 223 224 Repositories with no user-facing changes will have blank entries. 224 225 226 + If [~aggregate:true], also generates a .changes/YYYYMMDD.json file in the 227 + aggregated format suitable for the monopam_changes library and poe bot. 228 + 225 229 @param proc Eio process manager 226 230 @param fs Eio filesystem 227 231 @param config Monopam configuration ··· 229 233 @param package Optional specific repository to process 230 234 @param days Number of past days to analyze (default: 1) 231 235 @param history Number of recent days to include in DAILY-CHANGES.md (default: 30) 232 - @param dry_run If true, preview changes without writing files *) 236 + @param dry_run If true, preview changes without writing files 237 + @param aggregate If true, also generate .changes/YYYYMMDD.json aggregated file *)
+206
monopam/lib_changes/aggregated.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + type change_type = 7 + | Feature 8 + | Bugfix 9 + | Documentation 10 + | Refactor 11 + | New_library 12 + | Unknown 13 + 14 + let change_type_of_string = function 15 + | "feature" -> Feature 16 + | "bugfix" -> Bugfix 17 + | "documentation" -> Documentation 18 + | "refactor" -> Refactor 19 + | "new_library" -> New_library 20 + | _ -> Unknown 21 + 22 + let string_of_change_type = function 23 + | Feature -> "feature" 24 + | Bugfix -> "bugfix" 25 + | Documentation -> "documentation" 26 + | Refactor -> "refactor" 27 + | New_library -> "new_library" 28 + | Unknown -> "unknown" 29 + 30 + type commit_range = { 31 + from_hash : string; 32 + to_hash : string; 33 + count : int; 34 + } 35 + 36 + type entry = { 37 + repository : string; 38 + summary : string; 39 + changes : string list; 40 + commit_range : commit_range; 41 + contributors : string list; 42 + repo_url : string option; 43 + change_type : change_type; 44 + } 45 + 46 + type t = { 47 + date : string; 48 + generated_at : Ptime.t; 49 + git_head : string; 50 + entries : entry list; 51 + authors : string list; 52 + } 53 + 54 + (* JSON codecs *) 55 + 56 + let change_type_jsont = 57 + Jsont.enum ~kind:"change_type" [ 58 + ("feature", Feature); 59 + ("bugfix", Bugfix); 60 + ("documentation", Documentation); 61 + ("refactor", Refactor); 62 + ("new_library", New_library); 63 + ("unknown", Unknown); 64 + ] 65 + 66 + let commit_range_jsont = 67 + let make from_hash to_hash count = { from_hash; to_hash; count } in 68 + Jsont.Object.map ~kind:"commit_range" make 69 + |> Jsont.Object.mem "from" Jsont.string ~enc:(fun r -> r.from_hash) 70 + |> Jsont.Object.mem "to" Jsont.string ~enc:(fun r -> r.to_hash) 71 + |> Jsont.Object.mem "count" Jsont.int ~enc:(fun r -> r.count) 72 + |> Jsont.Object.finish 73 + 74 + let entry_jsont = 75 + let make repository summary changes commit_range contributors repo_url change_type = 76 + { repository; summary; changes; commit_range; contributors; repo_url; change_type } 77 + in 78 + Jsont.Object.map ~kind:"aggregated_entry" make 79 + |> Jsont.Object.mem "repository" Jsont.string ~enc:(fun e -> e.repository) 80 + |> Jsont.Object.mem "summary" Jsont.string ~enc:(fun e -> e.summary) 81 + |> Jsont.Object.mem "changes" (Jsont.list Jsont.string) ~enc:(fun e -> e.changes) 82 + |> Jsont.Object.mem "commit_range" commit_range_jsont ~enc:(fun e -> e.commit_range) 83 + |> Jsont.Object.mem "contributors" (Jsont.list Jsont.string) ~dec_absent:[] ~enc:(fun e -> e.contributors) 84 + |> Jsont.Object.mem "repo_url" (Jsont.option Jsont.string) ~dec_absent:None ~enc:(fun e -> e.repo_url) 85 + |> Jsont.Object.mem "change_type" change_type_jsont ~dec_absent:Unknown ~enc:(fun e -> e.change_type) 86 + |> Jsont.Object.finish 87 + 88 + let ptime_jsont = 89 + let enc t = 90 + match Ptime.to_rfc3339 t ~tz_offset_s:0 with 91 + | s -> s 92 + in 93 + let dec s = 94 + match Ptime.of_rfc3339 s with 95 + | Ok (t, _, _) -> t 96 + | Error _ -> failwith ("Invalid timestamp: " ^ s) 97 + in 98 + Jsont.map ~dec ~enc Jsont.string 99 + 100 + let jsont = 101 + let make date generated_at git_head entries authors = 102 + { date; generated_at; git_head; entries; authors } 103 + in 104 + Jsont.Object.map ~kind:"aggregated_changes" make 105 + |> Jsont.Object.mem "date" Jsont.string ~enc:(fun t -> t.date) 106 + |> Jsont.Object.mem "generated_at" ptime_jsont ~enc:(fun t -> t.generated_at) 107 + |> Jsont.Object.mem "git_head" Jsont.string ~enc:(fun t -> t.git_head) 108 + |> Jsont.Object.mem "entries" (Jsont.list entry_jsont) ~enc:(fun t -> t.entries) 109 + |> Jsont.Object.mem "authors" (Jsont.list Jsont.string) ~dec_absent:[] ~enc:(fun t -> t.authors) 110 + |> Jsont.Object.finish 111 + 112 + (* File I/O *) 113 + 114 + let filename_of_date date = 115 + (* date is in YYYY-MM-DD format, convert to YYYYMMDD.json *) 116 + let clean = String.concat "" (String.split_on_char '-' date) in 117 + clean ^ ".json" 118 + 119 + let date_of_filename filename = 120 + (* YYYYMMDD.json -> YYYY-MM-DD *) 121 + if String.length filename >= 12 && String.sub filename 8 5 = ".json" then 122 + let yyyymmdd = String.sub filename 0 8 in 123 + let yyyy = String.sub yyyymmdd 0 4 in 124 + let mm = String.sub yyyymmdd 4 2 in 125 + let dd = String.sub yyyymmdd 6 2 in 126 + Some (yyyy ^ "-" ^ mm ^ "-" ^ dd) 127 + else 128 + None 129 + 130 + let load ~fs ~changes_dir ~date = 131 + let filename = filename_of_date date in 132 + let file_path = Eio.Path.(fs / Fpath.to_string changes_dir / filename) in 133 + match Eio.Path.kind ~follow:true file_path with 134 + | `Regular_file -> ( 135 + let content = Eio.Path.load file_path in 136 + match Jsont_bytesrw.decode_string jsont content with 137 + | Ok t -> Ok t 138 + | Error e -> Error (Format.sprintf "Failed to parse %s: %s" filename e)) 139 + | _ -> Error (Format.sprintf "File not found: %s" filename) 140 + | exception Eio.Io _ -> Error (Format.sprintf "Could not read %s" filename) 141 + 142 + let load_range ~fs ~changes_dir ~from_date ~to_date = 143 + (* List all YYYYMMDD.json files and filter by range *) 144 + let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in 145 + match Eio.Path.kind ~follow:true dir_path with 146 + | `Directory -> ( 147 + let entries = Eio.Path.read_dir dir_path in 148 + let json_files = List.filter (fun f -> 149 + String.length f = 13 && String.ends_with ~suffix:".json" f && 150 + not (String.contains f '-')) entries 151 + in 152 + let sorted = List.sort String.compare json_files in 153 + let from_file = filename_of_date from_date in 154 + let to_file = filename_of_date to_date in 155 + let in_range = List.filter (fun f -> 156 + f >= from_file && f <= to_file) sorted 157 + in 158 + let results = List.filter_map (fun filename -> 159 + match date_of_filename filename with 160 + | Some date -> ( 161 + match load ~fs ~changes_dir ~date with 162 + | Ok t -> Some t 163 + | Error _ -> None) 164 + | None -> None) in_range 165 + in 166 + Ok results) 167 + | _ -> Error "Changes directory not found" 168 + | exception Eio.Io _ -> Error "Could not read changes directory" 169 + 170 + let latest ~fs ~changes_dir = 171 + let dir_path = Eio.Path.(fs / Fpath.to_string changes_dir) in 172 + match Eio.Path.kind ~follow:true dir_path with 173 + | `Directory -> ( 174 + let entries = Eio.Path.read_dir dir_path in 175 + let json_files = List.filter (fun f -> 176 + String.length f = 13 && String.ends_with ~suffix:".json" f && 177 + not (String.contains f '-')) entries 178 + in 179 + match List.sort (fun a b -> String.compare b a) json_files with 180 + | [] -> Ok None 181 + | latest_file :: _ -> ( 182 + match date_of_filename latest_file with 183 + | Some date -> ( 184 + match load ~fs ~changes_dir ~date with 185 + | Ok t -> Ok (Some t) 186 + | Error e -> Error e) 187 + | None -> Ok None)) 188 + | _ -> Ok None 189 + | exception Eio.Io _ -> Ok None 190 + 191 + let ensure_dir ~fs dir = 192 + let path = Eio.Path.(fs / Fpath.to_string dir) in 193 + match Eio.Path.kind ~follow:true path with 194 + | `Directory -> () 195 + | _ -> Eio.Path.mkdir ~perm:0o755 path 196 + | exception Eio.Io _ -> Eio.Path.mkdir ~perm:0o755 path 197 + 198 + let save ~fs ~changes_dir t = 199 + ensure_dir ~fs changes_dir; 200 + let filename = filename_of_date t.date in 201 + let file_path = Eio.Path.(fs / Fpath.to_string changes_dir / filename) in 202 + match Jsont_bytesrw.encode_string ~format:Jsont.Indent jsont t with 203 + | Ok content -> 204 + Eio.Path.save ~create:(`Or_truncate 0o644) file_path content; 205 + Ok () 206 + | Error e -> Error (Format.sprintf "Failed to encode %s: %s" filename e)
+82
monopam/lib_changes/aggregated.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Aggregated daily changes format. 7 + 8 + This module provides types and JSON codecs for the aggregated daily changes 9 + format stored in [.changes/YYYYMMDD.json] files. These files combine all 10 + repository changes for a single day into a structured format suitable for 11 + broadcasting. *) 12 + 13 + (** {1 Change Types} *) 14 + 15 + (** Classification of changes for grouping in broadcasts. *) 16 + type change_type = 17 + | Feature (** New features or capabilities *) 18 + | Bugfix (** Bug fixes *) 19 + | Documentation (** Documentation updates *) 20 + | Refactor (** Code refactoring *) 21 + | New_library (** Initial import of a new library *) 22 + | Unknown (** Unclassified changes *) 23 + 24 + val change_type_of_string : string -> change_type 25 + val string_of_change_type : change_type -> string 26 + 27 + (** {1 Entry Types} *) 28 + 29 + (** Commit range information. *) 30 + type commit_range = { 31 + from_hash : string; (** Starting commit hash *) 32 + to_hash : string; (** Ending commit hash *) 33 + count : int; (** Number of commits in range *) 34 + } 35 + 36 + (** A single repository's changes for the day. *) 37 + type entry = { 38 + repository : string; (** Repository name *) 39 + summary : string; (** One-line summary of changes *) 40 + changes : string list; (** List of change bullet points *) 41 + commit_range : commit_range; (** Commits included *) 42 + contributors : string list; (** Contributors to these changes *) 43 + repo_url : string option; (** Optional repository URL *) 44 + change_type : change_type; (** Classification of the change *) 45 + } 46 + 47 + (** {1 Aggregated File Type} *) 48 + 49 + (** The complete aggregated daily changes file. *) 50 + type t = { 51 + date : string; (** ISO date YYYY-MM-DD *) 52 + generated_at : Ptime.t; (** When this file was generated *) 53 + git_head : string; (** Monorepo HEAD at generation time *) 54 + entries : entry list; (** All repository entries for this day *) 55 + authors : string list; (** All unique authors for this day *) 56 + } 57 + 58 + (** {1 JSON Codecs} *) 59 + 60 + val jsont : t Jsont.t 61 + val entry_jsont : entry Jsont.t 62 + 63 + (** {1 File I/O} *) 64 + 65 + val load : fs:_ Eio.Path.t -> changes_dir:Fpath.t -> date:string -> (t, string) result 66 + (** Load aggregated changes for a specific date. 67 + [date] should be in YYYYMMDD format. *) 68 + 69 + val load_range : 70 + fs:_ Eio.Path.t -> 71 + changes_dir:Fpath.t -> 72 + from_date:string -> 73 + to_date:string -> 74 + (t list, string) result 75 + (** Load all aggregated changes files in date range. 76 + Dates should be in YYYYMMDD format. *) 77 + 78 + val latest : fs:_ Eio.Path.t -> changes_dir:Fpath.t -> (t option, string) result 79 + (** Load the most recent aggregated changes file. *) 80 + 81 + val save : fs:_ Eio.Path.t -> changes_dir:Fpath.t -> t -> (unit, string) result 82 + (** Save aggregated changes to the appropriate file. *)
+4
monopam/lib_changes/dune
··· 1 + (library 2 + (name monopam_changes) 3 + (public_name monopam-changes) 4 + (libraries jsont jsont.bytesrw eio ptime ptime.clock.os fpath))
+12
monopam/lib_changes/monopam_changes.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Library for parsing and querying aggregated daily changes. 7 + 8 + This library provides types and functions for working with the aggregated 9 + daily changes format used by the monopam tool and the poe Zulip bot. *) 10 + 11 + module Aggregated = Aggregated 12 + module Query = Query
+89
monopam/lib_changes/query.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + let changes_since ~fs ~changes_dir ~since = 7 + (* Get the date part of since for filtering *) 8 + let since_date = 9 + let (y, m, d), _ = Ptime.to_date_time since in 10 + Printf.sprintf "%04d-%02d-%02d" y m d 11 + in 12 + (* Get current date for range end *) 13 + let now = Ptime_clock.now () in 14 + let now_date = 15 + let (y, m, d), _ = Ptime.to_date_time now in 16 + Printf.sprintf "%04d-%02d-%02d" y m d 17 + in 18 + match Aggregated.load_range ~fs ~changes_dir ~from_date:since_date ~to_date:now_date with 19 + | Error e -> Error e 20 + | Ok aggregated_files -> 21 + (* Filter to files generated after 'since' and collect entries *) 22 + let entries = List.concat_map (fun (agg : Aggregated.t) -> 23 + if Ptime.compare agg.generated_at since > 0 then 24 + agg.entries 25 + else 26 + []) aggregated_files 27 + in 28 + Ok entries 29 + 30 + let has_new_changes ~fs ~changes_dir ~since = 31 + match changes_since ~fs ~changes_dir ~since with 32 + | Ok entries -> entries <> [] 33 + | Error _ -> false 34 + 35 + let format_repo_link repo url_opt = 36 + match url_opt with 37 + | Some url -> Printf.sprintf "[%s](%s)" repo url 38 + | None -> Printf.sprintf "[%s](https://tangled.org/@anil.recoil.org/%s.git)" repo repo 39 + 40 + let format_for_zulip ~entries ~include_date ~date = 41 + if entries = [] then 42 + "No changes to report." 43 + else begin 44 + let buf = Buffer.create 1024 in 45 + if include_date then begin 46 + match date with 47 + | Some d -> Buffer.add_string buf (Printf.sprintf "## Changes for %s\n\n" d) 48 + | None -> Buffer.add_string buf "## Recent Changes\n\n" 49 + end; 50 + (* Group by change type *) 51 + let by_type = [ 52 + (Aggregated.New_library, "New Libraries", []); 53 + (Aggregated.Feature, "Features", []); 54 + (Aggregated.Bugfix, "Bug Fixes", []); 55 + (Aggregated.Documentation, "Documentation", []); 56 + (Aggregated.Refactor, "Improvements", []); 57 + (Aggregated.Unknown, "Other Changes", []); 58 + ] in 59 + let grouped = List.map (fun (ct, title, _) -> 60 + let matching = List.filter (fun (e : Aggregated.entry) -> e.change_type = ct) entries in 61 + (ct, title, matching)) by_type 62 + in 63 + List.iter (fun (_ct, title, entries) -> 64 + if entries <> [] then begin 65 + Buffer.add_string buf (Printf.sprintf "### %s\n\n" title); 66 + List.iter (fun (entry : Aggregated.entry) -> 67 + let repo_link = format_repo_link entry.repository entry.repo_url in 68 + Buffer.add_string buf (Printf.sprintf "**%s**: %s\n" repo_link entry.summary); 69 + List.iter (fun change -> 70 + Buffer.add_string buf (Printf.sprintf "- %s\n" change)) entry.changes; 71 + if entry.contributors <> [] then 72 + Buffer.add_string buf (Printf.sprintf "*Contributors: %s*\n" 73 + (String.concat ", " entry.contributors)); 74 + Buffer.add_string buf "\n") entries 75 + end) grouped; 76 + Buffer.contents buf 77 + end 78 + 79 + let format_summary ~entries = 80 + if entries = [] then 81 + "No new changes." 82 + else 83 + let count = List.length entries in 84 + let repos = List.sort_uniq String.compare 85 + (List.map (fun (e : Aggregated.entry) -> e.repository) entries) in 86 + Printf.sprintf "%d change%s across %d repositor%s: %s" 87 + count (if count = 1 then "" else "s") 88 + (List.length repos) (if List.length repos = 1 then "y" else "ies") 89 + (String.concat ", " repos)
+42
monopam/lib_changes/query.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2026 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** High-level query interface for changes. 7 + 8 + This module provides convenient functions for querying changes since a 9 + specific timestamp and formatting them for broadcast. *) 10 + 11 + (** {1 Querying Changes} *) 12 + 13 + val changes_since : 14 + fs:_ Eio.Path.t -> 15 + changes_dir:Fpath.t -> 16 + since:Ptime.t -> 17 + (Aggregated.entry list, string) result 18 + (** Get all change entries from aggregated files created after [since]. 19 + Returns entries from all days after the timestamp. *) 20 + 21 + val has_new_changes : 22 + fs:_ Eio.Path.t -> 23 + changes_dir:Fpath.t -> 24 + since:Ptime.t -> 25 + bool 26 + (** Check if there are any new changes since the given timestamp. *) 27 + 28 + (** {1 Formatting} *) 29 + 30 + val format_for_zulip : 31 + entries:Aggregated.entry list -> 32 + include_date:bool -> 33 + date:string option -> 34 + string 35 + (** Format entries as markdown suitable for Zulip. 36 + If [include_date] is true, includes a date header. 37 + [date] is used for the header if provided. *) 38 + 39 + val format_summary : 40 + entries:Aggregated.entry list -> 41 + string 42 + (** Format a brief summary of the changes. *)
+35
monopam/monopam-changes.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Parse and query aggregated daily changes from monopam" 4 + description: 5 + "Library for parsing and querying the aggregated daily changes format produced by monopam, suitable for broadcasting via Zulip or other channels." 6 + maintainer: ["Anil Madhavapeddy <anil@recoil.org>"] 7 + authors: ["Anil Madhavapeddy <anil@recoil.org>"] 8 + license: "ISC" 9 + homepage: "https://tangled.org/@anil.recoil.org/monopam" 10 + bug-reports: "https://tangled.org/@anil.recoil.org/monopam/issues" 11 + depends: [ 12 + "ocaml" {>= "5.2.0"} 13 + "dune" {>= "3.20" & >= "3.20"} 14 + "eio" {>= "1.2"} 15 + "jsont" {>= "0.2.0"} 16 + "ptime" {>= "1.0.0"} 17 + "fpath" {>= "0.7.0"} 18 + "odoc" {with-doc} 19 + ] 20 + build: [ 21 + ["dune" "subst"] {dev} 22 + [ 23 + "dune" 24 + "build" 25 + "-p" 26 + name 27 + "-j" 28 + jobs 29 + "@install" 30 + "@runtest" {with-test} 31 + "@doc" {with-doc} 32 + ] 33 + ] 34 + dev-repo: "git+https://tangled.org/@anil.recoil.org/monopam.git" 35 + x-maintenance-intent: ["(latest)"]
+60 -2
poe/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
poe/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
poe/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
poe/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
poe/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
poe/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
poe/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
poe/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
poe/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
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)) 4 + (libraries eio eio_main zulip zulip.bot claude tomlt tomlt.bytesrw xdge logs fpath ptime ptime.clock.os monopam-changes))
+39 -24
poe/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
poe/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
poe/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
poe/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