ScuttleBot
Merge pull request #142 from ConflictHQ/feature/96-protocol-enhancements feat: TOON format, channel display config, relay envelope mode
Commit
a027855855f306f913585eb1feb75b8b39dc1af2cb5d757108eb326d4487dd9a
Parent
6d94dfdb60113d8…
11 files changed
+34
+34
+8
+8
+2
+9
-10
+34
-7
+34
-6
+3
+121
+65
+34
| --- internal/api/chat.go | ||
| +++ internal/api/chat.go | ||
| @@ -117,10 +117,44 @@ | ||
| 117 | 117 | users = []bridge.UserInfo{} |
| 118 | 118 | } |
| 119 | 119 | modes := s.bridge.ChannelModes(channel) |
| 120 | 120 | writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes}) |
| 121 | 121 | } |
| 122 | + | |
| 123 | +func (s *Server) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) { | |
| 124 | + channel := "#" + r.PathValue("channel") | |
| 125 | + if s.policies == nil { | |
| 126 | + writeJSON(w, http.StatusOK, ChannelDisplayConfig{}) | |
| 127 | + return | |
| 128 | + } | |
| 129 | + p := s.policies.Get() | |
| 130 | + cfg := p.Bridge.ChannelDisplay[channel] | |
| 131 | + writeJSON(w, http.StatusOK, cfg) | |
| 132 | +} | |
| 133 | + | |
| 134 | +func (s *Server) handlePutChannelConfig(w http.ResponseWriter, r *http.Request) { | |
| 135 | + channel := "#" + r.PathValue("channel") | |
| 136 | + if s.policies == nil { | |
| 137 | + writeError(w, http.StatusServiceUnavailable, "policies not configured") | |
| 138 | + return | |
| 139 | + } | |
| 140 | + var cfg ChannelDisplayConfig | |
| 141 | + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { | |
| 142 | + writeError(w, http.StatusBadRequest, "invalid request body") | |
| 143 | + return | |
| 144 | + } | |
| 145 | + p := s.policies.Get() | |
| 146 | + if p.Bridge.ChannelDisplay == nil { | |
| 147 | + p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig) | |
| 148 | + } | |
| 149 | + p.Bridge.ChannelDisplay[channel] = cfg | |
| 150 | + if err := s.policies.Set(p); err != nil { | |
| 151 | + writeError(w, http.StatusInternalServerError, "save failed") | |
| 152 | + return | |
| 153 | + } | |
| 154 | + w.WriteHeader(http.StatusNoContent) | |
| 155 | +} | |
| 122 | 156 | |
| 123 | 157 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 124 | 158 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 125 | 159 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 126 | 160 | token := r.URL.Query().Get("token") |
| 127 | 161 |
| --- internal/api/chat.go | |
| +++ internal/api/chat.go | |
| @@ -117,10 +117,44 @@ | |
| 117 | users = []bridge.UserInfo{} |
| 118 | } |
| 119 | modes := s.bridge.ChannelModes(channel) |
| 120 | writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes}) |
| 121 | } |
| 122 | |
| 123 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 124 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 125 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 126 | token := r.URL.Query().Get("token") |
| 127 |
| --- internal/api/chat.go | |
| +++ internal/api/chat.go | |
| @@ -117,10 +117,44 @@ | |
| 117 | users = []bridge.UserInfo{} |
| 118 | } |
| 119 | modes := s.bridge.ChannelModes(channel) |
| 120 | writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes}) |
| 121 | } |
| 122 | |
| 123 | func (s *Server) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) { |
| 124 | channel := "#" + r.PathValue("channel") |
| 125 | if s.policies == nil { |
| 126 | writeJSON(w, http.StatusOK, ChannelDisplayConfig{}) |
| 127 | return |
| 128 | } |
| 129 | p := s.policies.Get() |
| 130 | cfg := p.Bridge.ChannelDisplay[channel] |
| 131 | writeJSON(w, http.StatusOK, cfg) |
| 132 | } |
| 133 | |
| 134 | func (s *Server) handlePutChannelConfig(w http.ResponseWriter, r *http.Request) { |
| 135 | channel := "#" + r.PathValue("channel") |
| 136 | if s.policies == nil { |
| 137 | writeError(w, http.StatusServiceUnavailable, "policies not configured") |
| 138 | return |
| 139 | } |
| 140 | var cfg ChannelDisplayConfig |
| 141 | if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { |
| 142 | writeError(w, http.StatusBadRequest, "invalid request body") |
| 143 | return |
| 144 | } |
| 145 | p := s.policies.Get() |
| 146 | if p.Bridge.ChannelDisplay == nil { |
| 147 | p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig) |
| 148 | } |
| 149 | p.Bridge.ChannelDisplay[channel] = cfg |
| 150 | if err := s.policies.Set(p); err != nil { |
| 151 | writeError(w, http.StatusInternalServerError, "save failed") |
| 152 | return |
| 153 | } |
| 154 | w.WriteHeader(http.StatusNoContent) |
| 155 | } |
| 156 | |
| 157 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 158 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 159 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 160 | token := r.URL.Query().Get("token") |
| 161 |
+34
| --- internal/api/chat.go | ||
| +++ internal/api/chat.go | ||
| @@ -117,10 +117,44 @@ | ||
| 117 | 117 | users = []bridge.UserInfo{} |
| 118 | 118 | } |
| 119 | 119 | modes := s.bridge.ChannelModes(channel) |
| 120 | 120 | writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes}) |
| 121 | 121 | } |
| 122 | + | |
| 123 | +func (s *Server) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) { | |
| 124 | + channel := "#" + r.PathValue("channel") | |
| 125 | + if s.policies == nil { | |
| 126 | + writeJSON(w, http.StatusOK, ChannelDisplayConfig{}) | |
| 127 | + return | |
| 128 | + } | |
| 129 | + p := s.policies.Get() | |
| 130 | + cfg := p.Bridge.ChannelDisplay[channel] | |
| 131 | + writeJSON(w, http.StatusOK, cfg) | |
| 132 | +} | |
| 133 | + | |
| 134 | +func (s *Server) handlePutChannelConfig(w http.ResponseWriter, r *http.Request) { | |
| 135 | + channel := "#" + r.PathValue("channel") | |
| 136 | + if s.policies == nil { | |
| 137 | + writeError(w, http.StatusServiceUnavailable, "policies not configured") | |
| 138 | + return | |
| 139 | + } | |
| 140 | + var cfg ChannelDisplayConfig | |
| 141 | + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { | |
| 142 | + writeError(w, http.StatusBadRequest, "invalid request body") | |
| 143 | + return | |
| 144 | + } | |
| 145 | + p := s.policies.Get() | |
| 146 | + if p.Bridge.ChannelDisplay == nil { | |
| 147 | + p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig) | |
| 148 | + } | |
| 149 | + p.Bridge.ChannelDisplay[channel] = cfg | |
| 150 | + if err := s.policies.Set(p); err != nil { | |
| 151 | + writeError(w, http.StatusInternalServerError, "save failed") | |
| 152 | + return | |
| 153 | + } | |
| 154 | + w.WriteHeader(http.StatusNoContent) | |
| 155 | +} | |
| 122 | 156 | |
| 123 | 157 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 124 | 158 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 125 | 159 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 126 | 160 | token := r.URL.Query().Get("token") |
| 127 | 161 |
| --- internal/api/chat.go | |
| +++ internal/api/chat.go | |
| @@ -117,10 +117,44 @@ | |
| 117 | users = []bridge.UserInfo{} |
| 118 | } |
| 119 | modes := s.bridge.ChannelModes(channel) |
| 120 | writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes}) |
| 121 | } |
| 122 | |
| 123 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 124 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 125 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 126 | token := r.URL.Query().Get("token") |
| 127 |
| --- internal/api/chat.go | |
| +++ internal/api/chat.go | |
| @@ -117,10 +117,44 @@ | |
| 117 | users = []bridge.UserInfo{} |
| 118 | } |
| 119 | modes := s.bridge.ChannelModes(channel) |
| 120 | writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes}) |
| 121 | } |
| 122 | |
| 123 | func (s *Server) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) { |
| 124 | channel := "#" + r.PathValue("channel") |
| 125 | if s.policies == nil { |
| 126 | writeJSON(w, http.StatusOK, ChannelDisplayConfig{}) |
| 127 | return |
| 128 | } |
| 129 | p := s.policies.Get() |
| 130 | cfg := p.Bridge.ChannelDisplay[channel] |
| 131 | writeJSON(w, http.StatusOK, cfg) |
| 132 | } |
| 133 | |
| 134 | func (s *Server) handlePutChannelConfig(w http.ResponseWriter, r *http.Request) { |
| 135 | channel := "#" + r.PathValue("channel") |
| 136 | if s.policies == nil { |
| 137 | writeError(w, http.StatusServiceUnavailable, "policies not configured") |
| 138 | return |
| 139 | } |
| 140 | var cfg ChannelDisplayConfig |
| 141 | if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { |
| 142 | writeError(w, http.StatusBadRequest, "invalid request body") |
| 143 | return |
| 144 | } |
| 145 | p := s.policies.Get() |
| 146 | if p.Bridge.ChannelDisplay == nil { |
| 147 | p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig) |
| 148 | } |
| 149 | p.Bridge.ChannelDisplay[channel] = cfg |
| 150 | if err := s.policies.Set(p); err != nil { |
| 151 | writeError(w, http.StatusInternalServerError, "save failed") |
| 152 | return |
| 153 | } |
| 154 | w.WriteHeader(http.StatusNoContent) |
| 155 | } |
| 156 | |
| 157 | // handleChannelStream serves an SSE stream of IRC messages for a channel. |
| 158 | // Auth is via ?token= query param because EventSource doesn't support custom headers. |
| 159 | func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) { |
| 160 | token := r.URL.Query().Get("token") |
| 161 |
| --- internal/api/policies.go | ||
| +++ internal/api/policies.go | ||
| @@ -42,16 +42,24 @@ | ||
| 42 | 42 | Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size" |
| 43 | 43 | MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited |
| 44 | 44 | PerChannel bool `json:"per_channel"` // separate file per channel |
| 45 | 45 | MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all |
| 46 | 46 | } |
| 47 | + | |
| 48 | +// ChannelDisplayConfig holds per-channel rendering preferences. | |
| 49 | +type ChannelDisplayConfig struct { | |
| 50 | + MirrorDetail string `json:"mirror_detail,omitempty"` // "full", "compact", "minimal" | |
| 51 | + RenderMode string `json:"render_mode,omitempty"` // "rich", "text" | |
| 52 | +} | |
| 47 | 53 | |
| 48 | 54 | // BridgePolicy configures bridge-specific UI/relay behavior. |
| 49 | 55 | type BridgePolicy struct { |
| 50 | 56 | // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain |
| 51 | 57 | // visible in the channel user list after their last post. |
| 52 | 58 | WebUserTTLMinutes int `json:"web_user_ttl_minutes"` |
| 59 | + // ChannelDisplay holds per-channel rendering config. | |
| 60 | + ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"` | |
| 53 | 61 | } |
| 54 | 62 | |
| 55 | 63 | // PolicyLLMBackend stores an LLM backend configuration in the policy store. |
| 56 | 64 | // This allows backends to be added and edited from the web UI rather than |
| 57 | 65 | // requiring a change to scuttlebot.yaml. |
| 58 | 66 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -42,16 +42,24 @@ | |
| 42 | Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size" |
| 43 | MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited |
| 44 | PerChannel bool `json:"per_channel"` // separate file per channel |
| 45 | MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all |
| 46 | } |
| 47 | |
| 48 | // BridgePolicy configures bridge-specific UI/relay behavior. |
| 49 | type BridgePolicy struct { |
| 50 | // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain |
| 51 | // visible in the channel user list after their last post. |
| 52 | WebUserTTLMinutes int `json:"web_user_ttl_minutes"` |
| 53 | } |
| 54 | |
| 55 | // PolicyLLMBackend stores an LLM backend configuration in the policy store. |
| 56 | // This allows backends to be added and edited from the web UI rather than |
| 57 | // requiring a change to scuttlebot.yaml. |
| 58 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -42,16 +42,24 @@ | |
| 42 | Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size" |
| 43 | MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited |
| 44 | PerChannel bool `json:"per_channel"` // separate file per channel |
| 45 | MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all |
| 46 | } |
| 47 | |
| 48 | // ChannelDisplayConfig holds per-channel rendering preferences. |
| 49 | type ChannelDisplayConfig struct { |
| 50 | MirrorDetail string `json:"mirror_detail,omitempty"` // "full", "compact", "minimal" |
| 51 | RenderMode string `json:"render_mode,omitempty"` // "rich", "text" |
| 52 | } |
| 53 | |
| 54 | // BridgePolicy configures bridge-specific UI/relay behavior. |
| 55 | type BridgePolicy struct { |
| 56 | // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain |
| 57 | // visible in the channel user list after their last post. |
| 58 | WebUserTTLMinutes int `json:"web_user_ttl_minutes"` |
| 59 | // ChannelDisplay holds per-channel rendering config. |
| 60 | ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"` |
| 61 | } |
| 62 | |
| 63 | // PolicyLLMBackend stores an LLM backend configuration in the policy store. |
| 64 | // This allows backends to be added and edited from the web UI rather than |
| 65 | // requiring a change to scuttlebot.yaml. |
| 66 |
| --- internal/api/policies.go | ||
| +++ internal/api/policies.go | ||
| @@ -42,16 +42,24 @@ | ||
| 42 | 42 | Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size" |
| 43 | 43 | MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited |
| 44 | 44 | PerChannel bool `json:"per_channel"` // separate file per channel |
| 45 | 45 | MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all |
| 46 | 46 | } |
| 47 | + | |
| 48 | +// ChannelDisplayConfig holds per-channel rendering preferences. | |
| 49 | +type ChannelDisplayConfig struct { | |
| 50 | + MirrorDetail string `json:"mirror_detail,omitempty"` // "full", "compact", "minimal" | |
| 51 | + RenderMode string `json:"render_mode,omitempty"` // "rich", "text" | |
| 52 | +} | |
| 47 | 53 | |
| 48 | 54 | // BridgePolicy configures bridge-specific UI/relay behavior. |
| 49 | 55 | type BridgePolicy struct { |
| 50 | 56 | // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain |
| 51 | 57 | // visible in the channel user list after their last post. |
| 52 | 58 | WebUserTTLMinutes int `json:"web_user_ttl_minutes"` |
| 59 | + // ChannelDisplay holds per-channel rendering config. | |
| 60 | + ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"` | |
| 53 | 61 | } |
| 54 | 62 | |
| 55 | 63 | // PolicyLLMBackend stores an LLM backend configuration in the policy store. |
| 56 | 64 | // This allows backends to be added and edited from the web UI rather than |
| 57 | 65 | // requiring a change to scuttlebot.yaml. |
| 58 | 66 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -42,16 +42,24 @@ | |
| 42 | Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size" |
| 43 | MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited |
| 44 | PerChannel bool `json:"per_channel"` // separate file per channel |
| 45 | MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all |
| 46 | } |
| 47 | |
| 48 | // BridgePolicy configures bridge-specific UI/relay behavior. |
| 49 | type BridgePolicy struct { |
| 50 | // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain |
| 51 | // visible in the channel user list after their last post. |
| 52 | WebUserTTLMinutes int `json:"web_user_ttl_minutes"` |
| 53 | } |
| 54 | |
| 55 | // PolicyLLMBackend stores an LLM backend configuration in the policy store. |
| 56 | // This allows backends to be added and edited from the web UI rather than |
| 57 | // requiring a change to scuttlebot.yaml. |
| 58 |
| --- internal/api/policies.go | |
| +++ internal/api/policies.go | |
| @@ -42,16 +42,24 @@ | |
| 42 | Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size" |
| 43 | MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited |
| 44 | PerChannel bool `json:"per_channel"` // separate file per channel |
| 45 | MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all |
| 46 | } |
| 47 | |
| 48 | // ChannelDisplayConfig holds per-channel rendering preferences. |
| 49 | type ChannelDisplayConfig struct { |
| 50 | MirrorDetail string `json:"mirror_detail,omitempty"` // "full", "compact", "minimal" |
| 51 | RenderMode string `json:"render_mode,omitempty"` // "rich", "text" |
| 52 | } |
| 53 | |
| 54 | // BridgePolicy configures bridge-specific UI/relay behavior. |
| 55 | type BridgePolicy struct { |
| 56 | // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain |
| 57 | // visible in the channel user list after their last post. |
| 58 | WebUserTTLMinutes int `json:"web_user_ttl_minutes"` |
| 59 | // ChannelDisplay holds per-channel rendering config. |
| 60 | ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"` |
| 61 | } |
| 62 | |
| 63 | // PolicyLLMBackend stores an LLM backend configuration in the policy store. |
| 64 | // This allows backends to be added and edited from the web UI rather than |
| 65 | // requiring a change to scuttlebot.yaml. |
| 66 |
| --- internal/api/server.go | ||
| +++ internal/api/server.go | ||
| @@ -85,10 +85,12 @@ | ||
| 85 | 85 | apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.requireScope(auth.ScopeChannels, s.handleDeleteChannel)) |
| 86 | 86 | apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChannels, s.handleChannelMessages)) |
| 87 | 87 | apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChat, s.handleSendMessage)) |
| 88 | 88 | apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.requireScope(auth.ScopeChat, s.handleChannelPresence)) |
| 89 | 89 | apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.requireScope(auth.ScopeChannels, s.handleChannelUsers)) |
| 90 | + apiMux.HandleFunc("GET /v1/channels/{channel}/config", s.requireScope(auth.ScopeChannels, s.handleGetChannelConfig)) | |
| 91 | + apiMux.HandleFunc("PUT /v1/channels/{channel}/config", s.requireScope(auth.ScopeAdmin, s.handlePutChannelConfig)) | |
| 90 | 92 | } |
| 91 | 93 | |
| 92 | 94 | // Topology — topology scope. |
| 93 | 95 | if s.topoMgr != nil { |
| 94 | 96 | apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel)) |
| 95 | 97 |
| --- internal/api/server.go | |
| +++ internal/api/server.go | |
| @@ -85,10 +85,12 @@ | |
| 85 | apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.requireScope(auth.ScopeChannels, s.handleDeleteChannel)) |
| 86 | apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChannels, s.handleChannelMessages)) |
| 87 | apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChat, s.handleSendMessage)) |
| 88 | apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.requireScope(auth.ScopeChat, s.handleChannelPresence)) |
| 89 | apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.requireScope(auth.ScopeChannels, s.handleChannelUsers)) |
| 90 | } |
| 91 | |
| 92 | // Topology — topology scope. |
| 93 | if s.topoMgr != nil { |
| 94 | apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel)) |
| 95 |
| --- internal/api/server.go | |
| +++ internal/api/server.go | |
| @@ -85,10 +85,12 @@ | |
| 85 | apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.requireScope(auth.ScopeChannels, s.handleDeleteChannel)) |
| 86 | apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChannels, s.handleChannelMessages)) |
| 87 | apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChat, s.handleSendMessage)) |
| 88 | apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.requireScope(auth.ScopeChat, s.handleChannelPresence)) |
| 89 | apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.requireScope(auth.ScopeChannels, s.handleChannelUsers)) |
| 90 | apiMux.HandleFunc("GET /v1/channels/{channel}/config", s.requireScope(auth.ScopeChannels, s.handleGetChannelConfig)) |
| 91 | apiMux.HandleFunc("PUT /v1/channels/{channel}/config", s.requireScope(auth.ScopeAdmin, s.handlePutChannelConfig)) |
| 92 | } |
| 93 | |
| 94 | // Topology — topology scope. |
| 95 | if s.topoMgr != nil { |
| 96 | apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel)) |
| 97 |
+9
-10
| --- internal/bots/oracle/oracle.go | ||
| +++ internal/bots/oracle/oracle.go | ||
| @@ -22,10 +22,11 @@ | ||
| 22 | 22 | "time" |
| 23 | 23 | |
| 24 | 24 | "github.com/lrstanley/girc" |
| 25 | 25 | |
| 26 | 26 | "github.com/conflicthq/scuttlebot/pkg/chathistory" |
| 27 | + "github.com/conflicthq/scuttlebot/pkg/toon" | |
| 27 | 28 | ) |
| 28 | 29 | |
| 29 | 30 | const ( |
| 30 | 31 | botNick = "oracle" |
| 31 | 32 | defaultLimit = 50 |
| @@ -305,22 +306,20 @@ | ||
| 305 | 306 | } |
| 306 | 307 | return b.history.Query(channel, limit) |
| 307 | 308 | } |
| 308 | 309 | |
| 309 | 310 | func buildPrompt(channel string, entries []HistoryEntry) string { |
| 310 | - var sb strings.Builder | |
| 311 | - fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel) | |
| 312 | - fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n") | |
| 313 | - fmt.Fprintf(&sb, "Be concise. %d messages:\n\n", len(entries)) | |
| 314 | - for _, e := range entries { | |
| 315 | - if e.MessageType != "" { | |
| 316 | - fmt.Fprintf(&sb, "[%s] (type=%s) %s\n", e.Nick, e.MessageType, e.Raw) | |
| 317 | - } else { | |
| 318 | - fmt.Fprintf(&sb, "[%s] %s\n", e.Nick, e.Raw) | |
| 311 | + // Convert to TOON entries for token-efficient LLM context. | |
| 312 | + toonEntries := make([]toon.Entry, len(entries)) | |
| 313 | + for i, e := range entries { | |
| 314 | + toonEntries[i] = toon.Entry{ | |
| 315 | + Nick: e.Nick, | |
| 316 | + MessageType: e.MessageType, | |
| 317 | + Text: e.Raw, | |
| 319 | 318 | } |
| 320 | 319 | } |
| 321 | - return sb.String() | |
| 320 | + return toon.FormatPrompt(channel, toonEntries) | |
| 322 | 321 | } |
| 323 | 322 | |
| 324 | 323 | func formatResponse(channel string, count int, summary string, format Format) string { |
| 325 | 324 | switch format { |
| 326 | 325 | case FormatJSON: |
| 327 | 326 |
| --- internal/bots/oracle/oracle.go | |
| +++ internal/bots/oracle/oracle.go | |
| @@ -22,10 +22,11 @@ | |
| 22 | "time" |
| 23 | |
| 24 | "github.com/lrstanley/girc" |
| 25 | |
| 26 | "github.com/conflicthq/scuttlebot/pkg/chathistory" |
| 27 | ) |
| 28 | |
| 29 | const ( |
| 30 | botNick = "oracle" |
| 31 | defaultLimit = 50 |
| @@ -305,22 +306,20 @@ | |
| 305 | } |
| 306 | return b.history.Query(channel, limit) |
| 307 | } |
| 308 | |
| 309 | func buildPrompt(channel string, entries []HistoryEntry) string { |
| 310 | var sb strings.Builder |
| 311 | fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel) |
| 312 | fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n") |
| 313 | fmt.Fprintf(&sb, "Be concise. %d messages:\n\n", len(entries)) |
| 314 | for _, e := range entries { |
| 315 | if e.MessageType != "" { |
| 316 | fmt.Fprintf(&sb, "[%s] (type=%s) %s\n", e.Nick, e.MessageType, e.Raw) |
| 317 | } else { |
| 318 | fmt.Fprintf(&sb, "[%s] %s\n", e.Nick, e.Raw) |
| 319 | } |
| 320 | } |
| 321 | return sb.String() |
| 322 | } |
| 323 | |
| 324 | func formatResponse(channel string, count int, summary string, format Format) string { |
| 325 | switch format { |
| 326 | case FormatJSON: |
| 327 |
| --- internal/bots/oracle/oracle.go | |
| +++ internal/bots/oracle/oracle.go | |
| @@ -22,10 +22,11 @@ | |
| 22 | "time" |
| 23 | |
| 24 | "github.com/lrstanley/girc" |
| 25 | |
| 26 | "github.com/conflicthq/scuttlebot/pkg/chathistory" |
| 27 | "github.com/conflicthq/scuttlebot/pkg/toon" |
| 28 | ) |
| 29 | |
| 30 | const ( |
| 31 | botNick = "oracle" |
| 32 | defaultLimit = 50 |
| @@ -305,22 +306,20 @@ | |
| 306 | } |
| 307 | return b.history.Query(channel, limit) |
| 308 | } |
| 309 | |
| 310 | func buildPrompt(channel string, entries []HistoryEntry) string { |
| 311 | // Convert to TOON entries for token-efficient LLM context. |
| 312 | toonEntries := make([]toon.Entry, len(entries)) |
| 313 | for i, e := range entries { |
| 314 | toonEntries[i] = toon.Entry{ |
| 315 | Nick: e.Nick, |
| 316 | MessageType: e.MessageType, |
| 317 | Text: e.Raw, |
| 318 | } |
| 319 | } |
| 320 | return toon.FormatPrompt(channel, toonEntries) |
| 321 | } |
| 322 | |
| 323 | func formatResponse(channel string, count int, summary string, format Format) string { |
| 324 | switch format { |
| 325 | case FormatJSON: |
| 326 |
+34
-7
| --- internal/bots/scroll/scroll.go | ||
| +++ internal/bots/scroll/scroll.go | ||
| @@ -22,10 +22,11 @@ | ||
| 22 | 22 | |
| 23 | 23 | "github.com/lrstanley/girc" |
| 24 | 24 | |
| 25 | 25 | "github.com/conflicthq/scuttlebot/internal/bots/scribe" |
| 26 | 26 | "github.com/conflicthq/scuttlebot/pkg/chathistory" |
| 27 | + "github.com/conflicthq/scuttlebot/pkg/toon" | |
| 27 | 28 | ) |
| 28 | 29 | |
| 29 | 30 | const ( |
| 30 | 31 | botNick = "scroll" |
| 31 | 32 | defaultLimit = 50 |
| @@ -140,11 +141,11 @@ | ||
| 140 | 141 | } |
| 141 | 142 | |
| 142 | 143 | req, err := ParseCommand(text) |
| 143 | 144 | if err != nil { |
| 144 | 145 | client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err)) |
| 145 | - client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]") | |
| 146 | + client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>] [format=json|toon]") | |
| 146 | 147 | return |
| 147 | 148 | } |
| 148 | 149 | |
| 149 | 150 | entries, err := b.fetchHistory(req) |
| 150 | 151 | if err != nil { |
| @@ -155,16 +156,34 @@ | ||
| 155 | 156 | if len(entries) == 0 { |
| 156 | 157 | client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel)) |
| 157 | 158 | return |
| 158 | 159 | } |
| 159 | 160 | |
| 160 | - client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries))) | |
| 161 | - for _, e := range entries { | |
| 162 | - line, _ := json.Marshal(e) | |
| 163 | - client.Cmd.Notice(nick, string(line)) | |
| 161 | + if req.Format == "toon" { | |
| 162 | + toonEntries := make([]toon.Entry, len(entries)) | |
| 163 | + for i, e := range entries { | |
| 164 | + toonEntries[i] = toon.Entry{ | |
| 165 | + Nick: e.Nick, | |
| 166 | + MessageType: e.MessageType, | |
| 167 | + Text: e.Raw, | |
| 168 | + At: e.At, | |
| 169 | + } | |
| 170 | + } | |
| 171 | + output := toon.Format(toonEntries, toon.Options{Channel: req.Channel}) | |
| 172 | + for _, line := range strings.Split(output, "\n") { | |
| 173 | + if line != "" { | |
| 174 | + client.Cmd.Notice(nick, line) | |
| 175 | + } | |
| 176 | + } | |
| 177 | + } else { | |
| 178 | + client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries))) | |
| 179 | + for _, e := range entries { | |
| 180 | + line, _ := json.Marshal(e) | |
| 181 | + client.Cmd.Notice(nick, string(line)) | |
| 182 | + } | |
| 183 | + client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel)) | |
| 164 | 184 | } |
| 165 | - client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel)) | |
| 166 | 185 | } |
| 167 | 186 | |
| 168 | 187 | // fetchHistory tries CHATHISTORY first, falls back to scribe store. |
| 169 | 188 | func (b *Bot) fetchHistory(req *replayRequest) ([]scribe.Entry, error) { |
| 170 | 189 | if b.history != nil && b.client != nil { |
| @@ -208,11 +227,12 @@ | ||
| 208 | 227 | |
| 209 | 228 | // ReplayRequest is a parsed replay command. |
| 210 | 229 | type replayRequest struct { |
| 211 | 230 | Channel string |
| 212 | 231 | Limit int |
| 213 | - Since int64 // unix ms, 0 = no filter | |
| 232 | + Since int64 // unix ms, 0 = no filter | |
| 233 | + Format string // "json" (default) or "toon" | |
| 214 | 234 | } |
| 215 | 235 | |
| 216 | 236 | // ParseCommand parses a replay command string. Exported for testing. |
| 217 | 237 | func ParseCommand(text string) (*replayRequest, error) { |
| 218 | 238 | parts := strings.Fields(text) |
| @@ -246,10 +266,17 @@ | ||
| 246 | 266 | ts, err := strconv.ParseInt(kv[1], 10, 64) |
| 247 | 267 | if err != nil { |
| 248 | 268 | return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1]) |
| 249 | 269 | } |
| 250 | 270 | req.Since = ts |
| 271 | + case "format": | |
| 272 | + switch strings.ToLower(kv[1]) { | |
| 273 | + case "json", "toon": | |
| 274 | + req.Format = strings.ToLower(kv[1]) | |
| 275 | + default: | |
| 276 | + return nil, fmt.Errorf("unknown format %q (use json or toon)", kv[1]) | |
| 277 | + } | |
| 251 | 278 | default: |
| 252 | 279 | return nil, fmt.Errorf("unknown argument %q", kv[0]) |
| 253 | 280 | } |
| 254 | 281 | } |
| 255 | 282 | |
| 256 | 283 |
| --- internal/bots/scroll/scroll.go | |
| +++ internal/bots/scroll/scroll.go | |
| @@ -22,10 +22,11 @@ | |
| 22 | |
| 23 | "github.com/lrstanley/girc" |
| 24 | |
| 25 | "github.com/conflicthq/scuttlebot/internal/bots/scribe" |
| 26 | "github.com/conflicthq/scuttlebot/pkg/chathistory" |
| 27 | ) |
| 28 | |
| 29 | const ( |
| 30 | botNick = "scroll" |
| 31 | defaultLimit = 50 |
| @@ -140,11 +141,11 @@ | |
| 140 | } |
| 141 | |
| 142 | req, err := ParseCommand(text) |
| 143 | if err != nil { |
| 144 | client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err)) |
| 145 | client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]") |
| 146 | return |
| 147 | } |
| 148 | |
| 149 | entries, err := b.fetchHistory(req) |
| 150 | if err != nil { |
| @@ -155,16 +156,34 @@ | |
| 155 | if len(entries) == 0 { |
| 156 | client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel)) |
| 157 | return |
| 158 | } |
| 159 | |
| 160 | client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries))) |
| 161 | for _, e := range entries { |
| 162 | line, _ := json.Marshal(e) |
| 163 | client.Cmd.Notice(nick, string(line)) |
| 164 | } |
| 165 | client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel)) |
| 166 | } |
| 167 | |
| 168 | // fetchHistory tries CHATHISTORY first, falls back to scribe store. |
| 169 | func (b *Bot) fetchHistory(req *replayRequest) ([]scribe.Entry, error) { |
| 170 | if b.history != nil && b.client != nil { |
| @@ -208,11 +227,12 @@ | |
| 208 | |
| 209 | // ReplayRequest is a parsed replay command. |
| 210 | type replayRequest struct { |
| 211 | Channel string |
| 212 | Limit int |
| 213 | Since int64 // unix ms, 0 = no filter |
| 214 | } |
| 215 | |
| 216 | // ParseCommand parses a replay command string. Exported for testing. |
| 217 | func ParseCommand(text string) (*replayRequest, error) { |
| 218 | parts := strings.Fields(text) |
| @@ -246,10 +266,17 @@ | |
| 246 | ts, err := strconv.ParseInt(kv[1], 10, 64) |
| 247 | if err != nil { |
| 248 | return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1]) |
| 249 | } |
| 250 | req.Since = ts |
| 251 | default: |
| 252 | return nil, fmt.Errorf("unknown argument %q", kv[0]) |
| 253 | } |
| 254 | } |
| 255 | |
| 256 |
| --- internal/bots/scroll/scroll.go | |
| +++ internal/bots/scroll/scroll.go | |
| @@ -22,10 +22,11 @@ | |
| 22 | |
| 23 | "github.com/lrstanley/girc" |
| 24 | |
| 25 | "github.com/conflicthq/scuttlebot/internal/bots/scribe" |
| 26 | "github.com/conflicthq/scuttlebot/pkg/chathistory" |
| 27 | "github.com/conflicthq/scuttlebot/pkg/toon" |
| 28 | ) |
| 29 | |
| 30 | const ( |
| 31 | botNick = "scroll" |
| 32 | defaultLimit = 50 |
| @@ -140,11 +141,11 @@ | |
| 141 | } |
| 142 | |
| 143 | req, err := ParseCommand(text) |
| 144 | if err != nil { |
| 145 | client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err)) |
| 146 | client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>] [format=json|toon]") |
| 147 | return |
| 148 | } |
| 149 | |
| 150 | entries, err := b.fetchHistory(req) |
| 151 | if err != nil { |
| @@ -155,16 +156,34 @@ | |
| 156 | if len(entries) == 0 { |
| 157 | client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel)) |
| 158 | return |
| 159 | } |
| 160 | |
| 161 | if req.Format == "toon" { |
| 162 | toonEntries := make([]toon.Entry, len(entries)) |
| 163 | for i, e := range entries { |
| 164 | toonEntries[i] = toon.Entry{ |
| 165 | Nick: e.Nick, |
| 166 | MessageType: e.MessageType, |
| 167 | Text: e.Raw, |
| 168 | At: e.At, |
| 169 | } |
| 170 | } |
| 171 | output := toon.Format(toonEntries, toon.Options{Channel: req.Channel}) |
| 172 | for _, line := range strings.Split(output, "\n") { |
| 173 | if line != "" { |
| 174 | client.Cmd.Notice(nick, line) |
| 175 | } |
| 176 | } |
| 177 | } else { |
| 178 | client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries))) |
| 179 | for _, e := range entries { |
| 180 | line, _ := json.Marshal(e) |
| 181 | client.Cmd.Notice(nick, string(line)) |
| 182 | } |
| 183 | client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel)) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // fetchHistory tries CHATHISTORY first, falls back to scribe store. |
| 188 | func (b *Bot) fetchHistory(req *replayRequest) ([]scribe.Entry, error) { |
| 189 | if b.history != nil && b.client != nil { |
| @@ -208,11 +227,12 @@ | |
| 227 | |
| 228 | // ReplayRequest is a parsed replay command. |
| 229 | type replayRequest struct { |
| 230 | Channel string |
| 231 | Limit int |
| 232 | Since int64 // unix ms, 0 = no filter |
| 233 | Format string // "json" (default) or "toon" |
| 234 | } |
| 235 | |
| 236 | // ParseCommand parses a replay command string. Exported for testing. |
| 237 | func ParseCommand(text string) (*replayRequest, error) { |
| 238 | parts := strings.Fields(text) |
| @@ -246,10 +266,17 @@ | |
| 266 | ts, err := strconv.ParseInt(kv[1], 10, 64) |
| 267 | if err != nil { |
| 268 | return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1]) |
| 269 | } |
| 270 | req.Since = ts |
| 271 | case "format": |
| 272 | switch strings.ToLower(kv[1]) { |
| 273 | case "json", "toon": |
| 274 | req.Format = strings.ToLower(kv[1]) |
| 275 | default: |
| 276 | return nil, fmt.Errorf("unknown format %q (use json or toon)", kv[1]) |
| 277 | } |
| 278 | default: |
| 279 | return nil, fmt.Errorf("unknown argument %q", kv[0]) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 |
+34
-6
| --- pkg/sessionrelay/irc.go | ||
| +++ pkg/sessionrelay/irc.go | ||
| @@ -25,10 +25,11 @@ | ||
| 25 | 25 | nick string |
| 26 | 26 | addr string |
| 27 | 27 | agentType string |
| 28 | 28 | pass string |
| 29 | 29 | deleteOnClose bool |
| 30 | + envelopeMode bool | |
| 30 | 31 | |
| 31 | 32 | mu sync.RWMutex |
| 32 | 33 | channels []string |
| 33 | 34 | messages []Message |
| 34 | 35 | client *girc.Client |
| @@ -50,10 +51,11 @@ | ||
| 50 | 51 | nick: cfg.Nick, |
| 51 | 52 | addr: cfg.IRC.Addr, |
| 52 | 53 | agentType: cfg.IRC.AgentType, |
| 53 | 54 | pass: cfg.IRC.Pass, |
| 54 | 55 | deleteOnClose: cfg.IRC.DeleteOnClose, |
| 56 | + envelopeMode: cfg.IRC.EnvelopeMode, | |
| 55 | 57 | channels: append([]string(nil), cfg.Channels...), |
| 56 | 58 | messages: make([]Message, 0, defaultBufferSize), |
| 57 | 59 | errCh: make(chan error, 1), |
| 58 | 60 | }, nil |
| 59 | 61 | } |
| @@ -241,26 +243,28 @@ | ||
| 241 | 243 | |
| 242 | 244 | func (c *ircConnector) PostTo(_ context.Context, channel, text string) error { |
| 243 | 245 | return c.PostToWithMeta(context.Background(), channel, text, nil) |
| 244 | 246 | } |
| 245 | 247 | |
| 246 | -// PostWithMeta sends text to all channels. Meta is ignored — IRC is text-only. | |
| 247 | -func (c *ircConnector) PostWithMeta(_ context.Context, text string, _ json.RawMessage) error { | |
| 248 | +// PostWithMeta sends text to all channels. | |
| 249 | +// In envelope mode, wraps the message in a protocol.Envelope JSON. | |
| 250 | +func (c *ircConnector) PostWithMeta(_ context.Context, text string, meta json.RawMessage) error { | |
| 248 | 251 | c.mu.RLock() |
| 249 | 252 | client := c.client |
| 250 | 253 | c.mu.RUnlock() |
| 251 | 254 | if client == nil { |
| 252 | 255 | return fmt.Errorf("sessionrelay: irc client not connected") |
| 253 | 256 | } |
| 257 | + msg := c.formatMessage(text, meta) | |
| 254 | 258 | for _, channel := range c.Channels() { |
| 255 | - client.Cmd.Message(channel, text) | |
| 259 | + client.Cmd.Message(channel, msg) | |
| 256 | 260 | } |
| 257 | 261 | return nil |
| 258 | 262 | } |
| 259 | 263 | |
| 260 | -// PostToWithMeta sends text to a specific channel. Meta is ignored — IRC is text-only. | |
| 261 | -func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, _ json.RawMessage) error { | |
| 264 | +// PostToWithMeta sends text to a specific channel. | |
| 265 | +func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, meta json.RawMessage) error { | |
| 262 | 266 | c.mu.RLock() |
| 263 | 267 | client := c.client |
| 264 | 268 | c.mu.RUnlock() |
| 265 | 269 | if client == nil { |
| 266 | 270 | return fmt.Errorf("sessionrelay: irc client not connected") |
| @@ -267,13 +271,37 @@ | ||
| 267 | 271 | } |
| 268 | 272 | channel = normalizeChannel(channel) |
| 269 | 273 | if channel == "" { |
| 270 | 274 | return fmt.Errorf("sessionrelay: post channel is required") |
| 271 | 275 | } |
| 272 | - client.Cmd.Message(channel, text) | |
| 276 | + client.Cmd.Message(channel, c.formatMessage(text, meta)) | |
| 273 | 277 | return nil |
| 274 | 278 | } |
| 279 | + | |
| 280 | +// formatMessage wraps text in a JSON envelope when envelope mode is enabled. | |
| 281 | +func (c *ircConnector) formatMessage(text string, meta json.RawMessage) string { | |
| 282 | + if !c.envelopeMode { | |
| 283 | + return text | |
| 284 | + } | |
| 285 | + env := map[string]any{ | |
| 286 | + "v": 1, | |
| 287 | + "type": "relay.message", | |
| 288 | + "from": c.nick, | |
| 289 | + "ts": time.Now().UnixMilli(), | |
| 290 | + "payload": map[string]any{ | |
| 291 | + "text": text, | |
| 292 | + }, | |
| 293 | + } | |
| 294 | + if len(meta) > 0 { | |
| 295 | + env["payload"] = json.RawMessage(meta) | |
| 296 | + } | |
| 297 | + data, err := json.Marshal(env) | |
| 298 | + if err != nil { | |
| 299 | + return text // fallback to plain text | |
| 300 | + } | |
| 301 | + return string(data) | |
| 302 | +} | |
| 275 | 303 | |
| 276 | 304 | func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) { |
| 277 | 305 | c.mu.RLock() |
| 278 | 306 | defer c.mu.RUnlock() |
| 279 | 307 | |
| 280 | 308 |
| --- pkg/sessionrelay/irc.go | |
| +++ pkg/sessionrelay/irc.go | |
| @@ -25,10 +25,11 @@ | |
| 25 | nick string |
| 26 | addr string |
| 27 | agentType string |
| 28 | pass string |
| 29 | deleteOnClose bool |
| 30 | |
| 31 | mu sync.RWMutex |
| 32 | channels []string |
| 33 | messages []Message |
| 34 | client *girc.Client |
| @@ -50,10 +51,11 @@ | |
| 50 | nick: cfg.Nick, |
| 51 | addr: cfg.IRC.Addr, |
| 52 | agentType: cfg.IRC.AgentType, |
| 53 | pass: cfg.IRC.Pass, |
| 54 | deleteOnClose: cfg.IRC.DeleteOnClose, |
| 55 | channels: append([]string(nil), cfg.Channels...), |
| 56 | messages: make([]Message, 0, defaultBufferSize), |
| 57 | errCh: make(chan error, 1), |
| 58 | }, nil |
| 59 | } |
| @@ -241,26 +243,28 @@ | |
| 241 | |
| 242 | func (c *ircConnector) PostTo(_ context.Context, channel, text string) error { |
| 243 | return c.PostToWithMeta(context.Background(), channel, text, nil) |
| 244 | } |
| 245 | |
| 246 | // PostWithMeta sends text to all channels. Meta is ignored — IRC is text-only. |
| 247 | func (c *ircConnector) PostWithMeta(_ context.Context, text string, _ json.RawMessage) error { |
| 248 | c.mu.RLock() |
| 249 | client := c.client |
| 250 | c.mu.RUnlock() |
| 251 | if client == nil { |
| 252 | return fmt.Errorf("sessionrelay: irc client not connected") |
| 253 | } |
| 254 | for _, channel := range c.Channels() { |
| 255 | client.Cmd.Message(channel, text) |
| 256 | } |
| 257 | return nil |
| 258 | } |
| 259 | |
| 260 | // PostToWithMeta sends text to a specific channel. Meta is ignored — IRC is text-only. |
| 261 | func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, _ json.RawMessage) error { |
| 262 | c.mu.RLock() |
| 263 | client := c.client |
| 264 | c.mu.RUnlock() |
| 265 | if client == nil { |
| 266 | return fmt.Errorf("sessionrelay: irc client not connected") |
| @@ -267,13 +271,37 @@ | |
| 267 | } |
| 268 | channel = normalizeChannel(channel) |
| 269 | if channel == "" { |
| 270 | return fmt.Errorf("sessionrelay: post channel is required") |
| 271 | } |
| 272 | client.Cmd.Message(channel, text) |
| 273 | return nil |
| 274 | } |
| 275 | |
| 276 | func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) { |
| 277 | c.mu.RLock() |
| 278 | defer c.mu.RUnlock() |
| 279 | |
| 280 |
| --- pkg/sessionrelay/irc.go | |
| +++ pkg/sessionrelay/irc.go | |
| @@ -25,10 +25,11 @@ | |
| 25 | nick string |
| 26 | addr string |
| 27 | agentType string |
| 28 | pass string |
| 29 | deleteOnClose bool |
| 30 | envelopeMode bool |
| 31 | |
| 32 | mu sync.RWMutex |
| 33 | channels []string |
| 34 | messages []Message |
| 35 | client *girc.Client |
| @@ -50,10 +51,11 @@ | |
| 51 | nick: cfg.Nick, |
| 52 | addr: cfg.IRC.Addr, |
| 53 | agentType: cfg.IRC.AgentType, |
| 54 | pass: cfg.IRC.Pass, |
| 55 | deleteOnClose: cfg.IRC.DeleteOnClose, |
| 56 | envelopeMode: cfg.IRC.EnvelopeMode, |
| 57 | channels: append([]string(nil), cfg.Channels...), |
| 58 | messages: make([]Message, 0, defaultBufferSize), |
| 59 | errCh: make(chan error, 1), |
| 60 | }, nil |
| 61 | } |
| @@ -241,26 +243,28 @@ | |
| 243 | |
| 244 | func (c *ircConnector) PostTo(_ context.Context, channel, text string) error { |
| 245 | return c.PostToWithMeta(context.Background(), channel, text, nil) |
| 246 | } |
| 247 | |
| 248 | // PostWithMeta sends text to all channels. |
| 249 | // In envelope mode, wraps the message in a protocol.Envelope JSON. |
| 250 | func (c *ircConnector) PostWithMeta(_ context.Context, text string, meta json.RawMessage) error { |
| 251 | c.mu.RLock() |
| 252 | client := c.client |
| 253 | c.mu.RUnlock() |
| 254 | if client == nil { |
| 255 | return fmt.Errorf("sessionrelay: irc client not connected") |
| 256 | } |
| 257 | msg := c.formatMessage(text, meta) |
| 258 | for _, channel := range c.Channels() { |
| 259 | client.Cmd.Message(channel, msg) |
| 260 | } |
| 261 | return nil |
| 262 | } |
| 263 | |
| 264 | // PostToWithMeta sends text to a specific channel. |
| 265 | func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, meta json.RawMessage) error { |
| 266 | c.mu.RLock() |
| 267 | client := c.client |
| 268 | c.mu.RUnlock() |
| 269 | if client == nil { |
| 270 | return fmt.Errorf("sessionrelay: irc client not connected") |
| @@ -267,13 +271,37 @@ | |
| 271 | } |
| 272 | channel = normalizeChannel(channel) |
| 273 | if channel == "" { |
| 274 | return fmt.Errorf("sessionrelay: post channel is required") |
| 275 | } |
| 276 | client.Cmd.Message(channel, c.formatMessage(text, meta)) |
| 277 | return nil |
| 278 | } |
| 279 | |
| 280 | // formatMessage wraps text in a JSON envelope when envelope mode is enabled. |
| 281 | func (c *ircConnector) formatMessage(text string, meta json.RawMessage) string { |
| 282 | if !c.envelopeMode { |
| 283 | return text |
| 284 | } |
| 285 | env := map[string]any{ |
| 286 | "v": 1, |
| 287 | "type": "relay.message", |
| 288 | "from": c.nick, |
| 289 | "ts": time.Now().UnixMilli(), |
| 290 | "payload": map[string]any{ |
| 291 | "text": text, |
| 292 | }, |
| 293 | } |
| 294 | if len(meta) > 0 { |
| 295 | env["payload"] = json.RawMessage(meta) |
| 296 | } |
| 297 | data, err := json.Marshal(env) |
| 298 | if err != nil { |
| 299 | return text // fallback to plain text |
| 300 | } |
| 301 | return string(data) |
| 302 | } |
| 303 | |
| 304 | func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) { |
| 305 | c.mu.RLock() |
| 306 | defer c.mu.RUnlock() |
| 307 | |
| 308 |
| --- pkg/sessionrelay/sessionrelay.go | ||
| +++ pkg/sessionrelay/sessionrelay.go | ||
| @@ -35,10 +35,13 @@ | ||
| 35 | 35 | type IRCConfig struct { |
| 36 | 36 | Addr string |
| 37 | 37 | Pass string |
| 38 | 38 | AgentType string |
| 39 | 39 | DeleteOnClose bool |
| 40 | + // EnvelopeMode wraps outgoing messages in protocol.Envelope JSON. | |
| 41 | + // When true, agents in the channel can parse relay output as structured data. | |
| 42 | + EnvelopeMode bool | |
| 40 | 43 | } |
| 41 | 44 | |
| 42 | 45 | type Message struct { |
| 43 | 46 | At time.Time |
| 44 | 47 | Channel string |
| 45 | 48 | |
| 46 | 49 | ADDED pkg/toon/toon.go |
| 47 | 50 | ADDED pkg/toon/toon_test.go |
| --- pkg/sessionrelay/sessionrelay.go | |
| +++ pkg/sessionrelay/sessionrelay.go | |
| @@ -35,10 +35,13 @@ | |
| 35 | type IRCConfig struct { |
| 36 | Addr string |
| 37 | Pass string |
| 38 | AgentType string |
| 39 | DeleteOnClose bool |
| 40 | } |
| 41 | |
| 42 | type Message struct { |
| 43 | At time.Time |
| 44 | Channel string |
| 45 | |
| 46 | DDED pkg/toon/toon.go |
| 47 | DDED pkg/toon/toon_test.go |
| --- pkg/sessionrelay/sessionrelay.go | |
| +++ pkg/sessionrelay/sessionrelay.go | |
| @@ -35,10 +35,13 @@ | |
| 35 | type IRCConfig struct { |
| 36 | Addr string |
| 37 | Pass string |
| 38 | AgentType string |
| 39 | DeleteOnClose bool |
| 40 | // EnvelopeMode wraps outgoing messages in protocol.Envelope JSON. |
| 41 | // When true, agents in the channel can parse relay output as structured data. |
| 42 | EnvelopeMode bool |
| 43 | } |
| 44 | |
| 45 | type Message struct { |
| 46 | At time.Time |
| 47 | Channel string |
| 48 | |
| 49 | DDED pkg/toon/toon.go |
| 50 | DDED pkg/toon/toon_test.go |
+121
| --- a/pkg/toon/toon.go | ||
| +++ b/pkg/toon/toon.go | ||
| @@ -0,0 +1,121 @@ | ||
| 1 | +// Package toon implements the TOON format — Token-Optimized Object Notation | |
| 2 | +// for compact LLM context windows. | |
| 3 | +// | |
| 4 | +// TOON is designed for feeding IRC conversation history to language models. | |
| 5 | +// It strips noise (joins, parts, status messages, repeated tool calls), | |
| 6 | +// deduplicates, and compresses timestamps into relative offsets. | |
| 7 | +// | |
| 8 | +// Example output: | |
| 9 | +// | |
| 10 | +// #fleet 50msg 2h window | |
| 11 | +// --- | |
| 12 | +// claude-kohakku [orch] +0m | |
| 13 | +// task.create {file: main.go, action: edit} | |
| 14 | +// "editing main.go to add error handling" | |
| 15 | +// leo [op] +2m | |
| 16 | +// "looks good, ship it" | |
| 17 | +// claude-kohakku [orch] +3m | |
| 18 | +// task.complete {file: main.go, status: done} | |
| 19 | +// --- | |
| 20 | +// decisions: edit main.go error handling | |
| 21 | +// actions: task.create → task.complete (main.go) | |
| 22 | +package toon | |
| 23 | + | |
| 24 | +import ( | |
| 25 | + "fmt" | |
| 26 | + "strings" | |
| 27 | + "time" | |
| 28 | +) | |
| 29 | + | |
| 30 | +// Entry is a single message to include in the TOON output. | |
| 31 | +type Entry struct { | |
| 32 | + Nick string | |
| 33 | + Type string // agent type: "orch", "worker", "op", "bot", "" for unknown | |
| 34 | + MessageType string // envelope type (e.g. "task.create"), empty for plain text | |
| 35 | + Text string | |
| 36 | + At time.Time | |
| 37 | +} | |
| 38 | + | |
| 39 | +// Options controls TOON formatting. | |
| 40 | +type Options struct { | |
| 41 | + Channel string | |
| 42 | + MaxEntries int // 0 = no limit | |
| 43 | +} | |
| 44 | + | |
| 45 | +// Format renders a slice of entries into TOON format. | |
| 46 | +func Format(entries []Entry, opts Options) string { | |
| 47 | + if len(entries) == 0 { | |
| 48 | + return "" | |
| 49 | + } | |
| 50 | + | |
| 51 | + var b strings.Builder | |
| 52 | + | |
| 53 | + // Header. | |
| 54 | + window := "" | |
| 55 | + if len(entries) >= 2 { | |
| 56 | + dur := entries[len(entries)-1].At.Sub(entries[0].At) | |
| 57 | + window = " " + compactDuration(dur) + " window" | |
| 58 | + } | |
| 59 | + ch := opts.Channel | |
| 60 | + if ch == "" { | |
| 61 | + ch = "channel" | |
| 62 | + } | |
| 63 | + fmt.Fprintf(&b, "%s %dmsg%s\n---\n", ch, len(entries), window) | |
| 64 | + | |
| 65 | + // Body — group consecutive messages from same nick. | |
| 66 | + baseTime := entries[0].At | |
| 67 | + var lastNick string | |
| 68 | + for _, e := range entries { | |
| 69 | + offset := e.At.Sub(baseTime) | |
| 70 | + if e.Nick != lastNick { | |
| 71 | + tag := "" | |
| 72 | + if e.Type != "" { | |
| 73 | + tag = " [" + e.Type + "]" | |
| 74 | + } | |
| 75 | + fmt.Fprintf(&b, "%s%s +%s\n", e.Nick, tag, compactDuration(offset)) | |
| 76 | + lastNick = e.Nick | |
| 77 | + } | |
| 78 | + | |
| 79 | + if e.MessageType != "" { | |
| 80 | + fmt.Fprintf(&b, " %s\n", e.MessageType) | |
| 81 | + } | |
| 82 | + text := strings.TrimSpace(e.Text) | |
| 83 | + if text != "" && text != e.MessageType { | |
| 84 | + // Truncate very long messages to save tokens. | |
| 85 | + if len(text) > 200 { | |
| 86 | + text = text[:197] + "..." | |
| 87 | + } | |
| 88 | + fmt.Fprintf(&b, " \"%s\"\n", text) | |
| 89 | + } | |
| 90 | + } | |
| 91 | + | |
| 92 | + b.WriteString("---\n") | |
| 93 | + return b.String() | |
| 94 | +} | |
| 95 | + | |
| 96 | +// FormatPrompt wraps TOON-formatted history into an LLM summarization prompt. | |
| 97 | +func FormatPrompt(channel string, entries []Entry) string { | |
| 98 | + toon := Format(entries, Options{Channel: channel}) | |
| 99 | + var b strings.Builder | |
| 100 | + fmt.Fprintf(&b, "Summarize this IRC conversation. Focus on decisions, actions, and outcomes. Be concise.\n\n") | |
| 101 | + b.WriteString(toon) | |
| 102 | + return b.String() | |
| 103 | +} | |
| 104 | + | |
| 105 | +func compactDuration(d time.Duration) string { | |
| 106 | + if d < time.Minute { | |
| 107 | + return fmt.Sprintf("%ds", int(d.Seconds())) | |
| 108 | + } | |
| 109 | + if d < time.Hour { | |
| 110 | + return fmt.Sprintf("%dm", int(d.Minutes())) | |
| 111 | + } | |
| 112 | + if d < 24*time.Hour { | |
| 113 | + h := int(d.Hours()) | |
| 114 | + m := int(d.Minutes()) % 60 | |
| 115 | + if m == 0 { | |
| 116 | + return fmt.Sprintf("%dh", h) | |
| 117 | + } | |
| 118 | + return fmt.Sprintf("%dh%dm", h, m) | |
| 119 | + } | |
| 120 | + return fmt.Sprintf("%dd", int(d.Hours()/24)) | |
| 121 | +} |
| --- a/pkg/toon/toon.go | |
| +++ b/pkg/toon/toon.go | |
| @@ -0,0 +1,121 @@ | |
| --- a/pkg/toon/toon.go | |
| +++ b/pkg/toon/toon.go | |
| @@ -0,0 +1,121 @@ | |
| 1 | // Package toon implements the TOON format — Token-Optimized Object Notation |
| 2 | // for compact LLM context windows. |
| 3 | // |
| 4 | // TOON is designed for feeding IRC conversation history to language models. |
| 5 | // It strips noise (joins, parts, status messages, repeated tool calls), |
| 6 | // deduplicates, and compresses timestamps into relative offsets. |
| 7 | // |
| 8 | // Example output: |
| 9 | // |
| 10 | // #fleet 50msg 2h window |
| 11 | // --- |
| 12 | // claude-kohakku [orch] +0m |
| 13 | // task.create {file: main.go, action: edit} |
| 14 | // "editing main.go to add error handling" |
| 15 | // leo [op] +2m |
| 16 | // "looks good, ship it" |
| 17 | // claude-kohakku [orch] +3m |
| 18 | // task.complete {file: main.go, status: done} |
| 19 | // --- |
| 20 | // decisions: edit main.go error handling |
| 21 | // actions: task.create → task.complete (main.go) |
| 22 | package toon |
| 23 | |
| 24 | import ( |
| 25 | "fmt" |
| 26 | "strings" |
| 27 | "time" |
| 28 | ) |
| 29 | |
| 30 | // Entry is a single message to include in the TOON output. |
| 31 | type Entry struct { |
| 32 | Nick string |
| 33 | Type string // agent type: "orch", "worker", "op", "bot", "" for unknown |
| 34 | MessageType string // envelope type (e.g. "task.create"), empty for plain text |
| 35 | Text string |
| 36 | At time.Time |
| 37 | } |
| 38 | |
| 39 | // Options controls TOON formatting. |
| 40 | type Options struct { |
| 41 | Channel string |
| 42 | MaxEntries int // 0 = no limit |
| 43 | } |
| 44 | |
| 45 | // Format renders a slice of entries into TOON format. |
| 46 | func Format(entries []Entry, opts Options) string { |
| 47 | if len(entries) == 0 { |
| 48 | return "" |
| 49 | } |
| 50 | |
| 51 | var b strings.Builder |
| 52 | |
| 53 | // Header. |
| 54 | window := "" |
| 55 | if len(entries) >= 2 { |
| 56 | dur := entries[len(entries)-1].At.Sub(entries[0].At) |
| 57 | window = " " + compactDuration(dur) + " window" |
| 58 | } |
| 59 | ch := opts.Channel |
| 60 | if ch == "" { |
| 61 | ch = "channel" |
| 62 | } |
| 63 | fmt.Fprintf(&b, "%s %dmsg%s\n---\n", ch, len(entries), window) |
| 64 | |
| 65 | // Body — group consecutive messages from same nick. |
| 66 | baseTime := entries[0].At |
| 67 | var lastNick string |
| 68 | for _, e := range entries { |
| 69 | offset := e.At.Sub(baseTime) |
| 70 | if e.Nick != lastNick { |
| 71 | tag := "" |
| 72 | if e.Type != "" { |
| 73 | tag = " [" + e.Type + "]" |
| 74 | } |
| 75 | fmt.Fprintf(&b, "%s%s +%s\n", e.Nick, tag, compactDuration(offset)) |
| 76 | lastNick = e.Nick |
| 77 | } |
| 78 | |
| 79 | if e.MessageType != "" { |
| 80 | fmt.Fprintf(&b, " %s\n", e.MessageType) |
| 81 | } |
| 82 | text := strings.TrimSpace(e.Text) |
| 83 | if text != "" && text != e.MessageType { |
| 84 | // Truncate very long messages to save tokens. |
| 85 | if len(text) > 200 { |
| 86 | text = text[:197] + "..." |
| 87 | } |
| 88 | fmt.Fprintf(&b, " \"%s\"\n", text) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | b.WriteString("---\n") |
| 93 | return b.String() |
| 94 | } |
| 95 | |
| 96 | // FormatPrompt wraps TOON-formatted history into an LLM summarization prompt. |
| 97 | func FormatPrompt(channel string, entries []Entry) string { |
| 98 | toon := Format(entries, Options{Channel: channel}) |
| 99 | var b strings.Builder |
| 100 | fmt.Fprintf(&b, "Summarize this IRC conversation. Focus on decisions, actions, and outcomes. Be concise.\n\n") |
| 101 | b.WriteString(toon) |
| 102 | return b.String() |
| 103 | } |
| 104 | |
| 105 | func compactDuration(d time.Duration) string { |
| 106 | if d < time.Minute { |
| 107 | return fmt.Sprintf("%ds", int(d.Seconds())) |
| 108 | } |
| 109 | if d < time.Hour { |
| 110 | return fmt.Sprintf("%dm", int(d.Minutes())) |
| 111 | } |
| 112 | if d < 24*time.Hour { |
| 113 | h := int(d.Hours()) |
| 114 | m := int(d.Minutes()) % 60 |
| 115 | if m == 0 { |
| 116 | return fmt.Sprintf("%dh", h) |
| 117 | } |
| 118 | return fmt.Sprintf("%dh%dm", h, m) |
| 119 | } |
| 120 | return fmt.Sprintf("%dd", int(d.Hours()/24)) |
| 121 | } |
+65
| --- a/pkg/toon/toon_test.go | ||
| +++ b/pkg/toon/toon_test.go | ||
| @@ -0,0 +1,65 @@ | ||
| 1 | +package toon | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + "time" | |
| 7 | +) | |
| 8 | + | |
| 9 | +func TestFormatEmpty(t *testing.T) { | |
| 10 | + if got := Format(nil, Options{}); got != "" { | |
| 11 | + t.Errorf("expected empty, got %q", got) | |
| 12 | + } | |
| 13 | +} | |
| 14 | + | |
| 15 | +func TestFormatBasic(t *testing.T) { | |
| 16 | + base := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC) | |
| 17 | + entries := []Entry{ | |
| 18 | + {Nick: "alice", Type: "op", Text: "let's ship it", At: base}, | |
| 19 | + {Nick: "claude-abc", Type: "orch", MessageType: "task.create", Text: "editing main.go", At: base.Add(2 * time.Minute)}, | |
| 20 | + {Nick: "claude-abc", Type: "orch", MessageType: "task.complete", Text: "done", At: base.Add(5 * time.Minute)}, | |
| 21 | + } | |
| 22 | + out := Format(entries, Options{Channel: "#fleet"}) | |
| 23 | + | |
| 24 | + // Header. | |
| 25 | + if !strings.HasPrefix(out, "#fleet 3msg") { | |
| 26 | + t.Errorf("header mismatch: %q", out) | |
| 27 | + } | |
| 28 | + // Grouped consecutive messages from claude-abc. | |
| 29 | + if strings.Count(out, "claude-abc") != 1 { | |
| 30 | + t.Errorf("expected nick grouping, got:\n%s", out) | |
| 31 | + } | |
| 32 | + // Contains message types. | |
| 33 | + if !strings.Contains(out, "task.create") || !strings.Contains(out, "task.complete") { | |
| 34 | + t.Errorf("missing message types:\n%s", out) | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +func TestFormatPrompt(t *testing.T) { | |
| 39 | + entries := []Entry{{Nick: "a", Text: "hello"}} | |
| 40 | + out := FormatPrompt("#test", entries) | |
| 41 | + if !strings.Contains(out, "Summarize") { | |
| 42 | + t.Errorf("prompt missing instruction:\n%s", out) | |
| 43 | + } | |
| 44 | + if !strings.Contains(out, "#test") { | |
| 45 | + t.Errorf("prompt missing channel:\n%s", out) | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +func TestCompactDuration(t *testing.T) { | |
| 50 | + tests := []struct { | |
| 51 | + d time.Duration | |
| 52 | + want string | |
| 53 | + }{ | |
| 54 | + {30 * time.Second, "30s"}, | |
| 55 | + {5 * time.Minute, "5m"}, | |
| 56 | + {2 * time.Hour, "2h"}, | |
| 57 | + {2*time.Hour + 30*time.Minute, "2h30m"}, | |
| 58 | + {48 * time.Hour, "2d"}, | |
| 59 | + } | |
| 60 | + for _, tt := range tests { | |
| 61 | + if got := compactDuration(tt.d); got != tt.want { | |
| 62 | + t.Errorf("compactDuration(%v) = %q, want %q", tt.d, got, tt.want) | |
| 63 | + } | |
| 64 | + } | |
| 65 | +} |
| --- a/pkg/toon/toon_test.go | |
| +++ b/pkg/toon/toon_test.go | |
| @@ -0,0 +1,65 @@ | |
| --- a/pkg/toon/toon_test.go | |
| +++ b/pkg/toon/toon_test.go | |
| @@ -0,0 +1,65 @@ | |
| 1 | package toon |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | func TestFormatEmpty(t *testing.T) { |
| 10 | if got := Format(nil, Options{}); got != "" { |
| 11 | t.Errorf("expected empty, got %q", got) |
| 12 | } |
| 13 | } |
| 14 | |
| 15 | func TestFormatBasic(t *testing.T) { |
| 16 | base := time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC) |
| 17 | entries := []Entry{ |
| 18 | {Nick: "alice", Type: "op", Text: "let's ship it", At: base}, |
| 19 | {Nick: "claude-abc", Type: "orch", MessageType: "task.create", Text: "editing main.go", At: base.Add(2 * time.Minute)}, |
| 20 | {Nick: "claude-abc", Type: "orch", MessageType: "task.complete", Text: "done", At: base.Add(5 * time.Minute)}, |
| 21 | } |
| 22 | out := Format(entries, Options{Channel: "#fleet"}) |
| 23 | |
| 24 | // Header. |
| 25 | if !strings.HasPrefix(out, "#fleet 3msg") { |
| 26 | t.Errorf("header mismatch: %q", out) |
| 27 | } |
| 28 | // Grouped consecutive messages from claude-abc. |
| 29 | if strings.Count(out, "claude-abc") != 1 { |
| 30 | t.Errorf("expected nick grouping, got:\n%s", out) |
| 31 | } |
| 32 | // Contains message types. |
| 33 | if !strings.Contains(out, "task.create") || !strings.Contains(out, "task.complete") { |
| 34 | t.Errorf("missing message types:\n%s", out) |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | func TestFormatPrompt(t *testing.T) { |
| 39 | entries := []Entry{{Nick: "a", Text: "hello"}} |
| 40 | out := FormatPrompt("#test", entries) |
| 41 | if !strings.Contains(out, "Summarize") { |
| 42 | t.Errorf("prompt missing instruction:\n%s", out) |
| 43 | } |
| 44 | if !strings.Contains(out, "#test") { |
| 45 | t.Errorf("prompt missing channel:\n%s", out) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | func TestCompactDuration(t *testing.T) { |
| 50 | tests := []struct { |
| 51 | d time.Duration |
| 52 | want string |
| 53 | }{ |
| 54 | {30 * time.Second, "30s"}, |
| 55 | {5 * time.Minute, "5m"}, |
| 56 | {2 * time.Hour, "2h"}, |
| 57 | {2*time.Hour + 30*time.Minute, "2h30m"}, |
| 58 | {48 * time.Hour, "2d"}, |
| 59 | } |
| 60 | for _, tt := range tests { |
| 61 | if got := compactDuration(tt.d); got != tt.want { |
| 62 | t.Errorf("compactDuration(%v) = %q, want %q", tt.d, got, tt.want) |
| 63 | } |
| 64 | } |
| 65 | } |