ScuttleBot

scuttlebot / internal / config / config.go
Blame History Raw 520 lines
1
// Package config defines scuttlebot's configuration schema.
2
package config
3
4
import (
5
"fmt"
6
"os"
7
"time"
8
9
"gopkg.in/yaml.v3"
10
)
11
12
// Config is the top-level scuttlebot configuration.
13
type Config struct {
14
Ergo ErgoConfig `yaml:"ergo"`
15
Datastore DatastoreConfig `yaml:"datastore"`
16
Bridge BridgeConfig `yaml:"bridge"`
17
TLS TLSConfig `yaml:"tls"`
18
LLM LLMConfig `yaml:"llm"`
19
Topology TopologyConfig `yaml:"topology"`
20
History ConfigHistoryConfig `yaml:"config_history"`
21
AgentPolicy AgentPolicyConfig `yaml:"agent_policy" json:"agent_policy"`
22
Logging LoggingConfig `yaml:"logging" json:"logging"`
23
24
// APIAddr is the address for scuttlebot's own HTTP management API.
25
// Ignored when TLS.Domain is set (HTTPS runs on :443, HTTP on :80).
26
// Default: ":8080"
27
APIAddr string `yaml:"api_addr"`
28
29
// MCPAddr is the address for the MCP server.
30
// Default: ":8081"
31
MCPAddr string `yaml:"mcp_addr"`
32
}
33
34
// AgentPolicyConfig defines requirements applied to all registering agents.
35
type AgentPolicyConfig struct {
36
RequireCheckin bool `yaml:"require_checkin" json:"require_checkin"`
37
CheckinChannel string `yaml:"checkin_channel" json:"checkin_channel"`
38
RequiredChannels []string `yaml:"required_channels" json:"required_channels"`
39
}
40
41
// LoggingConfig configures message logging.
42
type LoggingConfig struct {
43
Enabled bool `yaml:"enabled" json:"enabled"`
44
Dir string `yaml:"dir" json:"dir"`
45
Format string `yaml:"format" json:"format"` // "jsonl" | "csv" | "text"
46
Rotation string `yaml:"rotation" json:"rotation"` // "none" | "daily" | "weekly" | "size"
47
MaxSizeMB int `yaml:"max_size_mb" json:"max_size_mb"`
48
PerChannel bool `yaml:"per_channel" json:"per_channel"`
49
MaxAgeDays int `yaml:"max_age_days" json:"max_age_days"`
50
}
51
52
// ConfigHistoryConfig controls config write-back history retention.
53
type ConfigHistoryConfig struct {
54
// Keep is the number of config snapshots to retain in Dir.
55
// 0 disables history. Default: 20.
56
Keep int `yaml:"keep" json:"keep"`
57
58
// Dir is the directory for config snapshots.
59
// Default: {ergo.data_dir}/config-history
60
Dir string `yaml:"dir" json:"dir,omitempty"`
61
}
62
63
// LLMConfig configures the omnibus LLM gateway used by oracle and any other
64
// bot or service that needs language model access.
65
type LLMConfig struct {
66
// Backends is the list of configured LLM backends.
67
// Each backend has a unique Name used to reference it from bot configs.
68
Backends []LLMBackendConfig `yaml:"backends"`
69
}
70
71
// LLMBackendConfig configures a single LLM backend instance.
72
type LLMBackendConfig struct {
73
// Name is a unique identifier for this backend (e.g. "openai-main", "local-ollama").
74
// Used when referencing the backend from bot configs.
75
Name string `yaml:"name"`
76
77
// Backend is the provider type. Supported values:
78
// Native: anthropic, gemini, bedrock, ollama
79
// OpenAI-compatible: openai, openrouter, together, groq, fireworks, mistral,
80
// ai21, huggingface, deepseek, cerebras, xai,
81
// litellm, lmstudio, jan, localai, vllm, anythingllm
82
Backend string `yaml:"backend"`
83
84
// APIKey is the authentication key for cloud backends.
85
APIKey string `yaml:"api_key"`
86
87
// BaseURL overrides the default base URL for OpenAI-compatible backends.
88
// Required for custom self-hosted endpoints without a known default.
89
BaseURL string `yaml:"base_url"`
90
91
// Model is the default model ID. If empty, the first discovered model
92
// that passes the allow/block filter is used.
93
Model string `yaml:"model"`
94
95
// Region is the AWS region (e.g. "us-east-1"). Bedrock only.
96
Region string `yaml:"region"`
97
98
// AWSKeyID is the AWS access key ID. Bedrock only.
99
AWSKeyID string `yaml:"aws_key_id"`
100
101
// AWSSecretKey is the AWS secret access key. Bedrock only.
102
AWSSecretKey string `yaml:"aws_secret_key"`
103
104
// Allow is a list of regex patterns. If non-empty, only model IDs matching
105
// at least one pattern are returned by model discovery.
106
Allow []string `yaml:"allow"`
107
108
// Block is a list of regex patterns. Matching model IDs are excluded
109
// from model discovery results.
110
Block []string `yaml:"block"`
111
112
// Default marks this backend as the one used when no backend is specified
113
// in a bot's config. Only one backend should have Default: true.
114
Default bool `yaml:"default"`
115
}
116
117
// TLSConfig configures automatic HTTPS via Let's Encrypt.
118
type TLSConfig struct {
119
// Domain enables TLS. When set, scuttlebot obtains a certificate from
120
// Let's Encrypt for this domain and serves HTTPS on :443.
121
Domain string `yaml:"domain"`
122
123
// Email is sent to Let's Encrypt for certificate expiry notifications.
124
Email string `yaml:"email"`
125
126
// CertDir is the directory for the certificate cache.
127
// Default: {Ergo.DataDir}/certs
128
CertDir string `yaml:"cert_dir"`
129
130
// AllowInsecure keeps plain HTTP running on :80 alongside HTTPS.
131
// The ACME HTTP-01 challenge always runs on :80 regardless.
132
// Default: true
133
AllowInsecure bool `yaml:"allow_insecure"`
134
}
135
136
// ErgoConfig holds settings for the managed Ergo IRC server.
137
type ErgoConfig struct {
138
// External disables subprocess management. When true, scuttlebot expects
139
// ergo to already be running and reachable at APIAddr and IRCAddr.
140
// Use this in Docker/K8s deployments where ergo runs as a separate container.
141
External bool `yaml:"external"`
142
143
// BinaryPath is the path to the ergo binary. Defaults to "ergo" (looks in PATH).
144
// Unused when External is true.
145
BinaryPath string `yaml:"binary_path"`
146
147
// DataDir is the directory where Ergo stores ircd.db and generated config.
148
// Unused when External is true.
149
DataDir string `yaml:"data_dir"`
150
151
// NetworkName is the human-readable IRC network name.
152
NetworkName string `yaml:"network_name"`
153
154
// ServerName is the IRC server hostname (e.g. "irc.example.com").
155
ServerName string `yaml:"server_name"`
156
157
// IRCAddr is the address Ergo listens for IRC connections on.
158
// Default: "127.0.0.1:6667" (loopback plaintext for private networks).
159
// Set to ":6667" or ":6697" to accept connections from outside the host.
160
IRCAddr string `yaml:"irc_addr"`
161
162
// APIAddr is the address of Ergo's HTTP management API.
163
// Default: "127.0.0.1:8089" (loopback only).
164
APIAddr string `yaml:"api_addr"`
165
166
// APIToken is the bearer token for Ergo's HTTP API.
167
// scuttlebot generates this on first start and stores it.
168
APIToken string `yaml:"api_token"`
169
170
// RequireSASL enforces SASL authentication for all IRC connections.
171
// When true, only agents and users with registered NickServ accounts
172
// can connect. Recommended for public deployments.
173
// Default: false
174
RequireSASL bool `yaml:"require_sasl"`
175
176
// DefaultChannelModes sets channel modes applied when a new channel is
177
// created. Common values: "+n" (no external messages), "+Rn" (registered
178
// users only). Default: "+n"
179
DefaultChannelModes string `yaml:"default_channel_modes"`
180
181
// TLSDomain enables a public TLS listener on TLSAddr with Let's Encrypt
182
// (ACME TLS-ALPN-01). When set, IRCAddr is kept as an internal plaintext
183
// listener for system bots, and a second TLS listener is added for
184
// external agents. Leave empty to use IRCAddr as the only listener.
185
TLSDomain string `yaml:"tls_domain"`
186
187
// TLSAddr is the address for the public TLS IRC listener.
188
// Only used when TLSDomain is set. Default: "0.0.0.0:6697"
189
TLSAddr string `yaml:"tls_addr"`
190
191
// History configures persistent message history storage.
192
History HistoryConfig `yaml:"history"`
193
}
194
195
// HistoryConfig configures Ergo's persistent message history.
196
type HistoryConfig struct {
197
// Enabled enables persistent history storage.
198
Enabled bool `yaml:"enabled"`
199
200
// PostgresDSN is the Postgres connection string for persistent history.
201
// Recommended. If empty and Enabled is true, MySQL config is used instead.
202
PostgresDSN string `yaml:"postgres_dsn"`
203
204
// MySQL is the MySQL connection config for persistent history.
205
MySQL MySQLConfig `yaml:"mysql"`
206
}
207
208
// MySQLConfig holds MySQL connection settings for Ergo history.
209
type MySQLConfig struct {
210
Host string `yaml:"host"`
211
Port int `yaml:"port"`
212
User string `yaml:"user"`
213
Password string `yaml:"password"`
214
Database string `yaml:"database"`
215
}
216
217
// BridgeConfig configures the IRC bridge bot that powers the web chat UI.
218
type BridgeConfig struct {
219
// Enabled controls whether the bridge bot starts. Default: true.
220
Enabled bool `yaml:"enabled"`
221
222
// Nick is the IRC nick for the bridge bot. Default: "bridge".
223
Nick string `yaml:"nick"`
224
225
// Password is the SASL PLAIN passphrase for the bridge's NickServ account.
226
// Auto-generated on first start if empty.
227
Password string `yaml:"password"`
228
229
// Channels is the list of IRC channels the bridge joins on startup.
230
Channels []string `yaml:"channels"`
231
232
// BufferSize is the number of messages to keep per channel. Default: 200.
233
BufferSize int `yaml:"buffer_size"`
234
235
// WebUserTTLMinutes controls how long HTTP bridge sender nicks remain visible
236
// in the channel user list after their last post. Default: 5.
237
WebUserTTLMinutes int `yaml:"web_user_ttl_minutes"`
238
}
239
240
// DatastoreConfig configures scuttlebot's own state store (separate from Ergo).
241
type DatastoreConfig struct {
242
// Driver is "sqlite" or "postgres". Default: "sqlite".
243
Driver string `yaml:"driver"`
244
245
// DSN is the data source name.
246
// For sqlite: path to the .db file.
247
// For postgres: connection string.
248
DSN string `yaml:"dsn"`
249
}
250
251
// TopologyConfig is the top-level channel topology declaration.
252
// It defines static channels provisioned at startup and dynamic channel type
253
// rules applied when agents create channels at runtime.
254
type TopologyConfig struct {
255
// Nick is the IRC nick used by the topology manager to provision channels
256
// via ChanServ. Defaults to "topology".
257
Nick string `yaml:"nick" json:"nick"`
258
259
// Channels are static channels provisioned at daemon startup.
260
Channels []StaticChannelConfig `yaml:"channels" json:"channels"`
261
262
// Types are prefix-based rules applied to dynamically created channels.
263
// The first matching prefix wins.
264
Types []ChannelTypeConfig `yaml:"types" json:"types"`
265
}
266
267
// StaticChannelConfig describes a channel that is provisioned at startup.
268
type StaticChannelConfig struct {
269
// Name is the full channel name including the # prefix (e.g. "#general").
270
Name string `yaml:"name" json:"name"`
271
272
// Topic is the initial channel topic.
273
Topic string `yaml:"topic" json:"topic,omitempty"`
274
275
// Ops is a list of nicks to grant channel operator (+o) access.
276
Ops []string `yaml:"ops" json:"ops,omitempty"`
277
278
// Voice is a list of nicks to grant voice (+v) access.
279
Voice []string `yaml:"voice" json:"voice,omitempty"`
280
281
// Autojoin is a list of bot nicks to invite when the channel is provisioned.
282
Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
283
284
// Modes is a list of channel modes to set after provisioning (e.g. "+m" for moderated).
285
Modes []string `yaml:"modes" json:"modes,omitempty"`
286
287
// OnJoinMessage is sent to agents when they join this channel.
288
// Supports template variables: {nick}, {channel}.
289
OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"`
290
}
291
292
// ChannelTypeConfig defines policy rules for a class of dynamically created channels.
293
// Matched by prefix against channel names (e.g. prefix "task." matches "#task.gh-42").
294
type ChannelTypeConfig struct {
295
// Name is a human-readable type identifier (e.g. "task", "sprint", "incident").
296
Name string `yaml:"name" json:"name"`
297
298
// Prefix is matched against channel names after stripping the leading #.
299
// The first matching type wins. (e.g. "task." matches "#task.gh-42")
300
Prefix string `yaml:"prefix" json:"prefix"`
301
302
// Autojoin is a list of bot nicks to invite when a channel of this type is created.
303
Autojoin []string `yaml:"autojoin" json:"autojoin,omitempty"`
304
305
// Supervision is the coordination channel where summaries should surface.
306
Supervision string `yaml:"supervision" json:"supervision,omitempty"`
307
308
// Modes is a list of channel modes to set when provisioning (e.g. "+m" for moderated).
309
Modes []string `yaml:"modes" json:"modes,omitempty"`
310
311
// Ephemeral marks channels of this type for automatic cleanup.
312
Ephemeral bool `yaml:"ephemeral" json:"ephemeral,omitempty"`
313
314
// TTL is the maximum lifetime of an ephemeral channel with no non-bot members.
315
// Zero means no TTL; cleanup only occurs when the channel is empty.
316
TTL Duration `yaml:"ttl" json:"ttl,omitempty"`
317
318
// OnJoinMessage is sent to agents when they join a channel of this type.
319
OnJoinMessage string `yaml:"on_join_message" json:"on_join_message,omitempty"`
320
}
321
322
// Duration wraps time.Duration for YAML/JSON marshalling ("72h", "30m", etc.).
323
type Duration struct {
324
time.Duration
325
}
326
327
func (d Duration) MarshalJSON() ([]byte, error) {
328
if d.Duration == 0 {
329
return []byte(`"0s"`), nil
330
}
331
return []byte(`"` + d.Duration.String() + `"`), nil
332
}
333
334
func (d *Duration) UnmarshalJSON(b []byte) error {
335
s := string(b)
336
if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
337
return fmt.Errorf("config: duration must be a quoted string, got %s", s)
338
}
339
dur, err := time.ParseDuration(s[1 : len(s)-1])
340
if err != nil {
341
return fmt.Errorf("config: invalid duration %s: %w", s, err)
342
}
343
d.Duration = dur
344
return nil
345
}
346
347
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
348
var s string
349
if err := value.Decode(&s); err != nil {
350
return err
351
}
352
dur, err := time.ParseDuration(s)
353
if err != nil {
354
return fmt.Errorf("config: invalid duration %q: %w", s, err)
355
}
356
d.Duration = dur
357
return nil
358
}
359
360
// MarshalYAML encodes Duration as a human-readable string ("72h", "30m").
361
func (d Duration) MarshalYAML() (any, error) {
362
if d.Duration == 0 {
363
return "0s", nil
364
}
365
return d.Duration.String(), nil
366
}
367
368
// Save marshals c to YAML and writes it to path atomically (write to a temp
369
// file in the same directory, then rename). Comments in the original file are
370
// not preserved after the first save.
371
func (c *Config) Save(path string) error {
372
data, err := yaml.Marshal(c)
373
if err != nil {
374
return fmt.Errorf("config: marshal: %w", err)
375
}
376
// Write to a sibling temp file then rename for atomic replacement.
377
tmp := path + ".tmp"
378
if err := os.WriteFile(tmp, data, 0o600); err != nil {
379
return fmt.Errorf("config: write %s: %w", tmp, err)
380
}
381
if err := os.Rename(tmp, path); err != nil {
382
_ = os.Remove(tmp)
383
return fmt.Errorf("config: rename %s → %s: %w", tmp, path, err)
384
}
385
return nil
386
}
387
388
// Defaults fills in zero values with sensible defaults.
389
func (c *Config) Defaults() {
390
if c.Ergo.BinaryPath == "" {
391
c.Ergo.BinaryPath = "ergo"
392
}
393
if c.Ergo.DataDir == "" {
394
c.Ergo.DataDir = "./data/ergo"
395
}
396
if c.Ergo.NetworkName == "" {
397
c.Ergo.NetworkName = "scuttlebot"
398
}
399
if c.Ergo.ServerName == "" {
400
c.Ergo.ServerName = "irc.scuttlebot.local"
401
}
402
if c.Ergo.IRCAddr == "" {
403
c.Ergo.IRCAddr = "127.0.0.1:6667"
404
}
405
if c.Ergo.TLSDomain != "" && c.Ergo.TLSAddr == "" {
406
c.Ergo.TLSAddr = "0.0.0.0:6697"
407
}
408
if c.Ergo.APIAddr == "" {
409
c.Ergo.APIAddr = "127.0.0.1:8089"
410
}
411
if c.Datastore.Driver == "" {
412
c.Datastore.Driver = "sqlite"
413
}
414
if c.Datastore.DSN == "" {
415
c.Datastore.DSN = "./data/scuttlebot.db"
416
}
417
if c.APIAddr == "" {
418
c.APIAddr = "127.0.0.1:8080"
419
}
420
if c.MCPAddr == "" {
421
c.MCPAddr = "127.0.0.1:8081"
422
}
423
if c.Ergo.DefaultChannelModes == "" {
424
c.Ergo.DefaultChannelModes = "+n"
425
}
426
if !c.Bridge.Enabled && c.Bridge.Nick == "" {
427
c.Bridge.Enabled = true // enabled by default
428
}
429
if c.TLS.Domain != "" && !c.TLS.AllowInsecure {
430
c.TLS.AllowInsecure = true // HTTP always on by default
431
}
432
if c.Bridge.Nick == "" {
433
c.Bridge.Nick = "bridge"
434
}
435
if c.Bridge.BufferSize == 0 {
436
c.Bridge.BufferSize = 200
437
}
438
if c.Bridge.WebUserTTLMinutes == 0 {
439
c.Bridge.WebUserTTLMinutes = 5
440
}
441
if c.Topology.Nick == "" {
442
c.Topology.Nick = "topology"
443
}
444
if c.History.Keep == 0 {
445
c.History.Keep = 20
446
}
447
}
448
449
func envStr(key string) string { return os.Getenv(key) }
450
451
// LoadFile reads a YAML config file into c. Missing file is not an error —
452
// returns nil so callers can treat an absent config file as "use defaults".
453
// Call Defaults() first, then LoadFile(), then ApplyEnv() so that file values
454
// override defaults and env values override the file.
455
func (c *Config) LoadFile(path string) error {
456
data, err := os.ReadFile(path)
457
if os.IsNotExist(err) {
458
return nil
459
}
460
if err != nil {
461
return fmt.Errorf("config: read %s: %w", path, err)
462
}
463
return c.LoadFromBytes(data)
464
}
465
466
// LoadFromBytes parses YAML config bytes into c.
467
func (c *Config) LoadFromBytes(data []byte) error {
468
if err := yaml.Unmarshal(data, c); err != nil {
469
return fmt.Errorf("config: parse: %w", err)
470
}
471
return nil
472
}
473
474
// ApplyEnv overrides config values with SCUTTLEBOT_* environment variables.
475
// Call after Defaults() to allow env to override defaults.
476
//
477
// Supported variables:
478
//
479
// SCUTTLEBOT_API_ADDR — scuttlebot HTTP API listen address (e.g. ":8080")
480
// SCUTTLEBOT_DB_DRIVER — "sqlite" or "postgres"
481
// SCUTTLEBOT_DB_DSN — datastore connection string
482
// SCUTTLEBOT_ERGO_EXTERNAL — "true" to skip subprocess management
483
// SCUTTLEBOT_ERGO_API_ADDR — ergo HTTP API address (e.g. "http://ergo:8089")
484
// SCUTTLEBOT_ERGO_API_TOKEN — ergo HTTP API bearer token
485
// SCUTTLEBOT_ERGO_IRC_ADDR — ergo IRC listen/connect address (e.g. "ergo:6667")
486
// SCUTTLEBOT_ERGO_NETWORK_NAME — IRC network name
487
// SCUTTLEBOT_ERGO_SERVER_NAME — IRC server hostname
488
func (c *Config) ApplyEnv() {
489
if v := envStr("SCUTTLEBOT_API_ADDR"); v != "" {
490
c.APIAddr = v
491
}
492
if v := envStr("SCUTTLEBOT_DB_DRIVER"); v != "" {
493
c.Datastore.Driver = v
494
}
495
if v := envStr("SCUTTLEBOT_DB_DSN"); v != "" {
496
c.Datastore.DSN = v
497
}
498
if v := envStr("SCUTTLEBOT_ERGO_EXTERNAL"); v == "true" || v == "1" {
499
c.Ergo.External = true
500
}
501
if v := envStr("SCUTTLEBOT_ERGO_API_ADDR"); v != "" {
502
c.Ergo.APIAddr = v
503
}
504
if v := envStr("SCUTTLEBOT_ERGO_API_TOKEN"); v != "" {
505
c.Ergo.APIToken = v
506
}
507
if v := envStr("SCUTTLEBOT_ERGO_IRC_ADDR"); v != "" {
508
c.Ergo.IRCAddr = v
509
}
510
if v := envStr("SCUTTLEBOT_ERGO_NETWORK_NAME"); v != "" {
511
c.Ergo.NetworkName = v
512
}
513
if v := envStr("SCUTTLEBOT_ERGO_SERVER_NAME"); v != "" {
514
c.Ergo.ServerName = v
515
}
516
if v := envStr("SCUTTLEBOT_MCP_ADDR"); v != "" {
517
c.MCPAddr = v
518
}
519
}
520

Keyboard Shortcuts

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