ScuttleBot

feat: TOON format, channel display config, relay envelope mode (#96, #97, #99) #96 — TOON format (pkg/toon): token-efficient conversation format for LLM context windows. Strips noise, groups consecutive messages, uses compact duration offsets. Oracle uses TOON for prompt building, scroll supports format=toon for replay output. #97 — Server-side mirror/rendering config: add ChannelDisplayConfig (mirror_detail, render_mode) to BridgePolicy with per-channel storage. New endpoints GET/PUT /v1/channels/{channel}/config for reading/writing display preferences server-side. #99 — Relay envelope mode: add EnvelopeMode to IRC relay config. When enabled, outgoing messages wrapped in JSON envelope format so other agents can parse relay output as structured data.

lmata 2026-04-05 15:15 trunk
Commit 36e8be3b820cf3b6b70aee763aaead03158c5d0c49ca0af6a7c3864c3fd5b583
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -113,10 +113,44 @@
113113
if users == nil {
114114
users = []string{}
115115
}
116116
writeJSON(w, http.StatusOK, map[string]any{"users": users})
117117
}
118
+
119
+func (s *Server) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
120
+ channel := "#" + r.PathValue("channel")
121
+ if s.policies == nil {
122
+ writeJSON(w, http.StatusOK, ChannelDisplayConfig{})
123
+ return
124
+ }
125
+ p := s.policies.Get()
126
+ cfg := p.Bridge.ChannelDisplay[channel]
127
+ writeJSON(w, http.StatusOK, cfg)
128
+}
129
+
130
+func (s *Server) handlePutChannelConfig(w http.ResponseWriter, r *http.Request) {
131
+ channel := "#" + r.PathValue("channel")
132
+ if s.policies == nil {
133
+ writeError(w, http.StatusServiceUnavailable, "policies not configured")
134
+ return
135
+ }
136
+ var cfg ChannelDisplayConfig
137
+ if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
138
+ writeError(w, http.StatusBadRequest, "invalid request body")
139
+ return
140
+ }
141
+ p := s.policies.Get()
142
+ if p.Bridge.ChannelDisplay == nil {
143
+ p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig)
144
+ }
145
+ p.Bridge.ChannelDisplay[channel] = cfg
146
+ if err := s.policies.Set(p); err != nil {
147
+ writeError(w, http.StatusInternalServerError, "save failed")
148
+ return
149
+ }
150
+ w.WriteHeader(http.StatusNoContent)
151
+}
118152
119153
// handleChannelStream serves an SSE stream of IRC messages for a channel.
120154
// Auth is via ?token= query param because EventSource doesn't support custom headers.
121155
func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
122156
token := r.URL.Query().Get("token")
123157
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -113,10 +113,44 @@
113 if users == nil {
114 users = []string{}
115 }
116 writeJSON(w, http.StatusOK, map[string]any{"users": users})
117 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
119 // handleChannelStream serves an SSE stream of IRC messages for a channel.
120 // Auth is via ?token= query param because EventSource doesn't support custom headers.
121 func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
122 token := r.URL.Query().Get("token")
123
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -113,10 +113,44 @@
113 if users == nil {
114 users = []string{}
115 }
116 writeJSON(w, http.StatusOK, map[string]any{"users": users})
117 }
118
119 func (s *Server) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
120 channel := "#" + r.PathValue("channel")
121 if s.policies == nil {
122 writeJSON(w, http.StatusOK, ChannelDisplayConfig{})
123 return
124 }
125 p := s.policies.Get()
126 cfg := p.Bridge.ChannelDisplay[channel]
127 writeJSON(w, http.StatusOK, cfg)
128 }
129
130 func (s *Server) handlePutChannelConfig(w http.ResponseWriter, r *http.Request) {
131 channel := "#" + r.PathValue("channel")
132 if s.policies == nil {
133 writeError(w, http.StatusServiceUnavailable, "policies not configured")
134 return
135 }
136 var cfg ChannelDisplayConfig
137 if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
138 writeError(w, http.StatusBadRequest, "invalid request body")
139 return
140 }
141 p := s.policies.Get()
142 if p.Bridge.ChannelDisplay == nil {
143 p.Bridge.ChannelDisplay = make(map[string]ChannelDisplayConfig)
144 }
145 p.Bridge.ChannelDisplay[channel] = cfg
146 if err := s.policies.Set(p); err != nil {
147 writeError(w, http.StatusInternalServerError, "save failed")
148 return
149 }
150 w.WriteHeader(http.StatusNoContent)
151 }
152
153 // handleChannelStream serves an SSE stream of IRC messages for a channel.
154 // Auth is via ?token= query param because EventSource doesn't support custom headers.
155 func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
156 token := r.URL.Query().Get("token")
157
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -42,16 +42,24 @@
4242
Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size"
4343
MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited
4444
PerChannel bool `json:"per_channel"` // separate file per channel
4545
MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all
4646
}
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
+}
4753
4854
// BridgePolicy configures bridge-specific UI/relay behavior.
4955
type BridgePolicy struct {
5056
// WebUserTTLMinutes controls how long HTTP bridge sender nicks remain
5157
// visible in the channel user list after their last post.
5258
WebUserTTLMinutes int `json:"web_user_ttl_minutes"`
59
+ // ChannelDisplay holds per-channel rendering config.
60
+ ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"`
5361
}
5462
5563
// PolicyLLMBackend stores an LLM backend configuration in the policy store.
5664
// This allows backends to be added and edited from the web UI rather than
5765
// requiring a change to scuttlebot.yaml.
5866
--- 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
@@ -79,10 +79,12 @@
7979
apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.handleDeleteChannel)
8080
apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
8181
apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
8282
apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
8383
apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
84
+ apiMux.HandleFunc("GET /v1/channels/{channel}/config", s.handleGetChannelConfig)
85
+ apiMux.HandleFunc("PUT /v1/channels/{channel}/config", s.handlePutChannelConfig)
8486
}
8587
if s.topoMgr != nil {
8688
apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
8789
apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel)
8890
apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
8991
--- internal/api/server.go
+++ internal/api/server.go
@@ -79,10 +79,12 @@
79 apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.handleDeleteChannel)
80 apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
81 apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
82 apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
83 apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
 
 
84 }
85 if s.topoMgr != nil {
86 apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
87 apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel)
88 apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
89
--- internal/api/server.go
+++ internal/api/server.go
@@ -79,10 +79,12 @@
79 apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.handleDeleteChannel)
80 apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
81 apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
82 apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
83 apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
84 apiMux.HandleFunc("GET /v1/channels/{channel}/config", s.handleGetChannelConfig)
85 apiMux.HandleFunc("PUT /v1/channels/{channel}/config", s.handlePutChannelConfig)
86 }
87 if s.topoMgr != nil {
88 apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
89 apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel)
90 apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
91
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -20,10 +20,12 @@
2020
"strings"
2121
"sync"
2222
"time"
2323
2424
"github.com/lrstanley/girc"
25
+
26
+ "github.com/conflicthq/scuttlebot/pkg/toon"
2527
)
2628
2729
const (
2830
botNick = "oracle"
2931
defaultLimit = 50
@@ -265,22 +267,20 @@
265267
}
266268
}
267269
}
268270
269271
func buildPrompt(channel string, entries []HistoryEntry) string {
270
- var sb strings.Builder
271
- fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel)
272
- fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n")
273
- fmt.Fprintf(&sb, "Be concise. %d messages:\n\n", len(entries))
274
- for _, e := range entries {
275
- if e.MessageType != "" {
276
- fmt.Fprintf(&sb, "[%s] (type=%s) %s\n", e.Nick, e.MessageType, e.Raw)
277
- } else {
278
- fmt.Fprintf(&sb, "[%s] %s\n", e.Nick, e.Raw)
272
+ // Convert to TOON entries for token-efficient LLM context.
273
+ toonEntries := make([]toon.Entry, len(entries))
274
+ for i, e := range entries {
275
+ toonEntries[i] = toon.Entry{
276
+ Nick: e.Nick,
277
+ MessageType: e.MessageType,
278
+ Text: e.Raw,
279279
}
280280
}
281
- return sb.String()
281
+ return toon.FormatPrompt(channel, toonEntries)
282282
}
283283
284284
func formatResponse(channel string, count int, summary string, format Format) string {
285285
switch format {
286286
case FormatJSON:
287287
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -20,10 +20,12 @@
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/lrstanley/girc"
 
 
25 )
26
27 const (
28 botNick = "oracle"
29 defaultLimit = 50
@@ -265,22 +267,20 @@
265 }
266 }
267 }
268
269 func buildPrompt(channel string, entries []HistoryEntry) string {
270 var sb strings.Builder
271 fmt.Fprintf(&sb, "Summarize the following IRC conversation from %s.\n", channel)
272 fmt.Fprintf(&sb, "Focus on: key decisions, actions taken, outstanding tasks, and important context.\n")
273 fmt.Fprintf(&sb, "Be concise. %d messages:\n\n", len(entries))
274 for _, e := range entries {
275 if e.MessageType != "" {
276 fmt.Fprintf(&sb, "[%s] (type=%s) %s\n", e.Nick, e.MessageType, e.Raw)
277 } else {
278 fmt.Fprintf(&sb, "[%s] %s\n", e.Nick, e.Raw)
279 }
280 }
281 return sb.String()
282 }
283
284 func formatResponse(channel string, count int, summary string, format Format) string {
285 switch format {
286 case FormatJSON:
287
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -20,10 +20,12 @@
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/lrstanley/girc"
25
26 "github.com/conflicthq/scuttlebot/pkg/toon"
27 )
28
29 const (
30 botNick = "oracle"
31 defaultLimit = 50
@@ -265,22 +267,20 @@
267 }
268 }
269 }
270
271 func buildPrompt(channel string, entries []HistoryEntry) string {
272 // Convert to TOON entries for token-efficient LLM context.
273 toonEntries := make([]toon.Entry, len(entries))
274 for i, e := range entries {
275 toonEntries[i] = toon.Entry{
276 Nick: e.Nick,
277 MessageType: e.MessageType,
278 Text: e.Raw,
 
 
279 }
280 }
281 return toon.FormatPrompt(channel, toonEntries)
282 }
283
284 func formatResponse(channel string, count int, summary string, format Format) string {
285 switch format {
286 case FormatJSON:
287
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -21,10 +21,11 @@
2121
"time"
2222
2323
"github.com/lrstanley/girc"
2424
2525
"github.com/conflicthq/scuttlebot/internal/bots/scribe"
26
+ "github.com/conflicthq/scuttlebot/pkg/toon"
2627
)
2728
2829
const (
2930
botNick = "scroll"
3031
defaultLimit = 50
@@ -129,11 +130,11 @@
129130
}
130131
131132
req, err := ParseCommand(text)
132133
if err != nil {
133134
client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
134
- client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]")
135
+ client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>] [format=json|toon]")
135136
return
136137
}
137138
138139
entries, err := b.store.Query(req.Channel, req.Limit)
139140
if err != nil {
@@ -144,16 +145,34 @@
144145
if len(entries) == 0 {
145146
client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel))
146147
return
147148
}
148149
149
- client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
150
- for _, e := range entries {
151
- line, _ := json.Marshal(e)
152
- client.Cmd.Notice(nick, string(line))
150
+ if req.Format == "toon" {
151
+ toonEntries := make([]toon.Entry, len(entries))
152
+ for i, e := range entries {
153
+ toonEntries[i] = toon.Entry{
154
+ Nick: e.Nick,
155
+ MessageType: e.MessageType,
156
+ Text: e.Raw,
157
+ At: e.At,
158
+ }
159
+ }
160
+ output := toon.Format(toonEntries, toon.Options{Channel: req.Channel})
161
+ for _, line := range strings.Split(output, "\n") {
162
+ if line != "" {
163
+ client.Cmd.Notice(nick, line)
164
+ }
165
+ }
166
+ } else {
167
+ client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
168
+ for _, e := range entries {
169
+ line, _ := json.Marshal(e)
170
+ client.Cmd.Notice(nick, string(line))
171
+ }
172
+ client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
153173
}
154
- client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
155174
}
156175
157176
func (b *Bot) checkRateLimit(nick string) bool {
158177
now := time.Now()
159178
if last, ok := b.rateLimit.Load(nick); ok {
@@ -167,11 +186,12 @@
167186
168187
// ReplayRequest is a parsed replay command.
169188
type replayRequest struct {
170189
Channel string
171190
Limit int
172
- Since int64 // unix ms, 0 = no filter
191
+ Since int64 // unix ms, 0 = no filter
192
+ Format string // "json" (default) or "toon"
173193
}
174194
175195
// ParseCommand parses a replay command string. Exported for testing.
176196
func ParseCommand(text string) (*replayRequest, error) {
177197
parts := strings.Fields(text)
@@ -205,10 +225,17 @@
205225
ts, err := strconv.ParseInt(kv[1], 10, 64)
206226
if err != nil {
207227
return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1])
208228
}
209229
req.Since = ts
230
+ case "format":
231
+ switch strings.ToLower(kv[1]) {
232
+ case "json", "toon":
233
+ req.Format = strings.ToLower(kv[1])
234
+ default:
235
+ return nil, fmt.Errorf("unknown format %q (use json or toon)", kv[1])
236
+ }
210237
default:
211238
return nil, fmt.Errorf("unknown argument %q", kv[0])
212239
}
213240
}
214241
215242
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -21,10 +21,11 @@
21 "time"
22
23 "github.com/lrstanley/girc"
24
25 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
 
26 )
27
28 const (
29 botNick = "scroll"
30 defaultLimit = 50
@@ -129,11 +130,11 @@
129 }
130
131 req, err := ParseCommand(text)
132 if err != nil {
133 client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
134 client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>]")
135 return
136 }
137
138 entries, err := b.store.Query(req.Channel, req.Limit)
139 if err != nil {
@@ -144,16 +145,34 @@
144 if len(entries) == 0 {
145 client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel))
146 return
147 }
148
149 client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
150 for _, e := range entries {
151 line, _ := json.Marshal(e)
152 client.Cmd.Notice(nick, string(line))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153 }
154 client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
155 }
156
157 func (b *Bot) checkRateLimit(nick string) bool {
158 now := time.Now()
159 if last, ok := b.rateLimit.Load(nick); ok {
@@ -167,11 +186,12 @@
167
168 // ReplayRequest is a parsed replay command.
169 type replayRequest struct {
170 Channel string
171 Limit int
172 Since int64 // unix ms, 0 = no filter
 
173 }
174
175 // ParseCommand parses a replay command string. Exported for testing.
176 func ParseCommand(text string) (*replayRequest, error) {
177 parts := strings.Fields(text)
@@ -205,10 +225,17 @@
205 ts, err := strconv.ParseInt(kv[1], 10, 64)
206 if err != nil {
207 return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1])
208 }
209 req.Since = ts
 
 
 
 
 
 
 
210 default:
211 return nil, fmt.Errorf("unknown argument %q", kv[0])
212 }
213 }
214
215
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -21,10 +21,11 @@
21 "time"
22
23 "github.com/lrstanley/girc"
24
25 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
26 "github.com/conflicthq/scuttlebot/pkg/toon"
27 )
28
29 const (
30 botNick = "scroll"
31 defaultLimit = 50
@@ -129,11 +130,11 @@
130 }
131
132 req, err := ParseCommand(text)
133 if err != nil {
134 client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
135 client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>] [format=json|toon]")
136 return
137 }
138
139 entries, err := b.store.Query(req.Channel, req.Limit)
140 if err != nil {
@@ -144,16 +145,34 @@
145 if len(entries) == 0 {
146 client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel))
147 return
148 }
149
150 if req.Format == "toon" {
151 toonEntries := make([]toon.Entry, len(entries))
152 for i, e := range entries {
153 toonEntries[i] = toon.Entry{
154 Nick: e.Nick,
155 MessageType: e.MessageType,
156 Text: e.Raw,
157 At: e.At,
158 }
159 }
160 output := toon.Format(toonEntries, toon.Options{Channel: req.Channel})
161 for _, line := range strings.Split(output, "\n") {
162 if line != "" {
163 client.Cmd.Notice(nick, line)
164 }
165 }
166 } else {
167 client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
168 for _, e := range entries {
169 line, _ := json.Marshal(e)
170 client.Cmd.Notice(nick, string(line))
171 }
172 client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
173 }
 
174 }
175
176 func (b *Bot) checkRateLimit(nick string) bool {
177 now := time.Now()
178 if last, ok := b.rateLimit.Load(nick); ok {
@@ -167,11 +186,12 @@
186
187 // ReplayRequest is a parsed replay command.
188 type replayRequest struct {
189 Channel string
190 Limit int
191 Since int64 // unix ms, 0 = no filter
192 Format string // "json" (default) or "toon"
193 }
194
195 // ParseCommand parses a replay command string. Exported for testing.
196 func ParseCommand(text string) (*replayRequest, error) {
197 parts := strings.Fields(text)
@@ -205,10 +225,17 @@
225 ts, err := strconv.ParseInt(kv[1], 10, 64)
226 if err != nil {
227 return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1])
228 }
229 req.Since = ts
230 case "format":
231 switch strings.ToLower(kv[1]) {
232 case "json", "toon":
233 req.Format = strings.ToLower(kv[1])
234 default:
235 return nil, fmt.Errorf("unknown format %q (use json or toon)", kv[1])
236 }
237 default:
238 return nil, fmt.Errorf("unknown argument %q", kv[0])
239 }
240 }
241
242
--- pkg/sessionrelay/irc.go
+++ pkg/sessionrelay/irc.go
@@ -25,10 +25,11 @@
2525
nick string
2626
addr string
2727
agentType string
2828
pass string
2929
deleteOnClose bool
30
+ envelopeMode bool
3031
3132
mu sync.RWMutex
3233
channels []string
3334
messages []Message
3435
client *girc.Client
@@ -50,10 +51,11 @@
5051
nick: cfg.Nick,
5152
addr: cfg.IRC.Addr,
5253
agentType: cfg.IRC.AgentType,
5354
pass: cfg.IRC.Pass,
5455
deleteOnClose: cfg.IRC.DeleteOnClose,
56
+ envelopeMode: cfg.IRC.EnvelopeMode,
5557
channels: append([]string(nil), cfg.Channels...),
5658
messages: make([]Message, 0, defaultBufferSize),
5759
errCh: make(chan error, 1),
5860
}, nil
5961
}
@@ -221,26 +223,28 @@
221223
222224
func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
223225
return c.PostToWithMeta(context.Background(), channel, text, nil)
224226
}
225227
226
-// PostWithMeta sends text to all channels. Meta is ignored — IRC is text-only.
227
-func (c *ircConnector) PostWithMeta(_ context.Context, text string, _ json.RawMessage) error {
228
+// PostWithMeta sends text to all channels.
229
+// In envelope mode, wraps the message in a protocol.Envelope JSON.
230
+func (c *ircConnector) PostWithMeta(_ context.Context, text string, meta json.RawMessage) error {
228231
c.mu.RLock()
229232
client := c.client
230233
c.mu.RUnlock()
231234
if client == nil {
232235
return fmt.Errorf("sessionrelay: irc client not connected")
233236
}
237
+ msg := c.formatMessage(text, meta)
234238
for _, channel := range c.Channels() {
235
- client.Cmd.Message(channel, text)
239
+ client.Cmd.Message(channel, msg)
236240
}
237241
return nil
238242
}
239243
240
-// PostToWithMeta sends text to a specific channel. Meta is ignored — IRC is text-only.
241
-func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, _ json.RawMessage) error {
244
+// PostToWithMeta sends text to a specific channel.
245
+func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, meta json.RawMessage) error {
242246
c.mu.RLock()
243247
client := c.client
244248
c.mu.RUnlock()
245249
if client == nil {
246250
return fmt.Errorf("sessionrelay: irc client not connected")
@@ -247,13 +251,37 @@
247251
}
248252
channel = normalizeChannel(channel)
249253
if channel == "" {
250254
return fmt.Errorf("sessionrelay: post channel is required")
251255
}
252
- client.Cmd.Message(channel, text)
256
+ client.Cmd.Message(channel, c.formatMessage(text, meta))
253257
return nil
254258
}
259
+
260
+// formatMessage wraps text in a JSON envelope when envelope mode is enabled.
261
+func (c *ircConnector) formatMessage(text string, meta json.RawMessage) string {
262
+ if !c.envelopeMode {
263
+ return text
264
+ }
265
+ env := map[string]any{
266
+ "v": 1,
267
+ "type": "relay.message",
268
+ "from": c.nick,
269
+ "ts": time.Now().UnixMilli(),
270
+ "payload": map[string]any{
271
+ "text": text,
272
+ },
273
+ }
274
+ if len(meta) > 0 {
275
+ env["payload"] = json.RawMessage(meta)
276
+ }
277
+ data, err := json.Marshal(env)
278
+ if err != nil {
279
+ return text // fallback to plain text
280
+ }
281
+ return string(data)
282
+}
255283
256284
func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) {
257285
c.mu.RLock()
258286
defer c.mu.RUnlock()
259287
260288
--- 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 }
@@ -221,26 +223,28 @@
221
222 func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
223 return c.PostToWithMeta(context.Background(), channel, text, nil)
224 }
225
226 // PostWithMeta sends text to all channels. Meta is ignored — IRC is text-only.
227 func (c *ircConnector) PostWithMeta(_ context.Context, text string, _ json.RawMessage) error {
 
228 c.mu.RLock()
229 client := c.client
230 c.mu.RUnlock()
231 if client == nil {
232 return fmt.Errorf("sessionrelay: irc client not connected")
233 }
 
234 for _, channel := range c.Channels() {
235 client.Cmd.Message(channel, text)
236 }
237 return nil
238 }
239
240 // PostToWithMeta sends text to a specific channel. Meta is ignored — IRC is text-only.
241 func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, _ json.RawMessage) error {
242 c.mu.RLock()
243 client := c.client
244 c.mu.RUnlock()
245 if client == nil {
246 return fmt.Errorf("sessionrelay: irc client not connected")
@@ -247,13 +251,37 @@
247 }
248 channel = normalizeChannel(channel)
249 if channel == "" {
250 return fmt.Errorf("sessionrelay: post channel is required")
251 }
252 client.Cmd.Message(channel, text)
253 return nil
254 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
256 func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) {
257 c.mu.RLock()
258 defer c.mu.RUnlock()
259
260
--- 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 }
@@ -221,26 +223,28 @@
223
224 func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
225 return c.PostToWithMeta(context.Background(), channel, text, nil)
226 }
227
228 // PostWithMeta sends text to all channels.
229 // In envelope mode, wraps the message in a protocol.Envelope JSON.
230 func (c *ircConnector) PostWithMeta(_ context.Context, text string, meta json.RawMessage) error {
231 c.mu.RLock()
232 client := c.client
233 c.mu.RUnlock()
234 if client == nil {
235 return fmt.Errorf("sessionrelay: irc client not connected")
236 }
237 msg := c.formatMessage(text, meta)
238 for _, channel := range c.Channels() {
239 client.Cmd.Message(channel, msg)
240 }
241 return nil
242 }
243
244 // PostToWithMeta sends text to a specific channel.
245 func (c *ircConnector) PostToWithMeta(_ context.Context, channel, text string, meta json.RawMessage) error {
246 c.mu.RLock()
247 client := c.client
248 c.mu.RUnlock()
249 if client == nil {
250 return fmt.Errorf("sessionrelay: irc client not connected")
@@ -247,13 +251,37 @@
251 }
252 channel = normalizeChannel(channel)
253 if channel == "" {
254 return fmt.Errorf("sessionrelay: post channel is required")
255 }
256 client.Cmd.Message(channel, c.formatMessage(text, meta))
257 return nil
258 }
259
260 // formatMessage wraps text in a JSON envelope when envelope mode is enabled.
261 func (c *ircConnector) formatMessage(text string, meta json.RawMessage) string {
262 if !c.envelopeMode {
263 return text
264 }
265 env := map[string]any{
266 "v": 1,
267 "type": "relay.message",
268 "from": c.nick,
269 "ts": time.Now().UnixMilli(),
270 "payload": map[string]any{
271 "text": text,
272 },
273 }
274 if len(meta) > 0 {
275 env["payload"] = json.RawMessage(meta)
276 }
277 data, err := json.Marshal(env)
278 if err != nil {
279 return text // fallback to plain text
280 }
281 return string(data)
282 }
283
284 func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) {
285 c.mu.RLock()
286 defer c.mu.RUnlock()
287
288
--- pkg/sessionrelay/sessionrelay.go
+++ pkg/sessionrelay/sessionrelay.go
@@ -35,10 +35,13 @@
3535
type IRCConfig struct {
3636
Addr string
3737
Pass string
3838
AgentType string
3939
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
4043
}
4144
4245
type Message struct {
4346
At time.Time
4447
Channel string
4548
4649
ADDED pkg/toon/toon.go
4750
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
--- 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 }
--- 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 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button