ScuttleBot

Merge pull request #142 from ConflictHQ/feature/96-protocol-enhancements feat: TOON format, channel display config, relay envelope mode

noreply 2026-04-05 16:35 trunk merge
Commit a027855855f306f913585eb1feb75b8b39dc1af2cb5d757108eb326d4487dd9a
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -117,10 +117,44 @@
117117
users = []bridge.UserInfo{}
118118
}
119119
modes := s.bridge.ChannelModes(channel)
120120
writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes})
121121
}
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
+}
122156
123157
// handleChannelStream serves an SSE stream of IRC messages for a channel.
124158
// Auth is via ?token= query param because EventSource doesn't support custom headers.
125159
func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
126160
token := r.URL.Query().Get("token")
127161
--- 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/chat.go
+++ internal/api/chat.go
@@ -117,10 +117,44 @@
117117
users = []bridge.UserInfo{}
118118
}
119119
modes := s.bridge.ChannelModes(channel)
120120
writeJSON(w, http.StatusOK, map[string]any{"users": users, "channel_modes": modes})
121121
}
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
+}
122156
123157
// handleChannelStream serves an SSE stream of IRC messages for a channel.
124158
// Auth is via ?token= query param because EventSource doesn't support custom headers.
125159
func (s *Server) handleChannelStream(w http.ResponseWriter, r *http.Request) {
126160
token := r.URL.Query().Get("token")
127161
--- 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 @@
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/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
@@ -85,10 +85,12 @@
8585
apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.requireScope(auth.ScopeChannels, s.handleDeleteChannel))
8686
apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChannels, s.handleChannelMessages))
8787
apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChat, s.handleSendMessage))
8888
apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.requireScope(auth.ScopeChat, s.handleChannelPresence))
8989
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))
9092
}
9193
9294
// Topology — topology scope.
9395
if s.topoMgr != nil {
9496
apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel))
9597
--- 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
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -22,10 +22,11 @@
2222
"time"
2323
2424
"github.com/lrstanley/girc"
2525
2626
"github.com/conflicthq/scuttlebot/pkg/chathistory"
27
+ "github.com/conflicthq/scuttlebot/pkg/toon"
2728
)
2829
2930
const (
3031
botNick = "oracle"
3132
defaultLimit = 50
@@ -305,22 +306,20 @@
305306
}
306307
return b.history.Query(channel, limit)
307308
}
308309
309310
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,
319318
}
320319
}
321
- return sb.String()
320
+ return toon.FormatPrompt(channel, toonEntries)
322321
}
323322
324323
func formatResponse(channel string, count int, summary string, format Format) string {
325324
switch format {
326325
case FormatJSON:
327326
--- 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
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -22,10 +22,11 @@
2222
2323
"github.com/lrstanley/girc"
2424
2525
"github.com/conflicthq/scuttlebot/internal/bots/scribe"
2626
"github.com/conflicthq/scuttlebot/pkg/chathistory"
27
+ "github.com/conflicthq/scuttlebot/pkg/toon"
2728
)
2829
2930
const (
3031
botNick = "scroll"
3132
defaultLimit = 50
@@ -140,11 +141,11 @@
140141
}
141142
142143
req, err := ParseCommand(text)
143144
if err != nil {
144145
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]")
146147
return
147148
}
148149
149150
entries, err := b.fetchHistory(req)
150151
if err != nil {
@@ -155,16 +156,34 @@
155156
if len(entries) == 0 {
156157
client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel))
157158
return
158159
}
159160
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))
164184
}
165
- client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
166185
}
167186
168187
// fetchHistory tries CHATHISTORY first, falls back to scribe store.
169188
func (b *Bot) fetchHistory(req *replayRequest) ([]scribe.Entry, error) {
170189
if b.history != nil && b.client != nil {
@@ -208,11 +227,12 @@
208227
209228
// ReplayRequest is a parsed replay command.
210229
type replayRequest struct {
211230
Channel string
212231
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"
214234
}
215235
216236
// ParseCommand parses a replay command string. Exported for testing.
217237
func ParseCommand(text string) (*replayRequest, error) {
218238
parts := strings.Fields(text)
@@ -246,10 +266,17 @@
246266
ts, err := strconv.ParseInt(kv[1], 10, 64)
247267
if err != nil {
248268
return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1])
249269
}
250270
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
+ }
251278
default:
252279
return nil, fmt.Errorf("unknown argument %q", kv[0])
253280
}
254281
}
255282
256283
--- 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
--- 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
}
@@ -241,26 +243,28 @@
241243
242244
func (c *ircConnector) PostTo(_ context.Context, channel, text string) error {
243245
return c.PostToWithMeta(context.Background(), channel, text, nil)
244246
}
245247
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 {
248251
c.mu.RLock()
249252
client := c.client
250253
c.mu.RUnlock()
251254
if client == nil {
252255
return fmt.Errorf("sessionrelay: irc client not connected")
253256
}
257
+ msg := c.formatMessage(text, meta)
254258
for _, channel := range c.Channels() {
255
- client.Cmd.Message(channel, text)
259
+ client.Cmd.Message(channel, msg)
256260
}
257261
return nil
258262
}
259263
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 {
262266
c.mu.RLock()
263267
client := c.client
264268
c.mu.RUnlock()
265269
if client == nil {
266270
return fmt.Errorf("sessionrelay: irc client not connected")
@@ -267,13 +271,37 @@
267271
}
268272
channel = normalizeChannel(channel)
269273
if channel == "" {
270274
return fmt.Errorf("sessionrelay: post channel is required")
271275
}
272
- client.Cmd.Message(channel, text)
276
+ client.Cmd.Message(channel, c.formatMessage(text, meta))
273277
return nil
274278
}
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
+}
275303
276304
func (c *ircConnector) MessagesSince(_ context.Context, since time.Time) ([]Message, error) {
277305
c.mu.RLock()
278306
defer c.mu.RUnlock()
279307
280308
--- 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 @@
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