ScuttleBot

feat: LLM gateway, admin auth, sentinel/steward/snitch bots, channel delete, scuttlectl improvements - LLM gateway: multi-backend routing (Anthropic, OpenAI, Gemini, Ollama, Bedrock) with policy store, model discovery, and /v1/llm/* endpoints - Admin auth: bcrypt account store, login endpoint, rate-limited session tokens - Bot manager: policy-driven lifecycle for system bots (start/stop on config change) - Sentinel + Steward: LLM-powered moderation — sentinel observes/reports, steward acts on policy violations - Snitch: nick-window tracking for agent activity correlation - Agent delete: DELETE /v1/agents/{nick} + scuttlectl agent delete - Channel delete: bridge LeaveChannel, DELETE /v1/channels/{channel}, scuttlectl channels delete - scuttlectl: channels list/users/delete, backend rename, agent delete - run.sh: agent sync on restart, _run_agent for fleet-style claude sessions - claude-relay.sh: starts IRC agent under same session nick as hooks - scuttlectl binary: compiled and tracked

lmata 2026-04-01 13:27 trunk
Commit 5ac549cc530d57e4c9889bf60d33ef18e32e042188b5d107791eb6ee0373dafa
56 files changed +37 -9 +128 -41 +217 -25 +217 +95 +190 -6 +9 -1 +16 +45 +1 -1 +7 +1 +331 +185 +203 +69 +36 +1 +25 +2413 -482 +67 +141 +12 +377 +204 +1 -88 +8 -3 +118 +304 +59 +189 +88 +39 +2 +86 +100 +77 +349 +87 +44 +39 +70 +68 +84 +34 +94 +81 +139 +67 +35 +13 +122 -10 +190 +228
~ CLAUDE.md ~ bootstrap.md ~ cmd/scuttlebot/main.go + cmd/scuttlectl/cmd_setup.go ~ cmd/scuttlectl/internal/apiclient/apiclient.go ~ cmd/scuttlectl/main.go ~ go.mod ~ go.sum ~ internal/api/agents.go ~ internal/api/api_test.go ~ internal/api/chat.go ~ internal/api/chat_test.go + internal/api/llm_handlers.go + internal/api/login.go + internal/api/login_test.go + internal/api/metrics.go + internal/api/policies.go + internal/api/policies_test.go ~ internal/api/server.go + internal/api/settings.go ~ internal/api/ui/index.html + internal/auth/admin.go + internal/auth/admin_test.go ~ internal/bots/bridge/bridge.go + internal/bots/manager/manager.go + internal/bots/manager/manager_test.go ~ internal/bots/oracle/providers.go ~ internal/bots/scribe/scribe.go ~ internal/bots/scribe/scribe_test.go ~ internal/bots/scribe/store.go + internal/bots/sentinel/sentinel.go + internal/bots/snitch/nickwindow_test.go + internal/bots/snitch/snitch.go + internal/bots/snitch/snitch_test.go + internal/bots/steward/steward.go ~ internal/config/config.go + internal/llm/anthropic.go + internal/llm/anthropic_test.go + internal/llm/bedrock.go + internal/llm/bedrock_test.go + internal/llm/config.go + internal/llm/factory.go + internal/llm/factory_test.go + internal/llm/filter.go + internal/llm/gemini.go + internal/llm/gemini_test.go + internal/llm/ollama.go + internal/llm/ollama_test.go + internal/llm/openai.go + internal/llm/openai_test.go + internal/llm/provider.go + internal/llm/stub.go ~ internal/registry/registry.go + pkg/agentrelay/relay.go + run.sh + scuttlectl
+37 -9
--- CLAUDE.md
+++ CLAUDE.md
@@ -1,19 +1,47 @@
11
# Claude — scuttlebot
22
33
Primary conventions doc: [`bootstrap.md`](bootstrap.md)
4
-Context seed: [`memory.md`](memory.md)
54
6
-Read both before writing any code.
5
+Read it before writing any code.
76
87
---
98
109
## Project-specific notes
1110
12
-- Language: Python 3.12+
13
-- Transport: IRC — all agent coordination flows through IRC channels and messages
14
-- Async runtime: asyncio throughout; IRC library TBD (irc3 or similar)
15
-- No web layer, no database — pure message-passing over IRC
11
+- Language: Go 1.22+
12
+- Transport: IRC — all agent coordination flows through Ergo IRC channels and messages
13
+- HTTP API: `internal/api/` — Bearer token auth, JSON, serves the web UI at `/ui/`
14
+- Admin auth: `internal/auth/` — bcrypt-hashed accounts, login at `POST /login`
15
+- Bot manager: `internal/bots/manager/` — starts/stops system bots based on policy changes
1616
- Human observable by design: everything an agent does is visible in IRC
17
-- Test runner: pytest + pytest-asyncio
18
-- Formatter/linter: Ruff (replaces black, flake8, isort)
19
-- Package manager: uv (`uv sync`, `uv run pytest`)
17
+- Test runner: `go test ./...`
18
+- Formatter: `gofmt` (enforced — run before committing)
19
+- Linter: `golangci-lint run`
20
+- Dev helper: `./run.sh` (start / stop / restart / token / log / test / e2e / clean)
21
+- No ORM, no database — state persisted as JSON files in `data/`
22
+
23
+## Key entry points
24
+
25
+| Path | Purpose |
26
+|------|---------|
27
+| `cmd/scuttlebot/` | daemon binary |
28
+| `cmd/scuttlectl/` | admin CLI |
29
+| `internal/api/` | HTTP API server + web UI |
30
+| `internal/auth/` | admin account store (bcrypt) |
31
+| `internal/registry/` | agent registration + credential issuance |
32
+| `internal/bots/manager/` | bot lifecycle (start/stop on policy change) |
33
+| `internal/ergo/` | Ergo IRC server lifecycle + config generation |
34
+| `internal/config/` | YAML config loading |
35
+| `pkg/client/` | Go agent SDK |
36
+| `pkg/protocol/` | JSON envelope wire format |
37
+
38
+## Conventions
39
+
40
+- Errors returned, not panicked. Wrap: `fmt.Errorf("pkg: operation: %w", err)`
41
+- Interfaces defined at point of use, not in the implementing package
42
+- No global state. Dependencies injected via constructor args or struct fields.
43
+- Env vars for secrets only (e.g. `ORACLE_OPENAI_API_KEY`); everything else in `scuttlebot.yaml`
44
+
45
+## Memory
46
+
47
+See `~/.claude/projects/<sanitized-cwd>/memory/MEMORY.md`
2048
--- CLAUDE.md
+++ CLAUDE.md
@@ -1,19 +1,47 @@
1 # Claude — scuttlebot
2
3 Primary conventions doc: [`bootstrap.md`](bootstrap.md)
4 Context seed: [`memory.md`](memory.md)
5
6 Read both before writing any code.
7
8 ---
9
10 ## Project-specific notes
11
12 - Language: Python 3.12+
13 - Transport: IRC — all agent coordination flows through IRC channels and messages
14 - Async runtime: asyncio throughout; IRC library TBD (irc3 or similar)
15 - No web layer, no database — pure message-passing over IRC
 
16 - Human observable by design: everything an agent does is visible in IRC
17 - Test runner: pytest + pytest-asyncio
18 - Formatter/linter: Ruff (replaces black, flake8, isort)
19 - Package manager: uv (`uv sync`, `uv run pytest`)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
--- CLAUDE.md
+++ CLAUDE.md
@@ -1,19 +1,47 @@
1 # Claude — scuttlebot
2
3 Primary conventions doc: [`bootstrap.md`](bootstrap.md)
 
4
5 Read it before writing any code.
6
7 ---
8
9 ## Project-specific notes
10
11 - Language: Go 1.22+
12 - Transport: IRC — all agent coordination flows through Ergo IRC channels and messages
13 - HTTP API: `internal/api/` — Bearer token auth, JSON, serves the web UI at `/ui/`
14 - Admin auth: `internal/auth/` — bcrypt-hashed accounts, login at `POST /login`
15 - Bot manager: `internal/bots/manager/` — starts/stops system bots based on policy changes
16 - Human observable by design: everything an agent does is visible in IRC
17 - Test runner: `go test ./...`
18 - Formatter: `gofmt` (enforced — run before committing)
19 - Linter: `golangci-lint run`
20 - Dev helper: `./run.sh` (start / stop / restart / token / log / test / e2e / clean)
21 - No ORM, no database — state persisted as JSON files in `data/`
22
23 ## Key entry points
24
25 | Path | Purpose |
26 |------|---------|
27 | `cmd/scuttlebot/` | daemon binary |
28 | `cmd/scuttlectl/` | admin CLI |
29 | `internal/api/` | HTTP API server + web UI |
30 | `internal/auth/` | admin account store (bcrypt) |
31 | `internal/registry/` | agent registration + credential issuance |
32 | `internal/bots/manager/` | bot lifecycle (start/stop on policy change) |
33 | `internal/ergo/` | Ergo IRC server lifecycle + config generation |
34 | `internal/config/` | YAML config loading |
35 | `pkg/client/` | Go agent SDK |
36 | `pkg/protocol/` | JSON envelope wire format |
37
38 ## Conventions
39
40 - Errors returned, not panicked. Wrap: `fmt.Errorf("pkg: operation: %w", err)`
41 - Interfaces defined at point of use, not in the implementing package
42 - No global state. Dependencies injected via constructor args or struct fields.
43 - Env vars for secrets only (e.g. `ORACLE_OPENAI_API_KEY`); everything else in `scuttlebot.yaml`
44
45 ## Memory
46
47 See `~/.claude/projects/<sanitized-cwd>/memory/MEMORY.md`
48
+128 -41
--- bootstrap.md
+++ bootstrap.md
@@ -55,35 +55,49 @@
5555
5656
## Monorepo Layout
5757
5858
```
5959
cmd/
60
- scuttlebot/ # daemon binary
61
- scuttlectl/ # CLI/REPL binary
60
+ scuttlebot/ # daemon binary
61
+ scuttlectl/ # admin CLI
62
+ internal/apiclient/ # typed API client used by scuttlectl
6263
internal/
63
- ergo/ # ergo lifecycle + config generation
64
- registry/ # agent registration + credential issuance
65
- topology/ # channel provisioning + mode/topic management
66
- bots/ # built-in bots (scribe, scroll, herald, oracle, warden)
67
- mcp/ # MCP server for AI agent connectivity
68
-internal/config/ # config loading + validation
64
+ api/ # HTTP API server (Bearer auth) + embedded web UI at /ui/
65
+ ui/index.html # single-file operator web UI
66
+ auth/ # admin account store — bcrypt hashed, persisted to JSON
67
+ bots/
68
+ manager/ # bot lifecycle — starts/stops bots on policy change
69
+ auditbot/ # immutable append-only audit trail
70
+ herald/ # external event → channel routing (webhooks)
71
+ oracle/ # on-demand channel summarization via LLM (PM only)
72
+ scribe/ # structured logging to rotating files
73
+ scroll/ # history replay to PM on request
74
+ snitch/ # flood + join/part cycling detection → operator alerts
75
+ systembot/ # IRC system events (joins, parts, modes, kicks)
76
+ warden/ # channel moderation — warn → mute → kick
77
+ config/ # YAML config loading + validation
78
+ ergo/ # Ergo IRC server lifecycle + config generation
79
+ mcp/ # MCP server for AI agent connectivity
80
+ registry/ # agent registration + SASL credential issuance
81
+ topology/ # channel provisioning + mode/topic management
6982
pkg/
70
- client/ # Go SDK (public)
71
- protocol/ # wire format (message envelope)
72
-apps/
73
- web/ # operator UI — separate app, own stack
74
-sdk/ # future: python, ruby, rust client SDKs
83
+ client/ # Go agent SDK (public)
84
+ protocol/ # JSON envelope wire format
7585
deploy/
76
- docker/ # Dockerfile(s)
77
- compose/ # docker compose (local dev + single-host)
78
- k8s/ # Kubernetes manifests
79
- standalone/ # single binary, no container required
86
+ docker/ # Dockerfile(s)
87
+ compose/ # Docker Compose (local dev + single-host)
88
+ k8s/ # Kubernetes manifests
89
+ standalone/ # single binary, no container required
90
+tests/
91
+ e2e/ # Playwright end-to-end tests (require scuttlebot running)
8092
go.mod
8193
go.sum
94
+bootstrap.md
95
+CLAUDE.md # Claude Code shim — points here
8296
```
8397
84
-Single Go module for everything under `cmd/`, `internal/`, `pkg/`. `apps/web/` and `sdk/*` are their own modules.
98
+Single Go module. All state persisted as JSON files under `data/` (no database required).
8599
86100
---
87101
88102
## Architecture
89103
@@ -143,34 +157,44 @@
143157
- `+v` (voice) — trusted worker agents
144158
- no mode — standard agents
145159
146160
### Built-in bots
147161
148
-| Bot | Role |
149
-|-----|------|
150
-| `scribe` | Structured logging to persistent store |
151
-| `scroll` | History replay to PM on request (never floods channels) |
152
-| `herald` | Alerts + notifications |
153
-| `oracle` | Summarization — packages context as TOON for agent consumption |
154
-| `warden` | Moderation + rate limiting |
155
-
156
-v0 ships `scribe` only. Pattern proven, others follow.
162
+All 8 bots are implemented. Enabled/configured via the web UI or `scuttlectl`. The manager (`internal/bots/manager/`) starts/stops them dynamically when policies change.
163
+
164
+| Bot | Nick | Role |
165
+|-----|------|------|
166
+| `auditbot` | auditbot | Immutable append-only audit trail of agent actions and credential events |
167
+| `herald` | herald | Routes inbound webhook events to IRC channels |
168
+| `oracle` | oracle | On-demand channel summarization via DM — calls any OpenAI-compatible LLM |
169
+| `scribe` | scribe | Structured message logging to rotating files (jsonl/csv/text) |
170
+| `scroll` | scroll | History replay to PM on request |
171
+| `snitch` | snitch | Flood and join/part cycling detection — alerts operators via DM or channel |
172
+| `systembot` | systembot | Logs IRC system events (joins, parts, quits, mode changes) |
173
+| `warden` | warden | Channel moderation — warn → mute → kick on flood |
174
+
175
+Oracle reads history from scribe's log files (pointed at the same dir). Configure `api_key_env` to the name of the env var holding the API key (e.g. `ORACLE_OPENAI_API_KEY`), and `base_url` for non-OpenAI providers.
157176
158177
### Scale
159178
160179
Target: 100s to low 1000s of agents on a private network. Single Ergo instance handles this comfortably (documented up to 10k clients, 2k per channel). Ergo scales up (multi-core), not out — no horizontal clustering today. Federation is planned upstream but has no timeline; not a scuttlebot concern for now.
161180
162181
### Persistence
163182
164
-| What | Standalone | Docker Compose / K8s |
165
-|------|-----------|----------------------|
166
-| Ergo state (accounts, channels, topics) | `ircd.db` local file | PersistentVolume (K8s) or named volume (Compose) |
167
-| Ergo message history | in-memory buffer | MySQL (Ergo-native, unlimited history) |
168
-| scuttlebot state (agent registry, config) | SQLite | Postgres |
169
-| scribe bot (chat/event logs) | SQLite | Postgres or S3 |
170
-
171
-K8s HA: single Ergo pod with PVC for `ircd.db`. Not multi-replica — Ergo is single-instance. HA = fast pod restart with durable storage.
183
+No database required. All state is persisted as JSON files under `data/` by default.
184
+
185
+| What | File | Notes |
186
+|------|------|-------|
187
+| Agent registry | `data/ergo/registry.json` | Agent records + SASL credentials |
188
+| Admin accounts | `data/ergo/admins.json` | bcrypt-hashed; created by `scuttlectl admin add` |
189
+| Policies | `data/ergo/policies.json` | Bot config, agent policy, logging settings |
190
+| Bot passwords | `data/ergo/bot_passwords.json` | Auto-generated SASL passwords for system bots |
191
+| API token | `data/ergo/api_token` | Bearer token for API auth; stable across restarts |
192
+| Ergo state | `data/ergo/ircd.db` | Ergo-native: accounts, channels, topics, history |
193
+| scribe logs | `data/logs/scribe/` | Rotating log files (jsonl/csv/text); configurable |
194
+
195
+K8s / Docker: mount a PersistentVolume at `data/`. Ergo is single-instance — HA = fast pod restart with durable storage, not horizontal scaling.
172196
173197
---
174198
175199
## Conventions
176200
@@ -196,19 +220,64 @@
196220
- Branch: `feature/{issue}-short-description` or `fix/{issue}-short-description`
197221
- No rebases. New commits only.
198222
- No AI attribution in commits.
199223
200224
---
225
+
226
+## HTTP API
227
+
228
+`internal/api/` — two-mux pattern:
229
+
230
+- **Outer mux** (unauthenticated): `POST /login`, `GET /` (redirect), `GET /ui/` (web UI)
231
+- **Inner mux** (`/v1/` routes): require `Authorization: Bearer <token>` header
232
+
233
+The API token is a random hex string generated once at startup, persisted to `data/ergo/api_token`.
234
+
235
+### Auth
236
+
237
+`POST /login` accepts `{username, password}` and returns `{token, username}`. The token is the shared server API token. Rate limited to 10 attempts per minute per IP.
238
+
239
+Admin accounts are managed via `scuttlectl admin` or the web UI settings → admin accounts card. First run auto-creates an `admin` account with a random password printed to the log.
240
+
241
+### Key endpoints
242
+
243
+| Method | Path | Description |
244
+|--------|------|-------------|
245
+| `POST` | `/login` | Username/password login (unauthenticated) |
246
+| `GET` | `/v1/status` | Server status |
247
+| `GET` | `/v1/metrics` | Runtime metrics + bridge stats |
248
+| `GET/PUT` | `/v1/settings/policies` | Bot config, agent policy, logging |
249
+| `GET` | `/v1/agents` | List all registered agents |
250
+| `POST` | `/v1/agents/register` | Register an agent |
251
+| `POST` | `/v1/agents/{nick}/rotate` | Rotate credentials |
252
+| `POST` | `/v1/agents/{nick}/revoke` | Revoke agent |
253
+| `GET` | `/v1/channels` | List joined channels |
254
+| `GET` | `/v1/channels/{ch}/stream` | SSE stream of channel messages |
255
+| `GET/POST` | `/v1/admins` | List / add admin accounts |
256
+| `DELETE` | `/v1/admins/{username}` | Remove admin |
257
+| `PUT` | `/v1/admins/{username}/password` | Change password |
258
+
259
+---
201260
202261
## Adding a New Bot
203262
204
-1. Create `internal/bots/{name}/` package
205
-2. Implement the `Bot` interface (defined in `internal/bots/bot.go`)
206
-3. Register in `internal/bots/registry.go`
207
-4. Add config struct to `internal/config/`
208
-5. Write tests: bot handles valid message, ignores malformed message, handles disconnect/reconnect
263
+1. Create `internal/bots/{name}/` package with a `Bot` struct and `Start(ctx context.Context) error` method
264
+2. Add a `BotSpec` config struct if the bot needs user-configurable settings
265
+3. Register in `internal/bots/manager/manager.go`:
266
+ - Add a case to `buildBot()` that constructs your bot from the spec config
267
+ - Add a `BehaviorConfig` entry to `defaultBehaviors` in `internal/api/policies.go`
268
+4. Add the UI config schema to `BEHAVIOR_SCHEMAS` in `internal/api/ui/index.html`
269
+5. Write tests: bot logic, config parsing, edge cases. IRC connection can be skipped in unit tests.
209270
6. Update this bootstrap
271
+
272
+No separate registration file or global registry. The manager builds bots by ID from the `BotSpec`. Bots satisfy the `bot` interface (unexported in manager package):
273
+
274
+```go
275
+type bot interface {
276
+ Start(ctx context.Context) error
277
+}
278
+```
210279
211280
---
212281
213282
## Adding a New SDK
214283
@@ -230,11 +299,29 @@
230299
---
231300
232301
## Common Commands
233302
234303
```bash
304
+# Dev helper (recommended)
305
+./run.sh # build + start
306
+./run.sh restart # rebuild + restart
307
+./run.sh stop # stop
308
+./run.sh token # print current API token
309
+./run.sh log # tail the log
310
+./run.sh test # go test ./...
311
+./run.sh e2e # Playwright e2e (requires scuttlebot running)
312
+
313
+# Direct Go commands
235314
go build ./cmd/scuttlebot # build daemon
236315
go build ./cmd/scuttlectl # build CLI
237316
go test ./... # run all tests
238317
golangci-lint run # lint
239
-docker compose up # boot ergo + scuttlebot locally
318
+
319
+# Admin CLI
320
+scuttlectl admin list # list admin accounts
321
+scuttlectl admin add alice # add admin (prompts for password)
322
+scuttlectl admin passwd alice # change password
323
+scuttlectl admin remove alice # remove admin
324
+
325
+# Docker
326
+docker compose -f deploy/compose/docker-compose.yml up
240327
```
241328
--- bootstrap.md
+++ bootstrap.md
@@ -55,35 +55,49 @@
55
56 ## Monorepo Layout
57
58 ```
59 cmd/
60 scuttlebot/ # daemon binary
61 scuttlectl/ # CLI/REPL binary
 
62 internal/
63 ergo/ # ergo lifecycle + config generation
64 registry/ # agent registration + credential issuance
65 topology/ # channel provisioning + mode/topic management
66 bots/ # built-in bots (scribe, scroll, herald, oracle, warden)
67 mcp/ # MCP server for AI agent connectivity
68 internal/config/ # config loading + validation
 
 
 
 
 
 
 
 
 
 
 
 
69 pkg/
70 client/ # Go SDK (public)
71 protocol/ # wire format (message envelope)
72 apps/
73 web/ # operator UI — separate app, own stack
74 sdk/ # future: python, ruby, rust client SDKs
75 deploy/
76 docker/ # Dockerfile(s)
77 compose/ # docker compose (local dev + single-host)
78 k8s/ # Kubernetes manifests
79 standalone/ # single binary, no container required
 
 
80 go.mod
81 go.sum
 
 
82 ```
83
84 Single Go module for everything under `cmd/`, `internal/`, `pkg/`. `apps/web/` and `sdk/*` are their own modules.
85
86 ---
87
88 ## Architecture
89
@@ -143,34 +157,44 @@
143 - `+v` (voice) — trusted worker agents
144 - no mode — standard agents
145
146 ### Built-in bots
147
148 | Bot | Role |
149 |-----|------|
150 | `scribe` | Structured logging to persistent store |
151 | `scroll` | History replay to PM on request (never floods channels) |
152 | `herald` | Alerts + notifications |
153 | `oracle` | Summarization — packages context as TOON for agent consumption |
154 | `warden` | Moderation + rate limiting |
155
156 v0 ships `scribe` only. Pattern proven, others follow.
 
 
 
 
 
157
158 ### Scale
159
160 Target: 100s to low 1000s of agents on a private network. Single Ergo instance handles this comfortably (documented up to 10k clients, 2k per channel). Ergo scales up (multi-core), not out — no horizontal clustering today. Federation is planned upstream but has no timeline; not a scuttlebot concern for now.
161
162 ### Persistence
163
164 | What | Standalone | Docker Compose / K8s |
165 |------|-----------|----------------------|
166 | Ergo state (accounts, channels, topics) | `ircd.db` local file | PersistentVolume (K8s) or named volume (Compose) |
167 | Ergo message history | in-memory buffer | MySQL (Ergo-native, unlimited history) |
168 | scuttlebot state (agent registry, config) | SQLite | Postgres |
169 | scribe bot (chat/event logs) | SQLite | Postgres or S3 |
170
171 K8s HA: single Ergo pod with PVC for `ircd.db`. Not multi-replica — Ergo is single-instance. HA = fast pod restart with durable storage.
 
 
 
 
 
172
173 ---
174
175 ## Conventions
176
@@ -196,19 +220,64 @@
196 - Branch: `feature/{issue}-short-description` or `fix/{issue}-short-description`
197 - No rebases. New commits only.
198 - No AI attribution in commits.
199
200 ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
202 ## Adding a New Bot
203
204 1. Create `internal/bots/{name}/` package
205 2. Implement the `Bot` interface (defined in `internal/bots/bot.go`)
206 3. Register in `internal/bots/registry.go`
207 4. Add config struct to `internal/config/`
208 5. Write tests: bot handles valid message, ignores malformed message, handles disconnect/reconnect
 
 
209 6. Update this bootstrap
 
 
 
 
 
 
 
 
210
211 ---
212
213 ## Adding a New SDK
214
@@ -230,11 +299,29 @@
230 ---
231
232 ## Common Commands
233
234 ```bash
 
 
 
 
 
 
 
 
 
 
235 go build ./cmd/scuttlebot # build daemon
236 go build ./cmd/scuttlectl # build CLI
237 go test ./... # run all tests
238 golangci-lint run # lint
239 docker compose up # boot ergo + scuttlebot locally
 
 
 
 
 
 
 
 
240 ```
241
--- bootstrap.md
+++ bootstrap.md
@@ -55,35 +55,49 @@
55
56 ## Monorepo Layout
57
58 ```
59 cmd/
60 scuttlebot/ # daemon binary
61 scuttlectl/ # admin CLI
62 internal/apiclient/ # typed API client used by scuttlectl
63 internal/
64 api/ # HTTP API server (Bearer auth) + embedded web UI at /ui/
65 ui/index.html # single-file operator web UI
66 auth/ # admin account store — bcrypt hashed, persisted to JSON
67 bots/
68 manager/ # bot lifecycle — starts/stops bots on policy change
69 auditbot/ # immutable append-only audit trail
70 herald/ # external event → channel routing (webhooks)
71 oracle/ # on-demand channel summarization via LLM (PM only)
72 scribe/ # structured logging to rotating files
73 scroll/ # history replay to PM on request
74 snitch/ # flood + join/part cycling detection → operator alerts
75 systembot/ # IRC system events (joins, parts, modes, kicks)
76 warden/ # channel moderation — warn → mute → kick
77 config/ # YAML config loading + validation
78 ergo/ # Ergo IRC server lifecycle + config generation
79 mcp/ # MCP server for AI agent connectivity
80 registry/ # agent registration + SASL credential issuance
81 topology/ # channel provisioning + mode/topic management
82 pkg/
83 client/ # Go agent SDK (public)
84 protocol/ # JSON envelope wire format
 
 
 
85 deploy/
86 docker/ # Dockerfile(s)
87 compose/ # Docker Compose (local dev + single-host)
88 k8s/ # Kubernetes manifests
89 standalone/ # single binary, no container required
90 tests/
91 e2e/ # Playwright end-to-end tests (require scuttlebot running)
92 go.mod
93 go.sum
94 bootstrap.md
95 CLAUDE.md # Claude Code shim — points here
96 ```
97
98 Single Go module. All state persisted as JSON files under `data/` (no database required).
99
100 ---
101
102 ## Architecture
103
@@ -143,34 +157,44 @@
157 - `+v` (voice) — trusted worker agents
158 - no mode — standard agents
159
160 ### Built-in bots
161
162 All 8 bots are implemented. Enabled/configured via the web UI or `scuttlectl`. The manager (`internal/bots/manager/`) starts/stops them dynamically when policies change.
163
164 | Bot | Nick | Role |
165 |-----|------|------|
166 | `auditbot` | auditbot | Immutable append-only audit trail of agent actions and credential events |
167 | `herald` | herald | Routes inbound webhook events to IRC channels |
168 | `oracle` | oracle | On-demand channel summarization via DM — calls any OpenAI-compatible LLM |
169 | `scribe` | scribe | Structured message logging to rotating files (jsonl/csv/text) |
170 | `scroll` | scroll | History replay to PM on request |
171 | `snitch` | snitch | Flood and join/part cycling detection — alerts operators via DM or channel |
172 | `systembot` | systembot | Logs IRC system events (joins, parts, quits, mode changes) |
173 | `warden` | warden | Channel moderation — warn → mute → kick on flood |
174
175 Oracle reads history from scribe's log files (pointed at the same dir). Configure `api_key_env` to the name of the env var holding the API key (e.g. `ORACLE_OPENAI_API_KEY`), and `base_url` for non-OpenAI providers.
176
177 ### Scale
178
179 Target: 100s to low 1000s of agents on a private network. Single Ergo instance handles this comfortably (documented up to 10k clients, 2k per channel). Ergo scales up (multi-core), not out — no horizontal clustering today. Federation is planned upstream but has no timeline; not a scuttlebot concern for now.
180
181 ### Persistence
182
183 No database required. All state is persisted as JSON files under `data/` by default.
184
185 | What | File | Notes |
186 |------|------|-------|
187 | Agent registry | `data/ergo/registry.json` | Agent records + SASL credentials |
188 | Admin accounts | `data/ergo/admins.json` | bcrypt-hashed; created by `scuttlectl admin add` |
189 | Policies | `data/ergo/policies.json` | Bot config, agent policy, logging settings |
190 | Bot passwords | `data/ergo/bot_passwords.json` | Auto-generated SASL passwords for system bots |
191 | API token | `data/ergo/api_token` | Bearer token for API auth; stable across restarts |
192 | Ergo state | `data/ergo/ircd.db` | Ergo-native: accounts, channels, topics, history |
193 | scribe logs | `data/logs/scribe/` | Rotating log files (jsonl/csv/text); configurable |
194
195 K8s / Docker: mount a PersistentVolume at `data/`. Ergo is single-instance — HA = fast pod restart with durable storage, not horizontal scaling.
196
197 ---
198
199 ## Conventions
200
@@ -196,19 +220,64 @@
220 - Branch: `feature/{issue}-short-description` or `fix/{issue}-short-description`
221 - No rebases. New commits only.
222 - No AI attribution in commits.
223
224 ---
225
226 ## HTTP API
227
228 `internal/api/` — two-mux pattern:
229
230 - **Outer mux** (unauthenticated): `POST /login`, `GET /` (redirect), `GET /ui/` (web UI)
231 - **Inner mux** (`/v1/` routes): require `Authorization: Bearer <token>` header
232
233 The API token is a random hex string generated once at startup, persisted to `data/ergo/api_token`.
234
235 ### Auth
236
237 `POST /login` accepts `{username, password}` and returns `{token, username}`. The token is the shared server API token. Rate limited to 10 attempts per minute per IP.
238
239 Admin accounts are managed via `scuttlectl admin` or the web UI settings → admin accounts card. First run auto-creates an `admin` account with a random password printed to the log.
240
241 ### Key endpoints
242
243 | Method | Path | Description |
244 |--------|------|-------------|
245 | `POST` | `/login` | Username/password login (unauthenticated) |
246 | `GET` | `/v1/status` | Server status |
247 | `GET` | `/v1/metrics` | Runtime metrics + bridge stats |
248 | `GET/PUT` | `/v1/settings/policies` | Bot config, agent policy, logging |
249 | `GET` | `/v1/agents` | List all registered agents |
250 | `POST` | `/v1/agents/register` | Register an agent |
251 | `POST` | `/v1/agents/{nick}/rotate` | Rotate credentials |
252 | `POST` | `/v1/agents/{nick}/revoke` | Revoke agent |
253 | `GET` | `/v1/channels` | List joined channels |
254 | `GET` | `/v1/channels/{ch}/stream` | SSE stream of channel messages |
255 | `GET/POST` | `/v1/admins` | List / add admin accounts |
256 | `DELETE` | `/v1/admins/{username}` | Remove admin |
257 | `PUT` | `/v1/admins/{username}/password` | Change password |
258
259 ---
260
261 ## Adding a New Bot
262
263 1. Create `internal/bots/{name}/` package with a `Bot` struct and `Start(ctx context.Context) error` method
264 2. Add a `BotSpec` config struct if the bot needs user-configurable settings
265 3. Register in `internal/bots/manager/manager.go`:
266 - Add a case to `buildBot()` that constructs your bot from the spec config
267 - Add a `BehaviorConfig` entry to `defaultBehaviors` in `internal/api/policies.go`
268 4. Add the UI config schema to `BEHAVIOR_SCHEMAS` in `internal/api/ui/index.html`
269 5. Write tests: bot logic, config parsing, edge cases. IRC connection can be skipped in unit tests.
270 6. Update this bootstrap
271
272 No separate registration file or global registry. The manager builds bots by ID from the `BotSpec`. Bots satisfy the `bot` interface (unexported in manager package):
273
274 ```go
275 type bot interface {
276 Start(ctx context.Context) error
277 }
278 ```
279
280 ---
281
282 ## Adding a New SDK
283
@@ -230,11 +299,29 @@
299 ---
300
301 ## Common Commands
302
303 ```bash
304 # Dev helper (recommended)
305 ./run.sh # build + start
306 ./run.sh restart # rebuild + restart
307 ./run.sh stop # stop
308 ./run.sh token # print current API token
309 ./run.sh log # tail the log
310 ./run.sh test # go test ./...
311 ./run.sh e2e # Playwright e2e (requires scuttlebot running)
312
313 # Direct Go commands
314 go build ./cmd/scuttlebot # build daemon
315 go build ./cmd/scuttlectl # build CLI
316 go test ./... # run all tests
317 golangci-lint run # lint
318
319 # Admin CLI
320 scuttlectl admin list # list admin accounts
321 scuttlectl admin add alice # add admin (prompts for password)
322 scuttlectl admin passwd alice # change password
323 scuttlectl admin remove alice # remove admin
324
325 # Docker
326 docker compose -f deploy/compose/docker-compose.yml up
327 ```
328
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -1,23 +1,29 @@
11
package main
22
33
import (
44
"context"
55
"crypto/rand"
6
+ "crypto/tls"
67
"encoding/hex"
78
"flag"
89
"fmt"
910
"log/slog"
1011
"net/http"
1112
"os"
1213
"os/signal"
1314
"path/filepath"
15
+ "strings"
1416
"syscall"
1517
"time"
18
+
19
+ "golang.org/x/crypto/acme/autocert"
1620
1721
"github.com/conflicthq/scuttlebot/internal/api"
22
+ "github.com/conflicthq/scuttlebot/internal/auth"
1823
"github.com/conflicthq/scuttlebot/internal/bots/bridge"
24
+ botmanager "github.com/conflicthq/scuttlebot/internal/bots/manager"
1925
"github.com/conflicthq/scuttlebot/internal/config"
2026
"github.com/conflicthq/scuttlebot/internal/ergo"
2127
"github.com/conflicthq/scuttlebot/internal/mcp"
2228
"github.com/conflicthq/scuttlebot/internal/registry"
2329
)
@@ -51,34 +57,43 @@
5157
os.Exit(1)
5258
}
5359
cfg.Ergo.BinaryPath = abs
5460
}
5561
56
- // Generate an API token for the Ergo management API if not set.
62
+ // Load or generate a stable Ergo management API token.
63
+ // We persist it to data/ergo-api-token so it survives restarts — without
64
+ // this the token changes every launch and the NickServ password-rotation
65
+ // API call fails with 401 because ergo already loaded the old token.
66
+ ergoTokenPath := filepath.Join(cfg.Ergo.DataDir, "ergo-api-token")
5767
if cfg.Ergo.APIToken == "" {
58
- cfg.Ergo.APIToken = mustGenToken()
68
+ if raw, err := os.ReadFile(ergoTokenPath); err == nil && len(raw) > 0 {
69
+ cfg.Ergo.APIToken = strings.TrimSpace(string(raw))
70
+ } else {
71
+ cfg.Ergo.APIToken = mustGenToken()
72
+ _ = os.WriteFile(ergoTokenPath, []byte(cfg.Ergo.APIToken), 0600)
73
+ }
5974
}
6075
6176
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
6277
defer cancel()
6378
6479
log.Info("scuttlebot starting", "version", version)
6580
6681
// Start Ergo.
67
- manager := ergo.NewManager(cfg.Ergo, log)
82
+ ergoMgr := ergo.NewManager(cfg.Ergo, log)
6883
ergoErr := make(chan error, 1)
6984
go func() {
70
- if err := manager.Start(ctx); err != nil {
85
+ if err := ergoMgr.Start(ctx); err != nil {
7186
ergoErr <- err
7287
}
7388
}()
7489
7590
// Wait for Ergo to become healthy before starting the rest.
7691
healthCtx, healthCancel := context.WithTimeout(ctx, 30*time.Second)
7792
defer healthCancel()
7893
for {
79
- if _, err := manager.API().Status(); err == nil {
94
+ if _, err := ergoMgr.API().Status(); err == nil {
8095
break
8196
}
8297
select {
8398
case <-healthCtx.Done():
8499
log.Error("ergo did not become healthy in time")
@@ -90,28 +105,41 @@
90105
}
91106
}
92107
log.Info("ergo healthy")
93108
94109
// Build registry backed by Ergo's NickServ API.
95
- signingKey := []byte(mustGenToken())
96
- reg := registry.New(manager.API(), signingKey)
110
+ // Signing key persists so issued payloads stay valid across restarts.
111
+ signingKeyHex, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "signing_key"))
112
+ if err != nil {
113
+ log.Error("signing key", "err", err)
114
+ os.Exit(1)
115
+ }
116
+ reg := registry.New(ergoMgr.API(), []byte(signingKeyHex))
117
+ if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil {
118
+ log.Error("registry load", "err", err)
119
+ os.Exit(1)
120
+ }
97121
98
- // Shared API token — used by both REST and MCP servers.
99
- apiToken := mustGenToken()
100
- log.Info("api token", "token", apiToken) // printed once on startup — user copies this
122
+ // Shared API token — persisted so the UI token survives restarts.
123
+ apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token"))
124
+ if err != nil {
125
+ log.Error("api token", "err", err)
126
+ os.Exit(1)
127
+ }
128
+ log.Info("api token", "token", apiToken) // printed on every startup
101129
tokens := []string{apiToken}
102130
103131
// Start bridge bot (powers the web chat UI).
104132
var bridgeBot *bridge.Bot
105133
if cfg.Bridge.Enabled {
106134
if cfg.Bridge.Password == "" {
107135
cfg.Bridge.Password = mustGenToken()
108136
}
109137
// Ensure the bridge's NickServ account exists with the current password.
110
- if err := manager.API().RegisterAccount(cfg.Bridge.Nick, cfg.Bridge.Password); err != nil {
138
+ if err := ergoMgr.API().RegisterAccount(cfg.Bridge.Nick, cfg.Bridge.Password); err != nil {
111139
// Account exists from a previous run — update the password so it matches.
112
- if err2 := manager.API().ChangePassword(cfg.Bridge.Nick, cfg.Bridge.Password); err2 != nil {
140
+ if err2 := ergoMgr.API().ChangePassword(cfg.Bridge.Nick, cfg.Bridge.Password); err2 != nil {
113141
log.Error("bridge account setup failed", "err", err2)
114142
os.Exit(1)
115143
}
116144
}
117145
bridgeBot = bridge.New(
@@ -118,34 +146,156 @@
118146
cfg.Ergo.IRCAddr,
119147
cfg.Bridge.Nick,
120148
cfg.Bridge.Password,
121149
cfg.Bridge.Channels,
122150
cfg.Bridge.BufferSize,
151
+ time.Duration(cfg.Bridge.WebUserTTLMinutes)*time.Minute,
123152
log,
124153
)
125154
go func() {
126155
if err := bridgeBot.Start(ctx); err != nil {
127156
log.Error("bridge bot error", "err", err)
128157
}
129158
}()
130159
}
160
+
161
+ // Policy store — persists behavior/agent/logging settings.
162
+ policyStore, err := api.NewPolicyStore(filepath.Join(cfg.Ergo.DataDir, "policies.json"), cfg.Bridge.WebUserTTLMinutes)
163
+ if err != nil {
164
+ log.Error("policy store", "err", err)
165
+ os.Exit(1)
166
+ }
167
+ if bridgeBot != nil {
168
+ bridgeBot.SetWebUserTTL(time.Duration(policyStore.Get().Bridge.WebUserTTLMinutes) * time.Minute)
169
+ }
170
+
171
+ // Admin store — bcrypt-hashed admin accounts.
172
+ adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json"))
173
+ if err != nil {
174
+ log.Error("admin store", "err", err)
175
+ os.Exit(1)
176
+ }
177
+ if adminStore.IsEmpty() {
178
+ password := mustGenToken()[:16]
179
+ if err := adminStore.Add("admin", password); err != nil {
180
+ log.Error("create default admin", "err", err)
181
+ os.Exit(1)
182
+ }
183
+ log.Info("first run — default admin created", "username", "admin", "password", password, "action", "change this password immediately")
184
+ }
185
+
186
+ // Bot manager — starts/stops system bots based on policy.
187
+ botMgr := botmanager.New(cfg.Ergo.IRCAddr, cfg.Ergo.DataDir, ergoMgr.API(), &ergoChannelListAdapter{ergoMgr.API()}, log)
188
+
189
+ // Wire policy onChange to re-sync bots on every policy update.
190
+ policyStore.OnChange(func(p api.Policies) {
191
+ specs := make([]botmanager.BotSpec, len(p.Behaviors))
192
+ for i, b := range p.Behaviors {
193
+ specs[i] = botmanager.BotSpec{
194
+ ID: b.ID,
195
+ Nick: b.Nick,
196
+ Enabled: b.Enabled,
197
+ JoinAllChannels: b.JoinAllChannels,
198
+ RequiredChannels: b.RequiredChannels,
199
+ Config: b.Config,
200
+ }
201
+ }
202
+ if bridgeBot != nil {
203
+ bridgeBot.SetWebUserTTL(time.Duration(p.Bridge.WebUserTTLMinutes) * time.Minute)
204
+ }
205
+ botMgr.Sync(ctx, specs)
206
+ })
207
+
208
+ // Initial bot sync from loaded policies.
209
+ {
210
+ p := policyStore.Get()
211
+ specs := make([]botmanager.BotSpec, len(p.Behaviors))
212
+ for i, b := range p.Behaviors {
213
+ specs[i] = botmanager.BotSpec{
214
+ ID: b.ID,
215
+ Nick: b.Nick,
216
+ Enabled: b.Enabled,
217
+ JoinAllChannels: b.JoinAllChannels,
218
+ RequiredChannels: b.RequiredChannels,
219
+ Config: b.Config,
220
+ }
221
+ }
222
+ botMgr.Sync(ctx, specs)
223
+ }
131224
132225
// Start HTTP REST API server.
133
- apiSrv := api.New(reg, tokens, bridgeBot, log)
134
- httpServer := &http.Server{
135
- Addr: cfg.APIAddr,
136
- Handler: apiSrv.Handler(),
137
- }
138
- go func() {
139
- log.Info("api server listening", "addr", httpServer.Addr)
140
- if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
141
- log.Error("api server error", "err", err)
142
- }
143
- }()
226
+ var llmCfg *config.LLMConfig
227
+ if len(cfg.LLM.Backends) > 0 {
228
+ llmCfg = &cfg.LLM
229
+ }
230
+ apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, cfg.TLS.Domain, log)
231
+ handler := apiSrv.Handler()
232
+
233
+ var httpServer, tlsServer *http.Server
234
+
235
+ if cfg.TLS.Domain != "" {
236
+ certDir := cfg.TLS.CertDir
237
+ if certDir == "" {
238
+ certDir = filepath.Join(cfg.Ergo.DataDir, "certs")
239
+ }
240
+ if err := os.MkdirAll(certDir, 0700); err != nil {
241
+ log.Error("create cert dir", "err", err)
242
+ os.Exit(1)
243
+ }
244
+
245
+ m := &autocert.Manager{
246
+ Cache: autocert.DirCache(certDir),
247
+ Prompt: autocert.AcceptTOS,
248
+ Email: cfg.TLS.Email,
249
+ HostPolicy: autocert.HostWhitelist(cfg.TLS.Domain),
250
+ }
251
+
252
+ // HTTPS on :443
253
+ tlsServer = &http.Server{
254
+ Addr: ":443",
255
+ Handler: handler,
256
+ TLSConfig: &tls.Config{GetCertificate: m.GetCertificate},
257
+ }
258
+ go func() {
259
+ log.Info("api server listening (TLS)", "addr", ":443", "domain", cfg.TLS.Domain)
260
+ if err := tlsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
261
+ log.Error("tls server error", "err", err)
262
+ }
263
+ }()
264
+
265
+ // HTTP on :80 — ACME challenge always enabled; also serves API when AllowInsecure.
266
+ var httpHandler http.Handler
267
+ if cfg.TLS.AllowInsecure {
268
+ httpHandler = m.HTTPHandler(handler)
269
+ } else {
270
+ httpHandler = m.HTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
271
+ http.Redirect(w, r, "https://"+cfg.TLS.Domain+r.RequestURI, http.StatusMovedPermanently)
272
+ }))
273
+ }
274
+ httpServer = &http.Server{Addr: ":80", Handler: httpHandler}
275
+ go func() {
276
+ log.Info("http server listening", "addr", ":80", "insecure", cfg.TLS.AllowInsecure)
277
+ if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
278
+ log.Error("http server error", "err", err)
279
+ }
280
+ }()
281
+ } else {
282
+ // No TLS — plain HTTP on configured addr.
283
+ httpServer = &http.Server{
284
+ Addr: cfg.APIAddr,
285
+ Handler: handler,
286
+ }
287
+ go func() {
288
+ log.Info("api server listening", "addr", httpServer.Addr)
289
+ if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
290
+ log.Error("api server error", "err", err)
291
+ }
292
+ }()
293
+ }
144294
145295
// Start MCP server.
146
- mcpSrv := mcp.New(reg, &ergoChannelLister{manager.API()}, tokens, log)
296
+ mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log)
147297
mcpServer := &http.Server{
148298
Addr: cfg.MCPAddr,
149299
Handler: mcpSrv.Handler(),
150300
}
151301
go func() {
@@ -158,15 +308,37 @@
158308
<-ctx.Done()
159309
log.Info("shutting down")
160310
161311
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
162312
defer shutdownCancel()
163
- _ = httpServer.Shutdown(shutdownCtx)
313
+ if httpServer != nil {
314
+ _ = httpServer.Shutdown(shutdownCtx)
315
+ }
316
+ if tlsServer != nil {
317
+ _ = tlsServer.Shutdown(shutdownCtx)
318
+ }
164319
_ = mcpServer.Shutdown(shutdownCtx)
165320
166321
log.Info("goodbye")
167322
}
323
+
324
+// ergoChannelListAdapter adapts ergo.APIClient to botmanager.ChannelLister.
325
+type ergoChannelListAdapter struct {
326
+ api *ergo.APIClient
327
+}
328
+
329
+func (e *ergoChannelListAdapter) ListChannels() ([]string, error) {
330
+ resp, err := e.api.ListChannels()
331
+ if err != nil {
332
+ return nil, err
333
+ }
334
+ out := make([]string, len(resp.Channels))
335
+ for i, ch := range resp.Channels {
336
+ out[i] = ch.Name
337
+ }
338
+ return out, nil
339
+}
168340
169341
// ergoChannelLister adapts ergo.APIClient to mcp.ChannelLister.
170342
type ergoChannelLister struct {
171343
api *ergo.APIClient
172344
}
@@ -193,5 +365,25 @@
193365
fmt.Fprintf(os.Stderr, "failed to generate token: %v\n", err)
194366
os.Exit(1)
195367
}
196368
return hex.EncodeToString(b)
197369
}
370
+
371
+// loadOrCreateToken reads a token from path. If the file doesn't exist it
372
+// generates a new token, writes it, and returns it.
373
+func loadOrCreateToken(path string) (string, error) {
374
+ data, err := os.ReadFile(path)
375
+ if err == nil {
376
+ t := strings.TrimSpace(string(data))
377
+ if t != "" {
378
+ return t, nil
379
+ }
380
+ }
381
+ if !os.IsNotExist(err) && err != nil {
382
+ return "", fmt.Errorf("read token %s: %w", path, err)
383
+ }
384
+ token := mustGenToken()
385
+ if err := os.WriteFile(path, []byte(token+"\n"), 0600); err != nil {
386
+ return "", fmt.Errorf("write token %s: %w", path, err)
387
+ }
388
+ return token, nil
389
+}
198390
199391
ADDED cmd/scuttlectl/cmd_setup.go
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -1,23 +1,29 @@
1 package main
2
3 import (
4 "context"
5 "crypto/rand"
 
6 "encoding/hex"
7 "flag"
8 "fmt"
9 "log/slog"
10 "net/http"
11 "os"
12 "os/signal"
13 "path/filepath"
 
14 "syscall"
15 "time"
 
 
16
17 "github.com/conflicthq/scuttlebot/internal/api"
 
18 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
 
19 "github.com/conflicthq/scuttlebot/internal/config"
20 "github.com/conflicthq/scuttlebot/internal/ergo"
21 "github.com/conflicthq/scuttlebot/internal/mcp"
22 "github.com/conflicthq/scuttlebot/internal/registry"
23 )
@@ -51,34 +57,43 @@
51 os.Exit(1)
52 }
53 cfg.Ergo.BinaryPath = abs
54 }
55
56 // Generate an API token for the Ergo management API if not set.
 
 
 
 
57 if cfg.Ergo.APIToken == "" {
58 cfg.Ergo.APIToken = mustGenToken()
 
 
 
 
 
59 }
60
61 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
62 defer cancel()
63
64 log.Info("scuttlebot starting", "version", version)
65
66 // Start Ergo.
67 manager := ergo.NewManager(cfg.Ergo, log)
68 ergoErr := make(chan error, 1)
69 go func() {
70 if err := manager.Start(ctx); err != nil {
71 ergoErr <- err
72 }
73 }()
74
75 // Wait for Ergo to become healthy before starting the rest.
76 healthCtx, healthCancel := context.WithTimeout(ctx, 30*time.Second)
77 defer healthCancel()
78 for {
79 if _, err := manager.API().Status(); err == nil {
80 break
81 }
82 select {
83 case <-healthCtx.Done():
84 log.Error("ergo did not become healthy in time")
@@ -90,28 +105,41 @@
90 }
91 }
92 log.Info("ergo healthy")
93
94 // Build registry backed by Ergo's NickServ API.
95 signingKey := []byte(mustGenToken())
96 reg := registry.New(manager.API(), signingKey)
 
 
 
 
 
 
 
 
 
97
98 // Shared API token — used by both REST and MCP servers.
99 apiToken := mustGenToken()
100 log.Info("api token", "token", apiToken) // printed once on startup — user copies this
 
 
 
 
101 tokens := []string{apiToken}
102
103 // Start bridge bot (powers the web chat UI).
104 var bridgeBot *bridge.Bot
105 if cfg.Bridge.Enabled {
106 if cfg.Bridge.Password == "" {
107 cfg.Bridge.Password = mustGenToken()
108 }
109 // Ensure the bridge's NickServ account exists with the current password.
110 if err := manager.API().RegisterAccount(cfg.Bridge.Nick, cfg.Bridge.Password); err != nil {
111 // Account exists from a previous run — update the password so it matches.
112 if err2 := manager.API().ChangePassword(cfg.Bridge.Nick, cfg.Bridge.Password); err2 != nil {
113 log.Error("bridge account setup failed", "err", err2)
114 os.Exit(1)
115 }
116 }
117 bridgeBot = bridge.New(
@@ -118,34 +146,156 @@
118 cfg.Ergo.IRCAddr,
119 cfg.Bridge.Nick,
120 cfg.Bridge.Password,
121 cfg.Bridge.Channels,
122 cfg.Bridge.BufferSize,
 
123 log,
124 )
125 go func() {
126 if err := bridgeBot.Start(ctx); err != nil {
127 log.Error("bridge bot error", "err", err)
128 }
129 }()
130 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
132 // Start HTTP REST API server.
133 apiSrv := api.New(reg, tokens, bridgeBot, log)
134 httpServer := &http.Server{
135 Addr: cfg.APIAddr,
136 Handler: apiSrv.Handler(),
137 }
138 go func() {
139 log.Info("api server listening", "addr", httpServer.Addr)
140 if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
141 log.Error("api server error", "err", err)
142 }
143 }()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
145 // Start MCP server.
146 mcpSrv := mcp.New(reg, &ergoChannelLister{manager.API()}, tokens, log)
147 mcpServer := &http.Server{
148 Addr: cfg.MCPAddr,
149 Handler: mcpSrv.Handler(),
150 }
151 go func() {
@@ -158,15 +308,37 @@
158 <-ctx.Done()
159 log.Info("shutting down")
160
161 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
162 defer shutdownCancel()
163 _ = httpServer.Shutdown(shutdownCtx)
 
 
 
 
 
164 _ = mcpServer.Shutdown(shutdownCtx)
165
166 log.Info("goodbye")
167 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
169 // ergoChannelLister adapts ergo.APIClient to mcp.ChannelLister.
170 type ergoChannelLister struct {
171 api *ergo.APIClient
172 }
@@ -193,5 +365,25 @@
193 fmt.Fprintf(os.Stderr, "failed to generate token: %v\n", err)
194 os.Exit(1)
195 }
196 return hex.EncodeToString(b)
197 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
199 DDED cmd/scuttlectl/cmd_setup.go
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -1,23 +1,29 @@
1 package main
2
3 import (
4 "context"
5 "crypto/rand"
6 "crypto/tls"
7 "encoding/hex"
8 "flag"
9 "fmt"
10 "log/slog"
11 "net/http"
12 "os"
13 "os/signal"
14 "path/filepath"
15 "strings"
16 "syscall"
17 "time"
18
19 "golang.org/x/crypto/acme/autocert"
20
21 "github.com/conflicthq/scuttlebot/internal/api"
22 "github.com/conflicthq/scuttlebot/internal/auth"
23 "github.com/conflicthq/scuttlebot/internal/bots/bridge"
24 botmanager "github.com/conflicthq/scuttlebot/internal/bots/manager"
25 "github.com/conflicthq/scuttlebot/internal/config"
26 "github.com/conflicthq/scuttlebot/internal/ergo"
27 "github.com/conflicthq/scuttlebot/internal/mcp"
28 "github.com/conflicthq/scuttlebot/internal/registry"
29 )
@@ -51,34 +57,43 @@
57 os.Exit(1)
58 }
59 cfg.Ergo.BinaryPath = abs
60 }
61
62 // Load or generate a stable Ergo management API token.
63 // We persist it to data/ergo-api-token so it survives restarts — without
64 // this the token changes every launch and the NickServ password-rotation
65 // API call fails with 401 because ergo already loaded the old token.
66 ergoTokenPath := filepath.Join(cfg.Ergo.DataDir, "ergo-api-token")
67 if cfg.Ergo.APIToken == "" {
68 if raw, err := os.ReadFile(ergoTokenPath); err == nil && len(raw) > 0 {
69 cfg.Ergo.APIToken = strings.TrimSpace(string(raw))
70 } else {
71 cfg.Ergo.APIToken = mustGenToken()
72 _ = os.WriteFile(ergoTokenPath, []byte(cfg.Ergo.APIToken), 0600)
73 }
74 }
75
76 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
77 defer cancel()
78
79 log.Info("scuttlebot starting", "version", version)
80
81 // Start Ergo.
82 ergoMgr := ergo.NewManager(cfg.Ergo, log)
83 ergoErr := make(chan error, 1)
84 go func() {
85 if err := ergoMgr.Start(ctx); err != nil {
86 ergoErr <- err
87 }
88 }()
89
90 // Wait for Ergo to become healthy before starting the rest.
91 healthCtx, healthCancel := context.WithTimeout(ctx, 30*time.Second)
92 defer healthCancel()
93 for {
94 if _, err := ergoMgr.API().Status(); err == nil {
95 break
96 }
97 select {
98 case <-healthCtx.Done():
99 log.Error("ergo did not become healthy in time")
@@ -90,28 +105,41 @@
105 }
106 }
107 log.Info("ergo healthy")
108
109 // Build registry backed by Ergo's NickServ API.
110 // Signing key persists so issued payloads stay valid across restarts.
111 signingKeyHex, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "signing_key"))
112 if err != nil {
113 log.Error("signing key", "err", err)
114 os.Exit(1)
115 }
116 reg := registry.New(ergoMgr.API(), []byte(signingKeyHex))
117 if err := reg.SetDataPath(filepath.Join(cfg.Ergo.DataDir, "registry.json")); err != nil {
118 log.Error("registry load", "err", err)
119 os.Exit(1)
120 }
121
122 // Shared API token — persisted so the UI token survives restarts.
123 apiToken, err := loadOrCreateToken(filepath.Join(cfg.Ergo.DataDir, "api_token"))
124 if err != nil {
125 log.Error("api token", "err", err)
126 os.Exit(1)
127 }
128 log.Info("api token", "token", apiToken) // printed on every startup
129 tokens := []string{apiToken}
130
131 // Start bridge bot (powers the web chat UI).
132 var bridgeBot *bridge.Bot
133 if cfg.Bridge.Enabled {
134 if cfg.Bridge.Password == "" {
135 cfg.Bridge.Password = mustGenToken()
136 }
137 // Ensure the bridge's NickServ account exists with the current password.
138 if err := ergoMgr.API().RegisterAccount(cfg.Bridge.Nick, cfg.Bridge.Password); err != nil {
139 // Account exists from a previous run — update the password so it matches.
140 if err2 := ergoMgr.API().ChangePassword(cfg.Bridge.Nick, cfg.Bridge.Password); err2 != nil {
141 log.Error("bridge account setup failed", "err", err2)
142 os.Exit(1)
143 }
144 }
145 bridgeBot = bridge.New(
@@ -118,34 +146,156 @@
146 cfg.Ergo.IRCAddr,
147 cfg.Bridge.Nick,
148 cfg.Bridge.Password,
149 cfg.Bridge.Channels,
150 cfg.Bridge.BufferSize,
151 time.Duration(cfg.Bridge.WebUserTTLMinutes)*time.Minute,
152 log,
153 )
154 go func() {
155 if err := bridgeBot.Start(ctx); err != nil {
156 log.Error("bridge bot error", "err", err)
157 }
158 }()
159 }
160
161 // Policy store — persists behavior/agent/logging settings.
162 policyStore, err := api.NewPolicyStore(filepath.Join(cfg.Ergo.DataDir, "policies.json"), cfg.Bridge.WebUserTTLMinutes)
163 if err != nil {
164 log.Error("policy store", "err", err)
165 os.Exit(1)
166 }
167 if bridgeBot != nil {
168 bridgeBot.SetWebUserTTL(time.Duration(policyStore.Get().Bridge.WebUserTTLMinutes) * time.Minute)
169 }
170
171 // Admin store — bcrypt-hashed admin accounts.
172 adminStore, err := auth.NewAdminStore(filepath.Join(cfg.Ergo.DataDir, "admins.json"))
173 if err != nil {
174 log.Error("admin store", "err", err)
175 os.Exit(1)
176 }
177 if adminStore.IsEmpty() {
178 password := mustGenToken()[:16]
179 if err := adminStore.Add("admin", password); err != nil {
180 log.Error("create default admin", "err", err)
181 os.Exit(1)
182 }
183 log.Info("first run — default admin created", "username", "admin", "password", password, "action", "change this password immediately")
184 }
185
186 // Bot manager — starts/stops system bots based on policy.
187 botMgr := botmanager.New(cfg.Ergo.IRCAddr, cfg.Ergo.DataDir, ergoMgr.API(), &ergoChannelListAdapter{ergoMgr.API()}, log)
188
189 // Wire policy onChange to re-sync bots on every policy update.
190 policyStore.OnChange(func(p api.Policies) {
191 specs := make([]botmanager.BotSpec, len(p.Behaviors))
192 for i, b := range p.Behaviors {
193 specs[i] = botmanager.BotSpec{
194 ID: b.ID,
195 Nick: b.Nick,
196 Enabled: b.Enabled,
197 JoinAllChannels: b.JoinAllChannels,
198 RequiredChannels: b.RequiredChannels,
199 Config: b.Config,
200 }
201 }
202 if bridgeBot != nil {
203 bridgeBot.SetWebUserTTL(time.Duration(p.Bridge.WebUserTTLMinutes) * time.Minute)
204 }
205 botMgr.Sync(ctx, specs)
206 })
207
208 // Initial bot sync from loaded policies.
209 {
210 p := policyStore.Get()
211 specs := make([]botmanager.BotSpec, len(p.Behaviors))
212 for i, b := range p.Behaviors {
213 specs[i] = botmanager.BotSpec{
214 ID: b.ID,
215 Nick: b.Nick,
216 Enabled: b.Enabled,
217 JoinAllChannels: b.JoinAllChannels,
218 RequiredChannels: b.RequiredChannels,
219 Config: b.Config,
220 }
221 }
222 botMgr.Sync(ctx, specs)
223 }
224
225 // Start HTTP REST API server.
226 var llmCfg *config.LLMConfig
227 if len(cfg.LLM.Backends) > 0 {
228 llmCfg = &cfg.LLM
229 }
230 apiSrv := api.New(reg, tokens, bridgeBot, policyStore, adminStore, llmCfg, cfg.TLS.Domain, log)
231 handler := apiSrv.Handler()
232
233 var httpServer, tlsServer *http.Server
234
235 if cfg.TLS.Domain != "" {
236 certDir := cfg.TLS.CertDir
237 if certDir == "" {
238 certDir = filepath.Join(cfg.Ergo.DataDir, "certs")
239 }
240 if err := os.MkdirAll(certDir, 0700); err != nil {
241 log.Error("create cert dir", "err", err)
242 os.Exit(1)
243 }
244
245 m := &autocert.Manager{
246 Cache: autocert.DirCache(certDir),
247 Prompt: autocert.AcceptTOS,
248 Email: cfg.TLS.Email,
249 HostPolicy: autocert.HostWhitelist(cfg.TLS.Domain),
250 }
251
252 // HTTPS on :443
253 tlsServer = &http.Server{
254 Addr: ":443",
255 Handler: handler,
256 TLSConfig: &tls.Config{GetCertificate: m.GetCertificate},
257 }
258 go func() {
259 log.Info("api server listening (TLS)", "addr", ":443", "domain", cfg.TLS.Domain)
260 if err := tlsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
261 log.Error("tls server error", "err", err)
262 }
263 }()
264
265 // HTTP on :80 — ACME challenge always enabled; also serves API when AllowInsecure.
266 var httpHandler http.Handler
267 if cfg.TLS.AllowInsecure {
268 httpHandler = m.HTTPHandler(handler)
269 } else {
270 httpHandler = m.HTTPHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
271 http.Redirect(w, r, "https://"+cfg.TLS.Domain+r.RequestURI, http.StatusMovedPermanently)
272 }))
273 }
274 httpServer = &http.Server{Addr: ":80", Handler: httpHandler}
275 go func() {
276 log.Info("http server listening", "addr", ":80", "insecure", cfg.TLS.AllowInsecure)
277 if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
278 log.Error("http server error", "err", err)
279 }
280 }()
281 } else {
282 // No TLS — plain HTTP on configured addr.
283 httpServer = &http.Server{
284 Addr: cfg.APIAddr,
285 Handler: handler,
286 }
287 go func() {
288 log.Info("api server listening", "addr", httpServer.Addr)
289 if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
290 log.Error("api server error", "err", err)
291 }
292 }()
293 }
294
295 // Start MCP server.
296 mcpSrv := mcp.New(reg, &ergoChannelLister{ergoMgr.API()}, tokens, log)
297 mcpServer := &http.Server{
298 Addr: cfg.MCPAddr,
299 Handler: mcpSrv.Handler(),
300 }
301 go func() {
@@ -158,15 +308,37 @@
308 <-ctx.Done()
309 log.Info("shutting down")
310
311 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
312 defer shutdownCancel()
313 if httpServer != nil {
314 _ = httpServer.Shutdown(shutdownCtx)
315 }
316 if tlsServer != nil {
317 _ = tlsServer.Shutdown(shutdownCtx)
318 }
319 _ = mcpServer.Shutdown(shutdownCtx)
320
321 log.Info("goodbye")
322 }
323
324 // ergoChannelListAdapter adapts ergo.APIClient to botmanager.ChannelLister.
325 type ergoChannelListAdapter struct {
326 api *ergo.APIClient
327 }
328
329 func (e *ergoChannelListAdapter) ListChannels() ([]string, error) {
330 resp, err := e.api.ListChannels()
331 if err != nil {
332 return nil, err
333 }
334 out := make([]string, len(resp.Channels))
335 for i, ch := range resp.Channels {
336 out[i] = ch.Name
337 }
338 return out, nil
339 }
340
341 // ergoChannelLister adapts ergo.APIClient to mcp.ChannelLister.
342 type ergoChannelLister struct {
343 api *ergo.APIClient
344 }
@@ -193,5 +365,25 @@
365 fmt.Fprintf(os.Stderr, "failed to generate token: %v\n", err)
366 os.Exit(1)
367 }
368 return hex.EncodeToString(b)
369 }
370
371 // loadOrCreateToken reads a token from path. If the file doesn't exist it
372 // generates a new token, writes it, and returns it.
373 func loadOrCreateToken(path string) (string, error) {
374 data, err := os.ReadFile(path)
375 if err == nil {
376 t := strings.TrimSpace(string(data))
377 if t != "" {
378 return t, nil
379 }
380 }
381 if !os.IsNotExist(err) && err != nil {
382 return "", fmt.Errorf("read token %s: %w", path, err)
383 }
384 token := mustGenToken()
385 if err := os.WriteFile(path, []byte(token+"\n"), 0600); err != nil {
386 return "", fmt.Errorf("write token %s: %w", path, err)
387 }
388 return token, nil
389 }
390
391 DDED cmd/scuttlectl/cmd_setup.go
--- a/cmd/scuttlectl/cmd_setup.go
+++ b/cmd/scuttlectl/cmd_setup.go
@@ -0,0 +1,217 @@
1
+package main
2
+
3
+import (
4
+ "bufio"
5
+ "fmt"
6
+ "os"
7
+ "strconv"
8
+ "strings"
9
+
10
+ "gopkg.in/yaml.v3"
11
+)
12
+
13
+// cmdSetup runs an interactive wizard that writes scuttlebot.yaml.
14
+// It does not require a running server or an API token.
15
+func cmdSetup(path string) {
16
+ s := newSetupScanner()
17
+
18
+ fmt.Println()
19
+ fmt.Println(" scuttlebot setup wizard")
20
+ fmt.Println(" ─────────────────────────────────────────")
21
+ fmt.Println(" Answers in [brackets] are the default — press Enter to accept.")
22
+ fmt.Println()
23
+
24
+ // Check for existing file.
25
+ if _, err := os.Stat(path); err == nil {
26
+ if !s.confirm(fmt.Sprintf(" %s already exists — overwrite?", path), false) {
27
+ fmt.Println(" Aborted.")
28
+ os.Exit(0)
29
+ }
30
+ }
31
+
32
+ cfg := buildConfig(s)
33
+
34
+ data, err := yaml.Marshal(cfg)
35
+ if err != nil {
36
+ fmt.Fprintln(os.Stderr, "error encoding yaml:", err)
37
+ os.Exit(1)
38
+ }
39
+
40
+ if err := os.WriteFile(path, data, 0600); err != nil {
41
+ fmt.Fprintln(os.Stderr, "error writing config:", err)
42
+ os.Exit(1)
43
+ }
44
+
45
+ fmt.Println()
46
+ fmt.Printf(" ✓ wrote %s\n", path)
47
+ fmt.Println()
48
+ fmt.Println(" Next steps:")
49
+ fmt.Println(" ./run.sh start # start scuttlebot")
50
+ fmt.Println(" ./run.sh token # print API token")
51
+ fmt.Println(" open http://localhost:8080/ui/")
52
+ fmt.Println()
53
+}
54
+
55
+func buildConfig(s *setupScanner) map[string]any {
56
+ cfg := map[string]any{}
57
+
58
+ // ── network ──────────────────────────────────────────────────────────────
59
+ printSection("IRC / network")
60
+
61
+ networkName := s.ask(" IRC network name",, "scuttlebot")
62
+ serverName := s.ask(" IRC server hostname", "irc.s scuttlebot.local")
63
+ ircAddr := s.a "127.0.0.1:6667")
64
+ apiAddr := s.ask(" HTTP API listen address", ":8080")
65
+
66
+ cfg["ergo"] = map[string]any{
67
+ "network_name": networkName,
68
+ "server_name": serverName,
69
+ "irc_addr": ircAddr,
70
+ }
71
+ cfg["api_addr"] = apiAddr
72
+
73
+ // ── TLS ──────────────────────────────────────────────────────────────────
74
+ printSection("TLS / HTTPS (skip for local/dev)")
75
+
76
+ if s.confirm(" Enable Let's Encrypt TLS?", faSprintf(" %s already exists — overwrite?", path), false) {
77
+ fmt.Println(" Aborted.")
78
+ os.Exit(0)
79
+ }
80
+ }
81
+
82
+ cfg := buildConfig(s)
83
+
84
+ data, err := yaml.Marshal(cfg)
85
+ if err != nil {
86
+ fmt.Fprintln(os.Stderr, "error encoding yaml:", err)
87
+ os.Exit(1)
88
+ }
89
+
90
+ if err := os.WriteFile(path, data, 0600); err != nil {
91
+ fmt.Fprintln(os.Stderr, "error writing config:", err)
92
+ os.Exit(1)
93
+ }
94
+
95
+ fmt.Println()
96
+ fmt.Printf(" ✓ wrote %s\n", path)
97
+ fmt.Println()
98
+ fmt.Println(" Next steps:")
99
+ fmt.Println(" ./run.sh start # start scuttlebot")
100
+ fmt.Println(" ./run.sh token # print API token")
101
+ fmt.Println(" open http://localhost:8080/ui/")
102
+ fmt.Println()
103
+}
104
+
105
+func buildConfig(s *setupScanner) map[string]any {
106
+ cfg := map[string]any{}
107
+
108
+ // ── network ──────────────────────────────────────────────────────────────
109
+ printSection("IRC / network")
110
+
111
+ networkName := s.ask(" IRC network name", "scuttlebot")
112
+ serverName := s.ask(" IRC server hostname", "irc.scuttlebot.local")
113
+ ircAddr := s.ask(" IRC listen address", "127.0.0.1:6667")
114
+ apiAddr := s.ask(" HTTP API listen address", ":8080")
115
+
116
+ cfg["ergo"] = map[string]any{
117
+ "network_name": networkName,
118
+ "server_name": serverName,
119
+ "irc_addr": ircAddr,
120
+ }
121
+ cfg["api_addr"] = apiAddr
122
+
123
+ // ── TLS ────────�───────────�────────────────────
124
+ printSection("TLS / HTTPS (skip for local/dev)")
125
+
126
+ if s.confirm(" Enable Let's Encrypt TLS?", false) {
127
+ domain := s.ask(" Domain name", "")
128
+ email := s.ask(" Email for cert expiry notices", "")
129
+ cfg["t; _ = format; _ = logDir
130
+ _ = format
131
+ _ = rotatef
132
+ fmt.Printf("\n Note: scribe is enabled via the web UI (settings → system behaviors).\n")
133
+ fmt.Printf(" Set dir=%s format=%s rotation=%s in oracle's behavior config.\n\n", logDir, format, rotatef)
134
+ }
135
+
136
+ return cfg
137
+}
138
+
139
+func buildBackend(s *setupScanner) map[string]any {
140
+ backends := []string{
141
+ "openai", "anthropic", "gemini", "bedrock", "ollama",
142
+ "openrouter", "groq", "together", "fireworks", "mistral",
143
+ "deepseek", "xai", "cerebras", "litellm", "lmstudio", "vllm", "localai",
144
+ }
145
+ backendType := s.choice(" Backend type", backends, "openai")
146
+ name := s.ask(" Backend name (identifier)", backendType+"-1")
147
+
148
+ b := map[string]any{
149
+ "name": name,
150
+ "backend": backendType,
151
+ }
152
+
153
+ switch backendType {
154
+ case "bedrock":
155
+ b["region"] = s.ask(" AWS region", "us-east-1")
156
+ if s.confirm(" Use static AWS credentials? (No = IAM role auto-detected)", f false) {
157
+ b["aws_key_id"] = s.ask(" AWS access key ID", "")
158
+ b["aws_secret_key"] = s.ask(" AWS secret access key", "")
159
+ } else {
160
+ fmt.Println(" → credentials will be resolved from env vars or instance/task role")
161
+ }
162
+ b["model"] = s.ask(" Default model", "anthropic.claude-3-5-sonnet-20241022-v2:0")
163
+
164
+ case "ollama":
165
+ b["base_url"] = s.ask(" Ollama base URL", "http://loca = s.ask(" Default model", "llama3.2")
166
+
167
+ case "anthropic":
168
+ b["api_key"] = s.secret(" API key")
169
+ b["model"] = s.ask(" Default model", "claude-3-5-sonnet-20241022")
170
+
171
+ case "gemini":
172
+ b["api_key"] = s.secret(" API key")
173
+ b["model"] = s.ask(" Default model", "gemini-1.5-flash")
174
+
175
+ default:
176
+ b["api_key"] = s.secret(" API key")
177
+ b["model"] = s.ask(" Default model", = s.ask(" Default model", defaultModelFor(backendType))
178
+ }
179
+
180
+ if s.confirm(" Add model allow/block regex filters?", false) {
181
+ allow := s.ask(" Allow patterns (comma-separated regex)", "")
182
+ block := s.ask(" Block patterns (comma-separated regex)", "")
183
+ if allow != "" {
184
+ b["allow"] = splitComma(allow)
185
+ }
186
+ if block != "" {
187
+ b["block"] = splitComma(block)
188
+ }
189
+ }
190
+
191
+ if s.confirm(" Mark as default backend?", len([]map[string]any{b}) == 0) {
192
+ b["default"] = true
193
+ }
194
+
195
+ return b
196
+}
197
+
198
+func defaultModelFor(backend string) strackage main
199
+
200
+import (
201
+ "bufio"
202
+ "
203
+ "fmt"
204
+ "ofmt"
205
+ "os"
206
+ "strcdentifier)", backendType+"-1")
207
+
208
+ me": name,
209
+ "backend": backendType,
210
+ }
211
+
212
+ switch backendType {
213
+ casse "bedrock":
214
+
215
+ b["region"] = s.ask(" AWS regionfirm(" Use static AWS crconfirm(" Use static AWS credent "",
216
+ "lmstudio": "",
217
+ "vllm
--- a/cmd/scuttlectl/cmd_setup.go
+++ b/cmd/scuttlectl/cmd_setup.go
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/cmd/scuttlectl/cmd_setup.go
+++ b/cmd/scuttlectl/cmd_setup.go
@@ -0,0 +1,217 @@
1 package main
2
3 import (
4 "bufio"
5 "fmt"
6 "os"
7 "strconv"
8 "strings"
9
10 "gopkg.in/yaml.v3"
11 )
12
13 // cmdSetup runs an interactive wizard that writes scuttlebot.yaml.
14 // It does not require a running server or an API token.
15 func cmdSetup(path string) {
16 s := newSetupScanner()
17
18 fmt.Println()
19 fmt.Println(" scuttlebot setup wizard")
20 fmt.Println(" ─────────────────────────────────────────")
21 fmt.Println(" Answers in [brackets] are the default — press Enter to accept.")
22 fmt.Println()
23
24 // Check for existing file.
25 if _, err := os.Stat(path); err == nil {
26 if !s.confirm(fmt.Sprintf(" %s already exists — overwrite?", path), false) {
27 fmt.Println(" Aborted.")
28 os.Exit(0)
29 }
30 }
31
32 cfg := buildConfig(s)
33
34 data, err := yaml.Marshal(cfg)
35 if err != nil {
36 fmt.Fprintln(os.Stderr, "error encoding yaml:", err)
37 os.Exit(1)
38 }
39
40 if err := os.WriteFile(path, data, 0600); err != nil {
41 fmt.Fprintln(os.Stderr, "error writing config:", err)
42 os.Exit(1)
43 }
44
45 fmt.Println()
46 fmt.Printf(" ✓ wrote %s\n", path)
47 fmt.Println()
48 fmt.Println(" Next steps:")
49 fmt.Println(" ./run.sh start # start scuttlebot")
50 fmt.Println(" ./run.sh token # print API token")
51 fmt.Println(" open http://localhost:8080/ui/")
52 fmt.Println()
53 }
54
55 func buildConfig(s *setupScanner) map[string]any {
56 cfg := map[string]any{}
57
58 // ── network ──────────────────────────────────────────────────────────────
59 printSection("IRC / network")
60
61 networkName := s.ask(" IRC network name",, "scuttlebot")
62 serverName := s.ask(" IRC server hostname", "irc.s scuttlebot.local")
63 ircAddr := s.a "127.0.0.1:6667")
64 apiAddr := s.ask(" HTTP API listen address", ":8080")
65
66 cfg["ergo"] = map[string]any{
67 "network_name": networkName,
68 "server_name": serverName,
69 "irc_addr": ircAddr,
70 }
71 cfg["api_addr"] = apiAddr
72
73 // ── TLS ──────────────────────────────────────────────────────────────────
74 printSection("TLS / HTTPS (skip for local/dev)")
75
76 if s.confirm(" Enable Let's Encrypt TLS?", faSprintf(" %s already exists — overwrite?", path), false) {
77 fmt.Println(" Aborted.")
78 os.Exit(0)
79 }
80 }
81
82 cfg := buildConfig(s)
83
84 data, err := yaml.Marshal(cfg)
85 if err != nil {
86 fmt.Fprintln(os.Stderr, "error encoding yaml:", err)
87 os.Exit(1)
88 }
89
90 if err := os.WriteFile(path, data, 0600); err != nil {
91 fmt.Fprintln(os.Stderr, "error writing config:", err)
92 os.Exit(1)
93 }
94
95 fmt.Println()
96 fmt.Printf(" ✓ wrote %s\n", path)
97 fmt.Println()
98 fmt.Println(" Next steps:")
99 fmt.Println(" ./run.sh start # start scuttlebot")
100 fmt.Println(" ./run.sh token # print API token")
101 fmt.Println(" open http://localhost:8080/ui/")
102 fmt.Println()
103 }
104
105 func buildConfig(s *setupScanner) map[string]any {
106 cfg := map[string]any{}
107
108 // ── network ──────────────────────────────────────────────────────────────
109 printSection("IRC / network")
110
111 networkName := s.ask(" IRC network name", "scuttlebot")
112 serverName := s.ask(" IRC server hostname", "irc.scuttlebot.local")
113 ircAddr := s.ask(" IRC listen address", "127.0.0.1:6667")
114 apiAddr := s.ask(" HTTP API listen address", ":8080")
115
116 cfg["ergo"] = map[string]any{
117 "network_name": networkName,
118 "server_name": serverName,
119 "irc_addr": ircAddr,
120 }
121 cfg["api_addr"] = apiAddr
122
123 // ── TLS ────────�───────────�────────────────────
124 printSection("TLS / HTTPS (skip for local/dev)")
125
126 if s.confirm(" Enable Let's Encrypt TLS?", false) {
127 domain := s.ask(" Domain name", "")
128 email := s.ask(" Email for cert expiry notices", "")
129 cfg["t; _ = format; _ = logDir
130 _ = format
131 _ = rotatef
132 fmt.Printf("\n Note: scribe is enabled via the web UI (settings → system behaviors).\n")
133 fmt.Printf(" Set dir=%s format=%s rotation=%s in oracle's behavior config.\n\n", logDir, format, rotatef)
134 }
135
136 return cfg
137 }
138
139 func buildBackend(s *setupScanner) map[string]any {
140 backends := []string{
141 "openai", "anthropic", "gemini", "bedrock", "ollama",
142 "openrouter", "groq", "together", "fireworks", "mistral",
143 "deepseek", "xai", "cerebras", "litellm", "lmstudio", "vllm", "localai",
144 }
145 backendType := s.choice(" Backend type", backends, "openai")
146 name := s.ask(" Backend name (identifier)", backendType+"-1")
147
148 b := map[string]any{
149 "name": name,
150 "backend": backendType,
151 }
152
153 switch backendType {
154 case "bedrock":
155 b["region"] = s.ask(" AWS region", "us-east-1")
156 if s.confirm(" Use static AWS credentials? (No = IAM role auto-detected)", f false) {
157 b["aws_key_id"] = s.ask(" AWS access key ID", "")
158 b["aws_secret_key"] = s.ask(" AWS secret access key", "")
159 } else {
160 fmt.Println(" → credentials will be resolved from env vars or instance/task role")
161 }
162 b["model"] = s.ask(" Default model", "anthropic.claude-3-5-sonnet-20241022-v2:0")
163
164 case "ollama":
165 b["base_url"] = s.ask(" Ollama base URL", "http://loca = s.ask(" Default model", "llama3.2")
166
167 case "anthropic":
168 b["api_key"] = s.secret(" API key")
169 b["model"] = s.ask(" Default model", "claude-3-5-sonnet-20241022")
170
171 case "gemini":
172 b["api_key"] = s.secret(" API key")
173 b["model"] = s.ask(" Default model", "gemini-1.5-flash")
174
175 default:
176 b["api_key"] = s.secret(" API key")
177 b["model"] = s.ask(" Default model", = s.ask(" Default model", defaultModelFor(backendType))
178 }
179
180 if s.confirm(" Add model allow/block regex filters?", false) {
181 allow := s.ask(" Allow patterns (comma-separated regex)", "")
182 block := s.ask(" Block patterns (comma-separated regex)", "")
183 if allow != "" {
184 b["allow"] = splitComma(allow)
185 }
186 if block != "" {
187 b["block"] = splitComma(block)
188 }
189 }
190
191 if s.confirm(" Mark as default backend?", len([]map[string]any{b}) == 0) {
192 b["default"] = true
193 }
194
195 return b
196 }
197
198 func defaultModelFor(backend string) strackage main
199
200 import (
201 "bufio"
202 "
203 "fmt"
204 "ofmt"
205 "os"
206 "strcdentifier)", backendType+"-1")
207
208 me": name,
209 "backend": backendType,
210 }
211
212 switch backendType {
213 casse "bedrock":
214
215 b["region"] = s.ask(" AWS regionfirm(" Use static AWS crconfirm(" Use static AWS credent "",
216 "lmstudio": "",
217 "vllm
--- cmd/scuttlectl/internal/apiclient/apiclient.go
+++ cmd/scuttlectl/internal/apiclient/apiclient.go
@@ -5,10 +5,11 @@
55
"bytes"
66
"encoding/json"
77
"fmt"
88
"io"
99
"net/http"
10
+ "strings"
1011
)
1112
1213
// Client calls the scuttlebot REST API.
1314
type Client struct {
1415
base string
@@ -57,10 +58,90 @@
5758
5859
// RotateAgent sends POST /v1/agents/{nick}/rotate and returns raw JSON.
5960
func (c *Client) RotateAgent(nick string) (json.RawMessage, error) {
6061
return c.post("/v1/agents/"+nick+"/rotate", nil)
6162
}
63
+
64
+// DeleteAgent sends DELETE /v1/agents/{nick}.
65
+func (c *Client) DeleteAgent(nick string) error {
66
+ _, err := c.doNoBody("DELETE", "/v1/agents/"+nick)
67
+ return err
68
+}
69
+
70
+// ChannelUsers sends GET /v1/channels/{channel}/users and returns raw JSON.
71
+func (c *Client) ChannelUsers(channel string) (json.RawMessage, error) {
72
+ return c.get("/v1/channels/" + channel + "/users")
73
+}
74
+
75
+// DeleteChannel sends DELETE /v1/channels/{channel}.
76
+func (c *Client) DeleteChannel(channel string) error {
77
+ channel = strings.TrimPrefix(channel, "#")
78
+ _, err := c.doNoBody("DELETE", "/v1/channels/"+channel)
79
+ return err
80
+}
81
+
82
+// ListChannels sends GET /v1/channels and returns raw JSON.
83
+func (c *Client) ListChannels() (json.RawMessage, error) {
84
+ return c.get("/v1/channels")
85
+}
86
+
87
+// GetLLMBackend sends GET /v1/llm/backends and finds the named backend, returning raw JSON.
88
+func (c *Client) GetLLMBackend(name string) (json.RawMessage, error) {
89
+ raw, err := c.get("/v1/llm/backends")
90
+ if err != nil {
91
+ return nil, err
92
+ }
93
+ var resp struct {
94
+ Backends []json.RawMessage `json:"backends"`
95
+ }
96
+ if err := json.Unmarshal(raw, &resp); err != nil {
97
+ return nil, err
98
+ }
99
+ for _, b := range resp.Backends {
100
+ var named struct {
101
+ Name string `json:"name"`
102
+ }
103
+ if json.Unmarshal(b, &named) == nil && named.Name == name {
104
+ return b, nil
105
+ }
106
+ }
107
+ return nil, fmt.Errorf("backend %q not found", name)
108
+}
109
+
110
+// CreateLLMBackend sends POST /v1/llm/backends.
111
+func (c *Client) CreateLLMBackend(cfg map[string]any) error {
112
+ _, err := c.post("/v1/llm/backends", cfg)
113
+ return err
114
+}
115
+
116
+// DeleteLLMBackend sends DELETE /v1/llm/backends/{name}.
117
+func (c *Client) DeleteLLMBackend(name string) error {
118
+ _, err := c.doNoBody("DELETE", "/v1/llm/backends/"+name)
119
+ return err
120
+}
121
+
122
+// ListAdmins sends GET /v1/admins and returns raw JSON.
123
+func (c *Client) ListAdmins() (json.RawMessage, error) {
124
+ return c.get("/v1/admins")
125
+}
126
+
127
+// AddAdmin sends POST /v1/admins and returns raw JSON.
128
+func (c *Client) AddAdmin(username, password string) (json.RawMessage, error) {
129
+ return c.post("/v1/admins", map[string]string{"username": username, "password": password})
130
+}
131
+
132
+// RemoveAdmin sends DELETE /v1/admins/{username}.
133
+func (c *Client) RemoveAdmin(username string) error {
134
+ _, err := c.doNoBody("DELETE", "/v1/admins/"+username)
135
+ return err
136
+}
137
+
138
+// SetAdminPassword sends PUT /v1/admins/{username}/password.
139
+func (c *Client) SetAdminPassword(username, password string) error {
140
+ _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password})
141
+ return err
142
+}
62143
63144
func (c *Client) get(path string) (json.RawMessage, error) {
64145
return c.do("GET", path, nil)
65146
}
66147
@@ -71,10 +152,24 @@
71152
return nil, err
72153
}
73154
}
74155
return c.do("POST", path, &buf)
75156
}
157
+
158
+func (c *Client) put(path string, body any) (json.RawMessage, error) {
159
+ var buf bytes.Buffer
160
+ if body != nil {
161
+ if err := json.NewEncoder(&buf).Encode(body); err != nil {
162
+ return nil, err
163
+ }
164
+ }
165
+ return c.do("PUT", path, &buf)
166
+}
167
+
168
+func (c *Client) doNoBody(method, path string) (json.RawMessage, error) {
169
+ return c.do(method, path, nil)
170
+}
76171
77172
func (c *Client) do(method, path string, body io.Reader) (json.RawMessage, error) {
78173
req, err := http.NewRequest(method, c.base+path, body)
79174
if err != nil {
80175
return nil, err
81176
--- cmd/scuttlectl/internal/apiclient/apiclient.go
+++ cmd/scuttlectl/internal/apiclient/apiclient.go
@@ -5,10 +5,11 @@
5 "bytes"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
 
10 )
11
12 // Client calls the scuttlebot REST API.
13 type Client struct {
14 base string
@@ -57,10 +58,90 @@
57
58 // RotateAgent sends POST /v1/agents/{nick}/rotate and returns raw JSON.
59 func (c *Client) RotateAgent(nick string) (json.RawMessage, error) {
60 return c.post("/v1/agents/"+nick+"/rotate", nil)
61 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
63 func (c *Client) get(path string) (json.RawMessage, error) {
64 return c.do("GET", path, nil)
65 }
66
@@ -71,10 +152,24 @@
71 return nil, err
72 }
73 }
74 return c.do("POST", path, &buf)
75 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
77 func (c *Client) do(method, path string, body io.Reader) (json.RawMessage, error) {
78 req, err := http.NewRequest(method, c.base+path, body)
79 if err != nil {
80 return nil, err
81
--- cmd/scuttlectl/internal/apiclient/apiclient.go
+++ cmd/scuttlectl/internal/apiclient/apiclient.go
@@ -5,10 +5,11 @@
5 "bytes"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "strings"
11 )
12
13 // Client calls the scuttlebot REST API.
14 type Client struct {
15 base string
@@ -57,10 +58,90 @@
58
59 // RotateAgent sends POST /v1/agents/{nick}/rotate and returns raw JSON.
60 func (c *Client) RotateAgent(nick string) (json.RawMessage, error) {
61 return c.post("/v1/agents/"+nick+"/rotate", nil)
62 }
63
64 // DeleteAgent sends DELETE /v1/agents/{nick}.
65 func (c *Client) DeleteAgent(nick string) error {
66 _, err := c.doNoBody("DELETE", "/v1/agents/"+nick)
67 return err
68 }
69
70 // ChannelUsers sends GET /v1/channels/{channel}/users and returns raw JSON.
71 func (c *Client) ChannelUsers(channel string) (json.RawMessage, error) {
72 return c.get("/v1/channels/" + channel + "/users")
73 }
74
75 // DeleteChannel sends DELETE /v1/channels/{channel}.
76 func (c *Client) DeleteChannel(channel string) error {
77 channel = strings.TrimPrefix(channel, "#")
78 _, err := c.doNoBody("DELETE", "/v1/channels/"+channel)
79 return err
80 }
81
82 // ListChannels sends GET /v1/channels and returns raw JSON.
83 func (c *Client) ListChannels() (json.RawMessage, error) {
84 return c.get("/v1/channels")
85 }
86
87 // GetLLMBackend sends GET /v1/llm/backends and finds the named backend, returning raw JSON.
88 func (c *Client) GetLLMBackend(name string) (json.RawMessage, error) {
89 raw, err := c.get("/v1/llm/backends")
90 if err != nil {
91 return nil, err
92 }
93 var resp struct {
94 Backends []json.RawMessage `json:"backends"`
95 }
96 if err := json.Unmarshal(raw, &resp); err != nil {
97 return nil, err
98 }
99 for _, b := range resp.Backends {
100 var named struct {
101 Name string `json:"name"`
102 }
103 if json.Unmarshal(b, &named) == nil && named.Name == name {
104 return b, nil
105 }
106 }
107 return nil, fmt.Errorf("backend %q not found", name)
108 }
109
110 // CreateLLMBackend sends POST /v1/llm/backends.
111 func (c *Client) CreateLLMBackend(cfg map[string]any) error {
112 _, err := c.post("/v1/llm/backends", cfg)
113 return err
114 }
115
116 // DeleteLLMBackend sends DELETE /v1/llm/backends/{name}.
117 func (c *Client) DeleteLLMBackend(name string) error {
118 _, err := c.doNoBody("DELETE", "/v1/llm/backends/"+name)
119 return err
120 }
121
122 // ListAdmins sends GET /v1/admins and returns raw JSON.
123 func (c *Client) ListAdmins() (json.RawMessage, error) {
124 return c.get("/v1/admins")
125 }
126
127 // AddAdmin sends POST /v1/admins and returns raw JSON.
128 func (c *Client) AddAdmin(username, password string) (json.RawMessage, error) {
129 return c.post("/v1/admins", map[string]string{"username": username, "password": password})
130 }
131
132 // RemoveAdmin sends DELETE /v1/admins/{username}.
133 func (c *Client) RemoveAdmin(username string) error {
134 _, err := c.doNoBody("DELETE", "/v1/admins/"+username)
135 return err
136 }
137
138 // SetAdminPassword sends PUT /v1/admins/{username}/password.
139 func (c *Client) SetAdminPassword(username, password string) error {
140 _, err := c.put("/v1/admins/"+username+"/password", map[string]string{"password": password})
141 return err
142 }
143
144 func (c *Client) get(path string) (json.RawMessage, error) {
145 return c.do("GET", path, nil)
146 }
147
@@ -71,10 +152,24 @@
152 return nil, err
153 }
154 }
155 return c.do("POST", path, &buf)
156 }
157
158 func (c *Client) put(path string, body any) (json.RawMessage, error) {
159 var buf bytes.Buffer
160 if body != nil {
161 if err := json.NewEncoder(&buf).Encode(body); err != nil {
162 return nil, err
163 }
164 }
165 return c.do("PUT", path, &buf)
166 }
167
168 func (c *Client) doNoBody(method, path string) (json.RawMessage, error) {
169 return c.do(method, path, nil)
170 }
171
172 func (c *Client) do(method, path string, body io.Reader) (json.RawMessage, error) {
173 req, err := http.NewRequest(method, c.base+path, body)
174 if err != nil {
175 return nil, err
176
--- cmd/scuttlectl/main.go
+++ cmd/scuttlectl/main.go
@@ -40,10 +40,20 @@
4040
args := flag.Args()
4141
if len(args) == 0 {
4242
usage()
4343
os.Exit(1)
4444
}
45
+
46
+ switch args[0] {
47
+ case "setup":
48
+ cfgPath := "scuttlebot.yaml"
49
+ if len(args) > 1 {
50
+ cfgPath = args[1]
51
+ }
52
+ cmdSetup(cfgPath)
53
+ return
54
+ }
4555
4656
if *tokenFlag == "" {
4757
fmt.Fprintln(os.Stderr, "error: API token required (set SCUTTLEBOT_TOKEN or use --token)")
4858
os.Exit(1)
4959
}
@@ -68,24 +78,72 @@
6878
requireArgs(args, 3, "scuttlectl agent register <nick> [--type worker] [--channels #a,#b]")
6979
cmdAgentRegister(api, args[2:], *jsonFlag)
7080
case "revoke":
7181
requireArgs(args, 3, "scuttlectl agent revoke <nick>")
7282
cmdAgentRevoke(api, args[2])
83
+ case "delete":
84
+ requireArgs(args, 3, "scuttlectl agent delete <nick>")
85
+ cmdAgentDelete(api, args[2])
7386
case "rotate":
7487
requireArgs(args, 3, "scuttlectl agent rotate <nick>")
7588
cmdAgentRotate(api, args[2], *jsonFlag)
7689
default:
7790
fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", args[1])
7891
os.Exit(1)
7992
}
80
- case "channels":
81
- if len(args) < 2 || args[1] == "list" {
82
- fmt.Fprintln(os.Stderr, "channels list: not yet implemented (requires #12 discovery)")
93
+ case "admin":
94
+ if len(args) < 2 {
95
+ fmt.Fprintf(os.Stderr, "usage: scuttlectl admin <subcommand>\n")
96
+ os.Exit(1)
97
+ }
98
+ switch args[1] {
99
+ case "list":
100
+ cmdAdminList(api, *jsonFlag)
101
+ case "add":
102
+ requireArgs(args, 3, "scuttlectl admin add <username>")
103
+ cmdAdminAdd(api, args[2])
104
+ case "remove":
105
+ requireArgs(args, 3, "scuttlectl admin remove <username>")
106
+ cmdAdminRemove(api, args[2])
107
+ case "passwd":
108
+ requireArgs(args, 3, "scuttlectl admin passwd <username>")
109
+ cmdAdminPasswd(api, args[2])
110
+ default:
111
+ fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1])
112
+ os.Exit(1)
113
+ }
114
+ case "channels", "channel":
115
+ if len(args) < 2 {
116
+ fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n")
117
+ os.Exit(1)
118
+ }
119
+ switch args[1] {
120
+ case "list":
121
+ cmdChannelList(api, *jsonFlag)
122
+ case "users":
123
+ requireArgs(args, 3, "scuttlectl channels users <channel>")
124
+ cmdChannelUsers(api, args[2], *jsonFlag)
125
+ case "delete", "rm":
126
+ requireArgs(args, 3, "scuttlectl channels delete <channel>")
127
+ cmdChannelDelete(api, args[2])
128
+ default:
129
+ fmt.Fprintf(os.Stderr, "unknown subcommand: channels %s\n", args[1])
130
+ os.Exit(1)
131
+ }
132
+ case "backend", "backends":
133
+ if len(args) < 2 {
134
+ fmt.Fprintf(os.Stderr, "usage: scuttlectl backend rename <old-name> <new-name>\n")
83135
os.Exit(1)
84136
}
85
- fmt.Fprintf(os.Stderr, "unknown subcommand: channels %s\n", args[1])
86
- os.Exit(1)
137
+ switch args[1] {
138
+ case "rename":
139
+ requireArgs(args, 4, "scuttlectl backend rename <old-name> <new-name>")
140
+ cmdBackendRename(api, args[2], args[3])
141
+ default:
142
+ fmt.Fprintf(os.Stderr, "unknown subcommand: backend %s\n", args[1])
143
+ os.Exit(1)
144
+ }
87145
case "logs":
88146
fmt.Fprintln(os.Stderr, "logs tail: not yet implemented (requires scribe HTTP endpoint)")
89147
os.Exit(1)
90148
default:
91149
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
@@ -227,15 +285,132 @@
227285
fmt.Fprintf(tw, "password\t%s\n", body.Credentials.Password)
228286
fmt.Fprintf(tw, "server\t%s\n", body.Credentials.Server)
229287
tw.Flush()
230288
fmt.Println("\nStore these credentials — the password will not be shown again.")
231289
}
290
+
291
+func cmdAdminList(api *apiclient.Client, asJSON bool) {
292
+ raw, err := api.ListAdmins()
293
+ die(err)
294
+ if asJSON {
295
+ printJSON(raw)
296
+ return
297
+ }
298
+
299
+ var body struct {
300
+ Admins []struct {
301
+ Username string `json:"username"`
302
+ Created string `json:"created"`
303
+ } `json:"admins"`
304
+ }
305
+ must(json.Unmarshal(raw, &body))
306
+
307
+ if len(body.Admins) == 0 {
308
+ fmt.Println("no admin accounts")
309
+ return
310
+ }
311
+
312
+ tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
313
+ fmt.Fprintln(tw, "USERNAME\tCREATED")
314
+ for _, a := range body.Admins {
315
+ fmt.Fprintf(tw, "%s\t%s\n", a.Username, a.Created)
316
+ }
317
+ tw.Flush()
318
+}
319
+
320
+func cmdAdminAdd(api *apiclient.Client, username string) {
321
+ pass := promptPassword()
322
+ _, err := api.AddAdmin(username, pass)
323
+ die(err)
324
+ fmt.Printf("Admin added: %s\n", username)
325
+}
326
+
327
+func cmdAdminRemove(api *apiclient.Client, username string) {
328
+ die(api.RemoveAdmin(username))
329
+ fmt.Printf("Admin removed: %s\n", username)
330
+}
331
+
332
+func cmdAdminPasswd(api *apiclient.Client, username string) {
333
+ pass := promptPassword()
334
+ die(api.SetAdminPassword(username, pass))
335
+ fmt.Printf("Password updated for: %s\n", username)
336
+}
337
+
338
+func promptPassword() string {
339
+ fmt.Fprint(os.Stderr, "password: ")
340
+ var pass string
341
+ fmt.Scanln(&pass)
342
+ return pass
343
+}
232344
233345
func cmdAgentRevoke(api *apiclient.Client, nick string) {
234346
die(api.RevokeAgent(nick))
235347
fmt.Printf("Agent revoked: %s\n", nick)
236348
}
349
+
350
+func cmdAgentDelete(api *apiclient.Client, nick string) {
351
+ die(api.DeleteAgent(nick))
352
+ fmt.Printf("Agent deleted: %s\n", nick)
353
+}
354
+
355
+func cmdChannelList(api *apiclient.Client, asJSON bool) {
356
+ raw, err := api.ListChannels()
357
+ die(err)
358
+ if asJSON {
359
+ printJSON(raw)
360
+ return
361
+ }
362
+ var body struct {
363
+ Channels []string `json:"channels"`
364
+ }
365
+ must(json.Unmarshal(raw, &body))
366
+ if len(body.Channels) == 0 {
367
+ fmt.Println("no channels")
368
+ return
369
+ }
370
+ for _, ch := range body.Channels {
371
+ fmt.Println(ch)
372
+ }
373
+}
374
+
375
+func cmdChannelUsers(api *apiclient.Client, channel string, asJSON bool) {
376
+ raw, err := api.ChannelUsers(channel)
377
+ die(err)
378
+ if asJSON {
379
+ printJSON(raw)
380
+ return
381
+ }
382
+ var body struct {
383
+ Users []string `json:"users"`
384
+ }
385
+ must(json.Unmarshal(raw, &body))
386
+ if len(body.Users) == 0 {
387
+ fmt.Printf("no users in %s\n", channel)
388
+ return
389
+ }
390
+ for _, u := range body.Users {
391
+ fmt.Println(u)
392
+ }
393
+}
394
+
395
+func cmdChannelDelete(api *apiclient.Client, channel string) {
396
+ die(api.DeleteChannel(channel))
397
+ fmt.Printf("Channel deleted: #%s\n", strings.TrimPrefix(channel, "#"))
398
+}
399
+
400
+func cmdBackendRename(api *apiclient.Client, oldName, newName string) {
401
+ raw, err := api.GetLLMBackend(oldName)
402
+ die(err)
403
+
404
+ var cfg map[string]any
405
+ must(json.Unmarshal(raw, &cfg))
406
+ cfg["name"] = newName
407
+
408
+ die(api.CreateLLMBackend(cfg))
409
+ die(api.DeleteLLMBackend(oldName))
410
+ fmt.Printf("Backend renamed: %s → %s\n", oldName, newName)
411
+}
237412
238413
func cmdAgentRotate(api *apiclient.Client, nick string, asJSON bool) {
239414
raw, err := api.RotateAgent(nick)
240415
die(err)
241416
if asJSON {
@@ -271,20 +446,29 @@
271446
--token API bearer token (default: $SCUTTLEBOT_TOKEN)
272447
--json output raw JSON
273448
--version print version and exit
274449
275450
Commands:
451
+ setup [path] interactive wizard — write scuttlebot.yaml (no token needed)
276452
status daemon + ergo health
277453
agents list list all registered agents
278454
agent get <nick> get a single agent
279455
agent register <nick> register a new agent, print credentials
280456
[--type worker|orchestrator|observer]
281457
[--channels #a,#b,#c]
282458
agent revoke <nick> revoke agent credentials
459
+ agent delete <nick> permanently remove agent from registry
283460
agent rotate <nick> rotate agent password
284
- channels list list provisioned channels (requires #12)
461
+ channels list list active channels
462
+ channels users <channel> list users in a channel
463
+ channels delete <channel> part bridge from channel (closes when empty)
464
+ backend rename <old> <new> rename an LLM backend
285465
logs tail tail scribe log (coming soon)
466
+ admin list list admin accounts
467
+ admin add <username> add admin (prompts for password)
468
+ admin remove <username> remove admin
469
+ admin passwd <username> change admin password (prompts)
286470
`, version)
287471
}
288472
289473
func printJSON(raw json.RawMessage) {
290474
var buf []byte
291475
--- cmd/scuttlectl/main.go
+++ cmd/scuttlectl/main.go
@@ -40,10 +40,20 @@
40 args := flag.Args()
41 if len(args) == 0 {
42 usage()
43 os.Exit(1)
44 }
 
 
 
 
 
 
 
 
 
 
45
46 if *tokenFlag == "" {
47 fmt.Fprintln(os.Stderr, "error: API token required (set SCUTTLEBOT_TOKEN or use --token)")
48 os.Exit(1)
49 }
@@ -68,24 +78,72 @@
68 requireArgs(args, 3, "scuttlectl agent register <nick> [--type worker] [--channels #a,#b]")
69 cmdAgentRegister(api, args[2:], *jsonFlag)
70 case "revoke":
71 requireArgs(args, 3, "scuttlectl agent revoke <nick>")
72 cmdAgentRevoke(api, args[2])
 
 
 
73 case "rotate":
74 requireArgs(args, 3, "scuttlectl agent rotate <nick>")
75 cmdAgentRotate(api, args[2], *jsonFlag)
76 default:
77 fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", args[1])
78 os.Exit(1)
79 }
80 case "channels":
81 if len(args) < 2 || args[1] == "list" {
82 fmt.Fprintln(os.Stderr, "channels list: not yet implemented (requires #12 discovery)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83 os.Exit(1)
84 }
85 fmt.Fprintf(os.Stderr, "unknown subcommand: channels %s\n", args[1])
86 os.Exit(1)
 
 
 
 
 
 
87 case "logs":
88 fmt.Fprintln(os.Stderr, "logs tail: not yet implemented (requires scribe HTTP endpoint)")
89 os.Exit(1)
90 default:
91 fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
@@ -227,15 +285,132 @@
227 fmt.Fprintf(tw, "password\t%s\n", body.Credentials.Password)
228 fmt.Fprintf(tw, "server\t%s\n", body.Credentials.Server)
229 tw.Flush()
230 fmt.Println("\nStore these credentials — the password will not be shown again.")
231 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
233 func cmdAgentRevoke(api *apiclient.Client, nick string) {
234 die(api.RevokeAgent(nick))
235 fmt.Printf("Agent revoked: %s\n", nick)
236 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
238 func cmdAgentRotate(api *apiclient.Client, nick string, asJSON bool) {
239 raw, err := api.RotateAgent(nick)
240 die(err)
241 if asJSON {
@@ -271,20 +446,29 @@
271 --token API bearer token (default: $SCUTTLEBOT_TOKEN)
272 --json output raw JSON
273 --version print version and exit
274
275 Commands:
 
276 status daemon + ergo health
277 agents list list all registered agents
278 agent get <nick> get a single agent
279 agent register <nick> register a new agent, print credentials
280 [--type worker|orchestrator|observer]
281 [--channels #a,#b,#c]
282 agent revoke <nick> revoke agent credentials
 
283 agent rotate <nick> rotate agent password
284 channels list list provisioned channels (requires #12)
 
 
 
285 logs tail tail scribe log (coming soon)
 
 
 
 
286 `, version)
287 }
288
289 func printJSON(raw json.RawMessage) {
290 var buf []byte
291
--- cmd/scuttlectl/main.go
+++ cmd/scuttlectl/main.go
@@ -40,10 +40,20 @@
40 args := flag.Args()
41 if len(args) == 0 {
42 usage()
43 os.Exit(1)
44 }
45
46 switch args[0] {
47 case "setup":
48 cfgPath := "scuttlebot.yaml"
49 if len(args) > 1 {
50 cfgPath = args[1]
51 }
52 cmdSetup(cfgPath)
53 return
54 }
55
56 if *tokenFlag == "" {
57 fmt.Fprintln(os.Stderr, "error: API token required (set SCUTTLEBOT_TOKEN or use --token)")
58 os.Exit(1)
59 }
@@ -68,24 +78,72 @@
78 requireArgs(args, 3, "scuttlectl agent register <nick> [--type worker] [--channels #a,#b]")
79 cmdAgentRegister(api, args[2:], *jsonFlag)
80 case "revoke":
81 requireArgs(args, 3, "scuttlectl agent revoke <nick>")
82 cmdAgentRevoke(api, args[2])
83 case "delete":
84 requireArgs(args, 3, "scuttlectl agent delete <nick>")
85 cmdAgentDelete(api, args[2])
86 case "rotate":
87 requireArgs(args, 3, "scuttlectl agent rotate <nick>")
88 cmdAgentRotate(api, args[2], *jsonFlag)
89 default:
90 fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", args[1])
91 os.Exit(1)
92 }
93 case "admin":
94 if len(args) < 2 {
95 fmt.Fprintf(os.Stderr, "usage: scuttlectl admin <subcommand>\n")
96 os.Exit(1)
97 }
98 switch args[1] {
99 case "list":
100 cmdAdminList(api, *jsonFlag)
101 case "add":
102 requireArgs(args, 3, "scuttlectl admin add <username>")
103 cmdAdminAdd(api, args[2])
104 case "remove":
105 requireArgs(args, 3, "scuttlectl admin remove <username>")
106 cmdAdminRemove(api, args[2])
107 case "passwd":
108 requireArgs(args, 3, "scuttlectl admin passwd <username>")
109 cmdAdminPasswd(api, args[2])
110 default:
111 fmt.Fprintf(os.Stderr, "unknown subcommand: admin %s\n", args[1])
112 os.Exit(1)
113 }
114 case "channels", "channel":
115 if len(args) < 2 {
116 fmt.Fprintf(os.Stderr, "usage: scuttlectl channels <list|users <channel>>\n")
117 os.Exit(1)
118 }
119 switch args[1] {
120 case "list":
121 cmdChannelList(api, *jsonFlag)
122 case "users":
123 requireArgs(args, 3, "scuttlectl channels users <channel>")
124 cmdChannelUsers(api, args[2], *jsonFlag)
125 case "delete", "rm":
126 requireArgs(args, 3, "scuttlectl channels delete <channel>")
127 cmdChannelDelete(api, args[2])
128 default:
129 fmt.Fprintf(os.Stderr, "unknown subcommand: channels %s\n", args[1])
130 os.Exit(1)
131 }
132 case "backend", "backends":
133 if len(args) < 2 {
134 fmt.Fprintf(os.Stderr, "usage: scuttlectl backend rename <old-name> <new-name>\n")
135 os.Exit(1)
136 }
137 switch args[1] {
138 case "rename":
139 requireArgs(args, 4, "scuttlectl backend rename <old-name> <new-name>")
140 cmdBackendRename(api, args[2], args[3])
141 default:
142 fmt.Fprintf(os.Stderr, "unknown subcommand: backend %s\n", args[1])
143 os.Exit(1)
144 }
145 case "logs":
146 fmt.Fprintln(os.Stderr, "logs tail: not yet implemented (requires scribe HTTP endpoint)")
147 os.Exit(1)
148 default:
149 fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
@@ -227,15 +285,132 @@
285 fmt.Fprintf(tw, "password\t%s\n", body.Credentials.Password)
286 fmt.Fprintf(tw, "server\t%s\n", body.Credentials.Server)
287 tw.Flush()
288 fmt.Println("\nStore these credentials — the password will not be shown again.")
289 }
290
291 func cmdAdminList(api *apiclient.Client, asJSON bool) {
292 raw, err := api.ListAdmins()
293 die(err)
294 if asJSON {
295 printJSON(raw)
296 return
297 }
298
299 var body struct {
300 Admins []struct {
301 Username string `json:"username"`
302 Created string `json:"created"`
303 } `json:"admins"`
304 }
305 must(json.Unmarshal(raw, &body))
306
307 if len(body.Admins) == 0 {
308 fmt.Println("no admin accounts")
309 return
310 }
311
312 tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
313 fmt.Fprintln(tw, "USERNAME\tCREATED")
314 for _, a := range body.Admins {
315 fmt.Fprintf(tw, "%s\t%s\n", a.Username, a.Created)
316 }
317 tw.Flush()
318 }
319
320 func cmdAdminAdd(api *apiclient.Client, username string) {
321 pass := promptPassword()
322 _, err := api.AddAdmin(username, pass)
323 die(err)
324 fmt.Printf("Admin added: %s\n", username)
325 }
326
327 func cmdAdminRemove(api *apiclient.Client, username string) {
328 die(api.RemoveAdmin(username))
329 fmt.Printf("Admin removed: %s\n", username)
330 }
331
332 func cmdAdminPasswd(api *apiclient.Client, username string) {
333 pass := promptPassword()
334 die(api.SetAdminPassword(username, pass))
335 fmt.Printf("Password updated for: %s\n", username)
336 }
337
338 func promptPassword() string {
339 fmt.Fprint(os.Stderr, "password: ")
340 var pass string
341 fmt.Scanln(&pass)
342 return pass
343 }
344
345 func cmdAgentRevoke(api *apiclient.Client, nick string) {
346 die(api.RevokeAgent(nick))
347 fmt.Printf("Agent revoked: %s\n", nick)
348 }
349
350 func cmdAgentDelete(api *apiclient.Client, nick string) {
351 die(api.DeleteAgent(nick))
352 fmt.Printf("Agent deleted: %s\n", nick)
353 }
354
355 func cmdChannelList(api *apiclient.Client, asJSON bool) {
356 raw, err := api.ListChannels()
357 die(err)
358 if asJSON {
359 printJSON(raw)
360 return
361 }
362 var body struct {
363 Channels []string `json:"channels"`
364 }
365 must(json.Unmarshal(raw, &body))
366 if len(body.Channels) == 0 {
367 fmt.Println("no channels")
368 return
369 }
370 for _, ch := range body.Channels {
371 fmt.Println(ch)
372 }
373 }
374
375 func cmdChannelUsers(api *apiclient.Client, channel string, asJSON bool) {
376 raw, err := api.ChannelUsers(channel)
377 die(err)
378 if asJSON {
379 printJSON(raw)
380 return
381 }
382 var body struct {
383 Users []string `json:"users"`
384 }
385 must(json.Unmarshal(raw, &body))
386 if len(body.Users) == 0 {
387 fmt.Printf("no users in %s\n", channel)
388 return
389 }
390 for _, u := range body.Users {
391 fmt.Println(u)
392 }
393 }
394
395 func cmdChannelDelete(api *apiclient.Client, channel string) {
396 die(api.DeleteChannel(channel))
397 fmt.Printf("Channel deleted: #%s\n", strings.TrimPrefix(channel, "#"))
398 }
399
400 func cmdBackendRename(api *apiclient.Client, oldName, newName string) {
401 raw, err := api.GetLLMBackend(oldName)
402 die(err)
403
404 var cfg map[string]any
405 must(json.Unmarshal(raw, &cfg))
406 cfg["name"] = newName
407
408 die(api.CreateLLMBackend(cfg))
409 die(api.DeleteLLMBackend(oldName))
410 fmt.Printf("Backend renamed: %s → %s\n", oldName, newName)
411 }
412
413 func cmdAgentRotate(api *apiclient.Client, nick string, asJSON bool) {
414 raw, err := api.RotateAgent(nick)
415 die(err)
416 if asJSON {
@@ -271,20 +446,29 @@
446 --token API bearer token (default: $SCUTTLEBOT_TOKEN)
447 --json output raw JSON
448 --version print version and exit
449
450 Commands:
451 setup [path] interactive wizard — write scuttlebot.yaml (no token needed)
452 status daemon + ergo health
453 agents list list all registered agents
454 agent get <nick> get a single agent
455 agent register <nick> register a new agent, print credentials
456 [--type worker|orchestrator|observer]
457 [--channels #a,#b,#c]
458 agent revoke <nick> revoke agent credentials
459 agent delete <nick> permanently remove agent from registry
460 agent rotate <nick> rotate agent password
461 channels list list active channels
462 channels users <channel> list users in a channel
463 channels delete <channel> part bridge from channel (closes when empty)
464 backend rename <old> <new> rename an LLM backend
465 logs tail tail scribe log (coming soon)
466 admin list list admin accounts
467 admin add <username> add admin (prompts for password)
468 admin remove <username> remove admin
469 admin passwd <username> change admin password (prompts)
470 `, version)
471 }
472
473 func printJSON(raw json.RawMessage) {
474 var buf []byte
475
M go.mod
+9 -1
--- go.mod
+++ go.mod
@@ -4,6 +4,14 @@
44
55
require github.com/oklog/ulid/v2 v2.1.1
66
77
require github.com/lrstanley/girc v1.1.1
88
9
-require gopkg.in/yaml.v3 v3.0.1 // indirect
9
+require (
10
+ github.com/creack/pty v1.1.24 // indirect
11
+ golang.org/x/crypto v0.39.0 // indirect
12
+ golang.org/x/net v0.41.0 // indirect
13
+ golang.org/x/sys v0.33.0 // indirect
14
+ golang.org/x/term v0.32.0 // indirect
15
+ golang.org/x/text v0.35.0 // indirect
16
+ gopkg.in/yaml.v3 v3.0.1 // indirect
17
+)
1018
--- go.mod
+++ go.mod
@@ -4,6 +4,14 @@
4
5 require github.com/oklog/ulid/v2 v2.1.1
6
7 require github.com/lrstanley/girc v1.1.1
8
9 require gopkg.in/yaml.v3 v3.0.1 // indirect
 
 
 
 
 
 
 
 
10
--- go.mod
+++ go.mod
@@ -4,6 +4,14 @@
4
5 require github.com/oklog/ulid/v2 v2.1.1
6
7 require github.com/lrstanley/girc v1.1.1
8
9 require (
10 github.com/creack/pty v1.1.24 // indirect
11 golang.org/x/crypto v0.39.0 // indirect
12 golang.org/x/net v0.41.0 // indirect
13 golang.org/x/sys v0.33.0 // indirect
14 golang.org/x/term v0.32.0 // indirect
15 golang.org/x/text v0.35.0 // indirect
16 gopkg.in/yaml.v3 v3.0.1 // indirect
17 )
18
M go.sum
+16
--- go.sum
+++ go.sum
@@ -1,8 +1,24 @@
1
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
2
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
13
github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk=
24
github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI=
35
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
46
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
57
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
8
+golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
9
+golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
10
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
11
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
12
+golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
13
+golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
14
+golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
15
+golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
16
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
17
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
18
+golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
19
+golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
20
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
21
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
622
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
723
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
824
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
925
--- go.sum
+++ go.sum
@@ -1,8 +1,24 @@
 
 
1 github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk=
2 github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI=
3 github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
4 github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
5 github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
7 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
8 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
9
--- go.sum
+++ go.sum
@@ -1,8 +1,24 @@
1 github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
2 github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
3 github.com/lrstanley/girc v1.1.1 h1:0Y8a2tqQGDeFXfBQkAYOu5DbWqlydCJsi+4N+td4azk=
4 github.com/lrstanley/girc v1.1.1/go.mod h1:lgrnhcF8bg/Bd5HA5DOb4Z+uGqUqGnp4skr+J2GwVgI=
5 github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
6 github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
7 github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
8 golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
9 golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
10 golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
11 golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
12 golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
13 golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
14 golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
15 golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
16 golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
17 golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
18 golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
19 golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
20 golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
21 golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
22 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
23 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
24 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
25
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -60,10 +60,41 @@
6060
writeJSON(w, http.StatusCreated, registerResponse{
6161
Credentials: creds,
6262
Payload: payload,
6363
})
6464
}
65
+
66
+func (s *Server) handleAdopt(w http.ResponseWriter, r *http.Request) {
67
+ nick := r.PathValue("nick")
68
+ var req struct {
69
+ Type registry.AgentType `json:"type"`
70
+ Channels []string `json:"channels"`
71
+ Permissions []string `json:"permissions"`
72
+ }
73
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
74
+ writeError(w, http.StatusBadRequest, "invalid request body")
75
+ return
76
+ }
77
+ if req.Type == "" {
78
+ req.Type = registry.AgentTypeWorker
79
+ }
80
+ cfg := registry.EngagementConfig{
81
+ Channels: req.Channels,
82
+ Permissions: req.Permissions,
83
+ }
84
+ payload, err := s.registry.Adopt(nick, req.Type, cfg)
85
+ if err != nil {
86
+ if strings.Contains(err.Error(), "already registered") {
87
+ writeError(w, http.StatusConflict, err.Error())
88
+ return
89
+ }
90
+ s.log.Error("adopt agent", "nick", nick, "err", err)
91
+ writeError(w, http.StatusInternalServerError, "adopt failed")
92
+ return
93
+ }
94
+ writeJSON(w, http.StatusOK, map[string]any{"nick": nick, "payload": payload})
95
+}
6596
6697
func (s *Server) handleRotate(w http.ResponseWriter, r *http.Request) {
6798
nick := r.PathValue("nick")
6899
creds, err := s.registry.Rotate(nick)
69100
if err != nil {
@@ -86,10 +117,24 @@
86117
return
87118
}
88119
s.log.Error("revoke agent", "nick", nick, "err", err)
89120
writeError(w, http.StatusInternalServerError, "revocation failed")
90121
return
122
+ }
123
+ w.WriteHeader(http.StatusNoContent)
124
+}
125
+
126
+func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
127
+ nick := r.PathValue("nick")
128
+ if err := s.registry.Delete(nick); err != nil {
129
+ if strings.Contains(err.Error(), "not found") {
130
+ writeError(w, http.StatusNotFound, err.Error())
131
+ return
132
+ }
133
+ s.log.Error("delete agent", "nick", nick, "err", err)
134
+ writeError(w, http.StatusInternalServerError, "deletion failed")
135
+ return
91136
}
92137
w.WriteHeader(http.StatusNoContent)
93138
}
94139
95140
func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
96141
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -60,10 +60,41 @@
60 writeJSON(w, http.StatusCreated, registerResponse{
61 Credentials: creds,
62 Payload: payload,
63 })
64 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
66 func (s *Server) handleRotate(w http.ResponseWriter, r *http.Request) {
67 nick := r.PathValue("nick")
68 creds, err := s.registry.Rotate(nick)
69 if err != nil {
@@ -86,10 +117,24 @@
86 return
87 }
88 s.log.Error("revoke agent", "nick", nick, "err", err)
89 writeError(w, http.StatusInternalServerError, "revocation failed")
90 return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91 }
92 w.WriteHeader(http.StatusNoContent)
93 }
94
95 func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
96
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -60,10 +60,41 @@
60 writeJSON(w, http.StatusCreated, registerResponse{
61 Credentials: creds,
62 Payload: payload,
63 })
64 }
65
66 func (s *Server) handleAdopt(w http.ResponseWriter, r *http.Request) {
67 nick := r.PathValue("nick")
68 var req struct {
69 Type registry.AgentType `json:"type"`
70 Channels []string `json:"channels"`
71 Permissions []string `json:"permissions"`
72 }
73 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
74 writeError(w, http.StatusBadRequest, "invalid request body")
75 return
76 }
77 if req.Type == "" {
78 req.Type = registry.AgentTypeWorker
79 }
80 cfg := registry.EngagementConfig{
81 Channels: req.Channels,
82 Permissions: req.Permissions,
83 }
84 payload, err := s.registry.Adopt(nick, req.Type, cfg)
85 if err != nil {
86 if strings.Contains(err.Error(), "already registered") {
87 writeError(w, http.StatusConflict, err.Error())
88 return
89 }
90 s.log.Error("adopt agent", "nick", nick, "err", err)
91 writeError(w, http.StatusInternalServerError, "adopt failed")
92 return
93 }
94 writeJSON(w, http.StatusOK, map[string]any{"nick": nick, "payload": payload})
95 }
96
97 func (s *Server) handleRotate(w http.ResponseWriter, r *http.Request) {
98 nick := r.PathValue("nick")
99 creds, err := s.registry.Rotate(nick)
100 if err != nil {
@@ -86,10 +117,24 @@
117 return
118 }
119 s.log.Error("revoke agent", "nick", nick, "err", err)
120 writeError(w, http.StatusInternalServerError, "revocation failed")
121 return
122 }
123 w.WriteHeader(http.StatusNoContent)
124 }
125
126 func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
127 nick := r.PathValue("nick")
128 if err := s.registry.Delete(nick); err != nil {
129 if strings.Contains(err.Error(), "not found") {
130 writeError(w, http.StatusNotFound, err.Error())
131 return
132 }
133 s.log.Error("delete agent", "nick", nick, "err", err)
134 writeError(w, http.StatusInternalServerError, "deletion failed")
135 return
136 }
137 w.WriteHeader(http.StatusNoContent)
138 }
139
140 func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
141
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -50,11 +50,11 @@
5050
const testToken = "test-api-token-abc123"
5151
5252
func newTestServer(t *testing.T) *httptest.Server {
5353
t.Helper()
5454
reg := registry.New(newMock(), []byte("test-signing-key"))
55
- srv := api.New(reg, []string{testToken}, nil, testLog)
55
+ srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, "", testLog)
5656
return httptest.NewServer(srv.Handler())
5757
}
5858
5959
func authHeader() http.Header {
6060
h := http.Header{}
6161
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -50,11 +50,11 @@
50 const testToken = "test-api-token-abc123"
51
52 func newTestServer(t *testing.T) *httptest.Server {
53 t.Helper()
54 reg := registry.New(newMock(), []byte("test-signing-key"))
55 srv := api.New(reg, []string{testToken}, nil, testLog)
56 return httptest.NewServer(srv.Handler())
57 }
58
59 func authHeader() http.Header {
60 h := http.Header{}
61
--- internal/api/api_test.go
+++ internal/api/api_test.go
@@ -50,11 +50,11 @@
50 const testToken = "test-api-token-abc123"
51
52 func newTestServer(t *testing.T) *httptest.Server {
53 t.Helper()
54 reg := registry.New(newMock(), []byte("test-signing-key"))
55 srv := api.New(reg, []string{testToken}, nil, nil, nil, nil, "", testLog)
56 return httptest.NewServer(srv.Handler())
57 }
58
59 func authHeader() http.Header {
60 h := http.Header{}
61
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -12,10 +12,11 @@
1212
1313
// chatBridge is the interface the API layer requires from the bridge bot.
1414
type chatBridge interface {
1515
Channels() []string
1616
JoinChannel(channel string)
17
+ LeaveChannel(channel string)
1718
Messages(channel string) []bridge.Message
1819
Subscribe(channel string) (<-chan bridge.Message, func())
1920
Send(ctx context.Context, channel, text, senderNick string) error
2021
Stats() bridge.Stats
2122
TouchUser(channel, nick string)
@@ -25,10 +26,16 @@
2526
func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) {
2627
channel := "#" + r.PathValue("channel")
2728
s.bridge.JoinChannel(channel)
2829
w.WriteHeader(http.StatusNoContent)
2930
}
31
+
32
+func (s *Server) handleDeleteChannel(w http.ResponseWriter, r *http.Request) {
33
+ channel := "#" + r.PathValue("channel")
34
+ s.bridge.LeaveChannel(channel)
35
+ w.WriteHeader(http.StatusNoContent)
36
+}
3037
3138
func (s *Server) handleListChannels(w http.ResponseWriter, r *http.Request) {
3239
writeJSON(w, http.StatusOK, map[string]any{"channels": s.bridge.Channels()})
3340
}
3441
3542
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -12,10 +12,11 @@
12
13 // chatBridge is the interface the API layer requires from the bridge bot.
14 type chatBridge interface {
15 Channels() []string
16 JoinChannel(channel string)
 
17 Messages(channel string) []bridge.Message
18 Subscribe(channel string) (<-chan bridge.Message, func())
19 Send(ctx context.Context, channel, text, senderNick string) error
20 Stats() bridge.Stats
21 TouchUser(channel, nick string)
@@ -25,10 +26,16 @@
25 func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) {
26 channel := "#" + r.PathValue("channel")
27 s.bridge.JoinChannel(channel)
28 w.WriteHeader(http.StatusNoContent)
29 }
 
 
 
 
 
 
30
31 func (s *Server) handleListChannels(w http.ResponseWriter, r *http.Request) {
32 writeJSON(w, http.StatusOK, map[string]any{"channels": s.bridge.Channels()})
33 }
34
35
--- internal/api/chat.go
+++ internal/api/chat.go
@@ -12,10 +12,11 @@
12
13 // chatBridge is the interface the API layer requires from the bridge bot.
14 type chatBridge interface {
15 Channels() []string
16 JoinChannel(channel string)
17 LeaveChannel(channel string)
18 Messages(channel string) []bridge.Message
19 Subscribe(channel string) (<-chan bridge.Message, func())
20 Send(ctx context.Context, channel, text, senderNick string) error
21 Stats() bridge.Stats
22 TouchUser(channel, nick string)
@@ -25,10 +26,16 @@
26 func (s *Server) handleJoinChannel(w http.ResponseWriter, r *http.Request) {
27 channel := "#" + r.PathValue("channel")
28 s.bridge.JoinChannel(channel)
29 w.WriteHeader(http.StatusNoContent)
30 }
31
32 func (s *Server) handleDeleteChannel(w http.ResponseWriter, r *http.Request) {
33 channel := "#" + r.PathValue("channel")
34 s.bridge.LeaveChannel(channel)
35 w.WriteHeader(http.StatusNoContent)
36 }
37
38 func (s *Server) handleListChannels(w http.ResponseWriter, r *http.Request) {
39 writeJSON(w, http.StatusOK, map[string]any{"channels": s.bridge.Channels()})
40 }
41
42
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -21,10 +21,11 @@
2121
}
2222
}
2323
2424
func (b *stubChatBridge) Channels() []string { return nil }
2525
func (b *stubChatBridge) JoinChannel(string) {}
26
+func (b *stubChatBridge) LeaveChannel(string) {}
2627
func (b *stubChatBridge) Messages(string) []bridge.Message { return nil }
2728
func (b *stubChatBridge) Subscribe(string) (<-chan bridge.Message, func()) {
2829
return make(chan bridge.Message), func() {}
2930
}
3031
func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
3132
3233
ADDED internal/api/llm_handlers.go
3334
ADDED internal/api/login.go
3435
ADDED internal/api/login_test.go
3536
ADDED internal/api/metrics.go
3637
ADDED internal/api/policies.go
3738
ADDED internal/api/policies_test.go
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -21,10 +21,11 @@
21 }
22 }
23
24 func (b *stubChatBridge) Channels() []string { return nil }
25 func (b *stubChatBridge) JoinChannel(string) {}
 
26 func (b *stubChatBridge) Messages(string) []bridge.Message { return nil }
27 func (b *stubChatBridge) Subscribe(string) (<-chan bridge.Message, func()) {
28 return make(chan bridge.Message), func() {}
29 }
30 func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
31
32 DDED internal/api/llm_handlers.go
33 DDED internal/api/login.go
34 DDED internal/api/login_test.go
35 DDED internal/api/metrics.go
36 DDED internal/api/policies.go
37 DDED internal/api/policies_test.go
--- internal/api/chat_test.go
+++ internal/api/chat_test.go
@@ -21,10 +21,11 @@
21 }
22 }
23
24 func (b *stubChatBridge) Channels() []string { return nil }
25 func (b *stubChatBridge) JoinChannel(string) {}
26 func (b *stubChatBridge) LeaveChannel(string) {}
27 func (b *stubChatBridge) Messages(string) []bridge.Message { return nil }
28 func (b *stubChatBridge) Subscribe(string) (<-chan bridge.Message, func()) {
29 return make(chan bridge.Message), func() {}
30 }
31 func (b *stubChatBridge) Send(context.Context, string, string, string) error { return nil }
32
33 DDED internal/api/llm_handlers.go
34 DDED internal/api/login.go
35 DDED internal/api/login_test.go
36 DDED internal/api/metrics.go
37 DDED internal/api/policies.go
38 DDED internal/api/policies_test.go
--- a/internal/api/llm_handlers.go
+++ b/internal/api/llm_handlers.go
@@ -0,0 +1,331 @@
1
+package api
2
+
3
+import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "sort"
7
+
8
+ "github.com/conflicthq/scuttlebot/internal/config"
9
+ "github.com/conflicthq/scuttlebot/internal/llm"
10
+)
11
+
12
+// backendView is the read-safe representation of a backend returned by the API.
13
+// API keys are replaced with "***" if set, so they are never exposed.
14
+type backendView struct {
15
+ Name string `json:"name"`
16
+ Backend string `json:"backend"`
17
+ APIKey string `json:"api_key,omitempty"` // "***" if set, "" if not
18
+ BaseURL string `json:"base_url,omitempty"`
19
+ Model string `json:"model,omitempty"`
20
+ Region string `json:"region,omitempty"`
21
+ AWSKeyID string `json:"aws_key_id,omitempty"` // "***" if set
22
+ Allow []string `json:"allow,omitempty"`
23
+ Block []string `json:"block,omitempty"`
24
+ Default bool `json:"default,omitempty"`
25
+ Source string `json:"source"` // "config" (yaml, read-only) or "policy" (ui-managed)
26
+}
27
+
28
+func mask(s string) string {
29
+ if s == "" {
30
+ return ""
31
+ }
32
+ return "***"
33
+}
34
+
35
+// handleLLMKnown returns the list of all known backend names.
36
+func (s *Server) handleLLMKnown(w http.ResponseWriter, _ *http.Request) {
37
+ type knownBackend struct {
38
+ Name string `json:"name"`
39
+ BaseURL string `json:"base_url,omitempty"`
40
+ Native bool `json:"native,omitempty"`
41
+ }
42
+
43
+ var backends []knownBackend
44
+ for name, url := range llm.KnownBackends {
45
+ backends = append(backends, knownBackend{Name: name, BaseURL: url})
46
+ }
47
+ backends = append(backends,
48
+ knownBackend{Name: "anthropic", Native: true},
49
+ knownBackend{Name: "gemini", Native: true},
50
+ knownBackend{Name: "bedrock", Native: true},
51
+ knownBackend{Name: "ollama", BaseURL: "http://localhost:11434", Native: true},
52
+ )
53
+ sort.Slice(backends, func(i, j int) bool {
54
+ return backends[i].Name < backends[j].Name
55
+ })
56
+ writeJSON(w, http.StatusOK, backends)
57
+}
58
+
59
+// handleLLMBackends lists all configured backends (YAML config + policy store).
60
+// API keys are masked.
61
+func (s *Server) handleLLMBackends(w http.ResponseWriter, _ *http.Request) {
62
+ var out []backendView
63
+
64
+ // YAML-configured backends (read-only).
65
+ if s.llmCfg != nil {
66
+ for _, b := range s.llmCfg.Backends {
67
+ out = append(out, backendView{
68
+ Name: b.Name,
69
+ Backend: b.Backend,
70
+ APIKey: mask(b.APIKey),
71
+ BaseURL: b.BaseURL,
72
+ Model: b.Model,
73
+ Region: b.Region,
74
+ AWSKeyID: mask(b.AWSKeyID),
75
+ Allow: b.Allow,
76
+ Block: b.Block,
77
+ Default: b.Default,
78
+ Source: "config",
79
+ })
80
+ }
81
+ }
82
+
83
+ // Policy-store backends (UI-managed, editable).
84
+ if s.policies != nil {
85
+ for _, b := range s.policies.Get().LLMBackends {
86
+ out = append(out, backendView{
87
+ Name: b.Name,
88
+ Backend: b.Backend,
89
+ APIKey: mask(b.APIKey),
90
+ BaseURL: b.BaseURL,
91
+ Model: b.Model,
92
+ Region: b.Region,
93
+ AWSKeyID: mask(b.AWSKeyID),
94
+ Allow: b.Allow,
95
+ Block: b.Block,
96
+ Default: b.Default,
97
+ Source: "policy",
98
+ })
99
+ }
100
+ }
101
+
102
+ if out == nil {
103
+ out = []backendView{}
104
+ }
105
+ writeJSON(w, http.StatusOK, out)
106
+}
107
+
108
+// handleLLMBackendCreate adds a new backend to the policy store.
109
+func (s *Server) handleLLMBackendCreate(w http.ResponseWriter, r *http.Request) {
110
+ if s.policies == nil {
111
+ http.Error(w, "policy store not available", http.StatusServiceUnavailable)
112
+ return
113
+ }
114
+ var b PolicyLLMBackend
115
+ if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
116
+ http.Error(w, "invalid request body", http.StatusBadRequest)
117
+ return
118
+ }
119
+ if b.Name == "" || b.Backend == "" {
120
+ http.Error(w, "name and backend are required", http.StatusBadRequest)
121
+ return
122
+ }
123
+
124
+ p := s.policies.Get()
125
+ for _, existing := range p.LLMBackends {
126
+ if existing.Name == b.Name {
127
+ http.Error(w, "backend name already exists", http.StatusConflict)
128
+ return
129
+ }
130
+ }
131
+ // Also check YAML backends.
132
+ if s.llmCfg != nil {
133
+ for _, existing := range s.llmCfg.Backends {
134
+ if existing.Name == b.Name {
135
+ http.Error(w, "backend name already exists in config", http.StatusConflict)
136
+ return
137
+ }
138
+ }
139
+ }
140
+
141
+ p.LLMBackends = append(p.LLMBackends, b)
142
+ if err := s.policies.Set(p); err != nil {
143
+ http.Error(w, "failed to save", http.StatusInternalServerError)
144
+ return
145
+ }
146
+ writeJSON(w, http.StatusCreated, backendView{
147
+ Name: b.Name,
148
+ Backend: b.Backend,
149
+ APIKey: mask(b.APIKey),
150
+ BaseURL: b.BaseURL,
151
+ Model: b.Model,
152
+ Region: b.Region,
153
+ Allow: b.Allow,
154
+ Block: b.Block,
155
+ Default: b.Default,
156
+ Source: "policy",
157
+ })
158
+}
159
+
160
+// handleLLMBackendUpdate updates a policy-store backend by name.
161
+// Fields present in the request body override the stored value.
162
+// Send api_key / aws_secret_key as "" to leave the stored value unchanged
163
+// (the UI masks these and should omit them if unchanged).
164
+func (s *Server) handleLLMBackendUpdate(w http.ResponseWriter, r *http.Request) {
165
+ if s.policies == nil {
166
+ http.Error(w, "policy store not available", http.StatusServiceUnavailable)
167
+ return
168
+ }
169
+ name := r.PathValue("name")
170
+ var req PolicyLLMBackend
171
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
172
+ http.Error(w, "invalid request body", http.StatusBadRequest)
173
+ return
174
+ }
175
+
176
+ p := s.policies.Get()
177
+ idx := -1
178
+ for i, b := range p.LLMBackends {
179
+ if b.Name == name {
180
+ idx = i
181
+ break
182
+ }
183
+ }
184
+ if idx == -1 {
185
+ http.Error(w, "backend not found (only policy backends are editable)", http.StatusNotFound)
186
+ return
187
+ }
188
+
189
+ existing := p.LLMBackends[idx]
190
+ // Preserve stored secrets when the UI sends "***" or empty.
191
+ if req.APIKey == "" || req.APIKey == "***" {
192
+ req.APIKey = existing.APIKey
193
+ }
194
+ if req.AWSSecretKey == "" || req.AWSSecretKey == "***" {
195
+ req.AWSSecretKey = existing.AWSSecretKey
196
+ }
197
+ if req.AWSKeyID == "" || req.AWSKeyID == "***" {
198
+ req.AWSKeyID = existing.AWSKeyID
199
+ }
200
+ req.Name = name // name is immutable
201
+ p.LLMBackends[idx] = req
202
+
203
+ if err := s.policies.Set(p); err != nil {
204
+ http.Error(w, "failed to save", http.StatusInternalServerError)
205
+ return
206
+ }
207
+ writeJSON(w, http.StatusOK, backendView{
208
+ Name: req.Name,
209
+ Backend: req.Backend,
210
+ APIKey: mask(req.APIKey),
211
+ BaseURL: req.BaseURL,
212
+ Model: req.Model,
213
+ Region: req.Region,
214
+ Allow: req.Allow,
215
+ Block: req.Block,
216
+ Default: req.Default,
217
+ Source: "policy",
218
+ })
219
+}
220
+
221
+// handleLLMBackendDelete removes a policy-store backend by name.
222
+func (s *Server) handleLLMBackendDelete(w http.ResponseWriter, r *http.Request) {
223
+ if s.policies == nil {
224
+ http.Error(w, "policy store not available", http.StatusServiceUnavailable)
225
+ return
226
+ }
227
+ name := r.PathValue("name")
228
+ p := s.policies.Get()
229
+ idx := -1
230
+ for i, b := range p.LLMBackends {
231
+ if b.Name == name {
232
+ idx = i
233
+ break
234
+ }
235
+ }
236
+ if idx == -1 {
237
+ http.Error(w, "backend not found (only policy backends are deletable)", http.StatusNotFound)
238
+ return
239
+ }
240
+ p.LLMBackends = append(p.LLMBackends[:idx], p.LLMBackends[idx+1:]...)
241
+ if err := s.policies.Set(p); err != nil {
242
+ http.Error(w, "failed to save", http.StatusInternalServerError)
243
+ return
244
+ }
245
+ w.WriteHeader(http.StatusNoContent)
246
+}
247
+
248
+// handleLLMModels runs model discovery for the named backend.
249
+// Looks in YAML config first, then policy store.
250
+func (s *Server) handleLLMModels(w http.ResponseWriter, r *http.Request) {
251
+ name := r.PathValue("name")
252
+ cfg, ok := s.findBackendConfig(name)
253
+ if !ok {
254
+ http.Error(w, "backend not found", http.StatusNotFound)
255
+ return
256
+ }
257
+ models, err := llm.Discover(r.Context(), cfg)
258
+ if err != nil {
259
+ s.log.Error("llm model discovery", "backend", name, "err", err)
260
+ http.Error(w, "model discovery failed: "+err.Error(), http.StatusBadGateway)
261
+ return
262
+ }
263
+ writeJSON(w, http.StatusOK, models)
264
+}
265
+
266
+// handleLLMDiscover runs ad-hoc model discovery from form credentials.
267
+// Used by the UI "load live models" button before a backend is saved.
268
+func (s *Server) handleLLMDiscover(w http.ResponseWriter, r *http.Request) {
269
+ var req struct {
270
+ Backend string `json:"backend"`
271
+ APIKey string `json:"api_key"`
272
+ BaseURL string `json:"base_url"`
273
+ Model string `json:"model"`
274
+ Region string `json:"region"`
275
+ AWSKeyID string `json:"aws_key_id"`
276
+ AWSSecretKey string `json:"aws_secret_key"`
277
+ Allow []string `json:"allow"`
278
+ Block []string `json:"block"`
279
+ }
280
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
281
+ http.Error(w, "invalid request body", http.StatusBadRequest)
282
+ return
283
+ }
284
+ if req.Backend == "" {
285
+ http.Error(w, "backend is required", http.StatusBadRequest)
286
+ return
287
+ }
288
+ cfg := llm.BackendConfig{
289
+ Backend: req.Backend,
290
+ APIKey: req.APIKey,
291
+ BaseURL: req.BaseURL,
292
+ Model: req.Model,
293
+ Region: req.Region,
294
+ AWSKeyID: req.AWSKeyID,
295
+ AWSSecretKey: req.AWSSecretKey,
296
+ Allow: req.Allow,
297
+ Block: req.Block,
298
+ }
299
+ models, err := llm.Discover(r.Context(), cfg)
300
+ if err != nil {
301
+ s.log.Error("llm ad-hoc discovery", "backend", req.Backend, "err", err)
302
+ http.Error(w, "model discovery failed: "+err.Error(), http.StatusBadGateway)
303
+ return
304
+ }
305
+ writeJSON(w, http.StatusOK, models)
306
+}
307
+
308
+// findBackendConfig looks up a backend by name in YAML config then policy store.
309
+func (s *Server) findBackendConfig(name string) (llm.BackendConfig, bool) {
310
+ if s.llmCfg != nil {
311
+ for _, b := range s.llmCfg.Backends {
312
+ if b.Name == name {
313
+ return yamlBackendToLLM(b), true
314
+ }
315
+ }
316
+ }
317
+ if s.policies != nil {
318
+ for _, b := range s.policies.Get().LLMBackends {
319
+ if b.Name == name {
320
+ return policyBackendToLLM(b), true
321
+ }
322
+ }
323
+ }
324
+ return llm.BackendConfig{}, false
325
+}
326
+
327
+func yamlBackendToLLM(b config.LLMBackendConfig) llm.BackendConfig {
328
+ return llm.BackendConfig{
329
+ Backend: b.Backend,
330
+ APIKey: b.APIKey,
331
+ BaseURL:
--- a/internal/api/llm_handlers.go
+++ b/internal/api/llm_handlers.go
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/llm_handlers.go
+++ b/internal/api/llm_handlers.go
@@ -0,0 +1,331 @@
1 package api
2
3 import (
4 "encoding/json"
5 "net/http"
6 "sort"
7
8 "github.com/conflicthq/scuttlebot/internal/config"
9 "github.com/conflicthq/scuttlebot/internal/llm"
10 )
11
12 // backendView is the read-safe representation of a backend returned by the API.
13 // API keys are replaced with "***" if set, so they are never exposed.
14 type backendView struct {
15 Name string `json:"name"`
16 Backend string `json:"backend"`
17 APIKey string `json:"api_key,omitempty"` // "***" if set, "" if not
18 BaseURL string `json:"base_url,omitempty"`
19 Model string `json:"model,omitempty"`
20 Region string `json:"region,omitempty"`
21 AWSKeyID string `json:"aws_key_id,omitempty"` // "***" if set
22 Allow []string `json:"allow,omitempty"`
23 Block []string `json:"block,omitempty"`
24 Default bool `json:"default,omitempty"`
25 Source string `json:"source"` // "config" (yaml, read-only) or "policy" (ui-managed)
26 }
27
28 func mask(s string) string {
29 if s == "" {
30 return ""
31 }
32 return "***"
33 }
34
35 // handleLLMKnown returns the list of all known backend names.
36 func (s *Server) handleLLMKnown(w http.ResponseWriter, _ *http.Request) {
37 type knownBackend struct {
38 Name string `json:"name"`
39 BaseURL string `json:"base_url,omitempty"`
40 Native bool `json:"native,omitempty"`
41 }
42
43 var backends []knownBackend
44 for name, url := range llm.KnownBackends {
45 backends = append(backends, knownBackend{Name: name, BaseURL: url})
46 }
47 backends = append(backends,
48 knownBackend{Name: "anthropic", Native: true},
49 knownBackend{Name: "gemini", Native: true},
50 knownBackend{Name: "bedrock", Native: true},
51 knownBackend{Name: "ollama", BaseURL: "http://localhost:11434", Native: true},
52 )
53 sort.Slice(backends, func(i, j int) bool {
54 return backends[i].Name < backends[j].Name
55 })
56 writeJSON(w, http.StatusOK, backends)
57 }
58
59 // handleLLMBackends lists all configured backends (YAML config + policy store).
60 // API keys are masked.
61 func (s *Server) handleLLMBackends(w http.ResponseWriter, _ *http.Request) {
62 var out []backendView
63
64 // YAML-configured backends (read-only).
65 if s.llmCfg != nil {
66 for _, b := range s.llmCfg.Backends {
67 out = append(out, backendView{
68 Name: b.Name,
69 Backend: b.Backend,
70 APIKey: mask(b.APIKey),
71 BaseURL: b.BaseURL,
72 Model: b.Model,
73 Region: b.Region,
74 AWSKeyID: mask(b.AWSKeyID),
75 Allow: b.Allow,
76 Block: b.Block,
77 Default: b.Default,
78 Source: "config",
79 })
80 }
81 }
82
83 // Policy-store backends (UI-managed, editable).
84 if s.policies != nil {
85 for _, b := range s.policies.Get().LLMBackends {
86 out = append(out, backendView{
87 Name: b.Name,
88 Backend: b.Backend,
89 APIKey: mask(b.APIKey),
90 BaseURL: b.BaseURL,
91 Model: b.Model,
92 Region: b.Region,
93 AWSKeyID: mask(b.AWSKeyID),
94 Allow: b.Allow,
95 Block: b.Block,
96 Default: b.Default,
97 Source: "policy",
98 })
99 }
100 }
101
102 if out == nil {
103 out = []backendView{}
104 }
105 writeJSON(w, http.StatusOK, out)
106 }
107
108 // handleLLMBackendCreate adds a new backend to the policy store.
109 func (s *Server) handleLLMBackendCreate(w http.ResponseWriter, r *http.Request) {
110 if s.policies == nil {
111 http.Error(w, "policy store not available", http.StatusServiceUnavailable)
112 return
113 }
114 var b PolicyLLMBackend
115 if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
116 http.Error(w, "invalid request body", http.StatusBadRequest)
117 return
118 }
119 if b.Name == "" || b.Backend == "" {
120 http.Error(w, "name and backend are required", http.StatusBadRequest)
121 return
122 }
123
124 p := s.policies.Get()
125 for _, existing := range p.LLMBackends {
126 if existing.Name == b.Name {
127 http.Error(w, "backend name already exists", http.StatusConflict)
128 return
129 }
130 }
131 // Also check YAML backends.
132 if s.llmCfg != nil {
133 for _, existing := range s.llmCfg.Backends {
134 if existing.Name == b.Name {
135 http.Error(w, "backend name already exists in config", http.StatusConflict)
136 return
137 }
138 }
139 }
140
141 p.LLMBackends = append(p.LLMBackends, b)
142 if err := s.policies.Set(p); err != nil {
143 http.Error(w, "failed to save", http.StatusInternalServerError)
144 return
145 }
146 writeJSON(w, http.StatusCreated, backendView{
147 Name: b.Name,
148 Backend: b.Backend,
149 APIKey: mask(b.APIKey),
150 BaseURL: b.BaseURL,
151 Model: b.Model,
152 Region: b.Region,
153 Allow: b.Allow,
154 Block: b.Block,
155 Default: b.Default,
156 Source: "policy",
157 })
158 }
159
160 // handleLLMBackendUpdate updates a policy-store backend by name.
161 // Fields present in the request body override the stored value.
162 // Send api_key / aws_secret_key as "" to leave the stored value unchanged
163 // (the UI masks these and should omit them if unchanged).
164 func (s *Server) handleLLMBackendUpdate(w http.ResponseWriter, r *http.Request) {
165 if s.policies == nil {
166 http.Error(w, "policy store not available", http.StatusServiceUnavailable)
167 return
168 }
169 name := r.PathValue("name")
170 var req PolicyLLMBackend
171 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
172 http.Error(w, "invalid request body", http.StatusBadRequest)
173 return
174 }
175
176 p := s.policies.Get()
177 idx := -1
178 for i, b := range p.LLMBackends {
179 if b.Name == name {
180 idx = i
181 break
182 }
183 }
184 if idx == -1 {
185 http.Error(w, "backend not found (only policy backends are editable)", http.StatusNotFound)
186 return
187 }
188
189 existing := p.LLMBackends[idx]
190 // Preserve stored secrets when the UI sends "***" or empty.
191 if req.APIKey == "" || req.APIKey == "***" {
192 req.APIKey = existing.APIKey
193 }
194 if req.AWSSecretKey == "" || req.AWSSecretKey == "***" {
195 req.AWSSecretKey = existing.AWSSecretKey
196 }
197 if req.AWSKeyID == "" || req.AWSKeyID == "***" {
198 req.AWSKeyID = existing.AWSKeyID
199 }
200 req.Name = name // name is immutable
201 p.LLMBackends[idx] = req
202
203 if err := s.policies.Set(p); err != nil {
204 http.Error(w, "failed to save", http.StatusInternalServerError)
205 return
206 }
207 writeJSON(w, http.StatusOK, backendView{
208 Name: req.Name,
209 Backend: req.Backend,
210 APIKey: mask(req.APIKey),
211 BaseURL: req.BaseURL,
212 Model: req.Model,
213 Region: req.Region,
214 Allow: req.Allow,
215 Block: req.Block,
216 Default: req.Default,
217 Source: "policy",
218 })
219 }
220
221 // handleLLMBackendDelete removes a policy-store backend by name.
222 func (s *Server) handleLLMBackendDelete(w http.ResponseWriter, r *http.Request) {
223 if s.policies == nil {
224 http.Error(w, "policy store not available", http.StatusServiceUnavailable)
225 return
226 }
227 name := r.PathValue("name")
228 p := s.policies.Get()
229 idx := -1
230 for i, b := range p.LLMBackends {
231 if b.Name == name {
232 idx = i
233 break
234 }
235 }
236 if idx == -1 {
237 http.Error(w, "backend not found (only policy backends are deletable)", http.StatusNotFound)
238 return
239 }
240 p.LLMBackends = append(p.LLMBackends[:idx], p.LLMBackends[idx+1:]...)
241 if err := s.policies.Set(p); err != nil {
242 http.Error(w, "failed to save", http.StatusInternalServerError)
243 return
244 }
245 w.WriteHeader(http.StatusNoContent)
246 }
247
248 // handleLLMModels runs model discovery for the named backend.
249 // Looks in YAML config first, then policy store.
250 func (s *Server) handleLLMModels(w http.ResponseWriter, r *http.Request) {
251 name := r.PathValue("name")
252 cfg, ok := s.findBackendConfig(name)
253 if !ok {
254 http.Error(w, "backend not found", http.StatusNotFound)
255 return
256 }
257 models, err := llm.Discover(r.Context(), cfg)
258 if err != nil {
259 s.log.Error("llm model discovery", "backend", name, "err", err)
260 http.Error(w, "model discovery failed: "+err.Error(), http.StatusBadGateway)
261 return
262 }
263 writeJSON(w, http.StatusOK, models)
264 }
265
266 // handleLLMDiscover runs ad-hoc model discovery from form credentials.
267 // Used by the UI "load live models" button before a backend is saved.
268 func (s *Server) handleLLMDiscover(w http.ResponseWriter, r *http.Request) {
269 var req struct {
270 Backend string `json:"backend"`
271 APIKey string `json:"api_key"`
272 BaseURL string `json:"base_url"`
273 Model string `json:"model"`
274 Region string `json:"region"`
275 AWSKeyID string `json:"aws_key_id"`
276 AWSSecretKey string `json:"aws_secret_key"`
277 Allow []string `json:"allow"`
278 Block []string `json:"block"`
279 }
280 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
281 http.Error(w, "invalid request body", http.StatusBadRequest)
282 return
283 }
284 if req.Backend == "" {
285 http.Error(w, "backend is required", http.StatusBadRequest)
286 return
287 }
288 cfg := llm.BackendConfig{
289 Backend: req.Backend,
290 APIKey: req.APIKey,
291 BaseURL: req.BaseURL,
292 Model: req.Model,
293 Region: req.Region,
294 AWSKeyID: req.AWSKeyID,
295 AWSSecretKey: req.AWSSecretKey,
296 Allow: req.Allow,
297 Block: req.Block,
298 }
299 models, err := llm.Discover(r.Context(), cfg)
300 if err != nil {
301 s.log.Error("llm ad-hoc discovery", "backend", req.Backend, "err", err)
302 http.Error(w, "model discovery failed: "+err.Error(), http.StatusBadGateway)
303 return
304 }
305 writeJSON(w, http.StatusOK, models)
306 }
307
308 // findBackendConfig looks up a backend by name in YAML config then policy store.
309 func (s *Server) findBackendConfig(name string) (llm.BackendConfig, bool) {
310 if s.llmCfg != nil {
311 for _, b := range s.llmCfg.Backends {
312 if b.Name == name {
313 return yamlBackendToLLM(b), true
314 }
315 }
316 }
317 if s.policies != nil {
318 for _, b := range s.policies.Get().LLMBackends {
319 if b.Name == name {
320 return policyBackendToLLM(b), true
321 }
322 }
323 }
324 return llm.BackendConfig{}, false
325 }
326
327 func yamlBackendToLLM(b config.LLMBackendConfig) llm.BackendConfig {
328 return llm.BackendConfig{
329 Backend: b.Backend,
330 APIKey: b.APIKey,
331 BaseURL:
--- a/internal/api/login.go
+++ b/internal/api/login.go
@@ -0,0 +1,185 @@
1
+package api
2
+
3
+import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "sync"
7
+ "time"
8
+
9
+ "github.com/conflicthq/scuttlebot/internal/auth"
10
+)
11
+
12
+// adminStore is the interface the Server uses for admin operations.
13
+type adminStore interface {
14
+ Authenticate(username, password string) bool
15
+ List() []auth.Admin
16
+ Add(username, password string) error
17
+ Remove(username string) error
18
+ SetPassword(username, password string) error
19
+}
20
+
21
+// loginRateLimiter enforces a per-IP sliding window of 10 attempts per minute.
22
+type loginRateLimiter struct {
23
+ mu sync.Mutex
24
+ windows map[string][]time.Time
25
+}
26
+
27
+func newLoginRateLimiter() *loginRateLimiter {
28
+ return &loginRateLimiter{windows: make(map[string][]time.Time)}
29
+}
30
+
31
+// Allow returns true if the IP is within the allowed rate.
32
+func (rl *loginRateLimiter) Allow(ip string) bool {
33
+ rl.mu.Lock()
34
+ defer rl.mu.Unlock()
35
+
36
+ const (
37
+ maxAttempts = 10
38
+ window = time.Minute
39
+ )
40
+
41
+ now := time.Now()
42
+ cutoff := now.Add(-window)
43
+
44
+ prev := rl.windows[ip]
45
+ var kept []time.Time
46
+ for _, t := range prev {
47
+ if t.After(cutoff) {
48
+ kept = append(kept, t)
49
+ }
50
+ }
51
+ kept = append(kept, now)
52
+ rl.windows[ip] = kept
53
+
54
+ return len(kept) <= maxAttempts
55
+}
56
+
57
+func clientIP(r *http.Request) string {
58
+ // Use RemoteAddr; X-Forwarded-For is not trustworthy without proxy config.
59
+ host := r.RemoteAddr
60
+ // Strip port if present.
61
+ for i := len(host) - 1; i >= 0; i-- {
62
+ if host[i] == ':' {
63
+ host = host[:i]
64
+ break
65
+ }
66
+ }
67
+ return host
68
+}
69
+
70
+// handleLogin handles POST /login.
71
+// Unauthenticated. Returns {token, username} on success.
72
+func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
73
+ if s.admins == nil {
74
+ writeError(w, http.StatusNotFound, "admin authentication not configured")
75
+ return
76
+ }
77
+
78
+ ip := clientIP(r)
79
+ if !s.loginRL.Allow(ip) {
80
+ writeError(w, http.StatusTooManyRequests, "too many login attempts")
81
+ return
82
+ }
83
+
84
+ var req struct {
85
+ Username string `json:"username"`
86
+ Password string `json:"password"`
87
+ }
88
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
89
+ writeError(w, http.StatusBadRequest, "invalid request body")
90
+ return
91
+ }
92
+
93
+ if !s.admins.Authenticate(req.Username, req.Password) {
94
+ writeError(w, http.StatusUnauthorized, "invalid credentials")
95
+ return
96
+ }
97
+
98
+ // Return the first API token — the shared server token.
99
+ var token string
100
+ for t := range s.tokenStrip port if pts = 10
101
+ window = time.Minute
102
+ )
103
+
104
+ now := time.Now()
105
+ cutoff := now.Add(-window)
106
+
107
+ prev := rl.windows[ip]
108
+ var kept []time.Time
109
+ for _, t := range prev {
110
+ if t.After(cutoff) {
111
+ kept = append(kept, t)
112
+ }
113
+ }
114
+ kept = append(kept, now)
115
+ rl.windows[ip] = kept
116
+
117
+ return len(kept) <= maxAttempts
118
+}
119
+
120
+func clientIP(r *http.Request) string {
121
+ // Use RemoteAddr; X-Forwarded-For is not trustworthy without proxy config.
122
+ host := r.RemoteAddr
123
+ // Strip port if present.
124
+ for i := len(host) - 1; i >= 0; i-- {
125
+ if host[i] == ':' {
126
+ host = host[:i]
127
+ break
128
+ }
129
+ }
130
+ return host
131
+}
132
+
133
+// handleLogin handles POST /login.
134
+// Unauthenticated. Returns {token, username} on success.
135
+func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
136
+ if s.admins == nil {
137
+ writeError(w, http.StatusNotFound, "admin authentication not configured")
138
+ return
139
+ }
140
+
141
+ ip := clientIP(r)
142
+ if !s.loginRL.Allow(ip) {
143
+ writeError(w, http.StatusTooManyRequests, "too many login attempts")
144
+ return
145
+ }
146
+
147
+ var req struct {
148
+ Username string `json:"username"`
149
+ Password string `json:"password"`
150
+ }
151
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
152
+ writeError(w, http.StatusBadRequest, "invalid request body")
153
+ return
154
+ }
155
+
156
+ if !s.admins.Authenticate(req.Username, req.Password) {
157
+ writeError(w, http.StatusUnauthorized, "invalid credentials")
158
+ return
159
+ }
160
+
161
+ // Create a session API key for this admin login.
162
+ sessionName := "session:" + req.Username
163
+ token, _, err := s.apiKeys.Create(sessionName, []auth.Scope{auth.ScopeAdmin}, time.Now().Add(24*time.Hour))
164
+ if err != nil {
165
+ s.log.Error("login: create session key", "err", err)
166
+ writeError(w, http.StatusInternalServerError, "failed to create session")
167
+ return
168
+ }
169
+
170
+ writeJSON(w, http.StatusOK, map[string]string{
171
+ "token": token,
172
+ "username": req.Username,
173
+ })
174
+}
175
+
176
+// handleAdminList handles GET /v1/admins.
177
+func (s *Server) handleAdminList(w http.ResponseWriter, r *http.Request) {
178
+ admins := s.admins.List()
179
+ type adminView struct {
180
+ Username string `json:"username"`
181
+ Created time.Time `json:"created"`
182
+ }
183
+ out := make([]adminView, len(admins))
184
+ for i, a := range admins {
185
+ out[i] = adminView{Username: a.Username, Created:
--- a/internal/api/login.go
+++ b/internal/api/login.go
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/login.go
+++ b/internal/api/login.go
@@ -0,0 +1,185 @@
1 package api
2
3 import (
4 "encoding/json"
5 "net/http"
6 "sync"
7 "time"
8
9 "github.com/conflicthq/scuttlebot/internal/auth"
10 )
11
12 // adminStore is the interface the Server uses for admin operations.
13 type adminStore interface {
14 Authenticate(username, password string) bool
15 List() []auth.Admin
16 Add(username, password string) error
17 Remove(username string) error
18 SetPassword(username, password string) error
19 }
20
21 // loginRateLimiter enforces a per-IP sliding window of 10 attempts per minute.
22 type loginRateLimiter struct {
23 mu sync.Mutex
24 windows map[string][]time.Time
25 }
26
27 func newLoginRateLimiter() *loginRateLimiter {
28 return &loginRateLimiter{windows: make(map[string][]time.Time)}
29 }
30
31 // Allow returns true if the IP is within the allowed rate.
32 func (rl *loginRateLimiter) Allow(ip string) bool {
33 rl.mu.Lock()
34 defer rl.mu.Unlock()
35
36 const (
37 maxAttempts = 10
38 window = time.Minute
39 )
40
41 now := time.Now()
42 cutoff := now.Add(-window)
43
44 prev := rl.windows[ip]
45 var kept []time.Time
46 for _, t := range prev {
47 if t.After(cutoff) {
48 kept = append(kept, t)
49 }
50 }
51 kept = append(kept, now)
52 rl.windows[ip] = kept
53
54 return len(kept) <= maxAttempts
55 }
56
57 func clientIP(r *http.Request) string {
58 // Use RemoteAddr; X-Forwarded-For is not trustworthy without proxy config.
59 host := r.RemoteAddr
60 // Strip port if present.
61 for i := len(host) - 1; i >= 0; i-- {
62 if host[i] == ':' {
63 host = host[:i]
64 break
65 }
66 }
67 return host
68 }
69
70 // handleLogin handles POST /login.
71 // Unauthenticated. Returns {token, username} on success.
72 func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
73 if s.admins == nil {
74 writeError(w, http.StatusNotFound, "admin authentication not configured")
75 return
76 }
77
78 ip := clientIP(r)
79 if !s.loginRL.Allow(ip) {
80 writeError(w, http.StatusTooManyRequests, "too many login attempts")
81 return
82 }
83
84 var req struct {
85 Username string `json:"username"`
86 Password string `json:"password"`
87 }
88 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
89 writeError(w, http.StatusBadRequest, "invalid request body")
90 return
91 }
92
93 if !s.admins.Authenticate(req.Username, req.Password) {
94 writeError(w, http.StatusUnauthorized, "invalid credentials")
95 return
96 }
97
98 // Return the first API token — the shared server token.
99 var token string
100 for t := range s.tokenStrip port if pts = 10
101 window = time.Minute
102 )
103
104 now := time.Now()
105 cutoff := now.Add(-window)
106
107 prev := rl.windows[ip]
108 var kept []time.Time
109 for _, t := range prev {
110 if t.After(cutoff) {
111 kept = append(kept, t)
112 }
113 }
114 kept = append(kept, now)
115 rl.windows[ip] = kept
116
117 return len(kept) <= maxAttempts
118 }
119
120 func clientIP(r *http.Request) string {
121 // Use RemoteAddr; X-Forwarded-For is not trustworthy without proxy config.
122 host := r.RemoteAddr
123 // Strip port if present.
124 for i := len(host) - 1; i >= 0; i-- {
125 if host[i] == ':' {
126 host = host[:i]
127 break
128 }
129 }
130 return host
131 }
132
133 // handleLogin handles POST /login.
134 // Unauthenticated. Returns {token, username} on success.
135 func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
136 if s.admins == nil {
137 writeError(w, http.StatusNotFound, "admin authentication not configured")
138 return
139 }
140
141 ip := clientIP(r)
142 if !s.loginRL.Allow(ip) {
143 writeError(w, http.StatusTooManyRequests, "too many login attempts")
144 return
145 }
146
147 var req struct {
148 Username string `json:"username"`
149 Password string `json:"password"`
150 }
151 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
152 writeError(w, http.StatusBadRequest, "invalid request body")
153 return
154 }
155
156 if !s.admins.Authenticate(req.Username, req.Password) {
157 writeError(w, http.StatusUnauthorized, "invalid credentials")
158 return
159 }
160
161 // Create a session API key for this admin login.
162 sessionName := "session:" + req.Username
163 token, _, err := s.apiKeys.Create(sessionName, []auth.Scope{auth.ScopeAdmin}, time.Now().Add(24*time.Hour))
164 if err != nil {
165 s.log.Error("login: create session key", "err", err)
166 writeError(w, http.StatusInternalServerError, "failed to create session")
167 return
168 }
169
170 writeJSON(w, http.StatusOK, map[string]string{
171 "token": token,
172 "username": req.Username,
173 })
174 }
175
176 // handleAdminList handles GET /v1/admins.
177 func (s *Server) handleAdminList(w http.ResponseWriter, r *http.Request) {
178 admins := s.admins.List()
179 type adminView struct {
180 Username string `json:"username"`
181 Created time.Time `json:"created"`
182 }
183 out := make([]adminView, len(admins))
184 for i, a := range admins {
185 out[i] = adminView{Username: a.Username, Created:
--- a/internal/api/login_test.go
+++ b/internal/api/login_test.go
@@ -0,0 +1,203 @@
1
+package api_test
2
+
3
+import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "testing"
7
+
8
+ "github.com/conflicthq/scuttlebot/internal/api"
9
+ "github.com/conflicthq/scuttlebot/internal/auth"
10
+ "github.com/conflicthq/scuttlebot/internal/registry"
11
+ "net/http/httptest"
12
+ "path/filepath"
13
+)
14
+
15
+// newAdminStore creates an AdminStore backed by a temp file.
16
+func newAdminStore(t *testing.T) *auth.AdminStore {
17
+ t.Helper()
18
+ s, err := auth.NewAdminStore(filepath.Join(t.TempDir(), "admins.json"))
19
+ if err != nil {
20
+ t.Fatalf("NewAdminStore: %v", err)
21
+ }
22
+ return s
23
+}
24
+
25
+// newTestServerWithAdmins creates a test server with admin auth configured.
26
+func newTestServerWithAdmins(t *testing.T) (*httptest.Server, *auth.AdminStore) {
27
+ t.Helper()
28
+ admins := newAdminStore(t)
29
+ if err := admins.Add("admin", "hunter2"); err != nil {
30
+ t.Fatalf("Add admin: %v", err)
31
+ }
32
+ reg := registry.New(newMock(), []byte("test-signing-key"))
33
+ srv := api.New(reg, []string{testTokeil, admins, nil, nil, nil, "", testLog)
34
+ return httptest.NewServer(srv.Handler()), admins
35
+}
36
+
37
+func TestLoginNoAdmins(t *testing.T) {
38
+ // When admins is nil, login returns 404.
39
+ reg := registry.New(newMock(), []byte("test-signing-key"))
40
+ srv := api.New(reg, []string{testT"", testLog)
41
+ ts := httptest.NewServer(srv.Handler())
42
+ defer ts.Close()
43
+
44
+ resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
45
+ defer resp.Body.Close()
46
+ if resp.StatusCode != http.StatusNotFound {
47
+ t.Errorf("expected 404 when no admins configured, got %d", resp.StatusCode)
48
+ }
49
+}
50
+
51
+func TestLoginValidCredentials(t *testing.T) {
52
+ ts, _ := newTestServerWithAdmins(t)
53
+ defer ts.Close()
54
+
55
+ resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "hunter2"}, nil)
56
+ defer resp.Body.Close()
57
+ if resp.StatusCode != http.StatusOK {
58
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
59
+ }
60
+
61
+ var body map[string]string
62
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
63
+ t.Fatalf("decode: %v", err)
64
+ }
65
+ if body["token"] == "" {
66
+ t.Error("expected token in response")
67
+ }
68
+ if body["username"] != "admin" {
69
+ t.Errorf("expected username=admin, got %q", body["username"])
70
+ }
71
+}
72
+
73
+func TestLoginWrongPassword(t *testing.T) {
74
+ ts, _ := newTestServerWithAdmins(t)
75
+ defer ts.Close()
76
+
77
+ resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "wrong"}, nil)
78
+ defer resp.Body.Close()
79
+ if resp.StatusCode != http.StatusUnauthorized {
80
+ t.Errorf("expected 401, got %d", resp.StatusCode)
81
+ }
82
+}
83
+
84
+func TestLoginUnknownUser(t *testing.T) {
85
+ ts, _ := newTestServerWithAdmins(t)
86
+ defer ts.Close()
87
+
88
+ resp := do(t, ts, "POST", "/login", map[string]any{"username": "nobody", "password": "hunter2"}, nil)
89
+ defer resp.Body.Close()
90
+ if resp.StatusCode != http.StatusUnauthorized {
91
+ t.Errorf("expected 401, got %d", resp.StatusCode)
92
+ }
93
+}
94
+
95
+func TestLoginBadBody(t *testing.T) {
96
+ ts, _ := newTestServerWithAdmins(t)
97
+ defer ts.Close()
98
+
99
+ resp := do(t, ts, "POST", "/login", "not-json", nil)
100
+ defer resp.Body.Close()
101
+ if resp.StatusCode != http.StatusBadRequest {
102
+ t.Errorf("expected 400, got %d", resp.StatusCode)
103
+ }
104
+}
105
+
106
+func TestLoginRateLimit(t *testing.T) {
107
+ ts, _ := newTestServerWithAdmins(t)
108
+ defer ts.Close()
109
+
110
+ // 11 attempts from the same IP — the 11th should be rate-limited.
111
+ var lastStatus int
112
+ for i := 0; i < 11; i++ {
113
+ resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "wrong"}, nil)
114
+ lastStatus = resp.StatusCode
115
+ resp.Body.Close()
116
+ }
117
+ if lastStatus != http.StatusTooManyRequests {
118
+ t.Errorf("expected 429 on 11th attempt, got %d", lastStatus)
119
+ }
120
+}
121
+
122
+// --- admin management endpoints ---
123
+
124
+func TestAdminList(t *testing.T) {
125
+ ts, _ := newTestServerWithAdmins(t)
126
+ defer ts.Close()
127
+
128
+ resp := do(t, ts, "GET", "/v1/admins", nil, authHeader())
129
+ defer resp.Body.Close()
130
+ if resp.StatusCode != http.StatusOK {
131
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
132
+ }
133
+
134
+ var body map[string]any
135
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
136
+ t.Fatalf("decode: %v", err)
137
+ }
138
+ admins := body["admins"].([]any)
139
+ if len(admins) != 1 {
140
+ t.Errorf("expected 1 admin, got %d", len(admins))
141
+ }
142
+}
143
+
144
+func TestAdminAdd(t *testing.T) {
145
+ ts, _ := newTestServerWithAdmins(t)
146
+ defer ts.Close()
147
+
148
+ resp := do(t, ts, "POST", "/v1/admins", map[string]any{"username": "bob", "password": "passw0rd"}, authHeader())
149
+ defer resp.Body.Close()
150
+ if resp.StatusCode != http.StatusCreated {
151
+ t.Errorf("expected 201, got %d", resp.StatusCode)
152
+ }
153
+
154
+ // List should now have 2.
155
+ resp2 := do(t, ts, "GET", "/v1/admins", nil, authHeader())
156
+ defer resp2.Body.Close()
157
+ var body map[string]any
158
+ json.NewDecoder(resp2.Body).Decode(&body)
159
+ admins := body["admins"].([]any)
160
+ if len(admins) != 2 {
161
+ t.Errorf("expected 2 admins after add, got %d", len(admins))
162
+ }
163
+}
164
+
165
+func TestAdminAddDuplicate(t *testing.T) {
166
+ ts, _ := newTestServerWithAdmins(t)
167
+ defer ts.Close()
168
+
169
+ resp := do(t, ts, "POST", "/v1/admins", map[string]any{"username": "admin", "password": "pw"}, authHeader())
170
+ defer resp.Body.Close()
171
+ if resp.StatusCode != http.StatusConflict {
172
+ t.Errorf("expected 409 on duplicate, got %d", resp.StatusCode)
173
+ }
174
+}
175
+
176
+func TestAdminAddMissingFields(t *testing.T) {
177
+ ts, _ := newTestServerWithAdmins(t)
178
+ defer ts.Close()
179
+
180
+ resp := do(t, ts, "POST", "/v1/admins", map[string]any{"username": "bob"}, authHeader())
181
+ defer resp.Body.Close()
182
+ if resp.StatusCode != http.StatusBadRequest {
183
+ t.Errorf("expected 400 when password missing, got %d", resp.StatusCode)
184
+ }
185
+}
186
+
187
+func TestAdminRemove(t *testing.T) {
188
+ ts, admins := newTestServerWithAdmins(t)
189
+ defer ts.Close()
190
+
191
+ // Add a second admin first so we're not removing the only one.
192
+ _ = admins.Add("bob", "pw")
193
+
194
+ resp := do(t, ts, "DELETE", "/v1/admins/admin", nil, authHeader())
195
+ defer resp.Body.Close()
196
+ if resp.StatusCode != http.StatusNoContent {
197
+ t.Errorf("expected 204, got %d", resp.StatusCode)
198
+ }
199
+
200
+ resp2 := do(t, ts, "GET", "/v1/admins", nil, authHeader())
201
+ defer resp2.Body.Close()
202
+ var body map[string]any
203
+ json.NewDecoder(resp2.B
--- a/internal/api/login_test.go
+++ b/internal/api/login_test.go
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/login_test.go
+++ b/internal/api/login_test.go
@@ -0,0 +1,203 @@
1 package api_test
2
3 import (
4 "encoding/json"
5 "net/http"
6 "testing"
7
8 "github.com/conflicthq/scuttlebot/internal/api"
9 "github.com/conflicthq/scuttlebot/internal/auth"
10 "github.com/conflicthq/scuttlebot/internal/registry"
11 "net/http/httptest"
12 "path/filepath"
13 )
14
15 // newAdminStore creates an AdminStore backed by a temp file.
16 func newAdminStore(t *testing.T) *auth.AdminStore {
17 t.Helper()
18 s, err := auth.NewAdminStore(filepath.Join(t.TempDir(), "admins.json"))
19 if err != nil {
20 t.Fatalf("NewAdminStore: %v", err)
21 }
22 return s
23 }
24
25 // newTestServerWithAdmins creates a test server with admin auth configured.
26 func newTestServerWithAdmins(t *testing.T) (*httptest.Server, *auth.AdminStore) {
27 t.Helper()
28 admins := newAdminStore(t)
29 if err := admins.Add("admin", "hunter2"); err != nil {
30 t.Fatalf("Add admin: %v", err)
31 }
32 reg := registry.New(newMock(), []byte("test-signing-key"))
33 srv := api.New(reg, []string{testTokeil, admins, nil, nil, nil, "", testLog)
34 return httptest.NewServer(srv.Handler()), admins
35 }
36
37 func TestLoginNoAdmins(t *testing.T) {
38 // When admins is nil, login returns 404.
39 reg := registry.New(newMock(), []byte("test-signing-key"))
40 srv := api.New(reg, []string{testT"", testLog)
41 ts := httptest.NewServer(srv.Handler())
42 defer ts.Close()
43
44 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "pw"}, nil)
45 defer resp.Body.Close()
46 if resp.StatusCode != http.StatusNotFound {
47 t.Errorf("expected 404 when no admins configured, got %d", resp.StatusCode)
48 }
49 }
50
51 func TestLoginValidCredentials(t *testing.T) {
52 ts, _ := newTestServerWithAdmins(t)
53 defer ts.Close()
54
55 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "hunter2"}, nil)
56 defer resp.Body.Close()
57 if resp.StatusCode != http.StatusOK {
58 t.Fatalf("expected 200, got %d", resp.StatusCode)
59 }
60
61 var body map[string]string
62 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
63 t.Fatalf("decode: %v", err)
64 }
65 if body["token"] == "" {
66 t.Error("expected token in response")
67 }
68 if body["username"] != "admin" {
69 t.Errorf("expected username=admin, got %q", body["username"])
70 }
71 }
72
73 func TestLoginWrongPassword(t *testing.T) {
74 ts, _ := newTestServerWithAdmins(t)
75 defer ts.Close()
76
77 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "wrong"}, nil)
78 defer resp.Body.Close()
79 if resp.StatusCode != http.StatusUnauthorized {
80 t.Errorf("expected 401, got %d", resp.StatusCode)
81 }
82 }
83
84 func TestLoginUnknownUser(t *testing.T) {
85 ts, _ := newTestServerWithAdmins(t)
86 defer ts.Close()
87
88 resp := do(t, ts, "POST", "/login", map[string]any{"username": "nobody", "password": "hunter2"}, nil)
89 defer resp.Body.Close()
90 if resp.StatusCode != http.StatusUnauthorized {
91 t.Errorf("expected 401, got %d", resp.StatusCode)
92 }
93 }
94
95 func TestLoginBadBody(t *testing.T) {
96 ts, _ := newTestServerWithAdmins(t)
97 defer ts.Close()
98
99 resp := do(t, ts, "POST", "/login", "not-json", nil)
100 defer resp.Body.Close()
101 if resp.StatusCode != http.StatusBadRequest {
102 t.Errorf("expected 400, got %d", resp.StatusCode)
103 }
104 }
105
106 func TestLoginRateLimit(t *testing.T) {
107 ts, _ := newTestServerWithAdmins(t)
108 defer ts.Close()
109
110 // 11 attempts from the same IP — the 11th should be rate-limited.
111 var lastStatus int
112 for i := 0; i < 11; i++ {
113 resp := do(t, ts, "POST", "/login", map[string]any{"username": "admin", "password": "wrong"}, nil)
114 lastStatus = resp.StatusCode
115 resp.Body.Close()
116 }
117 if lastStatus != http.StatusTooManyRequests {
118 t.Errorf("expected 429 on 11th attempt, got %d", lastStatus)
119 }
120 }
121
122 // --- admin management endpoints ---
123
124 func TestAdminList(t *testing.T) {
125 ts, _ := newTestServerWithAdmins(t)
126 defer ts.Close()
127
128 resp := do(t, ts, "GET", "/v1/admins", nil, authHeader())
129 defer resp.Body.Close()
130 if resp.StatusCode != http.StatusOK {
131 t.Fatalf("expected 200, got %d", resp.StatusCode)
132 }
133
134 var body map[string]any
135 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
136 t.Fatalf("decode: %v", err)
137 }
138 admins := body["admins"].([]any)
139 if len(admins) != 1 {
140 t.Errorf("expected 1 admin, got %d", len(admins))
141 }
142 }
143
144 func TestAdminAdd(t *testing.T) {
145 ts, _ := newTestServerWithAdmins(t)
146 defer ts.Close()
147
148 resp := do(t, ts, "POST", "/v1/admins", map[string]any{"username": "bob", "password": "passw0rd"}, authHeader())
149 defer resp.Body.Close()
150 if resp.StatusCode != http.StatusCreated {
151 t.Errorf("expected 201, got %d", resp.StatusCode)
152 }
153
154 // List should now have 2.
155 resp2 := do(t, ts, "GET", "/v1/admins", nil, authHeader())
156 defer resp2.Body.Close()
157 var body map[string]any
158 json.NewDecoder(resp2.Body).Decode(&body)
159 admins := body["admins"].([]any)
160 if len(admins) != 2 {
161 t.Errorf("expected 2 admins after add, got %d", len(admins))
162 }
163 }
164
165 func TestAdminAddDuplicate(t *testing.T) {
166 ts, _ := newTestServerWithAdmins(t)
167 defer ts.Close()
168
169 resp := do(t, ts, "POST", "/v1/admins", map[string]any{"username": "admin", "password": "pw"}, authHeader())
170 defer resp.Body.Close()
171 if resp.StatusCode != http.StatusConflict {
172 t.Errorf("expected 409 on duplicate, got %d", resp.StatusCode)
173 }
174 }
175
176 func TestAdminAddMissingFields(t *testing.T) {
177 ts, _ := newTestServerWithAdmins(t)
178 defer ts.Close()
179
180 resp := do(t, ts, "POST", "/v1/admins", map[string]any{"username": "bob"}, authHeader())
181 defer resp.Body.Close()
182 if resp.StatusCode != http.StatusBadRequest {
183 t.Errorf("expected 400 when password missing, got %d", resp.StatusCode)
184 }
185 }
186
187 func TestAdminRemove(t *testing.T) {
188 ts, admins := newTestServerWithAdmins(t)
189 defer ts.Close()
190
191 // Add a second admin first so we're not removing the only one.
192 _ = admins.Add("bob", "pw")
193
194 resp := do(t, ts, "DELETE", "/v1/admins/admin", nil, authHeader())
195 defer resp.Body.Close()
196 if resp.StatusCode != http.StatusNoContent {
197 t.Errorf("expected 204, got %d", resp.StatusCode)
198 }
199
200 resp2 := do(t, ts, "GET", "/v1/admins", nil, authHeader())
201 defer resp2.Body.Close()
202 var body map[string]any
203 json.NewDecoder(resp2.B
--- a/internal/api/metrics.go
+++ b/internal/api/metrics.go
@@ -0,0 +1,69 @@
1
+package api
2
+
3
+import (
4
+ "net/http"
5
+ "runtime"
6
+ "time"
7
+)
8
+
9
+type metricsResponse struct {
10
+ Timestamp string `json:"timestamp"`
11
+ Runtime runtimeMetrics `json:"runtime"`
12
+ Bridge *bridgeMetrics `json:"bridge,omitempty"`
13
+ Registry registryMetrics `json:"registry"`
14
+}
15
+
16
+type runtimeMetrics struct {
17
+ Goroutines int `json:"goroutines"`
18
+ HeapAlloc uint64 `json:"heap_alloc_bytes"`
19
+ HeapSys uint64 `json:"heap_sys_bytes"`
20
+ GCRuns uint32 `json:"gc_runs"`
21
+}
22
+
23
+type bridgeMetrics struct {
24
+ Channels int `json:"channels"`
25
+ MessagesTotal int64 `json:"messages_total"`
26
+ ActiveSubs int `json:"active_subscribers"`
27
+}
28
+
29
+type registryMetrics struct {
30
+ Total int `json:"total"`
31
+ Active int `json:"active"`
32
+ Revoked int `json:"revoked"`
33
+}
34
+
35
+func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
36
+ var ms runtime.MemStats
37
+ runtime.ReadMemStats(&ms)
38
+
39
+ resp := metricsResponse{
40
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
41
+ Runtime: runtimeMetrics{
42
+ Goroutines: runtime.NumGoroutine(),
43
+ HeapAlloc: ms.HeapAlloc,
44
+ HeapSys: ms.HeapSys,
45
+ GCRuns: ms.NumGC,
46
+ },
47
+ }
48
+
49
+ if s.bridge != nil {
50
+ st := s.bridge.Stats()
51
+ resp.Bridge = &bridgeMetrics{
52
+ Channels: st.Channels,
53
+ MessagesTotal: st.MessagesTotal,
54
+ ActiveSubs: st.ActiveSubs,
55
+ }
56
+ }
57
+
58
+ agents := s.registry.List()
59
+ for _, a := range agents {
60
+ resp.Registry.Total++
61
+ if a.Revoked {
62
+ resp.Registry.Revoked++
63
+ } else {
64
+ resp.Registry.Active++
65
+ }
66
+ }
67
+
68
+ writeJSON(w, http.StatusOK, resp)
69
+}
--- a/internal/api/metrics.go
+++ b/internal/api/metrics.go
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/metrics.go
+++ b/internal/api/metrics.go
@@ -0,0 +1,69 @@
1 package api
2
3 import (
4 "net/http"
5 "runtime"
6 "time"
7 )
8
9 type metricsResponse struct {
10 Timestamp string `json:"timestamp"`
11 Runtime runtimeMetrics `json:"runtime"`
12 Bridge *bridgeMetrics `json:"bridge,omitempty"`
13 Registry registryMetrics `json:"registry"`
14 }
15
16 type runtimeMetrics struct {
17 Goroutines int `json:"goroutines"`
18 HeapAlloc uint64 `json:"heap_alloc_bytes"`
19 HeapSys uint64 `json:"heap_sys_bytes"`
20 GCRuns uint32 `json:"gc_runs"`
21 }
22
23 type bridgeMetrics struct {
24 Channels int `json:"channels"`
25 MessagesTotal int64 `json:"messages_total"`
26 ActiveSubs int `json:"active_subscribers"`
27 }
28
29 type registryMetrics struct {
30 Total int `json:"total"`
31 Active int `json:"active"`
32 Revoked int `json:"revoked"`
33 }
34
35 func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
36 var ms runtime.MemStats
37 runtime.ReadMemStats(&ms)
38
39 resp := metricsResponse{
40 Timestamp: time.Now().UTC().Format(time.RFC3339),
41 Runtime: runtimeMetrics{
42 Goroutines: runtime.NumGoroutine(),
43 HeapAlloc: ms.HeapAlloc,
44 HeapSys: ms.HeapSys,
45 GCRuns: ms.NumGC,
46 },
47 }
48
49 if s.bridge != nil {
50 st := s.bridge.Stats()
51 resp.Bridge = &bridgeMetrics{
52 Channels: st.Channels,
53 MessagesTotal: st.MessagesTotal,
54 ActiveSubs: st.ActiveSubs,
55 }
56 }
57
58 agents := s.registry.List()
59 for _, a := range agents {
60 resp.Registry.Total++
61 if a.Revoked {
62 resp.Registry.Revoked++
63 } else {
64 resp.Registry.Active++
65 }
66 }
67
68 writeJSON(w, http.StatusOK, resp)
69 }

No diff available

--- a/internal/api/policies_test.go
+++ b/internal/api/policies_test.go
@@ -0,0 +1,36 @@
1
+package api
2
+
3
+import (
4
+ "path/filepath"
5
+ "testing"
6
+)
7
+
8
+func TestNewPolicyStoreDefaultsBridgeTTL(t *testing.T) {
9
+ ps, err := NewPolicyStore(filepath.Join(t.TempDir(), "policies.json"), 5)
10
+ if err != nil {
11
+ t.Fatalf("NewPolicyStore() error = %v", err)
12
+ }
13
+
14
+ got := ps.Get().Bridge.WebUserTTLMinutes
15
+ if got != 5 {
16
+ t.Fatalf("default bridge ttl = %d, want 5", got)
17
+ }
18
+}
19
+
20
+func TestPolicyStoreSetNormalizesBridgeTTL(t *testing.T) {
21
+ ps, err := NewPolicyStore(filepath.Join(t.TempDir(), "policies.json"), 5)
22
+ if err != nil {
23
+ t.Fatalf("NewPolicyStore() error = %v", err)
24
+ }
25
+
26
+ p := ps.Get()
27
+ p.Bridge.WebUserTTLMinutes = 0
28
+ if err := ps.Set(p); err != nil {
29
+ t.Fatalf("Set() error = %v", err)
30
+ }
31
+
32
+ got := ps.Get().Bridge.WebUserTTLMinutes
33
+ if got != 5 {
34
+ t.Fatalf("normalized bridge ttl = %d, want 5", got)
35
+ }
36
+}
--- a/internal/api/policies_test.go
+++ b/internal/api/policies_test.go
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/policies_test.go
+++ b/internal/api/policies_test.go
@@ -0,0 +1,36 @@
1 package api
2
3 import (
4 "path/filepath"
5 "testing"
6 )
7
8 func TestNewPolicyStoreDefaultsBridgeTTL(t *testing.T) {
9 ps, err := NewPolicyStore(filepath.Join(t.TempDir(), "policies.json"), 5)
10 if err != nil {
11 t.Fatalf("NewPolicyStore() error = %v", err)
12 }
13
14 got := ps.Get().Bridge.WebUserTTLMinutes
15 if got != 5 {
16 t.Fatalf("default bridge ttl = %d, want 5", got)
17 }
18 }
19
20 func TestPolicyStoreSetNormalizesBridgeTTL(t *testing.T) {
21 ps, err := NewPolicyStore(filepath.Join(t.TempDir(), "policies.json"), 5)
22 if err != nil {
23 t.Fatalf("NewPolicyStore() error = %v", err)
24 }
25
26 p := ps.Get()
27 p.Bridge.WebUserTTLMinutes = 0
28 if err := ps.Set(p); err != nil {
29 t.Fatalf("Set() error = %v", err)
30 }
31
32 got := ps.Get().Bridge.WebUserTTLMinutes
33 if got != 5 {
34 t.Fatalf("normalized bridge ttl = %d, want 5", got)
35 }
36 }
--- internal/api/server.go
+++ internal/api/server.go
@@ -66,10 +66,11 @@
6666
apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
6767
apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete)
6868
if s.bridge != nil {
6969
apiMux.HandleFunc("GET /v1/channels", s.handleListChannels)
7070
apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel)
71
+ apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.handleDeleteChannel)
7172
apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
7273
apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
7374
apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
7475
apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
7576
}
7677
7778
ADDED internal/api/settings.go
--- internal/api/server.go
+++ internal/api/server.go
@@ -66,10 +66,11 @@
66 apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
67 apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete)
68 if s.bridge != nil {
69 apiMux.HandleFunc("GET /v1/channels", s.handleListChannels)
70 apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel)
 
71 apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
72 apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
73 apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
74 apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
75 }
76
77 DDED internal/api/settings.go
--- internal/api/server.go
+++ internal/api/server.go
@@ -66,10 +66,11 @@
66 apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.handleRevoke)
67 apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.handleDelete)
68 if s.bridge != nil {
69 apiMux.HandleFunc("GET /v1/channels", s.handleListChannels)
70 apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.handleJoinChannel)
71 apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.handleDeleteChannel)
72 apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.handleChannelMessages)
73 apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.handleSendMessage)
74 apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
75 apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
76 }
77
78 DDED internal/api/settings.go
--- a/internal/api/settings.go
+++ b/internal/api/settings.go
@@ -0,0 +1,25 @@
1
+package api
2
+
3
+import "net/http"
4
+
5
+type settingsResponse struct {
6
+ TLS tlsInfo `json:"tls"`
7
+ Policies Policies `json:"policies"`
8
+:"bot_commands,omitempty"`
9
+}
10
+
11
+type tlsInfo struct {
12
+ Enabled bool `json:"enabled"`
13
+ Domain string `json:"domain,omitempty"`
14
+ AllowInsecure bool `json:"allow_insecure"`
15
+}
16
+
17
+func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
18
+ resp := settingsResponse{
19
+ TLS: tlsInfo{
20
+ Enabled: s.tlsDomain != "",
21
+ Domain: s.tlsDoma // always true in current build Domain: s.tlsDomain,
22
+ AllowInsecure: true,
23
+ },
24
+ }
25
+ if s.pol
--- a/internal/api/settings.go
+++ b/internal/api/settings.go
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/api/settings.go
+++ b/internal/api/settings.go
@@ -0,0 +1,25 @@
1 package api
2
3 import "net/http"
4
5 type settingsResponse struct {
6 TLS tlsInfo `json:"tls"`
7 Policies Policies `json:"policies"`
8 :"bot_commands,omitempty"`
9 }
10
11 type tlsInfo struct {
12 Enabled bool `json:"enabled"`
13 Domain string `json:"domain,omitempty"`
14 AllowInsecure bool `json:"allow_insecure"`
15 }
16
17 func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
18 resp := settingsResponse{
19 TLS: tlsInfo{
20 Enabled: s.tlsDomain != "",
21 Domain: s.tlsDoma // always true in current build Domain: s.tlsDomain,
22 AllowInsecure: true,
23 },
24 }
25 if s.pol
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -3,545 +3,2476 @@
33
<head>
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
66
<title>scuttlebot</title>
77
<style>
8
- * { box-sizing: border-box; margin: 0; padding: 0; }
9
- body { font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace; background: #0d1117; color: #e6edf3; min-height: 100vh; }
10
- header { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 24px; display: flex; align-items: center; gap: 16px; }
11
- header h1 { font-size: 16px; color: #58a6ff; letter-spacing: 0.05em; }
12
- header .tagline { font-size: 12px; color: #8b949e; }
13
- header .spacer { flex: 1; }
14
- .token-badge { font-size: 12px; color: #8b949e; display: flex; align-items: center; gap: 8px; }
15
- .token-badge code { background: #21262d; border: 1px solid #30363d; border-radius: 4px; padding: 2px 8px; color: #a5d6ff; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
16
- button { cursor: pointer; border: 1px solid #30363d; border-radius: 6px; padding: 6px 12px; font-size: 13px; font-family: inherit; background: #21262d; color: #e6edf3; transition: background 0.1s; }
17
- button:hover { background: #30363d; }
18
- button.primary { background: #1f6feb; border-color: #1f6feb; color: #fff; }
19
- button.primary:hover { background: #388bfd; }
20
- button.danger { background: #21262d; border-color: #f85149; color: #f85149; }
21
- button.danger:hover { background: #3d1f1e; }
22
- button.small { padding: 3px 8px; font-size: 12px; }
23
- main { max-width: 960px; margin: 0 auto; padding: 24px; display: flex; flex-direction: column; gap: 24px; }
24
- .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
25
- .card-header { padding: 12px 16px; border-bottom: 1px solid #30363d; display: flex; align-items: center; gap: 8px; }
26
- .card-header h2 { font-size: 14px; color: #e6edf3; font-weight: 600; }
27
- .card-header .badge { background: #1f6feb22; border: 1px solid #1f6feb44; color: #58a6ff; border-radius: 999px; padding: 1px 8px; font-size: 12px; }
28
- .card-body { padding: 16px; }
29
- .status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
30
- .stat { background: #0d1117; border: 1px solid #21262d; border-radius: 6px; padding: 12px 16px; }
31
- .stat .label { font-size: 11px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; }
32
- .stat .value { font-size: 20px; color: #58a6ff; font-weight: 600; }
33
- .stat .sub { font-size: 11px; color: #8b949e; margin-top: 2px; }
34
- .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
35
- .dot.green { background: #3fb950; box-shadow: 0 0 6px #3fb950aa; }
36
- .dot.red { background: #f85149; }
37
- table { width: 100%; border-collapse: collapse; font-size: 13px; }
38
- th { text-align: left; padding: 8px 12px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #8b949e; border-bottom: 1px solid #21262d; }
39
- td { padding: 10px 12px; border-bottom: 1px solid #21262d; color: #e6edf3; vertical-align: top; }
40
- tr:last-child td { border-bottom: none; }
41
- tr:hover td { background: #1c2128; }
42
- .tag { display: inline-block; background: #1f6feb22; border: 1px solid #1f6feb44; color: #79c0ff; border-radius: 4px; padding: 1px 6px; font-size: 11px; margin: 1px; }
43
- .tag.type-operator { background: #db613622; border-color: #db613644; color: #ffa657; }
44
- .tag.type-orchestrator { background: #8957e522; border-color: #8957e544; color: #d2a8ff; }
45
- .tag.type-worker { background: #1f6feb22; border-color: #1f6feb44; color: #79c0ff; }
46
- .tag.type-observer { background: #21262d; border-color: #30363d; color: #8b949e; }
47
- .tag.revoked { background: #f8514922; border-color: #f8514944; color: #ff7b72; }
48
- .actions { display: flex; gap: 6px; flex-wrap: wrap; }
49
- .empty { color: #8b949e; font-size: 13px; text-align: center; padding: 24px; }
50
- .chan-item { padding: 8px 12px; font-size: 13px; cursor: pointer; color: #8b949e; border-bottom: 1px solid #21262d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
51
- .chan-item:hover { background: #1c2128; color: #e6edf3; }
52
- .chan-item.active { background: #1f6feb22; color: #58a6ff; border-left: 2px solid #58a6ff; }
53
- .msg-row { display: flex; gap: 8px; font-size: 13px; line-height: 1.6; padding: 1px 0; }
54
- .msg-time { color: #8b949e; font-size: 11px; flex-shrink: 0; padding-top: 3px; min-width: 44px; }
55
- .msg-nick { color: #58a6ff; font-weight: 600; flex-shrink: 0; min-width: 80px; text-align: right; }
56
- .msg-nick.bridge-nick { color: #3fb950; }
57
- .msg-text { color: #e6edf3; word-break: break-word; }
58
- form { display: flex; flex-direction: column; gap: 14px; }
59
- .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
60
- label { display: block; font-size: 12px; color: #8b949e; margin-bottom: 4px; }
61
- input, select, textarea { width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 8px 10px; font-size: 13px; font-family: inherit; color: #e6edf3; outline: none; transition: border-color 0.1s; }
62
- input:focus, select:focus, textarea:focus { border-color: #58a6ff; }
63
- select option { background: #161b22; }
64
- textarea { resize: vertical; min-height: 60px; }
65
- .hint { font-size: 11px; color: #8b949e; margin-top: 3px; }
66
- .alert { border-radius: 6px; padding: 12px 14px; font-size: 13px; display: flex; gap: 10px; align-items: flex-start; }
67
- .alert.info { background: #1f6feb1a; border: 1px solid #1f6feb44; color: #79c0ff; }
68
- .alert.error { background: #f851491a; border: 1px solid #f8514944; color: #ff7b72; }
69
- .alert.success { background: #3fb9501a; border: 1px solid #3fb95044; color: #7ee787; }
70
- .alert .icon { flex-shrink: 0; font-size: 16px; line-height: 1.3; }
71
- .cred-box { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 12px; font-size: 12px; }
72
- .cred-box .cred-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
73
- .cred-box .cred-row:last-child { margin-bottom: 0; }
74
- .cred-box .cred-key { color: #8b949e; min-width: 90px; }
75
- .cred-box .cred-val { color: #a5d6ff; word-break: break-all; flex: 1; }
76
- .cred-box .copy-btn { flex-shrink: 0; }
77
- .modal-overlay { display: none; position: fixed; inset: 0; background: #0d111788; z-index: 100; align-items: center; justify-content: center; }
78
- .modal-overlay.open { display: flex; }
79
- .modal { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 24px; width: 480px; max-width: 95vw; }
80
- .modal h3 { font-size: 15px; margin-bottom: 16px; }
81
- .modal .btn-row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
8
+* { box-sizing: border-box; margin: 0; padding: 0; }
9
+body { font-family: ui-monospace,'Cascadia Code','Source Code Pro',monospace; background:#0d1117; color:#e6edf3; height:100vh; display:flex; flex-direction:column; overflow:hidden; }
10
+
11
+/* header */
12
+header { background:#161b22; border-bottom:1px solid #30363d; padding:0 20px; display:flex; align-items:stretch; flex-shrink:0; height:48px; }
13
+.brand { display:flex; align-items:center; gap:8px; padding-right:20px; border-right:1px solid #30363d; margin-right:4px; }
14
+.brand h1 { font-size:14px; color:#58a6ff; letter-spacing:.05em; }
15
+.brand span { font-size:11px; color:#8b949e; }
16
+nav { display:flex; align-items:stretch; flex:1; }
17
+.nav-tab { display:flex; align-items:center; gap:6px; padding:0 14px; font-size:13px; color:#8b949e; cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; transition:color .1s; white-space:nowrap; }
18
+.nav-tab:hover { color:#c9d1d9; }
19
+.nav-tab.active { color:#e6edf3; border-bottom-color:#58a6ff; }
20
+.header-right { display:flex; align-items:center; gap:8px; margin-left:auto; font-size:12px; color:#8b949e; }
21
+.header-right code { background:#21262d; border:1px solid #30363d; border-radius:4px; padding:2px 7px; color:#a5d6ff; max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22
+
23
+/* tab panes */
24
+.tab-pane { display:none; flex:1; min-height:0; overflow-y:auto; }
25
+.tab-pane.active { display:flex; flex-direction:column; }
26
+.pane-scroll { flex:1; overflow-y:auto; }
27
+.pane-inner { max-width:1000px; margin:0 auto; padding:24px; display:flex; flex-direction:column; gap:20px; }
28
+
29
+/* cards */
30
+.card { background:#161b22; border:1px solid #30363d; border-radius:8px; overflow:hidden; }
31
+.card-header { padding:12px 16px; border-bottom:1px solid #30363d; display:flex; align-items:center; gap:8px; cursor:pointer; user-select:none; }
32
+.card-header:hover { background:#1c2128; }
33
+.card-header h2 { font-size:14px; font-weight:600; }
34
+.card-header .collapse-icon { font-size:11px; color:#8b949e; margin-left:2px; transition:transform .15s; }
35
+.card.collapsed .card-header { border-bottom:none; }
36
+.card.collapsed .card-body { display:none; }
37
+.card.collapsed .collapse-icon { transform:rotate(-90deg); }
38
+.card-body { padding:16px; }
39
+/* behavior config panel */
40
+.beh-config { background:#0d1117; border-top:1px solid #21262d; padding:14px 16px 14px 42px; display:none; }
41
+.beh-config.open { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:12px; }
42
+.beh-field label { display:block; font-size:11px; color:#8b949e; margin-bottom:3px; }
43
+.beh-field input[type=text],.beh-field input[type=number],.beh-field select { width:100%; }
44
+.beh-field .hint { font-size:10px; color:#6e7681; margin-top:2px; }
45
+.spacer { flex:1; }
46
+.badge { background:#1f6feb22; border:1px solid #1f6feb44; color:#58a6ff; border-radius:999px; padding:1px 8px; font-size:12px; }
47
+
48
+/* status */
49
+.stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; }
50
+.stat { background:#0d1117; border:1px solid #21262d; border-radius:6px; padding:12px 16px; }
51
+.stat .lbl { font-size:11px; color:#8b949e; text-transform:uppercase; letter-spacing:.06em; margin-bottom:4px; }
52
+.stat .val { font-size:20px; color:#58a6ff; font-weight:600; }
53
+.stat .sub { font-size:11px; color:#8b949e; margin-top:2px; }
54
+.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:5px; }
55
+.dot.green { background:#3fb950; box-shadow:0 0 6px #3fb950aa; }
56
+.dot.red { background:#f85149; }
57
+
58
+/* table */
59
+table { width:100%; border-collapse:collapse; font-size:13px; }
60
+th { text-align:left; padding:8px 12px; font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:#8b949e; border-bottom:1px solid #21262d; white-space:nowrap; }
61
+td { padding:9px 12px; border-bottom:1px solid #21262d; color:#e6edf3; vertical-align:middle; }
62
+tr:last-child td { border-bottom:none; }
63
+tr:hover td { background:#1c2128; }
64
+
65
+/* tags */
66
+.tag { display:inline-block; border-radius:4px; padding:1px 6px; font-size:11px; margin:1px; border:1px solid; }
67
+.tag.ch { background:#1f6feb22; border-color:#1f6feb44; color:#79c0ff; }
68
+.tag.perm{ background:#21262d; border-color:#30363d; color:#8b949e; }
69
+.tag.type-operator { background:#db613622; border-color:#db613644; color:#ffa657; }
70
+.tag.type-orchestrator { background:#8957e522; border-color:#8957e544; color:#d2a8ff; }
71
+.tag.type-worker { background:#1f6feb22; border-color:#1f6feb44; color:#79c0ff; }
72
+.tag.type-observer { background:#21262d; border-color:#30363d; color:#8b949e; }
73
+.tag.revoked { background:#f8514922; border-color:#f8514944; color:#ff7b72; }
74
+
75
+/* buttons */
76
+button { cursor:pointer; border:1px solid #30363d; border-radius:6px; padding:6px 12px; font-size:13px; font-family:inherit; background:#21262d; color:#e6edf3; transition:background .1s; }
77
+button:hover:not(:disabled) { background:#30363d; }
78
+button:disabled { opacity:.5; cursor:default; }
79
+button.primary { background:#1f6feb; border-color:#1f6feb; color:#fff; }
80
+button.primary:hover:not(:disabled) { background:#388bfd; }
81
+button.danger { border-color:#f85149; color:#f85149; }
82
+button.danger:hover:not(:disabled) { background:#3d1f1e; }
83
+button.sm { padding:3px 8px; font-size:12px; }
84
+.actions { display:flex; gap:6px; }
85
+
86
+/* forms */
87
+form { display:flex; flex-direction:column; gap:14px; }
88
+.form-row { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
89
+label { display:block; font-size:12px; color:#8b949e; margin-bottom:4px; }
90
+input,select,textarea { width:100%; background:#0d1117; border:1px solid #30363d; border-radius:6px; padding:8px 10px; font-size:13px; font-family:inherit; color:#e6edf3; outline:none; transition:border-color .1s; }
91
+input:focus,select:focus,textarea:focus { border-color:#58a6ff; }
92
+select option { background:#161b22; }
93
+.hint { font-size:11px; color:#8b949e; margin-top:3px; }
94
+
95
+/* alerts */
96
+.alert { border-radius:6px; padding:12px 14px; font-size:13px; display:flex; gap:10px; align-items:flex-start; }
97
+.alert.info { background:#1f6feb1a; border:1px solid #1f6feb44; color:#79c0ff; }
98
+.alert.error { background:#f851491a; border:1px solid #f8514944; color:#ff7b72; }
99
+.alert.success { background:#3fb9501a; border:1px solid #3fb95044; color:#7ee787; }
100
+.alert .icon { flex-shrink:0; font-size:15px; line-height:1.4; }
101
+.cred-box { background:#0d1117; border:1px solid #30363d; border-radius:6px; padding:12px; font-size:12px; margin-top:10px; }
102
+.cred-row { display:flex; align-items:baseline; gap:8px; margin-bottom:6px; }
103
+.cred-row:last-child { margin-bottom:0; }
104
+.cred-key { color:#8b949e; min-width:90px; }
105
+.cred-val { color:#a5d6ff; word-break:break-all; flex:1; }
106
+
107
+/* search/filter bar */
108
+.filter-bar { display:flex; gap:8px; align-items:center; padding:10px 16px; border-bottom:1px solid #30363d; }
109
+.filter-bar input { max-width:280px; padding:5px 10px; }
110
+
111
+/* empty */
112
+.empty { color:#8b949e; font-size:13px; text-align:center; padding:28px; }
113
+
114
+/* drawer */
115
+.drawer-overlay { display:none; position:fixed; inset:0; background:#0d111760; z-index:50; }
116
+.drawer-overlay.open { display:block; }
117
+.drawer { position:fixed; top:48px; right:0; bottom:0; width:480px; max-width:95vw; background:#161b22; border-left:1px solid #30363d; transform:translateX(100%); transition:transform .2s; z-index:51; display:flex; flex-direction:column; }
118
+.drawer.open { transform:translateX(0); }
119
+.drawer-header { padding:16px 20px; border-bottom:1px solid #30363d; display:flex; align-items:center; gap:8px; flex-shrink:0; }
120
+.drawer-header h3 { font-size:14px; font-weight:600; flex:1; }
121
+.drawer-body { flex:1; overflow-y:auto; padding:20px; }
122
+
123
+/* chat */
124
+#pane-chat { flex-direction:row; overflow:hidden; }
125
+.chat-sidebar { width:180px; min-width:0; flex-shrink:0; border-right:1px solid #30363d; display:flex; flex-direction:column; background:#161b22; overflow:hidden; transition:width .15s; }
126
+.chat-sidebar.collapsed { width:28px; }
127
+.chat-sidebar.collapsed .chan-join,.chat-sidebar.collapsed .chan-list { display:none; }
128
+.sidebar-head { padding:8px 12px; font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:#8b949e; border-bottom:1px solid #30363d; flex-shrink:0; display:flex; align-items:center; gap:4px; }
129
+.sidebar-toggle { margin-left:auto; background:none; border:none; color:#8b949e; cursor:pointer; font-size:14px; padding:0 2px; line-height:1; }
130
+.sidebar-toggle:hover { color:#e6edf3; }
131
+.sidebar-resize { width:4px; flex-shrink:0; cursor:col-resize; background:transparent; transition:background .1s; z-index:10; }
132
+.sidebar-resize:hover,.sidebar-resize.dragging { background:#58a6ff55; }
133
+.chan-join { display:flex; gap:5px; padding:7px 9px; border-bottom:1px solid #21262d; flex-shrink:0; }
134
+.chan-join input { flex:1; padding:4px 7px; font-size:12px; }
135
+.chan-list { flex:1; overflow-y:auto; }
136
+.chan-item { padding:7px 14px; font-size:13px; cursor:pointer; color:#8b949e; border-bottom:1px solid #21262d; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
137
+.chan-item:hover { background:#1c2128; color:#e6edf3; }
138
+.chan-item.active { background:#1f6feb22; color:#58a6ff; border-left:2px solid #58a6ff; padding-left:12px; }
139
+.chat-main { flex:1; display:flex; flex-direction:column; min-width:0; }
140
+.chat-topbar { padding:9px 16px; border-bottom:1px solid #30363d; display:flex; align-items:center; gap:10px; flex-shrink:0; background:#161b22; font-size:13px; }
141
+.chat-ch-name { font-weight:600; color:#58a6ff; }
142
+.stream-badge { font-size:11px; color:#8b949e; margin-left:auto; }
143
+.chat-msgs { flex:1; overflow-y:auto; padding:10px 16px; display:flex; flex-direction:column; gap:1px; }
144
+.msg-row { display:flex; gap:10px; font-size:13px; line-height:1.6; padding:1px 0; }
145
+.msg-time { color:#8b949e; font-size:11px; flex-shrink:0; padding-top:3px; min-width:40px; }
146
+.msg-nick { font-weight:600; flex-shrink:0; min-width:90px; text-align:right; }
147
+.msg-grouped .msg-nick { visibility:hidden; }
148
+.msg-grouped .msg-time { color:transparent; }
149
+.msg-grouped:hover .msg-time { color:#8b949e; transition:color .1s; }
150
+.chat-nicklist { width:148px; min-width:0; flex-shrink:0; border-left:1px solid #30363d; display:flex; flex-direction:column; background:#161b22; overflow-y:auto; overflow-x:hidden; transition:width .15s; }
151
+.chat-nicklist.collapsed { width:28px; overflow:hidden; }
152
+.chat-nicklist.collapsed #nicklist-users { display:none; }
153
+.nicklist-head { padding:8px 12px; font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:#8b949e; border-bottom:1px solid #30363d; flex-shrink:0; display:flex; align-items:center; gap:4px; }
154
+.nicklist-nick { padding:5px 12px; font-size:12px; color:#8b949e; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
155
+.nicklist-nick.is-bot { color:#58a6ff; }
156
+.nicklist-nick::before { content:"● "; font-size:8px; vertical-align:middle; }
157
+.chat-new-banner { align-self:center; margin:4px auto 0; background:#1f6feb; color:#fff; border-radius:20px; padding:3px 14px; font-size:12px; cursor:pointer; display:inline-block; white-space:nowrap; }
158
+/* login screen */
159
+.login-screen { position:fixed; inset:0; background:#0d1117; z-index:200; display:flex; align-items:center; justify-content:center; flex-direction:column; }
160
+.login-box { width:340px; }
161
+.login-brand { text-align:center; margin-bottom:24px; }
162
+.login-brand h1 { font-size:22px; color:#58a6ff; letter-spacing:.05em; }
163
+.login-brand p { color:#8b949e; font-size:13px; margin-top:6px; }
164
+/* unread badge on chat tab */
165
+.nav-tab[data-unread]::after { content:attr(data-unread); background:#f85149; color:#fff; border-radius:999px; padding:1px 5px; font-size:10px; margin-left:5px; vertical-align:middle; }
166
+.msg-text { color:#e6edf3; word-break:break-word; }
167
+.chat-input { padding:9px 13px; border-top:1px solid #30363d; display:flex; gap:7px; flex-shrink:0; background:#161b22; }
168
+
169
+/* channels tab */
170
+.chan-card { display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; }
171
+.chan-card:last-child { border-bottom:none; }
172
+.chan-card:hover { background:#1c2128; }
173
+.chan-name { font-size:14px; font-weight:600; color:#58a6ff; }
174
+.chan-meta { font-size:12px; color:#8b949e; }
175
+
176
+/* settings */
177
+.setting-row { display:flex; align-items:center; gap:12px; padding:14px 0; border-bottom:1px solid #21262d; }
178
+.setting-row:last-child { border-bottom:none; }
179
+.setting-label { min-width:160px; font-size:13px; color:#c9d1d9; }
180
+.setting-desc { font-size:12px; color:#8b949e; flex:1; }
181
+.setting-val { font-size:12px; font-family:inherit; color:#a5d6ff; background:#0d1117; border:1px solid #30363d; border-radius:4px; padding:4px 10px; }
182
+
183
+/* modal */
184
+.modal-overlay { display:none; position:fixed; inset:0; background:#0d111788; z-index:100; align-items:center; justify-content:center; }
185
+.modal-overlay.open { display:flex; }
186
+.modal { background:#161b22; border:1px solid #30363d; border-radius:10px; padding:24px; width:480px; max-width:95vw; }
187
+.modal h3 { font-size:15px; margin-bottom:16px; }
188
+.modal .btn-row { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; }
189
+/* charts */
190
+.charts-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; }
191
+.chart-card { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:14px 16px; }
192
+.chart-label { font-size:11px; color:#8b949e; text-transform:uppercase; letter-spacing:.06em; margin-bottom:10px; display:flex; align-items:center; gap:6px; }
193
+.chart-label .val { margin-left:auto; font-size:13px; color:#e6edf3; font-weight:600; letter-spacing:0; text-transform:none; }
194
+canvas { display:block; width:100% !important; }
195
+.bridge-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
82196
</style>
197
+<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
83198
</head>
84199
<body>
200
+
201
+<!-- login screen — shown when unauthenticated -->
202
+<div class="login-screen" id="login-screen" style="display:none">
203
+ <div class="login-box">
204
+ <div class="login-brand">
205
+ <h1>⬡ scuttlebot</h1>
206
+ <p>agent coordination backplane</p>
207
+ </div>
208
+ <div class="card">
209
+ <div class="card-body">
210
+ <form id="login-form" onsubmit="handleLogin(event)" style="gap:12px">
211
+ <div>
212
+ <label>username</label>
213
+ <input type="text" id="login-username" autocomplete="username">
214
+ </div>
215
+ <div>
216
+ <label>password</label>
217
+ <input type="password" id="login-password" autocomplete="current-password">
218
+ </div>
219
+ <div id="login-error" style="display:none"></div>
220
+ <button type="submit" class="primary" style="width:100%;margin-top:4px" id="login-btn">sign in</button>
221
+ </form>
222
+ <details style="margin-top:16px;border-top:1px solid #21262d;padding-top:14px">
223
+ <summary style="font-size:12px;color:#8b949e;cursor:pointer;user-select:none">use API token instead</summary>
224
+ <div style="display:flex;gap:8px;margin-top:10px">
225
+ <input type="text" id="token-login-input" placeholder="paste API token" style="flex:1;font-size:12px" autocomplete="off" spellcheck="false">
226
+ <button class="sm primary" onclick="saveTokenLogin()">apply</button>
227
+ </div>
228
+ <div class="hint" style="margin-top:4px">Token is printed to stderr at startup.</div>
229
+ </details>
230
+ </div>
231
+ </div>
232
+ <p style="text-align:center;font-size:11px;color:#6e7681;margin-top:14px">
233
+ <a href="https://scuttlebot.dev" target="_blank" rel="noopener" style="color:#58a6ff;text-decoration:none">ScuttleBot</a> · Powered by <a href="https://weareconflict.com" target="_blank" rel="noopener" style="color:#58a6ff;text-decoration:none">CONFLICT</a>
234
+ </p>
235
+ </div>
236
+</div>
85237
86238
<header>
87
- <h1>⬡ scuttlebot</h1>
88
- <span class="tagline">agent coordination backplane</span>
89
- <div class="spacer"></div>
90
- <div class="token-badge">
91
- <span>token:</span>
92
- <code id="token-display">not set</code>
93
- <button class="small" onclick="openTokenModal()">set</button>
239
+ <div class="brand">
240
+ <h1>⬡ scuttlebot</h1>
241
+ <span>agent coordination backplane</span>
242
+ </div>
243
+ <nav>
244
+ <div class="nav-tab active" id="tab-status" onclick="switchTab('status')">◈ status</div>
245
+ <div class="nav-tab" id="tab-users" onclick="switchTab('users')">◉ users</div>
246
+ <div class="nav-tab" id="tab-agents" onclick="switchTab('agents')">◎ agents</div>
247
+ <div class="nav-tab" id="tab-channels" onclick="switchTab('channels')">◎ channels</div>
248
+ <div class="nav-tab" id="tab-chat" onclick="switchTab('chat')">◌ chat</div>
249
+ <div class="nav-tab" id="tab-ai" onclick="switchTab('ai')">✦ ai</div>
250
+ <div class="nav-tab" id="tab-settings" onclick="switchTab('settings')">⚙ settings</div>
251
+ </nav>
252
+ <div class="header-right">
253
+ <span id="header-user-display" style="font-size:12px;color:#8b949e"></span>
254
+ <button class="sm" onclick="logout()">sign out</button>
94255
</div>
95256
</header>
96257
97
-<main>
98
- <div id="no-token-banner" class="alert info" style="display:none">
99
- <span class="icon">ℹ</span>
100
- <span>Paste your API token to continue. It was printed to stderr when scuttlebot started:<br><code style="color:#a5d6ff">level=INFO msg="api token" token=...</code></span>
101
- </div>
102
-
103
- <!-- Status -->
104
- <div class="card">
105
- <div class="card-header">
106
- <span class="dot green" id="status-dot"></span>
107
- <h2>status</h2>
108
- </div>
109
- <div class="card-body">
110
- <div class="status-grid" id="status-grid">
111
- <div class="stat"><div class="label">state</div><div class="value" id="stat-status">—</div></div>
112
- <div class="stat"><div class="label">uptime</div><div class="value" id="stat-uptime">—</div></div>
113
- <div class="stat"><div class="label">agents</div><div class="value" id="stat-agents">—</div></div>
114
- <div class="stat"><div class="label">started</div><div class="value" style="font-size:13px" id="stat-started">—</div><div class="sub" id="stat-started-rel"></div></div>
115
- </div>
116
- <div id="status-error" style="margin-top:12px;display:none"></div>
117
- </div>
118
- </div>
119
-
120
- <!-- Agents -->
121
- <div class="card">
122
- <div class="card-header">
123
- <h2>agents</h2>
124
- <span class="badge" id="agent-count">0</span>
125
- <div class="spacer"></div>
126
- <button class="small" onclick="loadAgents()">↻ refresh</button>
127
- </div>
128
- <div class="card-body" style="padding:0">
129
- <div id="agents-container"></div>
130
- </div>
131
- </div>
132
-
133
- <!-- Register -->
134
- <div class="card">
135
- <div class="card-header"><h2>register agent</h2></div>
136
- <div class="card-body">
137
- <form id="register-form" onsubmit="handleRegister(event)">
138
- <div class="form-row">
139
- <div>
140
- <label>nick *</label>
141
- <input type="text" id="reg-nick" placeholder="my-agent-01" required>
142
- </div>
143
- <div>
144
- <label>type</label>
145
- <select id="reg-type">
146
- <option value="operator">operator — human, +o + full permissions</option>
147
- <option value="worker">worker — gets +v in channels</option>
148
- <option value="orchestrator">orchestrator — gets +o in channels</option>
149
- <option value="observer">observer — read-only</option>
150
- </select>
151
- </div>
152
- </div>
153
- <div>
154
- <label>channels</label>
155
- <input type="text" id="reg-channels" placeholder="#fleet, #ops, #project.foo">
156
- <div class="hint">comma-separated; must start with #</div>
157
- </div>
158
- <div>
159
- <label>permissions</label>
160
- <input type="text" id="reg-permissions" placeholder="task.create, task.update">
161
- <div class="hint">comma-separated message types this agent is allowed to send</div>
162
- </div>
163
- <div id="register-result" style="display:none"></div>
164
- <div style="display:flex;justify-content:flex-end">
165
- <button type="submit" class="primary">register</button>
166
- </div>
167
- </form>
168
- </div>
169
- </div>
170
-
171
- <!-- Chat -->
172
- <div class="card" id="chat-card" style="display:none">
173
- <div class="card-header">
174
- <h2>chat</h2>
175
- <span class="badge" id="chat-channel-badge" style="display:none"></span>
176
- <div class="spacer"></div>
177
- <span id="chat-stream-status" style="font-size:11px;color:#8b949e"></span>
178
- </div>
179
- <div style="display:flex;height:440px">
180
- <div id="chat-channel-list" style="width:140px;border-right:1px solid #30363d;overflow-y:auto;flex-shrink:0;padding:8px 0;display:flex;flex-direction:column">
181
- <div style="padding:6px 12px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#8b949e">channels</div>
182
- <div style="padding:6px 8px;border-bottom:1px solid #21262d;display:flex;gap:4px">
183
- <input type="text" id="join-channel-input" placeholder="#channel" style="flex:1;font-size:11px;padding:3px 6px" autocomplete="off">
184
- <button class="small" onclick="joinChannel()" style="padding:3px 6px;font-size:11px">+</button>
185
- </div>
186
- </div>
187
- <div style="display:flex;flex-direction:column;flex:1;min-width:0">
188
- <div id="chat-messages" style="flex:1;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:2px">
189
- <div class="empty" id="chat-placeholder">select a channel to view messages</div>
190
- </div>
191
- <div style="padding:10px 14px;border-top:1px solid #30363d;display:flex;gap:8px;align-items:center">
192
- <input type="text" id="chat-nick-input" placeholder="your nick" style="width:110px;flex-shrink:0" autocomplete="off">
193
- <input type="text" id="chat-text-input" placeholder="type a message…" style="flex:1" autocomplete="off">
194
- <button class="primary small" id="chat-send-btn" onclick="sendChatMessage()">send</button>
195
- </div>
196
- </div>
197
- </div>
198
- </div>
199
-</main>
200
-
201
-<!-- Token modal -->
202
-<div class="modal-overlay" id="token-modal">
203
- <div class="modal">
204
- <h3>set API token</h3>
205
- <p style="font-size:13px;color:#8b949e;margin-bottom:14px">The token is printed to stderr when scuttlebot starts:<br><code style="color:#a5d6ff">level=INFO msg="api token" token=&lt;value&gt;</code></p>
206
- <label>token</label>
207
- <input type="text" id="token-input" placeholder="paste token here" autocomplete="off" spellcheck="false">
208
- <div class="btn-row">
209
- <button onclick="closeTokenModal()">cancel</button>
210
- <button class="primary" onclick="saveToken()">save</button>
211
- </div>
212
- </div>
213
-</div>
214
-
215
-<script>
216
-// --- token management ---
217
-function getToken() { return localStorage.getItem('scuttlebot_token') || ''; }
218
-function setToken(t) {
219
- localStorage.setItem('scuttlebot_token', t);
220
- updateTokenDisplay();
221
-}
222
-function updateTokenDisplay() {
223
- const t = getToken();
224
- const el = document.getElementById('token-display');
225
- const banner = document.getElementById('no-token-banner');
226
- if (t) {
227
- el.textContent = t.slice(0, 8) + '…' + t.slice(-4);
228
- banner.style.display = 'none';
229
- } else {
230
- el.textContent = 'not set';
231
- banner.style.display = 'flex';
232
- }
233
-}
234
-function openTokenModal() {
235
- document.getElementById('token-input').value = getToken();
236
- document.getElementById('token-modal').classList.add('open');
237
- setTimeout(() => document.getElementById('token-input').focus(), 50);
238
-}
239
-function closeTokenModal() { document.getElementById('token-modal').classList.remove('open'); }
240
-function saveToken() {
241
- const v = document.getElementById('token-input').value.trim();
242
- if (v) { setToken(v); closeTokenModal(); loadAll(); }
243
-}
244
-document.getElementById('token-modal').addEventListener('click', function(e) {
245
- if (e.target === this) closeTokenModal();
246
-});
247
-document.addEventListener('keydown', function(e) {
248
- if (e.key === 'Escape') closeTokenModal();
249
-});
250
-
251
-// --- API ---
252
-async function api(method, path, body) {
253
- const token = getToken();
254
- const opts = { method, headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' } };
255
- if (body !== undefined) opts.body = JSON.stringify(body);
256
- const res = await fetch(path, opts);
258
+<!-- STATUS -->
259
+<div class="tab-pane active" id="pane-status">
260
+ <div class="pane-inner">
261
+ <div id="no-token-banner" class="alert info" style="display:none">
262
+ <span class="icon">ℹ</span>
263
+ <span>Paste your API token to continue — printed to stderr at startup: <code style="color:#a5d6ff">level=INFO msg="api token" token=…</code></span>
264
+ </div>
265
+
266
+ <!-- server status card -->
267
+ <div class="card" id="card-status">
268
+ <div class="card-header" onclick="toggleCard('card-status',event)">
269
+ <span class="dot green" id="status-dot"></span><h2>server status</h2>
270
+ <span class="collapse-icon">▾</span>
271
+ <div class="spacer"></div>
272
+ <span style="font-size:11px;color:#8b949e" id="metrics-updated"></span>
273
+ <button class="sm" onclick="loadStatus()" title="refresh">↻</button>
274
+ </div>
275
+ <div class="card-body">
276
+ <div class="stat-grid">
277
+ <div class="stat"><div class="lbl">state</div><div class="val" id="stat-status">—</div></div>
278
+ <div class="stat"><div class="lbl">uptime</div><div class="val" id="stat-uptime">—</div></div>
279
+ <div class="stat"><div class="lbl">agents</div><div class="val" id="stat-agents">—</div></div>
280
+ <div class="stat"><div class="lbl">started</div><div class="val" style="font-size:13px" id="stat-started">—</div><div class="sub" id="stat-started-rel"></div></div>
281
+ </div>
282
+ <div id="status-error" style="margin-top:12px;display:none"></div>
283
+ </div>
284
+ </div>
285
+
286
+ <!-- runtime -->
287
+ <div class="card" id="card-runtime">
288
+ <div class="card-header" onclick="toggleCard('card-runtime',event)"><h2>runtime</h2><span class="collapse-icon">▾</span></div>
289
+ <div class="card-body" style="display:flex;flex-direction:column;gap:16px">
290
+ <div class="stat-grid">
291
+ <div class="stat"><div class="lbl">goroutines</div><div class="val" id="stat-goroutines">—</div></div>
292
+ <div class="stat"><div class="lbl">heap alloc</div><div class="val" id="stat-heap">—</div></div>
293
+ <div class="stat"><div class="lbl">heap sys</div><div class="val" id="stat-heapsys">—</div></div>
294
+ <div class="stat"><div class="lbl">GC runs</div><div class="val" id="stat-gc">—</div></div>
295
+ </div>
296
+ <div class="charts-grid">
297
+ <div class="chart-card">
298
+ <div class="chart-label">heap alloc <span class="val" id="chart-heap-val">—</span></div>
299
+ <canvas id="chart-heap" height="80"></canvas>
300
+ </div>
301
+ <div class="chart-card">
302
+ <div class="chart-label">goroutines <span class="val" id="chart-goroutines-val">—</span></div>
303
+ <canvas id="chart-goroutines" height="80"></canvas>
304
+ </div>
305
+ <div class="chart-card">
306
+ <div class="chart-label">messages total <span class="val" id="chart-messages-val">—</span></div>
307
+ <canvas id="chart-messages" height="80"></canvas>
308
+ </div>
309
+ </div>
310
+ </div>
311
+ </div>
312
+
313
+ <!-- bridge -->
314
+ <div class="card" id="bridge-card" style="display:none">
315
+ <div class="card-header" onclick="toggleCard('bridge-card',event)"><h2>bridge</h2><span class="collapse-icon">▾</span></div>
316
+ <div class="card-body">
317
+ <div class="bridge-grid">
318
+ <div class="stat"><div class="lbl">channels</div><div class="val" id="stat-bridge-channels">—</div></div>
319
+ <div class="stat"><div class="lbl">messages total</div><div class="val" id="stat-bridge-msgs">—</div></div>
320
+ <div class="stat"><div class="lbl">active streams</div><div class="val" id="stat-bridge-subs">—</div></div>
321
+ </div>
322
+ </div>
323
+ </div>
324
+
325
+ <!-- registry -->
326
+ <div class="card" id="card-registry">
327
+ <div class="card-header" onclick="toggleCard('card-registry',event)"><h2>registry</h2><span class="collapse-icon">▾</span></div>
328
+ <div class="card-body">
329
+ <div class="stat-grid">
330
+ <div class="stat"><div class="lbl">total</div><div class="val" id="stat-reg-total">—</div></div>
331
+ <div class="stat"><div class="lbl">active</div><div class="val" id="stat-reg-active">—</div></div>
332
+ <div class="stat"><div class="lbl">revoked</div><div class="val" id="stat-reg-revoked">—</div></div>
333
+ </div>
334
+ </div>
335
+ </div>
336
+
337
+ </div>
338
+</div>
339
+
340
+<!-- USERS -->
341
+<div class="tab-pane" id="pane-users">
342
+ <div class="filter-bar">
343
+ <input type="text" id="user-search" placeholder="search by nick or channel…" oninput="renderUsersTable()" style="max-width:320px">
344
+ <div class="spacer"></div>
345
+ <span class="badge" id="user-count" style="margin-right:4px">0</span>
346
+ <button class="sm" onclick="loadAgents()">↻ refresh</button>
347
+ <button class="sm" onclick="openAdoptDrawer()">adopt existing user</button>
348
+ <button class="sm primary" onclick="openRegisterUserDrawer()">+ register user</button>
349
+ </div>
350
+ <div style="flex:1;overflow-y:auto">
351
+ <div id="users-container"></div>
352
+ </div>
353
+</div>
354
+
355
+<!-- AGENTS -->
356
+<div class="tab-pane" id="pane-agents">
357
+ <div class="filter-bar">
358
+ <input type="text" id="agent-search" placeholder="search by nick, type, channel…" oninput="renderAgentTable()" style="max-width:320px">
359
+ <div class="spacer"></div>
360
+ <span class="badge" id="agent-count" style="margin-right:4px">0</span>
361
+ <button class="sm" onclick="loadAgents()">↻ refresh</button>
362
+ <button class="sm primary" onclick="openDrawer()">+ register agent</button>
363
+ </div>
364
+ <div style="flex:1;overflow-y:auto">
365
+ <div id="agents-container"></div>
366
+ </div>
367
+</div>
368
+
369
+<!-- CHANNELS -->
370
+<div class="tab-pane" id="pane-channels">
371
+ <div class="pane-inner">
372
+ <div class="card">
373
+ <div class="card-header">
374
+ <h2>channels</h2>
375
+ <span class="badge" id="chan-count">0</span>
376
+ <div class="spacer"></div>
377
+ <div style="display:flex;gap:6px;align-items:center">
378
+ <input type="text" id="quick-join-input" placeholder="#channel" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
379
+ <button class="sm primary" onclick="quickJoin()">join</button>
380
+ </div>
381
+ </div>
382
+ <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
383
+ </div>
384
+ </div>
385
+</div>
386
+
387
+<!-- CHAT -->
388
+<div class="tab-pane" id="pane-chat">
389
+ <div class="chat-sidebar" id="chat-sidebar-left">
390
+ <div class="sidebar-head">
391
+ <span id="sidebar-left-label">channels</span>
392
+ <button class="sidebar-toggle" id="sidebar-left-toggle" title="collapse" onclick="toggleSidebar('left')">‹</button>
393
+ </div>
394
+ <div class="chan-join">
395
+ <input type="text" id="join-channel-input" placeholder="#general" autocomplete="off">
396
+ <button class="sm" onclick="joinChannel()">+</button>
397
+ </div>
398
+ <div class="chan-list" id="chan-list"></div>
399
+ </div>
400
+ <div class="sidebar-resize" id="resize-left" title="drag to resize"></div>
401
+ <div class="chat-main">
402
+ <div class="chat-topbar">
403
+ <span class="chat-ch-name" id="chat-ch-name">select a channel</span>
404
+ <div class="spacer"></div>
405
+ <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
406
+ <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
407
+ <option value="">— pick a user —</option>
408
+ </select>
409
+ <span class="stream-badge" id="chat-stream-status" style="margin-left:8px"></span>
410
+ </div>
411
+ <div class="chat-msgs" id="chat-msgs">
412
+ <div class="empty" id="chat-placeholder">join a channel to start chatting</div>
413
+ </div>
414
+ <div class="chat-input">
415
+ <input type="text" id="chat-text-input" placeholder="type a message…" style="flex:1" autocomplete="off">
416
+ <button class="primary sm" id="chat-send-btn" onclick="sendMsg()">send</button>
417
+ </div>
418
+ </div>
419
+ <div class="sidebar-resize" id="resize-right" title="drag to resize"></div>
420
+ <div class="chat-nicklist" id="chat-nicklist">
421
+ <div class="nicklist-head">
422
+ <button class="sidebar-toggle" id="sidebar-right-toggle" title="collapse" onclick="toggleSidebar('right')">›</button>
423
+ <span id="sidebar-right-label">users</span>
424
+ </div>
425
+ <div id="nicklist-users"></div>
426
+ </div>
427
+</div>
428
+
429
+<!-- SETTINGS -->
430
+<div class="tab-pane" id="pane-settings">
431
+ <div class="pane-inner">
432
+
433
+ <!-- connection -->
434
+ <div class="card" id="card-connection">
435
+ <div class="card-header" style="cursor:default"><h2>connection</h2></div>
436
+ <div class="card-body">
437
+ <div class="setting-row">
438
+ <div class="setting-label">signed in as</div>
439
+ <div class="setting-desc">Current admin session.</div>
440
+ <code class="setting-val" id="settings-username-display">—</code>
441
+ <button class="sm danger" onclick="logout()">sign out</button>
442
+ </div>
443
+ <div class="setting-row">
444
+ <div class="setting-label">API endpoint</div>
445
+ <div class="setting-desc">REST API base URL.</div>
446
+ <code class="setting-val" id="settings-api-url"></code>
447
+ </div>
448
+ <div class="setting-row">
449
+ <div class="setting-label">IRC network</div>
450
+ <div class="setting-desc">Ergo IRC server address.</div>
451
+ <code class="setting-val">localhost:6667</code>
452
+ </div>
453
+ <div class="setting-row">
454
+ <div class="setting-label">MCP server</div>
455
+ <div class="setting-desc">Model Context Protocol endpoint.</div>
456
+ <code class="setting-val">localhost:8081</code>
457
+ </div>
458
+ </div>
459
+ </div>
460
+
461
+ <!-- admin accounts -->
462
+ <div class="card" id="card-admins">
463
+ <div class="card-header" onclick="toggleCard('card-admins',event)"><h2>admin accounts</h2><span class="collapse-icon">▾</span></div>
464
+ <div id="admins-list-container"></div>
465
+ <div class="card-body" style="border-top:1px solid #21262d">
466
+ <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Add an admin account. Admins sign in at the login screen with username + password.</p>
467
+ <form id="add-admin-form" onsubmit="addAdmin(event)" style="flex-direction:row;align-items:flex-end;gap:10px;flex-wrap:wrap">
468
+ <div style="flex:1;min-width:130px"><label>username</label><input type="text" id="new-admin-username" autocomplete="off"></div>
469
+ <div style="flex:1;min-width:130px"><label>password</label><input type="password" id="new-admin-password" autocomplete="new-password"></div>
470
+ <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button>
471
+ </form>
472
+ <div id="add-admin-result" style="margin-top:10px"></div>
473
+ </div>
474
+ </div>
475
+
476
+ <!-- tls -->
477
+ <div class="card" id="card-tls">
478
+ <div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div>
479
+ <div class="card-body">
480
+ <div id="tls-status-rows"></div>
481
+ <div class="alert info" style="margin-top:12px;font-size:12px">
482
+ <span class="icon">ℹ</span>
483
+ <span>TLS is configured in <code style="color:#a5d6ff">scuttlebot.yaml</code> under <code style="color:#a5d6ff">tls:</code>.
484
+ Set <code style="color:#a5d6ff">domain:</code> to enable Let's Encrypt. <code style="color:#a5d6ff">allow_insecure: true</code> keeps HTTP running alongside HTTPS.</span>
485
+ </div>
486
+ </div>
487
+ </div>
488
+
489
+ <!-- system behaviors -->
490
+ <div class="card" id="card-behaviors">
491
+ <div class="card-header" onclick="toggleCard('card-behaviors',event)">
492
+ <h2>system behaviors</h2><span class="collapse-icon">▾</span>
493
+ <div class="spacer"></div>
494
+ <button class="sm primary" onclick="savePolicies()">save</button>
495
+ </div>
496
+ <div class="card-body" style="padding:0">
497
+ <div id="behaviors-list"></div>
498
+ </div>
499
+ </div>
500
+
501
+ <!-- agent policy -->
502
+ <div class="card" id="card-agentpolicy">
503
+ <div class="card-header" onclick="toggleCard('card-agentpolicy',event)"><h2>agent policy</h2><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="savePolicies()">save</button></div>
504
+ <div class="card-body">
505
+ <div class="setting-row">
506
+ <div class="setting-label">require check-in</div>
507
+ <div class="setting-desc">Agents must join a coordination channel before others.</div>
508
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
509
+ <input type="checkbox" id="policy-checkin-enabled" onchange="toggleCheckinChannel()">
510
+ <span style="font-size:12px">enabled</span>
511
+ </label>
512
+ </div>
513
+ <div class="setting-row" id="policy-checkin-row" style="display:none">
514
+ <div class="setting-label">check-in channel</div>
515
+ <div class="setting-desc">Channel all agents must join first.</div>
516
+ <input type="text" id="policy-checkin-channel" placeholder="#coordination" style="width:180px;padding:4px 8px;font-size:12px">
517
+ </div>
518
+ <div class="setting-row">
519
+ <div class="setting-label">required channels</div>
520
+ <div class="setting-desc">Channels every agent is added to automatically.</div>
521
+ <input type="text" id="policy-required-channels" placeholder="#fleet, #alerts" style="width:220px;padding:4px 8px;font-size:12px">
522
+ </div>
523
+ </div>
524
+ </div>
525
+
526
+ <!-- bridge -->
527
+ <div class="card" id="card-bridgepolicy">
528
+ <div class="card-header" onclick="toggleCard('card-bridgepolicy',event)"><h2>web bridge</h2><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="savePolicies()">save</button></div>
529
+ <div class="card-body">
530
+ <div class="setting-row">
531
+ <div class="setting-label">web user TTL</div>
532
+ <div class="setting-desc">How long HTTP-posted nicks stay visible in the channel user list after their last message.</div>
533
+ <div style="display:flex;align-items:center;gap:6px">
534
+ <input type="number" id="policy-bridge-web-user-ttl" placeholder="5" min="1" style="width:80px;padding:4px 8px;font-size:12px">
535
+ <span style="font-size:12px;color:#8b949e">minutes</span>
536
+ </div>
537
+ </div>
538
+ </div>
539
+ </div>
540
+
541
+ <!-- logging -->
542
+ <div class="card" id="card-logging">
543
+ <div class="card-header" onclick="toggleCard('card-logging',event)"><h2>message logging</h2><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="savePolicies()">save</button></div>
544
+ <div class="card-body">
545
+ <div class="setting-row">
546
+ <div class="setting-label">enabled</div>
547
+ <div class="setting-desc">Write every channel message to disk.</div>
548
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
549
+ <input type="checkbox" id="policy-logging-enabled" onchange="toggleLogOptions()">
550
+ <span style="font-size:12px">enabled</span>
551
+ </label>
552
+ </div>
553
+ <div id="policy-log-options" style="display:none">
554
+ <div class="setting-row">
555
+ <div class="setting-label">log directory</div>
556
+ <div class="setting-desc">Directory to write log files into.</div>
557
+ <input type="text" id="policy-log-dir" placeholder="./data/logs" style="width:280px;padding:4px 8px;font-size:12px">
558
+ </div>
559
+ <div class="setting-row">
560
+ <div class="setting-label">format</div>
561
+ <div class="setting-desc">Output format for log lines.</div>
562
+ <select id="policy-log-format" style="width:160px;padding:4px 8px;font-size:12px">
563
+ <option value="jsonl">JSON Lines (.jsonl)</option>
564
+ <option value="csv">CSV (.csv)</option>
565
+ <option value="text">Plain text (.log)</option>
566
+ </select>
567
+ </div>
568
+ <div class="setting-row">
569
+ <div class="setting-label">rotation</div>
570
+ <div class="setting-desc">When to start a new log file.</div>
571
+ <select id="policy-log-rotation" style="width:160px;padding:4px 8px;font-size:12px" onchange="toggleRotationOptions()">
572
+ <option value="none">None</option>
573
+ <option value="daily">Daily</option>
574
+ <option value="weekly">Weekly</option>
575
+ <option value="monthly">Monthly</option>
576
+ <option value="yearly">Yearly</option>
577
+ <option value="size">By size</option>
578
+ </select>
579
+ </div>
580
+ <div class="setting-row" id="policy-log-size-row" style="display:none">
581
+ <div class="setting-label">max file size</div>
582
+ <div class="setting-desc">Rotate when file reaches this size.</div>
583
+ <div style="display:flex;align-items:center;gap:6px">
584
+ <input type="number" id="policy-log-max-size" placeholder="100" min="1" style="width:80px;padding:4px 8px;font-size:12px">
585
+ <span style="font-size:12px;color:#8b949e">MiB</span>
586
+ </div>
587
+ </div>
588
+ <div class="setting-row">
589
+ <div class="setting-label">per-channel files</div>
590
+ <div class="setting-desc">Write a separate file for each channel.</div>
591
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
592
+ <input type="checkbox" id="policy-log-per-channel">
593
+ <span style="font-size:12px">enabled</span>
594
+ </label>
595
+ </div>
596
+ <div class="setting-row">
597
+ <div class="setting-label">max age</div>
598
+ <div class="setting-desc">Delete rotated files older than N days. 0 = keep forever.</div>
599
+ <div style="display:flex;align-items:center;gap:6px">
600
+ <input type="number" id="policy-log-max-age" placeholder="0" min="0" style="width:80px;padding:4px 8px;font-size:12px">
601
+ <span style="font-size:12px;color:#8b949e">days</span>
602
+ </div>
603
+ </div>
604
+ </div>
605
+ </div>
606
+ </div>
607
+
608
+ <div id="policies-save-result" style="display:none"></div>
609
+
610
+ <!-- about -->
611
+ <div class="card">
612
+ <div class="card-header" style="cursor:default"><h2>about</h2></div>
613
+ <div class="card-body" style="font-size:13px;color:#8b949e;line-height:1.8">
614
+ <p><strong style="color:#e6edf3">ScuttleBot</strong> — agent coordination backplane over IRC.</p>
615
+ <p>Agents register, receive SASL credentials, and coordinate in IRC channels.</p>
616
+ <p>Everything is human observable: all activity is visible in the IRC channel log.</p>
617
+ <p style="margin-top:12px;font-size:11px;color:#6e7681">
618
+ Copyright &copy; 2026 CONFLICT LLC. All rights reserved.<br>
619
+ <a href="https://scuttlebot.dev" target="_blank" rel="noopener" style="color:#58a6ff;text-decoration:none">ScuttleBot</a> — Powered by <a href="https://weareconflict.com" target="_blank" rel="noopener" style="color:#58a6ff;text-decoration:none">CONFLICT</a>
620
+ </p>
621
+ </div>
622
+ </div>
623
+
624
+ </div>
625
+</div>
626
+
627
+<!-- AI -->
628
+<div class="tab-pane" id="pane-ai">
629
+ <div class="pane-inner">
630
+
631
+ <!-- LLM backends -->
632
+ <div class="card" id="card-ai-backends">
633
+ <div class="card-header" style="cursor:default">
634
+ <h2>LLM backends</h2>
635
+ <div class="spacer"></div>
636
+ <button class="sm" onclick="loadAI()">↺ refresh</button>
637
+ <button class="sm primary" onclick="openAddBackend()">+ add backend</button>
638
+ </div>
639
+ <div class="card-body" style="padding:0">
640
+ <div id="ai-backends-list" style="padding:16px">
641
+ <div class="empty-state">loading…</div>
642
+ </div>
643
+ </div>
644
+ </div>
645
+
646
+ <!-- add/edit backend form (hidden until opened) -->
647
+ <div class="card" id="card-ai-form" style="display:none">
648
+ <div class="card-header" style="cursor:default">
649
+ <h2 id="ai-form-title">add backend</h2>
650
+ <div class="spacer"></div>
651
+ <button class="sm" onclick="closeBackendForm()">✕ cancel</button>
652
+ </div>
653
+ <div class="card-body">
654
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
655
+ <div>
656
+ <label>name *</label>
657
+ <input type="text" id="bf-name" placeholder="openai-main" autocomplete="off">
658
+ <div class="hint">unique identifier — used in oracle's backend field</div>
659
+ </div>
660
+ <div>
661
+ <label>backend type *</label>
662
+ <select id="bf-backend" onchange="onBackendTypeChange()">
663
+ <option value="">— select type —</option>
664
+ <optgroup label="Native APIs">
665
+ <option value="anthropic">anthropic</option>
666
+ <option value="gemini">gemini</option>
667
+ <option value="bedrock">bedrock</option>
668
+ <option value="ollama">ollama</option>
669
+ </optgroup>
670
+ <optgroup label="OpenAI-compatible">
671
+ <option value="openai">openai</option>
672
+ <option value="openrouter">openrouter</option>
673
+ <option value="together">together</option>
674
+ <option value="groq">groq</option>
675
+ <option value="fireworks">fireworks</option>
676
+ <option value="mistral">mistral</option>
677
+ <option value="ai21">ai21</option>
678
+ <option value="huggingface">huggingface</option>
679
+ <option value="deepseek">deepseek</option>
680
+ <option value="cerebras">cerebras</option>
681
+ <option value="xai">xai</option>
682
+ <option value="litellm">litellm (local)</option>
683
+ <option value="lmstudio">lm studio (local)</option>
684
+ <option value="jan">jan (local)</option>
685
+ <option value="localai">localai (local)</option>
686
+ <option value="vllm">vllm (local)</option>
687
+ <option value="anythingllm">anythingllm (local)</option>
688
+ </optgroup>
689
+ </select>
690
+ </div>
691
+
692
+ <!-- shown for non-bedrock backends -->
693
+ <div id="bf-apikey-row">
694
+ <label>API key</label>
695
+ <input type="password" id="bf-apikey" placeholder="sk-…" autocomplete="new-password">
696
+ <div class="hint" id="bf-apikey-hint">Leave blank to use env var or instance role</div>
697
+ </div>
698
+
699
+ <!-- shown for ollama and OpenAI-compat local backends -->
700
+ <div id="bf-baseurl-row">
701
+ <label>base URL</label>
702
+ <input type="text" id="bf-baseurl" placeholder="http://localhost:11434" autocomplete="off">
703
+ <div class="hint">Override default endpoint for self-hosted backends</div>
704
+ </div>
705
+
706
+ <div>
707
+ <label>model</label>
708
+ <div style="display:flex;gap:6px;align-items:flex-start">
709
+ <div style="flex:1">
710
+ <select id="bf-model-select" onchange="onModelSelectChange()" style="width:100%">
711
+ <option value="">— select or load models —</option>
712
+ </select>
713
+ <input type="text" id="bf-model-custom" placeholder="model-id" autocomplete="off" style="display:none;margin-top:6px">
714
+ </div>
715
+ <button type="button" class="sm" id="bf-load-models-btn" onclick="loadLiveModels(this)" style="white-space:nowrap;margin-top:1px">↺ load models</button>
716
+ </div>
717
+ <div class="hint">Pick from list or load live from API. Leave blank to auto-select.</div>
718
+ </div>
719
+
720
+ <div style="display:flex;align-items:flex-end;gap:8px">
721
+ <label style="margin:0;cursor:pointer;display:flex;align-items:center;gap:6px">
722
+ <input type="checkbox" id="bf-default"> mark as default backend
723
+ </label>
724
+ </div>
725
+
726
+ <!-- Bedrock-specific fields -->
727
+ <div id="bf-bedrock-group" style="display:none;grid-column:1/-1">
728
+ <div style="font-size:12px;color:#8b949e;font-weight:500;text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px">AWS / Bedrock</div>
729
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
730
+ <div>
731
+ <label>region *</label>
732
+ <input type="text" id="bf-region" placeholder="us-east-1" autocomplete="off">
733
+ </div>
734
+ <div>
735
+ <label>AWS key ID</label>
736
+ <input type="text" id="bf-aws-key-id" placeholder="AKIA… (or leave blank for IAM role)" autocomplete="off">
737
+ <div class="hint">Leave blank — scuttlebot will auto-detect IAM role (ECS/EC2/EKS)</div>
738
+ </div>
739
+ <div>
740
+ <label>AWS secret key</label>
741
+ <input type="password" id="bf-aws-secret" placeholder="(or leave blank for IAM role)" autocomplete="new-password">
742
+ </div>
743
+ </div>
744
+ </div>
745
+
746
+ <!-- allow/block filters -->
747
+ <div style="grid-column:1/-1">
748
+ <div style="font-size:12px;color:#8b949e;font-weight:500;text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px">Model filters (regex, one per line)</div>
749
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
750
+ <div>
751
+ <label>allow list</label>
752
+ <textarea id="bf-allow" rows="3" placeholder="^gpt-4&#10;^claude-3" style="font-family:var(--font-mono);font-size:12px"></textarea>
753
+ <div class="hint">Only these models shown. Empty = all.</div>
754
+ </div>
755
+ <div>
756
+ <label>block list</label>
757
+ <textarea id="bf-block" rows="3" placeholder=".*-instruct$&#10;.*-preview$" style="font-family:var(--font-mono);font-size:12px"></textarea>
758
+ <div class="hint">Always hidden.</div>
759
+ </div>
760
+ </div>
761
+ </div>
762
+ </div>
763
+ <div id="ai-form-result" style="display:none;margin-top:12px"></div>
764
+ <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
765
+ <button class="sm" onclick="closeBackendForm()">cancel</button>
766
+ <button class="sm primary" id="bf-submit-btn" onclick="submitBackendForm()">add backend</button>
767
+ </div>
768
+ </div>
769
+ </div>
770
+
771
+ <!-- supported backends reference -->
772
+ <div class="card" id="card-ai-supported">
773
+ <div class="card-header" onclick="toggleCard('card-ai-supported',event)"><h2>supported backends</h2><span class="collapse-icon">▾</span></div>
774
+ <div class="card-body" id="ai-supported-list">
775
+ <div class="empty-state">loading…</div>
776
+ </div>
777
+ </div>
778
+
779
+ <!-- config example -->
780
+ <div class="card" id="card-ai-example" style="display:none">
781
+ <div class="card-header" onclick="toggleCard('card-ai-example',event)"><h2>YAML example</h2><span class="collapse-icon">▾</span></div>
782
+ <div class="card-body">
783
+ <pre style="font-size:12px;color:#a5d6ff;background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:12px;overflow-x:auto;white-space:pre">llm:
784
+ backends:
785
+ - name: openai-main
786
+ backend: openai
787
+ api_key: sk-...
788
+ model: gpt-4o-mini
789
+ block: [".*-instruct$"] # optional regex filter
790
+
791
+ - name: local-ollama
792
+ backend: ollama
793
+ base_url: http://localhost:11434
794
+ model: llama3.2
795
+ default: true
796
+
797
+ - name: anthropic-claude
798
+ backend: anthropic
799
+ api_key: sk-ant-...
800
+ model: claude-3-5-sonnet-20241022
801
+
802
+ - name: bedrock-us
803
+ backend: bedrock
804
+ region: us-east-1
805
+ aws_key_id: AKIA...
806
+ aws_secret_key: ...
807
+ allow: ["^anthropic\\."] # only Anthropic models
808
+
809
+ - name: groq-fast
810
+ backend: groq
811
+ api_key: gsk_...</pre>
812
+ <p style="font-size:12px;color:#8b949e;margin-top:8px">
813
+ Reference a backend from oracle's behavior config using the <code>backend</code> key.
814
+ </p>
815
+ </div>
816
+ </div>
817
+
818
+ </div>
819
+</div>
820
+
821
+<!-- Register drawer -->
822
+<div class="drawer-overlay" id="drawer-overlay" onclick="closeDrawer()"></div>
823
+<div class="drawer" id="register-drawer">
824
+ <div class="drawer-header">
825
+ <h3>register agent</h3>
826
+ <div class="spacer"></div>
827
+ <button class="sm" onclick="closeDrawer()">✕</button>
828
+ </div>
829
+ <div class="drawer-body">
830
+ <form id="register-form" onsubmit="handleRegister(event)">
831
+ <div class="form-row">
832
+ <div>
833
+ <label>nick *</label>
834
+ <input type="text" id="reg-nick" placeholder="my-agent-01" required autocomplete="off">
835
+ </div>
836
+ <div>
837
+ <label>type</label>
838
+ <select id="reg-type">
839
+ <option value="worker" selected>worker — +v</option>
840
+ <option value="orchestrator">orchestrator — +o</option>
841
+ <option value="observer">observer — read only</option>
842
+ </select>
843
+ </div>
844
+ </div>
845
+ <div>
846
+ <label>channels</label>
847
+ <input type="text" id="reg-channels" placeholder="#fleet, #ops, #project.foo" autocomplete="off">
848
+ <div class="hint">comma-separated; must start with #</div>
849
+ </div>
850
+ <div>
851
+ <label>permissions</label>
852
+ <input type="text" id="reg-permissions" placeholder="task.create, task.update" autocomplete="off">
853
+ <div class="hint">comma-separated message types this agent may send</div>
854
+ </div>
855
+ <div id="register-result" style="display:none"></div>
856
+ <div style="display:flex;justify-content:flex-end;gap:8px">
857
+ <button type="button" onclick="closeDrawer()">cancel</button>
858
+ <button type="submit" class="primary">register</button>
859
+ </div>
860
+ </form>
861
+ </div>
862
+</div>
863
+
864
+<!-- Register user drawer (operator with fresh credentials) -->
865
+<div class="drawer-overlay" id="register-user-overlay" onclick="closeRegisterUserDrawer()"></div>
866
+<div class="drawer" id="register-user-drawer">
867
+ <div class="drawer-header">
868
+ <h3>register user</h3>
869
+ <div class="spacer"></div>
870
+ <button class="sm" onclick="closeRegisterUserDrawer()">✕</button>
871
+ </div>
872
+ <div class="drawer-body">
873
+ <form id="register-user-form" onsubmit="handleRegisterUser(event)">
874
+ <div>
875
+ <label>nick *</label>
876
+ <input type="text" id="regu-nick" placeholder="alice" required autocomplete="off">
877
+ <div class="hint">new NickServ account will be created; credentials returned once</div>
878
+ </div>
879
+ <div>
880
+ <label>channels</label>
881
+ <input type="text" id="regu-channels" placeholder="#ops, #general" autocomplete="off">
882
+ <div class="hint">comma-separated</div>
883
+ </div>
884
+ <div id="register-user-result" style="display:none"></div>
885
+ <div style="display:flex;justify-content:flex-end;gap:8px">
886
+ <button type="button" onclick="closeRegisterUserDrawer()">cancel</button>
887
+ <button type="submit" class="primary">register</button>
888
+ </div>
889
+ </form>
890
+ </div>
891
+</div>
892
+
893
+<!-- Adopt user drawer (claim pre-existing NickServ account) -->
894
+<div class="drawer-overlay" id="adopt-overlay" onclick="closeAdoptDrawer()"></div>
895
+<div class="drawer" id="adopt-drawer">
896
+ <div class="drawer-header">
897
+ <h3>adopt existing user</h3>
898
+ <div class="spacer"></div>
899
+ <button class="sm" onclick="closeAdoptDrawer()">✕</button>
900
+ </div>
901
+ <div class="drawer-body">
902
+ <p style="font-size:12px;color:#8b949e;margin-bottom:16px">Adds a pre-existing NickServ account to the registry without changing its password. Use this for accounts already connected to IRC.</p>
903
+ <form id="adopt-form" onsubmit="handleAdopt(event)">
904
+ <div>
905
+ <label>nick *</label>
906
+ <input type="text" id="adopt-nick" placeholder="existing-irc-nick" required autocomplete="off">
907
+ </div>
908
+ <div>
909
+ <label>channels</label>
910
+ <input type="text" id="adopt-channels" placeholder="#ops, #general" autocomplete="off">
911
+ <div class="hint">comma-separated</div>
912
+ </div>
913
+ <div id="adopt-result" style="display:none"></div>
914
+ <div style="display:flex;justify-content:flex-end;gap:8px">
915
+ <button type="button" onclick="closeAdoptDrawer()">cancel</button>
916
+ <button type="submit" class="primary">adopt</button>
917
+ </div>
918
+ </form>
919
+ </div>
920
+</div>
921
+
922
+
923
+<script>
924
+// --- tabs ---
925
+const TAB_LOADERS = { status: loadStatus, users: loadAgents, agents: loadAgents, channels: loadChanTab, chat: () => { populateChatIdentity(); loadChannels(); }, ai: loadAI, settings: loadSettings };
926
+function switchTab(name) {
927
+ document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
928
+ document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
929
+ document.getElementById('tab-' + name).classList.add('active');
930
+ document.getElementById('pane-' + name).classList.add('active');
931
+ if (name === 'chat') {
932
+ _chatUnread = 0;
933
+ delete document.getElementById('tab-chat').dataset.unread;
934
+ }
935
+ if (TAB_LOADERS[name]) TAB_LOADERS[name]();
936
+}
937
+
938
+// --- auth ---
939
+function getToken() { return localStorage.getItem('sb_token') || ''; }
940
+function getUsername() { return localStorage.getItem('sb_username') || ''; }
941
+
942
+function showLoginScreen() {
943
+ document.getElementById('login-screen').style.display = 'flex';
944
+ setTimeout(() => document.getElementById('login-username')?.focus(), 80);
945
+}
946
+function hideLoginScreen() {
947
+ document.getElementById('login-screen').style.display = 'none';
948
+}
949
+
950
+function updateHeaderUser() {
951
+ const u = getUsername();
952
+ const t = getToken();
953
+ const label = u ? '@' + u : (t ? t.slice(0,8)+'…' : '');
954
+ document.getElementById('header-user-display').textContent = label;
955
+ const su = document.getElementById('settings-username-display');
956
+ if (su) su.textContent = u || 'token auth';
957
+ document.getElementById('settings-api-url').textContent = location.origin;
958
+ document.getElementById('no-token-banner').style.display = t ? 'none' : 'flex';
959
+}
960
+
961
+async function handleLogin(e) {
962
+ e.preventDefault();
963
+ const username = document.getElementById('login-username').value.trim();
964
+ const password = document.getElementById('login-password').value;
965
+ const btn = document.getElementById('login-btn');
966
+ const errEl = document.getElementById('login-error');
967
+ if (!username || !password) return;
968
+ btn.disabled = true; btn.textContent = 'signing in…';
969
+ errEl.style.display = 'none';
970
+ try {
971
+ const resp = await fetch('/login', {
972
+ method: 'POST',
973
+ headers: {'Content-Type':'application/json'},
974
+ body: JSON.stringify({username, password}),
975
+ });
976
+ const data = await resp.json().catch(() => ({}));
977
+ if (!resp.ok) throw new Error(data.error || 'Login failed');
978
+ localStorage.setItem('sb_token', data.token);
979
+ localStorage.setItem('sb_username', data.username || username);
980
+ hideLoginScreen();
981
+ updateHeaderUser();
982
+ loadAll();
983
+ } catch(err) {
984
+ errEl.style.display = 'block';
985
+ errEl.innerHTML = renderAlert('error', err.message);
986
+ btn.disabled = false; btn.textContent = 'sign in';
987
+ }
988
+}
989
+
990
+function saveTokenLogin() {
991
+ const v = document.getElementById('token-login-input').value.trim();
992
+ if (!v) return;
993
+ localStorage.setItem('sb_token', v);
994
+ localStorage.removeItem('sb_username');
995
+ hideLoginScreen();
996
+ updateHeaderUser();
997
+ loadAll();
998
+}
999
+
1000
+function logout() {
1001
+ localStorage.removeItem('sb_token');
1002
+ localStorage.removeItem('sb_username');
1003
+ location.reload();
1004
+}
1005
+
1006
+function initAuth() {
1007
+ if (!getToken()) { showLoginScreen(); return; }
1008
+ hideLoginScreen();
1009
+ updateHeaderUser();
1010
+ loadAll();
1011
+}
1012
+
1013
+document.addEventListener('keydown', e => { if(e.key==='Escape'){ closeDrawer(); closeRegisterUserDrawer(); closeAdoptDrawer(); } });
1014
+
1015
+// --- API ---
1016
+async function api(method, path, body) {
1017
+ const opts = { method, headers: { 'Authorization':'Bearer '+getToken(), 'Content-Type':'application/json' } };
1018
+ if (body !== undefined) opts.body = JSON.stringify(body);
1019
+ const res = await fetch(path, opts);
1020
+ if (res.status === 401) {
1021
+ localStorage.removeItem('sb_token');
1022
+ localStorage.removeItem('sb_username');
1023
+ showLoginScreen();
1024
+ throw new Error('Session expired — please sign in again');
1025
+ }
2571026
if (res.status === 204) return null;
2581027
const data = await res.json().catch(() => ({ error: res.statusText }));
2591028
if (!res.ok) throw Object.assign(new Error(data.error || res.statusText), { status: res.status });
2601029
return data;
2611030
}
262
-
2631031
function copyText(text, btn) {
264
- navigator.clipboard.writeText(text).then(() => {
265
- const orig = btn.textContent;
266
- btn.textContent = '✓';
267
- setTimeout(() => { btn.textContent = orig; }, 1200);
1032
+ navigator.clipboard.writeText(text).then(() => { const o=btn.textContent; btn.textContent='✓'; setTimeout(()=>{btn.textContent=o;},1200); });
1033
+}
1034
+
1035
+// --- charts ---
1036
+const CHART_POINTS = 60; // 5 min at 5s intervals
1037
+const chartData = {
1038
+ labels: Array(CHART_POINTS).fill(''),
1039
+ heap: Array(CHART_POINTS).fill(null),
1040
+ goroutines: Array(CHART_POINTS).fill(null),
1041
+ messages: Array(CHART_POINTS).fill(null),
1042
+};
1043
+let charts = {};
1044
+
1045
+function mkChart(id, label, color) {
1046
+ const ctx = document.getElementById(id).getContext('2d');
1047
+ return new Chart(ctx, {
1048
+ type: 'line',
1049
+ data: {
1050
+ labels: chartData.labels,
1051
+ datasets: [{
1052
+ label,
1053
+ data: chartData[id.replace('chart-','')],
1054
+ borderColor: color,
1055
+ backgroundColor: color+'22',
1056
+ borderWidth: 1.5,
1057
+ pointRadius: 0,
1058
+ tension: 0.3,
1059
+ fill: true,
1060
+ }],
1061
+ },
1062
+ options: {
1063
+ responsive: true,
1064
+ animation: false,
1065
+ plugins: { legend: { display: false } },
1066
+ scales: {
1067
+ x: { display: false },
1068
+ y: { display: true, grid: { color: '#21262d' }, ticks: { color: '#8b949e', font: { size: 10 }, maxTicksLimit: 4 } },
1069
+ },
1070
+ },
2681071
});
2691072
}
1073
+
1074
+function initCharts() {
1075
+ if (charts.heap) return;
1076
+ charts.heap = mkChart('chart-heap', 'heap', '#58a6ff');
1077
+ charts.goroutines = mkChart('chart-goroutines', 'goroutines', '#3fb950');
1078
+ charts.messages = mkChart('chart-messages', 'messages', '#d2a8ff');
1079
+}
1080
+
1081
+function fmtBytes(b) {
1082
+ if (b == null) return '—';
1083
+ if (b < 1024) return b+'B';
1084
+ if (b < 1048576) return (b/1024).toFixed(1)+'KB';
1085
+ return (b/1048576).toFixed(1)+'MB';
1086
+}
1087
+
1088
+function pushMetrics(m) {
1089
+ chartData.heap.push(m.runtime.heap_alloc_bytes/1048576);
1090
+ chartData.heap.shift();
1091
+ chartData.goroutines.push(m.runtime.goroutines);
1092
+ chartData.goroutines.shift();
1093
+ const msgs = m.bridge ? m.bridge.messages_total : null;
1094
+ chartData.messages.push(msgs);
1095
+ chartData.messages.shift();
1096
+
1097
+ // Reassign dataset data arrays (shared reference, Chart.js reads them directly).
1098
+ charts.heap.data.datasets[0].data = chartData.heap;
1099
+ charts.goroutines.data.datasets[0].data = chartData.goroutines;
1100
+ charts.messages.data.datasets[0].data = chartData.messages;
1101
+ charts.heap.update('none');
1102
+ charts.goroutines.update('none');
1103
+ charts.messages.update('none');
1104
+}
2701105
2711106
// --- status ---
2721107
async function loadStatus() {
2731108
try {
274
- const s = await api('GET', '/v1/status');
275
- document.getElementById('stat-status').innerHTML = '<span class="dot green"></span>' + s.status;
1109
+ const [s, m] = await Promise.all([
1110
+ api('GET', '/v1/status'),
1111
+ api('GET', '/v1/metrics'),
1112
+ ]);
1113
+
1114
+ // Status card.
1115
+ document.getElementById('stat-status').innerHTML = '<span class="dot green"></span>'+s.status;
2761116
document.getElementById('stat-uptime').textContent = s.uptime;
2771117
document.getElementById('stat-agents').textContent = s.agents;
278
- const started = new Date(s.started);
279
- document.getElementById('stat-started').textContent = started.toLocaleTimeString();
280
- document.getElementById('stat-started-rel').textContent = started.toLocaleDateString();
1118
+ const d = new Date(s.started);
1119
+ document.getElementById('stat-started').textContent = d.toLocaleTimeString();
1120
+ document.getElementById('stat-started-rel').textContent = d.toLocaleDateString();
2811121
document.getElementById('status-error').style.display = 'none';
282
- } catch (e) {
1122
+ document.getElementById('metrics-updated').textContent = 'updated '+new Date().toLocaleTimeString();
1123
+
1124
+ // Runtime card.
1125
+ document.getElementById('stat-goroutines').textContent = m.runtime.goroutines;
1126
+ document.getElementById('stat-heap').textContent = fmtBytes(m.runtime.heap_alloc_bytes);
1127
+ document.getElementById('stat-heapsys').textContent = fmtBytes(m.runtime.heap_sys_bytes);
1128
+ document.getElementById('stat-gc').textContent = m.runtime.gc_runs;
1129
+ document.getElementById('chart-heap-val').textContent = fmtBytes(m.runtime.heap_alloc_bytes);
1130
+ document.getElementById('chart-goroutines-val').textContent = m.runtime.goroutines;
1131
+ document.getElementById('chart-messages-val').textContent = m.bridge ? m.bridge.messages_total : '—';
1132
+
1133
+ // Bridge card.
1134
+ if (m.bridge) {
1135
+ document.getElementById('bridge-card').style.display = '';
1136
+ document.getElementById('stat-bridge-channels').textContent = m.bridge.channels;
1137
+ document.getElementById('stat-bridge-msgs').textContent = m.bridge.messages_total;
1138
+ document.getElementById('stat-bridge-subs').textContent = m.bridge.active_subscribers;
1139
+ }
1140
+
1141
+ // Registry card.
1142
+ document.getElementById('stat-reg-total').textContent = m.registry.total;
1143
+ document.getElementById('stat-reg-active').textContent = m.registry.active;
1144
+ document.getElementById('stat-reg-revoked').textContent = m.registry.revoked;
1145
+
1146
+ // Push to charts.
1147
+ initCharts();
1148
+ pushMetrics(m);
1149
+ } catch(e) {
2831150
document.getElementById('stat-status').innerHTML = '<span class="dot red"></span>error';
284
- const err = document.getElementById('status-error');
285
- err.style.display = 'block';
286
- err.innerHTML = renderAlert('error', e.message);
1151
+ const el = document.getElementById('status-error');
1152
+ el.style.display = 'block';
1153
+ el.innerHTML = renderAlert('error', e.message);
2871154
}
2881155
}
2891156
290
-// --- agents ---
1157
+let metricsTimer = null;
1158
+function startMetricsPoll() {
1159
+ if (metricsTimer) return;
1160
+ metricsTimer = setInterval(() => {
1161
+ if (document.getElementById('pane-status').classList.contains('active')) loadStatus();
1162
+ }, 5000);
1163
+}
1164
+
1165
+// --- agents + users (shared data) ---
1166
+let allAgents = [];
2911167
async function loadAgents() {
292
- const container = document.getElementById('agents-container');
2931168
try {
2941169
const data = await api('GET', '/v1/agents');
295
- const agents = data.agents || [];
296
- document.getElementById('agent-count').textContent = agents.length;
297
- if (agents.length === 0) {
298
- container.innerHTML = '<div class="empty">no agents registered yet</div>';
299
- return;
300
- }
301
- let rows = agents.map(a => {
302
- const channels = (a.config?.channels || []).map(c => `<span class="tag">${esc(c)}</span>`).join('');
303
- const perms = (a.config?.permissions || []).map(p => `<span class="tag">${esc(p)}</span>`).join('');
304
- const revoked = a.revoked_at ? '<span class="tag revoked">revoked</span>' : '';
305
- return `<tr>
306
- <td><strong>${esc(a.nick)}</strong></td>
307
- <td><span class="tag type-${a.type}">${esc(a.type)}</span>${revoked}</td>
308
- <td>${channels || '<span style="color:#8b949e">—</span>'}</td>
309
- <td>${perms || '<span style="color:#8b949e">—</span>'}</td>
310
- <td>${fmtTime(a.created_at)}</td>
311
- <td>
312
- <div class="actions">
313
- ${!a.revoked_at ? `<button class="small" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
314
- <button class="small danger" onclick="revokeAgent('${esc(a.nick)}')">revoke</button>` : ''}
315
- </div>
316
- </td>
317
- </tr>`;
318
- }).join('');
319
- container.innerHTML = `<table>
320
- <thead><tr><th>nick</th><th>type</th><th>channels</th><th>permissions</th><th>registered</th><th>actions</th></tr></thead>
321
- <tbody>${rows}</tbody>
322
- </table>`;
323
- } catch (e) {
324
- container.innerHTML = renderAlert('error', e.message);
325
- }
1170
+ allAgents = data.agents || [];
1171
+ renderUsersTable();
1172
+ renderAgentTable();
1173
+ populateChatIdentity();
1174
+ } catch(e) {
1175
+ const msg = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1176
+ document.getElementById('agents-container').innerHTML = msg;
1177
+ document.getElementById('users-container').innerHTML = msg;
1178
+ }
1179
+}
1180
+
1181
+function renderTable(container, countEl, rows, emptyMsg, cols) {
1182
+ if (rows.length === 0) {
1183
+ document.getElementById(container).innerHTML = '<div class="empty">'+emptyMsg+'</div>';
1184
+ } else {
1185
+ const ths = cols.map(c=>`<th>${c}</th>`).join('');
1186
+ document.getElementById(container).innerHTML =
1187
+ `<table><thead><tr>${ths}</tr></thead><tbody>${rows.join('')}</tbody></table>`;
1188
+ }
1189
+ if (countEl) document.getElementById(countEl).textContent = rows.length;
1190
+}
1191
+
1192
+function renderUsersTable() {
1193
+ const q = (document.getElementById('user-search').value||'').toLowerCase();
1194
+ const users = allAgents.filter(a => a.type === 'operator' && (!q ||
1195
+ a.nick.toLowerCase().includes(q) ||
1196
+ (a.config?.channels||[]).some(c => c.toLowerCase().includes(q))));
1197
+ const rows = users.map(a => {
1198
+ const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
1199
+ const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1200
+ return `<tr>
1201
+ <td><strong>${esc(a.nick)}</strong></td>
1202
+ <td><span class="tag type-operator">operator</span>${rev}</td>
1203
+ <td>${chs||'<span style="color:#8b949e">—</span>'}</td>
1204
+ <td style="white-space:nowrap">${fmtTime(a.created_at)}</td>
1205
+ <td><div class="actions">${!a.revoked?`
1206
+ <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
1207
+ <button class="sm danger" onclick="revokeAgent('${esc(a.nick)}')">revoke</button>`:``}
1208
+ <button class="sm danger" onclick="deleteAgent('${esc(a.nick)}')">delete</button></div></td>
1209
+ </tr>`;
1210
+ });
1211
+ const all = allAgents.filter(a => a.type === 'operator');
1212
+ const countTxt = all.length + (rows.length !== all.length ? ' / '+rows.length+' shown' : '');
1213
+ document.getElementById('user-count').textContent = countTxt;
1214
+ renderTable('users-container', null, rows,
1215
+ all.length ? 'no users match the filter' : 'no users registered yet',
1216
+ ['nick','type','channels','registered','']);
1217
+}
1218
+
1219
+function renderAgentTable() {
1220
+ const q = (document.getElementById('agent-search').value||'').toLowerCase();
1221
+ const bots = allAgents.filter(a => a.type !== 'operator');
1222
+ const agents = bots.filter(a => !q ||
1223
+ a.nick.toLowerCase().includes(q) ||
1224
+ a.type.toLowerCase().includes(q) ||
1225
+ (a.config?.channels||[]).some(c => c.toLowerCase().includes(q)) ||
1226
+ (a.config?.permissions||[]).some(p => p.toLowerCase().includes(q)));
1227
+ document.getElementById('agent-count').textContent = bots.length + (agents.length !== bots.length ? ' / '+agents.length+' shown' : '');
1228
+ const rows = agents.map(a => {
1229
+ const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
1230
+ const perms = (a.config?.permissions||[]).map(p=>`<span class="tag perm">${esc(p)}</span>`).join('');
1231
+ const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1232
+ return `<tr>
1233
+ <td><strong>${esc(a.nick)}</strong></td>
1234
+ <td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td>
1235
+ <td>${chs||'<span style="color:#8b949e">—</span>'}</td>
1236
+ <td>${perms||'<span style="color:#8b949e">—</span>'}</td>
1237
+ <td style="white-space:nowrap">${fmtTime(a.created_at)}</td>
1238
+ <td><div class="actions">${!a.revoked?`
1239
+ <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
1240
+ <button class="sm danger" onclick="revokeAgent('${esc(a.nick)}')">revoke</button>`:``}
1241
+ <button class="sm danger" onclick="deleteAgent('${esc(a.nick)}')">delete</button></div></td>
1242
+ </tr>`;
1243
+ });
1244
+ renderTable('agents-container', null, rows,
1245
+ bots.length ? 'no agents match the filter' : 'no agents registered yet',
1246
+ ['nick','type','channels','permissions','registered','']);
3261247
}
3271248
3281249
async function revokeAgent(nick) {
329
- if (!confirm(`Revoke agent "${nick}"? This cannot be undone.`)) return;
330
- try {
331
- await api('POST', `/v1/agents/${nick}/revoke`);
332
- await loadAgents();
333
- await loadStatus();
334
- } catch(e) {
335
- alert('Revoke failed: ' + e.message);
336
- }
337
-}
338
-
1250
+ if (!confirm(`Revoke "${nick}"? This cannot be undone.`)) return;
1251
+ try { await api('POST', `/v1/agents/${nick}/revoke`); await loadAgents(); await loadStatus(); }
1252
+ catch(e) { alert('Revoke failed: '+e.message); }
1253
+}
1254
+async function deleteAgent(nick) {
1255
+ if (!confirm(`Delete "${nick}"? This permanently removes the agent from the registry.`)) return;
1256
+ try { await api('DELETE', `/v1/agents/${nick}`); await loadAgents(); await loadStatus(); }
1257
+ catch(e) { alert('Delete failed: '+e.message); }
1258
+}
3391259
async function rotateAgent(nick) {
3401260
try {
3411261
const creds = await api('POST', `/v1/agents/${nick}/rotate`);
1262
+ // Show result in whichever drawer is relevant.
3421263
showCredentials(nick, creds, null, 'rotate');
343
- } catch(e) {
344
- alert('Rotate failed: ' + e.message);
1264
+ openDrawer();
3451265
}
1266
+ catch(e) { alert('Rotate failed: '+e.message); }
1267
+}
1268
+
1269
+// --- users drawers ---
1270
+function openRegisterUserDrawer() {
1271
+ document.getElementById('register-user-overlay').classList.add('open');
1272
+ document.getElementById('register-user-drawer').classList.add('open');
1273
+ setTimeout(() => document.getElementById('regu-nick').focus(), 100);
1274
+}
1275
+function closeRegisterUserDrawer() {
1276
+ document.getElementById('register-user-overlay').classList.remove('open');
1277
+ document.getElementById('register-user-drawer').classList.remove('open');
1278
+}
1279
+function openAdoptDrawer() {
1280
+ document.getElementById('adopt-overlay').classList.add('open');
1281
+ document.getElementById('adopt-drawer').classList.add('open');
1282
+ setTimeout(() => document.getElementById('adopt-nick').focus(), 100);
1283
+}
1284
+function closeAdoptDrawer() {
1285
+ document.getElementById('adopt-overlay').classList.remove('open');
1286
+ document.getElementById('adopt-drawer').classList.remove('open');
1287
+}
1288
+
1289
+async function handleRegisterUser(e) {
1290
+ e.preventDefault();
1291
+ const nick = document.getElementById('regu-nick').value.trim();
1292
+ const channels = document.getElementById('regu-channels').value.split(',').map(s=>s.trim()).filter(Boolean);
1293
+ const resultEl = document.getElementById('register-user-result');
1294
+ resultEl.style.display = 'none';
1295
+ try {
1296
+ const res = await api('POST', '/v1/agents/register', { nick, type: 'operator', channels, permissions: [] });
1297
+ resultEl.style.display = 'block';
1298
+ const pass = res.credentials?.passphrase || '';
1299
+ resultEl.innerHTML = renderAlert('success',
1300
+ `<div><strong>Registered: ${esc(nick)}</strong>
1301
+ <div class="cred-box">
1302
+ <div class="cred-row"><span class="cred-key">nick</span><span class="cred-val">${esc(nick)}</span><button class="sm" onclick="copyText('${esc(nick)}',this)">copy</button></div>
1303
+ <div class="cred-row"><span class="cred-key">passphrase</span><span class="cred-val">${esc(pass)}</span><button class="sm" onclick="copyText('${esc(pass)}',this)">copy</button></div>
1304
+ </div>
1305
+ <div style="margin-top:8px;font-size:11px;color:#7ee787">⚠ Save the passphrase — shown once only.</div></div>`);
1306
+ document.getElementById('register-user-form').reset();
1307
+ await loadAgents(); await loadStatus();
1308
+ } catch(e) { resultEl.style.display='block'; resultEl.innerHTML=renderAlert('error', e.message); }
1309
+}
1310
+
1311
+async function handleAdopt(e) {
1312
+ e.preventDefault();
1313
+ const nick = document.getElementById('adopt-nick').value.trim();
1314
+ const channels = document.getElementById('adopt-channels').value.split(',').map(s=>s.trim()).filter(Boolean);
1315
+ const resultEl = document.getElementById('adopt-result');
1316
+ resultEl.style.display = 'none';
1317
+ try {
1318
+ await api('POST', `/v1/agents/${nick}/adopt`, { type: 'operator', channels, permissions: [] });
1319
+ resultEl.style.display = 'block';
1320
+ resultEl.innerHTML = renderAlert('success',
1321
+ `<strong>${esc(nick)}</strong> adopted as operator — existing IRC session and passphrase unchanged.`);
1322
+ document.getElementById('adopt-form').reset();
1323
+ await loadAgents(); await loadStatus();
1324
+ } catch(e) { resultEl.style.display='block'; resultEl.innerHTML=renderAlert('error', e.message); }
1325
+}
1326
+
1327
+// --- drawer ---
1328
+function openDrawer() {
1329
+ document.getElementById('drawer-overlay').classList.add('open');
1330
+ document.getElementById('register-drawer').classList.add('open');
1331
+ setTimeout(() => document.getElementById('reg-nick').focus(), 100);
1332
+}
1333
+function closeDrawer() {
1334
+ document.getElementById('drawer-overlay').classList.remove('open');
1335
+ document.getElementById('register-drawer').classList.remove('open');
3461336
}
3471337
3481338
// --- register ---
3491339
async function handleRegister(e) {
3501340
e.preventDefault();
351
- const nick = document.getElementById('reg-nick').value.trim();
352
- const type = document.getElementById('reg-type').value;
353
- const channelsRaw = document.getElementById('reg-channels').value;
354
- const permsRaw = document.getElementById('reg-permissions').value;
355
-
356
- const channels = channelsRaw.split(',').map(s => s.trim()).filter(Boolean);
357
- const permissions = permsRaw.split(',').map(s => s.trim()).filter(Boolean);
358
-
359
- const resultEl = document.getElementById('register-result');
1341
+ const nick = document.getElementById('reg-nick').value.trim();
1342
+ const type = document.getElementById('reg-type').value;
1343
+ const channels = document.getElementById('reg-channels').value.split(',').map(s=>s.trim()).filter(Boolean);
1344
+ const permissions = document.getElementById('reg-permissions').value.split(',').map(s=>s.trim()).filter(Boolean);
1345
+ const resultEl = document.getElementById('register-result');
3601346
resultEl.style.display = 'none';
361
-
3621347
try {
3631348
const res = await api('POST', '/v1/agents/register', { nick, type, channels, permissions });
3641349
showCredentials(nick, res.credentials, res.payload, 'register');
3651350
document.getElementById('register-form').reset();
366
- await loadAgents();
367
- await loadStatus();
1351
+ await loadAgents(); await loadStatus();
1352
+ } catch(e) { resultEl.style.display='block'; resultEl.innerHTML=renderAlert('error', e.message); }
1353
+}
1354
+function showCredentials(nick, creds, payload, mode) {
1355
+ const resultEl = document.getElementById('register-result');
1356
+ resultEl.style.display = 'block';
1357
+ const pass = creds?.passphrase || creds?.Passphrase || '';
1358
+ const sig = payload?.signature || '';
1359
+ resultEl.innerHTML = renderAlert('success',
1360
+ `<div><strong>${mode==='register'?'Registered':'Rotated'}: ${esc(nick)}</strong>
1361
+ <div class="cred-box">
1362
+ <div class="cred-row"><span class="cred-key">nick</span><span class="cred-val">${esc(creds?.nick||nick)}</span><button class="sm" onclick="copyText('${esc(creds?.nick||nick)}',this)">copy</button></div>
1363
+ <div class="cred-row"><span class="cred-key">passphrase</span><span class="cred-val">${esc(pass)}</span><button class="sm" onclick="copyText('${esc(pass)}',this)">copy</button></div>
1364
+ ${sig?`<div class="cred-row"><span class="cred-key">sig</span><span class="cred-val" style="font-size:11px">${esc(sig)}</span></div>`:''}
1365
+ </div>
1366
+ <div style="margin-top:8px;font-size:11px;color:#7ee787">⚠ Save the passphrase — shown once only.</div></div>`
1367
+ );
1368
+}
1369
+
1370
+// --- channels tab ---
1371
+async function loadChanTab() {
1372
+ if (!getToken()) return;
1373
+ try {
1374
+ const data = await api('GET', '/v1/channels');
1375
+ const channels = data.channels || [];
1376
+ document.getElementById('chan-count').textContent = channels.length;
1377
+ if (channels.length === 0) {
1378
+ document.getElementById('channels-list').innerHTML = '<div class="empty">no channels joined yet — type a channel name above and click join</div>';
1379
+ return;
1380
+ }
1381
+ document.getElementById('channels-list').innerHTML = channels.sort().map(ch =>
1382
+ `<div class="chan-card">
1383
+ <div>
1384
+ <div class="chan-name">${esc(ch)}</div>
1385
+ <div class="chan-meta">joined</div>
1386
+ </div>
1387
+ <div class="spacer"></div>
1388
+ <button class="sm" onclick="switchTab('chat');setTimeout(()=>selectChannel('${esc(ch)}'),50)">open chat →</button>
1389
+ </div>`
1390
+ ).join('');
1391
+ } catch(e) {
1392
+ document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1393
+ }
1394
+}
1395
+async function quickJoin() {
1396
+ let ch = document.getElementById('quick-join-input').value.trim();
1397
+ if (!ch) return;
1398
+ if (!ch.startsWith('#')) ch = '#' + ch;
1399
+ const slug = ch.replace(/^#/,'');
1400
+ try {
1401
+ await api('POST', `/v1/channels/${slug}/join`);
1402
+ document.getElementById('quick-join-input').value = '';
1403
+ await loadChanTab();
1404
+ renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
1405
+ } catch(e) { alert('Join failed: '+e.message); }
1406
+}
1407
+document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
1408
+
1409
+// --- chat ---
1410
+let chatChannel = null, chatSSE = null;
1411
+
1412
+async function loadChannels() {
1413
+ if (!getToken()) return;
1414
+ try {
1415
+ const data = await api('GET', '/v1/channels');
1416
+ renderChanSidebar(data.channels || []);
1417
+ } catch(e) {}
1418
+}
1419
+function renderChanSidebar(channels) {
1420
+ const list = document.getElementById('chan-list');
1421
+ list.innerHTML = '';
1422
+ channels.sort().forEach(ch => {
1423
+ const el = document.createElement('div');
1424
+ el.className = 'chan-item' + (ch===chatChannel?' active':'');
1425
+ el.textContent = ch;
1426
+ el.onclick = () => selectChannel(ch);
1427
+ list.appendChild(el);
1428
+ });
1429
+}
1430
+async function joinChannel() {
1431
+ let ch = document.getElementById('join-channel-input').value.trim();
1432
+ if (!ch) return;
1433
+ if (!ch.startsWith('#')) ch = '#' + ch;
1434
+ const slug = ch.replace(/^#/,'');
1435
+ try {
1436
+ await api('POST', `/v1/channels/${slug}/join`);
1437
+ document.getElementById('join-channel-input').value = '';
1438
+ const data = await api('GET', '/v1/channels');
1439
+ renderChanSidebar(data.channels||[]);
1440
+ selectChannel(ch);
1441
+ } catch(e) { alert('Join failed: '+e.message); }
1442
+}
1443
+document.getElementById('join-channel-input').addEventListener('keydown', e => { if(e.key==='Enter')joinChannel(); });
1444
+
1445
+async function selectChannel(ch) {
1446
+ _lastMsgNick = null; _lastMsgAt = 0; _hideChatBanner(); _chatUnread = 0;
1447
+ chatChannel = ch;
1448
+ document.getElementById('chat-ch-name').textContent = ch;
1449
+ document.getElementById('chat-placeholder').style.display = 'none';
1450
+ document.querySelectorAll('.chan-item').forEach(el => el.classList.toggle('active', el.textContent===ch));
1451
+
1452
+ const area = document.getElementById('chat-msgs');
1453
+ Array.from(area.children).forEach(el => { if(!el.id) el.remove(); });
1454
+
1455
+ try {
1456
+ const slug = ch.replace(/^#/,'');
1457
+ const data = await api('GET', `/v1/channels/${slug}/messages`);
1458
+ (data.messages||[]).forEach(m => appendMsg(m, true));
1459
+ area.scrollTop = area.scrollHeight;
1460
+ } catch(e) {}
1461
+
1462
+ if (chatSSE) { chatSSE.close(); chatSSE = null; }
1463
+ const slug = ch.replace(/^#/,'');
1464
+ const es = new EventSource(`/v1/channels/${slug}/stream?token=${encodeURIComponent(getToken())}`);
1465
+ chatSSE = es;
1466
+ const badge = document.getElementById('chat-stream-status');
1467
+ es.onopen = () => { badge.textContent='● live'; badge.style.color='#3fb950'; };
1468
+ es.onmessage = ev => { try { appendMsg(JSON.parse(ev.data)); area.scrollTop=area.scrollHeight; } catch(_){} };
1469
+ es.onerror = () => { badge.textContent='○ reconnecting…'; badge.style.color='#8b949e'; };
1470
+
1471
+ loadNicklist(ch);
1472
+ if (_nicklistTimer) clearInterval(_nicklistTimer);
1473
+ _nicklistTimer = setInterval(() => loadNicklist(chatChannel), 10000);
1474
+}
1475
+
1476
+let _nicklistTimer = null;
1477
+async function loadNicklist(ch) {
1478
+ if (!ch) return;
1479
+ try {
1480
+ const slug = ch.replace(/^#/,'');
1481
+ const data = await api('GET', `/v1/channels/${slug}/users`);
1482
+ renderNicklist(data.users || []);
1483
+ } catch(e) {}
1484
+}
1485
+function renderNicklist(users) {
1486
+ const el = document.getElementById('nicklist-users');
1487
+ const knownBots = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot','claude']);
1488
+ el.innerHTML = users.sort((a,b) => a.localeCompare(b)).map(nick => {
1489
+ const isBot = knownBots.has(nick.toLowerCase());
1490
+ return `<div class="nicklist-nick${isBot?' is-bot':''}" title="${esc(nick)}">${esc(nick)}</div>`;
1491
+ }).join('');
1492
+}
1493
+// Nick colors — deterministic hash over a palette
1494
+const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353'];
1495
+function nickColor(nick) {
1496
+ let h = 0;
1497
+ for (let i = 0; i < nick.length; i++) h = (h * 31 + nick.charCodeAt(i)) & 0xffff;
1498
+ return NICK_PALETTE[h % NICK_PALETTE.length];
1499
+}
1500
+
1501
+let _lastMsgNick = null, _lastMsgAt = 0;
1502
+const GROUP_MS = 5 * 60 * 1000;
1503
+let _chatNewBanner = null;
1504
+let _chatUnread = 0;
1505
+
1506
+function appendMsg(msg, isHistory) {
1507
+ const area = document.getElementById('chat-msgs');
1508
+
1509
+ // Parse "[nick] text" sent by the bridge bot on behalf of a web user
1510
+ let displayNick = msg.nick;
1511
+ let displayText = msg.text;
1512
+ if (msg.nick === 'bridge') {
1513
+ const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/);
1514
+ if (m) { displayNick = m[1]; displayText = m[2]; }
1515
+ }
1516
+
1517
+ const atMs = new Date(msg.at).getTime();
1518
+ const grouped = !isHistory && displayNick === _lastMsgNick && (atMs - _lastMsgAt) < GROUP_MS;
1519
+ _lastMsgNick = displayNick;
1520
+ _lastMsgAt = atMs;
1521
+
1522
+ const timeStr = new Date(msg.at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
1523
+ const color = nickColor(displayNick);
1524
+
1525
+ const row = document.createElement('div');
1526
+ row.className = 'msg-row' + (grouped ? ' msg-grouped' : '');
1527
+ row.innerHTML =
1528
+ `<span class="msg-time" title="${esc(new Date(msg.at).toLocaleString())}">${esc(timeStr)}</span>` +
1529
+ `<span class="msg-nick" style="color:${color}">${esc(displayNick)}</span>` +
1530
+ `<span class="msg-text">${esc(displayText)}</span>`;
1531
+ area.appendChild(row);
1532
+
1533
+ // Unread badge when chat tab not active
1534
+ if (!isHistory && !document.getElementById('tab-chat').classList.contains('active')) {
1535
+ _chatUnread++;
1536
+ document.getElementById('tab-chat').dataset.unread = _chatUnread > 9 ? '9+' : _chatUnread;
1537
+ }
1538
+
1539
+ if (isHistory) {
1540
+ area.scrollTop = area.scrollHeight;
1541
+ } else {
1542
+ const nearBottom = area.scrollHeight - area.clientHeight - area.scrollTop < 100;
1543
+ if (nearBottom) {
1544
+ area.scrollTop = area.scrollHeight;
1545
+ _hideChatBanner();
1546
+ } else {
1547
+ _showChatBanner(area);
1548
+ }
1549
+ }
1550
+}
1551
+
1552
+function _showChatBanner(area) {
1553
+ if (_chatNewBanner) return;
1554
+ _chatNewBanner = document.createElement('div');
1555
+ _chatNewBanner.className = 'chat-new-banner';
1556
+ _chatNewBanner.textContent = '↓ new messages';
1557
+ _chatNewBanner.onclick = () => { area.scrollTop = area.scrollHeight; _hideChatBanner(); };
1558
+ area.appendChild(_chatNewBanner);
1559
+}
1560
+function _hideChatBanner() {
1561
+ if (_chatNewBanner) { _chatNewBanner.remove(); _chatNewBanner = null; }
1562
+}
1563
+function saveChatIdentity() {
1564
+ localStorage.setItem('sb_chat_nick', document.getElementById('chat-identity').value);
1565
+}
1566
+function getChatNick() {
1567
+ return localStorage.getItem('sb_chat_nick') || '';
1568
+}
1569
+function populateChatIdentity() {
1570
+ const sel = document.getElementById('chat-identity');
1571
+ const current = getChatNick();
1572
+ // Operators + any registered nick can send (all types visible, operators first)
1573
+ const operators = allAgents.filter(a => a.type === 'operator' && !a.revoked);
1574
+ const bots = allAgents.filter(a => a.type !== 'operator' && !a.revoked);
1575
+ const options = [...operators, ...bots];
1576
+ sel.innerHTML = '<option value="">— pick a user —</option>' +
1577
+ options.map(a => `<option value="${esc(a.nick)}"${a.nick===current?' selected':''}>${esc(a.nick)} (${esc(a.type)})</option>`).join('');
1578
+ // Restore saved selection.
1579
+ if (current) sel.value = current;
1580
+}
1581
+
1582
+async function sendMsg() {
1583
+ if (!chatChannel) return;
1584
+ const input = document.getElementById('chat-text-input');
1585
+ const nick = document.getElementById('chat-identity').value.trim() || 'web';
1586
+ const text = input.value.trim();
1587
+ if (!text) return;
1588
+ input.disabled = true;
1589
+ document.getElementById('chat-send-btn').disabled = true;
1590
+ try {
1591
+ const slug = chatChannel.replace(/^#/,'');
1592
+ await api('POST', `/v1/channels/${slug}/messages`, { text, nick });
1593
+ input.value = '';
1594
+ } catch(e) { alert('Send failed: '+e.message); }
1595
+ finally { input.disabled=false; document.getElementById('chat-send-btn').disabled=false; input.focus(); }
1596
+}
1597
+// --- chat input: Enter to send, Tab for nick completion ---
1598
+(function() {
1599
+ const input = document.getElementById('chat-text-input');
1600
+ let _tabCandidates = [];
1601
+ let _tabIdx = -1;
1602
+ let _tabPrefix = '';
1603
+
1604
+ input.addEventListener('keydown', e => {
1605
+ if (e.key === 'Enter') { e.preventDefault(); sendMsg(); return; }
1606
+ if (e.key === 'Tab') {
1607
+ e.preventDefault();
1608
+ const val = input.value;
1609
+ const cursor = input.selectionStart;
1610
+ // Find the word being typed up to cursor
1611
+ const before = val.slice(0, cursor);
1612
+ const wordStart = before.search(/\S+$/);
1613
+ const word = wordStart === -1 ? '' : before.slice(wordStart);
1614
+ if (!word) return;
1615
+
1616
+ // On first Tab press with this prefix, build candidate list
1617
+ if (_tabIdx === -1 || word.toLowerCase() !== _tabPrefix.toLowerCase()) {
1618
+ _tabPrefix = word;
1619
+ const nicks = Array.from(document.querySelectorAll('#nicklist-users .nicklist-nick'))
1620
+ .map(el => el.textContent.replace(/^●\s*/, '').trim())
1621
+ .filter(n => n.toLowerCase().startsWith(word.toLowerCase()));
1622
+ if (!nicks.length) return;
1623
+ _tabCandidates = nicks;
1624
+ _tabIdx = 0;
1625
+ } else {
1626
+ _tabIdx = (_tabIdx + 1) % _tabCandidates.length;
1627
+ }
1628
+
1629
+ const chosen = _tabCandidates[_tabIdx];
1630
+ // If at start of message, append ': '
1631
+ const suffix = (wordStart === 0 && before.slice(0, wordStart) === '') ? ': ' : ' ';
1632
+ const newVal = val.slice(0, wordStart === -1 ? 0 : wordStart) + chosen + suffix + val.slice(cursor);
1633
+ input.value = newVal;
1634
+ const newPos = (wordStart === -1 ? 0 : wordStart) + chosen.length + suffix.length;
1635
+ input.setSelectionRange(newPos, newPos);
1636
+ return;
1637
+ }
1638
+ // Any other key resets tab state
1639
+ _tabIdx = -1;
1640
+ _tabPrefix = '';
1641
+ });
1642
+})();
1643
+
1644
+// --- sidebar collapse toggles ---
1645
+function toggleSidebar(side) {
1646
+ if (side === 'left') {
1647
+ const el = document.getElementById('chat-sidebar-left');
1648
+ const btn = document.getElementById('sidebar-left-toggle');
1649
+ const collapsed = el.classList.toggle('collapsed');
1650
+ btn.textContent = collapsed ? '›' : '‹';
1651
+ btn.title = collapsed ? 'expand' : 'collapse';
1652
+ } else {
1653
+ const el = document.getElementById('chat-nicklist');
1654
+ const btn = document.getElementById('sidebar-right-toggle');
1655
+ const collapsed = el.classList.toggle('collapsed');
1656
+ btn.textContent = collapsed ? '‹' : '›';
1657
+ btn.title = collapsed ? 'expand' : 'collapse';
1658
+ }
1659
+}
1660
+
1661
+// --- sidebar drag-to-resize ---
1662
+(function() {
1663
+ function makeResizable(handleId, sidebarId, side) {
1664
+ const handle = document.getElementById(handleId);
1665
+ const sidebar = document.getElementById(sidebarId);
1666
+ if (!handle || !sidebar) return;
1667
+ let startX, startW;
1668
+ handle.addEventListener('mousedown', e => {
1669
+ e.preventDefault();
1670
+ startX = e.clientX;
1671
+ startW = sidebar.offsetWidth;
1672
+ handle.classList.add('dragging');
1673
+ const onMove = mv => {
1674
+ const delta = side === 'left' ? mv.clientX - startX : startX - mv.clientX;
1675
+ const w = Math.max(28, Math.min(400, startW + delta));
1676
+ sidebar.style.width = w + 'px';
1677
+ if (w <= 30) sidebar.classList.add('collapsed');
1678
+ else sidebar.classList.remove('collapsed');
1679
+ };
1680
+ const onUp = () => {
1681
+ handle.classList.remove('dragging');
1682
+ document.removeEventListener('mousemove', onMove);
1683
+ document.removeEventListener('mouseup', onUp);
1684
+ };
1685
+ document.addEventListener('mousemove', onMove);
1686
+ document.addEventListener('mouseup', onUp);
1687
+ });
1688
+ }
1689
+ makeResizable('resize-left', 'chat-sidebar-left', 'left');
1690
+ makeResizable('resize-right', 'chat-nicklist', 'right');
1691
+})();
1692
+
1693
+// --- helpers ---
1694
+function renderAlert(type, msg) {
1695
+ return `<div class="alert ${type}"><span class="icon">${{info:'ℹ',error:'✕',success:'✓'}[type]}</span><div>${msg}</div></div>`;
1696
+}
1697
+function esc(s) {
1698
+ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
1699
+}
1700
+function fmtTime(iso) {
1701
+ if (!iso) return '—';
1702
+ const d = new Date(iso);
1703
+ return d.toLocaleDateString()+' '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
1704
+}
1705
+
1706
+// --- collapsible cards ---
1707
+function toggleCard(id, e) {
1708
+ // Don't collapse when clicking buttons inside the header.
1709
+ if (e && e.target.tagName === 'BUTTON') return;
1710
+ document.getElementById(id).classList.toggle('collapsed');
1711
+}
1712
+
1713
+// --- per-bot config schemas ---
1714
+const BEHAVIOR_SCHEMAS = {
1715
+ snitch: [
1716
+ { key:'alert_channel', label:'Alert channel', type:'text', placeholder:'#ops', hint:'Channel to post alerts in' },
1717
+ { key:'alert_nicks', label:'Alert nicks', type:'text', placeholder:'alice, bob', hint:'Operators to DM (comma-separated)' },
1718
+ { key:'flood_messages', label:'Flood threshold', type:'number', placeholder:'10', hint:'Messages in window that triggers alert' },
1719
+ { key:'flood_window_s', label:'Flood window (s)', type:'number', placeholder:'5', hint:'Rolling window duration in seconds' },
1720
+ { key:'joinpart_threshold',label:'Join/part threshold', type:'number', placeholder:'5', hint:'Join+part events before cycling alert' },
1721
+ { key:'joinpart_window_s', label:'Join/part window (s)',type:'number', placeholder:'30' },
1722
+ ],
1723
+ warden: [
1724
+ { key:'flood_threshold', label:'Flood threshold', type:'number', placeholder:'10', hint:'Messages/window before action' },
1725
+ { key:'window_s', label:'Window (s)', type:'number', placeholder:'5' },
1726
+ { key:'warn_before_mute', label:'Warn before mute', type:'checkbox' },
1727
+ { key:'mute_duration_s', label:'Mute duration (s)', type:'number', placeholder:'300' },
1728
+ { key:'kick_after_mutes', label:'Kick after N mutes',type:'number', placeholder:'3' },
1729
+ ],
1730
+ oracle: [
1731
+ { key:'backend', label:'LLM backend', type:'llm-backend', hint:'Backend configured in the AI tab' },
1732
+ { key:'model', label:'Model override', type:'model-override', backendKey:'backend', hint:'Override the backend default model (optional)' },
1733
+ { key:'scribe_dir', label:'Scribe log dir', type:'text', placeholder:'./data/logs/scribe', hint:'Directory scribe writes to — oracle reads history from here' },
1734
+ { key:'max_messages', label:'Max messages', type:'number', placeholder:'50', hint:'Default message count for summaries' },
1735
+ ],
1736
+ scribe: [
1737
+ { key:'dir', label:'Log directory', type:'text', placeholder:'./data/logs', hint:'Directory to write log files into' },
1738
+ { key:'format', label:'Format', type:'select', options:['jsonl','csv','text'], hint:'jsonl=structured, csv=spreadsheet, text=human-readable' },
1739
+ { key:'rotation', label:'Rotation', type:'select', options:['none','daily','weekly','monthly','yearly','size'], hint:'When to start a new log file' },
1740
+ { key:'max_size_mb', label:'Max size (MiB)', type:'number', placeholder:'100', hint:'Only applies when rotation = size' },
1741
+ { key:'per_channel', label:'Per-channel files',type:'checkbox', hint:'Separate file per channel' },
1742
+ { key:'max_age_days', label:'Max age (days)', type:'number', placeholder:'0', hint:'Prune old rotated files; 0 = keep all' },
1743
+ ],
1744
+ herald: [
1745
+ { key:'webhook_path', label:'Webhook path', type:'text', placeholder:'/webhooks/herald', hint:'HTTP path that receives inbound events' },
1746
+ { key:'rate_limit', label:'Rate limit (msg/min)', type:'number', placeholder:'60' },
1747
+ ],
1748
+ scroll: [
1749
+ { key:'max_replay', label:'Max replay', type:'number', placeholder:'100', hint:'Max messages per request' },
1750
+ { key:'require_auth', label:'Require auth', type:'checkbox', hint:'Only registered agents can query history' },
1751
+ ],
1752
+ systembot: [
1753
+ { key:'log_joins', label:'Log joins', type:'checkbox' },
1754
+ { key:'log_parts', label:'Log parts/quits', type:'checkbox' },
1755
+ { key:'log_modes', label:'Log mode changes', type:'checkbox' },
1756
+ { key:'log_kicks', label:'Log kicks', type:'checkbox' },
1757
+ ],
1758
+ auditbot: [
1759
+ { key:'retention_days', label:'Retention (days)', type:'number', placeholder:'90', hint:'0 = keep forever' },
1760
+ { key:'log_path', label:'Log path', type:'text', placeholder:'/var/log/scuttlebot/audit.log' },
1761
+ ],
1762
+ sentinel: [
1763
+ { key:'backend', label:'LLM backend', type:'llm-backend', hint:'Backend configured in the AI tab' },
1764
+ { key:'model', label:'Model override', type:'model-override', backendKey:'backend', hint:'Override the backend default model (optional)' },
1765
+ { key:'mod_channel', label:'Mod channel', type:'text', placeholder:'#moderation', hint:'Channel where incident reports are posted' },
1766
+ { key:'dm_operators', label:'DM operators', type:'checkbox', hint:'Also send incident reports as DMs to operator nicks' },
1767
+ { key:'alert_nicks', label:'Operator nicks', type:'text', placeholder:'alice,bob', hint:'Comma-separated nicks to DM on incidents (requires DM operators)' },
1768
+ { key:'policy', label:'Policy', type:'text', placeholder:'Flag harassment, hate speech, spam and threats.', hint:'Plain-English description of what to flag' },
1769
+ { key:'min_severity', label:'Min severity', type:'select', options:['low','medium','high'], hint:'Minimum severity level to report' },
1770
+ { key:'window_size', label:'Window size', type:'number', placeholder:'20', hint:'Messages to buffer per channel before analysis' },
1771
+ { key:'window_age_sec', label:'Window age (s)', type:'number', placeholder:'300', hint:'Max seconds before a stale buffer is force-scanned' },
1772
+ { key:'cooldown_sec', label:'Cooldown (s)', type:'number', placeholder:'600', hint:'Min seconds between reports about the same nick' },
1773
+ ],
1774
+ steward: [
1775
+ { key:'mod_channel', label:'Mod channel', type:'text', placeholder:'#moderation', hint:'Channel steward watches for sentinel reports and posts action logs' },
1776
+ { key:'operator_nicks', label:'Operator nicks', type:'text', placeholder:'alice,bob', hint:'Comma-separated nicks allowed to issue direct commands via DM' },
1777
+ { key:'dm_on_action', label:'DM operators', type:'checkbox', hint:'Send a DM to operator nicks when steward takes action' },
1778
+ { key:'auto_act', label:'Auto-act', type:'checkbox', hint:'Automatically act on sentinel incident reports' },
1779
+ { key:'warn_on_low', label:'Warn on low', type:'checkbox', hint:'Send a warning notice for low-severity incidents' },
1780
+ { key:'mute_duration_sec', label:'Mute duration (s)',type:'number', placeholder:'600', hint:'How long medium-severity mutes last' },
1781
+ { key:'cooldown_sec', label:'Cooldown (s)', type:'number', placeholder:'300', hint:'Min seconds between automated actions on the same nick' },
1782
+ ],
1783
+};
1784
+
1785
+function renderBehConfig(b) {
1786
+ const schema = BEHAVIOR_SCHEMAS[b.id];
1787
+ if (!schema) return '';
1788
+ const cfg = b.config || {};
1789
+ const fields = schema.map(f => {
1790
+ const val = cfg[f.key];
1791
+ let input = '';
1792
+ if (f.type === 'checkbox') {
1793
+ input = `<input type="checkbox" ${val?'checked':''} style="accent-color:#58a6ff" onchange="onBehCfg('${esc(b.id)}','${f.key}',this.checked)">`;
1794
+ } else if (f.type === 'select') {
1795
+ input = `<select onchange="onBehCfg('${esc(b.id)}','${f.key}',this.value)">${(f.options||[]).map(o=>`<option ${val===o?'selected':''}>${esc(o)}</option>`).join('')}</select>`;
1796
+ } else if (f.type === 'llm-backend') {
1797
+ const opts = _llmBackendNames.map(n => `<option value="${esc(n)}" ${val===n?'selected':''}>${esc(n)}</option>`).join('');
1798
+ const noMatch = val && !_llmBackendNames.includes(val);
1799
+ input = `<select onchange="onBehCfg('${esc(b.id)}','${f.key}',this.value)">
1800
+ <option value="">— select backend —</option>
1801
+ ${opts}
1802
+ ${noMatch ? `<option value="${esc(val)}" selected>${esc(val)}</option>` : ''}
1803
+ </select>`;
1804
+ } else if (f.type === 'model-override') {
1805
+ const selId = `beh-msel-${esc(b.id)}`;
1806
+ const customId = `beh-mcustom-${esc(b.id)}`;
1807
+ const backendKey = f.backendKey || 'backend';
1808
+ const currentVal = val || '';
1809
+ input = `<div style="display:flex;gap:6px;align-items:flex-start">
1810
+ <div style="flex:1">
1811
+ <select id="${selId}" style="width:100%" onchange="onBehModelSelect('${esc(b.id)}','${f.key}','${selId}','${customId}')">
1812
+ <option value="">— none / auto-select —</option>
1813
+ ${currentVal ? `<option value="${esc(currentVal)}" selected>${esc(currentVal)}</option>` : ''}
1814
+ <option value="__other__">— other (type below) —</option>
1815
+ </select>
1816
+ <input type="text" id="${customId}" placeholder="model-id" autocomplete="off"
1817
+ style="display:none;margin-top:6px"
1818
+ onchange="onBehCfg('${esc(b.id)}','${f.key}',this.value)">
1819
+ </div>
1820
+ <button type="button" class="sm" style="white-space:nowrap;margin-top:1px"
1821
+ onclick="loadBehModels(this,'${esc(b.id)}','${backendKey}','${f.key}','${selId}','${customId}')">↺</button>
1822
+ </div>`;
1823
+ } else {
1824
+ input = `<input type="${f.type}" placeholder="${esc(f.placeholder||'')}" value="${esc(String(val??''))}" onchange="onBehCfg('${esc(b.id)}','${f.key}',this.value${f.type==='number'?'*1':''})">`;
1825
+ }
1826
+ return `<div class="beh-field"><label>${esc(f.label)}</label>${input}${f.hint?`<div class="hint">${esc(f.hint)}</div>`:''}</div>`;
1827
+ }).join('');
1828
+ return `<div class="beh-config" id="beh-cfg-${esc(b.id)}">${fields}</div>`;
1829
+}
1830
+
1831
+function toggleBehConfig(id) {
1832
+ const el = document.getElementById('beh-cfg-' + id);
1833
+ if (!el) return;
1834
+ el.classList.toggle('open');
1835
+ const btn = document.getElementById('beh-cfg-btn-' + id);
1836
+ if (btn) btn.textContent = el.classList.contains('open') ? 'configure ▴' : 'configure ▾';
1837
+}
1838
+
1839
+function onBehCfg(id, key, val) {
1840
+ const b = currentPolicies.behaviors.find(x => x.id === id);
1841
+ if (!b) return;
1842
+ if (!b.config) b.config = {};
1843
+ b.config[key] = val;
1844
+}
1845
+
1846
+function onBehModelSelect(botId, key, selId, customId) {
1847
+ const sel = document.getElementById(selId);
1848
+ const custom = document.getElementById(customId);
1849
+ if (!sel) return;
1850
+ custom.style.display = sel.value === '__other__' ? '' : 'none';
1851
+ if (sel.value !== '__other__') onBehCfg(botId, key, sel.value);
1852
+}
1853
+
1854
+async function loadBehModels(btn, botId, backendKey, modelKey, selId, customId) {
1855
+ const b = currentPolicies && currentPolicies.behaviors.find(x => x.id === botId);
1856
+ const backendName = b && b.config && b.config[backendKey];
1857
+ if (!backendName) {
1858
+ alert('Select a backend first, then click ↺ to load its models.');
1859
+ return;
1860
+ }
1861
+ const origText = btn.textContent;
1862
+ btn.textContent = '…';
1863
+ btn.disabled = true;
1864
+ try {
1865
+ const models = await api('GET', `/v1/llm/backends/${encodeURIComponent(backendName)}/models`);
1866
+ const sel = document.getElementById(selId);
1867
+ const custom = document.getElementById(customId);
1868
+ if (!sel) return;
1869
+ const current = (b.config && b.config[modelKey]) || '';
1870
+ sel.innerHTML = '<option value="">— none / auto-select —</option>';
1871
+ for (const m of (models || [])) {
1872
+ const id = typeof m === 'string' ? m : m.id;
1873
+ const label = (typeof m === 'object' && m.name && m.name !== id) ? `${id} — ${m.name}` : id;
1874
+ const opt = document.createElement('option');
1875
+ opt.value = id;
1876
+ opt.textContent = label;
1877
+ if (id === current) opt.selected = true;
1878
+ sel.appendChild(opt);
1879
+ }
1880
+ const other = document.createElement('option');
1881
+ other.value = '__other__';
1882
+ other.textContent = '— other (type below) —';
1883
+ const matched = (models || []).some(m => (typeof m === 'string' ? m : m.id) === current);
1884
+ if (current && !matched) {
1885
+ other.selected = true;
1886
+ if (custom) { custom.value = current; custom.style.display = ''; }
1887
+ }
1888
+ sel.appendChild(other);
1889
+ } catch(e) {
1890
+ alert('Model discovery failed: ' + e.message);
1891
+ } finally {
1892
+ btn.textContent = origText;
1893
+ btn.disabled = false;
1894
+ }
1895
+}
1896
+
1897
+// --- admin accounts ---
1898
+async function loadAdmins() {
1899
+ try {
1900
+ const data = await api('GET', '/v1/admins');
1901
+ renderAdmins(data.admins || []);
1902
+ } catch(e) {
1903
+ // admins endpoint may not exist on token-only setups
1904
+ document.getElementById('admins-list-container').innerHTML = '';
1905
+ }
1906
+}
1907
+
1908
+function renderAdmins(admins) {
1909
+ const el = document.getElementById('admins-list-container');
1910
+ if (!admins.length) { el.innerHTML = ''; return; }
1911
+ const rows = admins.map(a => `<tr>
1912
+ <td><strong>${esc(a.username)}</strong></td>
1913
+ <td style="color:#8b949e;font-size:12px">${fmtTime(a.created)}</td>
1914
+ <td><div class="actions">
1915
+ <button class="sm" onclick="promptAdminPassword('${esc(a.username)}')">change password</button>
1916
+ <button class="sm danger" onclick="removeAdmin('${esc(a.username)}')">remove</button>
1917
+ </div></td>
1918
+ </tr>`).join('');
1919
+ el.innerHTML = `<table><thead><tr><th>username</th><th>created</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1920
+}
1921
+
1922
+async function addAdmin(e) {
1923
+ e.preventDefault();
1924
+ const username = document.getElementById('new-admin-username').value.trim();
1925
+ const password = document.getElementById('new-admin-password').value;
1926
+ const resultEl = document.getElementById('add-admin-result');
1927
+ if (!username || !password) return;
1928
+ try {
1929
+ await api('POST', '/v1/admins', { username, password });
1930
+ resultEl.innerHTML = renderAlert('success', `Admin <strong>${esc(username)}</strong> added.`);
1931
+ resultEl.style.display = 'block';
1932
+ document.getElementById('add-admin-form').reset();
1933
+ await loadAdmins();
1934
+ setTimeout(() => { resultEl.style.display = 'none'; }, 3000);
1935
+ } catch(e) {
1936
+ resultEl.innerHTML = renderAlert('error', e.message);
1937
+ resultEl.style.display = 'block';
1938
+ }
1939
+}
1940
+
1941
+async function removeAdmin(username) {
1942
+ if (!confirm(`Remove admin "${username}"?`)) return;
1943
+ try {
1944
+ await api('DELETE', `/v1/admins/${encodeURIComponent(username)}`);
1945
+ await loadAdmins();
1946
+ } catch(e) { alert('Remove failed: ' + e.message); }
1947
+}
1948
+
1949
+async function promptAdminPassword(username) {
1950
+ const pw = prompt(`New password for ${username}:`);
1951
+ if (!pw) return;
1952
+ try {
1953
+ await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw });
1954
+ alert('Password updated.');
1955
+ } catch(e) { alert('Failed: ' + e.message); }
1956
+}
1957
+
1958
+// --- AI / LLM tab ---
1959
+async function loadAI() {
1960
+ await Promise.all([loadAIBackends(), loadAIKnown()]);
1961
+}
1962
+
1963
+async function loadAIBackends() {
1964
+ const el = document.getElementById('ai-backends-list');
1965
+ try {
1966
+ const backends = await api('GET', '/v1/llm/backends');
1967
+ if (!backends || backends.length === 0) {
1968
+ el.innerHTML = '<div class="empty-state">No LLM backends configured yet. Click <strong>+ add backend</strong> above or add them to <code>scuttlebot.yaml</code>.</div>';
1969
+ return;
1970
+ }
1971
+ el.innerHTML = backends.map(b => {
1972
+ const sid = CSS.escape(b.name);
1973
+ const editable = b.source === 'policy';
1974
+ const srcBadge = b.source === 'config'
1975
+ ? '<span class="badge" style="background:#21262d;color:#8b949e;border:1px solid #30363d">yaml</span>'
1976
+ : '<span class="badge" style="background:#21262d;color:#58a6ff;border:1px solid #1f6feb">ui</span>';
1977
+ return `<div class="setting-row" style="flex-wrap:wrap;gap:8px;align-items:center">
1978
+ <div style="flex:1;min-width:140px">
1979
+ <div style="font-weight:500;color:#e6edf3">${esc(b.name)} ${srcBadge}${b.default ? ' <span class="badge" style="background:#1f6feb">default</span>' : ''}</div>
1980
+ <div style="font-size:11px;color:#8b949e;margin-top:2px">${esc(b.backend)}${b.region ? ' · ' + esc(b.region) : ''}${b.base_url ? ' · ' + esc(b.base_url) : ''}</div>
1981
+ </div>
1982
+ <div style="min-width:100px;font-size:12px;color:#8b949e">${b.model ? 'model: <code style="color:#a5d6ff">' + esc(b.model) + '</code>' : '<span style="color:#6e7681">model: auto</span>'}</div>
1983
+ <div id="ai-models-${sid}" style="width:100%;display:none"></div>
1984
+ <button class="sm" onclick="discoverModels('${esc(b.name)}', this)">discover models</button>
1985
+ ${editable ? `<button class="sm" onclick="openEditBackend('${esc(b.name)}')">edit</button>
1986
+ <button class="sm danger" onclick="deleteBackend('${esc(b.name)}', this)">delete</button>` : ''}
1987
+ </div>`;
1988
+ }).join('<div style="height:1px;background:#21262d;margin:4px 0"></div>');
1989
+ } catch(e) {
1990
+ el.innerHTML = `<div class="empty-state" style="color:#f85149">${esc(String(e))}</div>`;
1991
+ }
1992
+}
1993
+
1994
+// --- backend form ---
1995
+
1996
+let _editingBackend = null; // null = adding, string = name being edited
1997
+let _backendList = []; // cached for edit lookups
1998
+
1999
+async function openAddBackend() {
2000
+ _editingBackend = null;
2001
+ document.getElementById('ai-form-title').textContent = 'add backend';
2002
+ document.getElementById('bf-submit-btn').textContent = 'add backend';
2003
+ document.getElementById('bf-name').value = '';
2004
+ document.getElementById('bf-name').disabled = false;
2005
+ document.getElementById('bf-backend').value = '';
2006
+ document.getElementById('bf-apikey').value = '';
2007
+ document.getElementById('bf-baseurl').value = '';
2008
+ populateModelSelect([], '');
2009
+ document.getElementById('bf-model-custom').value = '';
2010
+ document.getElementById('bf-model-custom').style.display = 'none';
2011
+ document.getElementById('bf-load-models-btn').textContent = '↺ load models';
2012
+ document.getElementById('bf-default').checked = false;
2013
+ document.getElementById('bf-region').value = '';
2014
+ document.getElementById('bf-aws-key-id').value = '';
2015
+ document.getElementById('bf-aws-secret').value = '';
2016
+ document.getElementById('bf-allow').value = '';
2017
+ document.getElementById('bf-block').value = '';
2018
+ document.getElementById('ai-form-result').style.display = 'none';
2019
+ onBackendTypeChange();
2020
+ const card = document.getElementById('card-ai-form');
2021
+ card.style.display = '';
2022
+ card.scrollIntoView({ behavior: 'smooth', block: 'start' });
2023
+}
2024
+
2025
+async function openEditBackend(name) {
2026
+ let b;
2027
+ try {
2028
+ const backends = await api('GET', '/v1/llm/backends');
2029
+ b = backends.find(x => x.name === name);
2030
+ } catch(e) { return; }
2031
+ if (!b) return;
2032
+
2033
+ _editingBackend = name;
2034
+ document.getElementById('ai-form-title').textContent = 'edit backend — ' + esc(name);
2035
+ document.getElementById('bf-submit-btn').textContent = 'save changes';
2036
+ document.getElementById('bf-name').value = name;
2037
+ document.getElementById('bf-name').disabled = true; // name is immutable
2038
+ document.getElementById('bf-backend').value = b.backend || '';
2039
+ document.getElementById('bf-apikey').value = ''; // never pre-fill secrets
2040
+ document.getElementById('bf-baseurl').value = b.base_url || '';
2041
+ const curated = KNOWN_MODELS[b.backend] || [];
2042
+ populateModelSelect(curated, b.model || '');
2043
+ document.getElementById('bf-model-custom').style.display = 'none';
2044
+ document.getElementById('bf-load-models-btn').textContent = '↺ load models';
2045
+ document.getElementById('bf-default').checked = !!b.default;
2046
+ document.getElementById('bf-region').value = b.region || '';
2047
+ document.getElementById('bf-aws-key-id').value = ''; // never pre-fill
2048
+ document.getElementById('bf-aws-secret').value = '';
2049
+ document.getElementById('bf-allow').value = (b.allow || []).join('\n');
2050
+ document.getElementById('bf-block').value = (b.block || []).join('\n');
2051
+ document.getElementById('ai-form-result').style.display = 'none';
2052
+ onBackendTypeChange();
2053
+ const card = document.getElementById('card-ai-form');
2054
+ card.style.display = '';
2055
+ card.scrollIntoView({ behavior: 'smooth', block: 'start' });
2056
+}
2057
+
2058
+function closeBackendForm() {
2059
+ document.getElementById('card-ai-form').style.display = 'none';
2060
+ _editingBackend = null;
2061
+}
2062
+
2063
+// Curated model lists per backend — shown before live discovery.
2064
+const KNOWN_MODELS = {
2065
+ anthropic: [
2066
+ 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001',
2067
+ 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229',
2068
+ ],
2069
+ gemini: [
2070
+ 'gemini-2.0-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-flash',
2071
+ 'gemini-1.5-flash-8b', 'gemini-1.5-pro',
2072
+ ],
2073
+ openai: [
2074
+ 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'o1', 'o1-mini', 'o3-mini', 'gpt-3.5-turbo',
2075
+ ],
2076
+ bedrock: [
2077
+ 'anthropic.claude-3-5-sonnet-20241022-v2:0', 'anthropic.claude-3-5-haiku-20241022-v1:0',
2078
+ 'anthropic.claude-3-opus-20240229-v1:0',
2079
+ 'amazon.nova-pro-v1:0', 'amazon.nova-lite-v1:0', 'amazon.nova-micro-v1:0',
2080
+ 'meta.llama3-70b-instruct-v1:0', 'meta.llama3-8b-instruct-v1:0',
2081
+ 'mistral.mistral-large-2402-v1:0',
2082
+ ],
2083
+ ollama: ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'gemma2', 'qwen2.5', 'phi3'],
2084
+ groq: [
2085
+ 'llama-3.3-70b-versatile', 'llama-3.1-8b-instant',
2086
+ 'mixtral-8x7b-32768', 'gemma2-9b-it',
2087
+ ],
2088
+ mistral: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest', 'open-mistral-nemo'],
2089
+ deepseek: ['deepseek-chat', 'deepseek-reasoner'],
2090
+ xai: ['grok-2', 'grok-2-mini', 'grok-beta'],
2091
+ cerebras: ['llama3.1-8b', 'llama3.1-70b', 'llama3.3-70b'],
2092
+ together: [
2093
+ 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
2094
+ 'meta-llama/Llama-3.1-8B-Instruct-Turbo',
2095
+ 'mistralai/Mixtral-8x7B-Instruct-v0.1',
2096
+ 'Qwen/Qwen2.5-72B-Instruct-Turbo',
2097
+ ],
2098
+ fireworks: [
2099
+ 'accounts/fireworks/models/llama-v3p3-70b-instruct',
2100
+ 'accounts/fireworks/models/mixtral-8x7b-instruct',
2101
+ ],
2102
+ openrouter: [], // too varied — always load live
2103
+ huggingface: [
2104
+ 'meta-llama/Llama-3.3-70B-Instruct',
2105
+ 'mistralai/Mistral-7B-Instruct-v0.3',
2106
+ 'Qwen/Qwen2.5-72B-Instruct',
2107
+ ],
2108
+};
2109
+
2110
+function populateModelSelect(models, currentVal) {
2111
+ const sel = document.getElementById('bf-model-select');
2112
+ sel.innerHTML = '<option value="">— none / auto-select —</option>';
2113
+ for (const m of models) {
2114
+ const id = typeof m === 'string' ? m : m.id;
2115
+ const label = (typeof m === 'object' && m.name && m.name !== id) ? `${id} — ${m.name}` : id;
2116
+ const opt = document.createElement('option');
2117
+ opt.value = id;
2118
+ opt.textContent = label;
2119
+ if (id === currentVal) opt.selected = true;
2120
+ sel.appendChild(opt);
2121
+ }
2122
+ const other = document.createElement('option');
2123
+ other.value = '__other__';
2124
+ other.textContent = '— other (type below) —';
2125
+ if (currentVal && !models.find(m => (typeof m === 'string' ? m : m.id) === currentVal)) {
2126
+ other.selected = true;
2127
+ document.getElementById('bf-model-custom').value = currentVal;
2128
+ document.getElementById('bf-model-custom').style.display = '';
2129
+ }
2130
+ sel.appendChild(other);
2131
+}
2132
+
2133
+function onModelSelectChange() {
2134
+ const sel = document.getElementById('bf-model-select');
2135
+ const custom = document.getElementById('bf-model-custom');
2136
+ custom.style.display = sel.value === '__other__' ? '' : 'none';
2137
+}
2138
+
2139
+function getModelValue() {
2140
+ const sel = document.getElementById('bf-model-select');
2141
+ if (sel.value === '__other__') return document.getElementById('bf-model-custom').value.trim();
2142
+ return sel.value || '';
2143
+}
2144
+
2145
+function onBackendTypeChange() {
2146
+ const t = document.getElementById('bf-backend').value;
2147
+ const isBedrock = t === 'bedrock';
2148
+ const isLocal = ['ollama','litellm','lmstudio','jan','localai','vllm','anythingllm'].includes(t);
2149
+ const hasKey = !isBedrock;
2150
+
2151
+ document.getElementById('bf-bedrock-group').style.display = isBedrock ? '' : 'none';
2152
+ document.getElementById('bf-apikey-row').style.display = hasKey ? '' : 'none';
2153
+ document.getElementById('bf-baseurl-row').style.display = (isLocal || isBedrock) ? 'none' : '';
2154
+
2155
+ const curated = KNOWN_MODELS[t] || [];
2156
+ populateModelSelect(curated, '');
2157
+}
2158
+
2159
+async function loadLiveModels(btn) {
2160
+ const t = document.getElementById('bf-backend').value;
2161
+ if (!t) { alert('Select a backend type first.'); return; }
2162
+
2163
+ btn.disabled = true;
2164
+ btn.textContent = 'loading…';
2165
+ try {
2166
+ const payload = {
2167
+ backend: t,
2168
+ api_key: document.getElementById('bf-apikey')?.value || '',
2169
+ base_url: document.getElementById('bf-baseurl')?.value.trim() || '',
2170
+ region: document.getElementById('bf-region')?.value.trim() || '',
2171
+ aws_key_id: document.getElementById('bf-aws-key-id')?.value.trim() || '',
2172
+ aws_secret_key: document.getElementById('bf-aws-secret')?.value || '',
2173
+ };
2174
+ const models = await api('POST', '/v1/llm/discover', payload);
2175
+ const current = getModelValue();
2176
+ populateModelSelect(models, current);
2177
+ btn.textContent = `↺ ${models.length} loaded`;
2178
+ } catch(e) {
2179
+ btn.textContent = '✕ failed';
2180
+ setTimeout(() => { btn.textContent = '↺ load models'; }, 2000);
2181
+ alert('Discovery failed: ' + String(e));
2182
+ } finally {
2183
+ btn.disabled = false;
2184
+ }
2185
+}
2186
+
2187
+async function submitBackendForm() {
2188
+ const name = document.getElementById('bf-name').value.trim();
2189
+ const backend = document.getElementById('bf-backend').value;
2190
+ if (!name || !backend) {
2191
+ showFormResult('name and backend type are required', 'error');
2192
+ return;
2193
+ }
2194
+
2195
+ const allow = document.getElementById('bf-allow').value.trim().split('\n').map(s=>s.trim()).filter(Boolean);
2196
+ const block = document.getElementById('bf-block').value.trim().split('\n').map(s=>s.trim()).filter(Boolean);
2197
+
2198
+ const payload = {
2199
+ name, backend,
2200
+ api_key: document.getElementById('bf-apikey').value || undefined,
2201
+ base_url: document.getElementById('bf-baseurl').value.trim() || undefined,
2202
+ model: getModelValue() || undefined,
2203
+ default: document.getElementById('bf-default').checked || undefined,
2204
+ region: document.getElementById('bf-region').value.trim() || undefined,
2205
+ aws_key_id: document.getElementById('bf-aws-key-id').value.trim() || undefined,
2206
+ aws_secret_key: document.getElementById('bf-aws-secret').value || undefined,
2207
+ allow: allow.length ? allow : undefined,
2208
+ block: block.length ? block : undefined,
2209
+ };
2210
+
2211
+ const btn = document.getElementById('bf-submit-btn');
2212
+ btn.disabled = true;
2213
+ try {
2214
+ if (_editingBackend) {
2215
+ await api('PUT', `/v1/llm/backends/${encodeURIComponent(_editingBackend)}`, payload);
2216
+ } else {
2217
+ await api('POST', '/v1/llm/backends', payload);
2218
+ }
2219
+ closeBackendForm();
2220
+ await loadAIBackends();
2221
+ } catch(e) {
2222
+ showFormResult(String(e), 'error');
2223
+ } finally {
2224
+ btn.disabled = false;
2225
+ }
2226
+}
2227
+
2228
+async function deleteBackend(name, btn) {
2229
+ btn.disabled = true;
2230
+ try {
2231
+ await api('DELETE', `/v1/llm/backends/${encodeURIComponent(name)}`);
2232
+ await loadAIBackends();
2233
+ } catch(e) {
2234
+ btn.disabled = false;
2235
+ alert('Delete failed: ' + String(e));
2236
+ }
2237
+}
2238
+
2239
+function showFormResult(msg, type) {
2240
+ const el = document.getElementById('ai-form-result');
2241
+ el.style.display = '';
2242
+ el.className = 'alert ' + (type === 'error' ? 'danger' : 'info');
2243
+ el.innerHTML = `<span class="icon">${type === 'error' ? '✕' : 'ℹ'}</span><span>${esc(msg)}</span>`;
2244
+}
2245
+
2246
+async function discoverModels(name, btn) {
2247
+ const el = document.getElementById('ai-models-' + name);
2248
+ if (!el) return;
2249
+ btn.disabled = true;
2250
+ btn.textContent = 'discovering…';
2251
+ try {
2252
+ const models = await api('GET', `/v1/llm/backends/${encodeURIComponent(name)}/models`);
2253
+ el.style.display = 'block';
2254
+ if (!models || models.length === 0) {
2255
+ el.innerHTML = '<div style="font-size:12px;color:#8b949e;padding:6px 0">No models found (check filters).</div>';
2256
+ } else {
2257
+ el.innerHTML = `<div style="font-size:12px;color:#8b949e;margin-bottom:6px">${models.length} model${models.length !== 1 ? 's' : ''} available:</div>
2258
+ <div style="display:flex;flex-wrap:wrap;gap:4px">${models.map(m =>
2259
+ `<code style="background:#161b22;border:1px solid #30363d;border-radius:4px;padding:2px 6px;font-size:11px;color:#a5d6ff">${esc(m.id)}${m.name && m.name !== m.id ? ' <span style="color:#6e7681">(' + esc(m.name) + ')</span>' : ''}</code>`
2260
+ ).join('')}</div>`;
2261
+ }
2262
+ btn.textContent = '↺ refresh';
2263
+ } catch(e) {
2264
+ el.style.display = 'block';
2265
+ el.innerHTML = `<div style="font-size:12px;color:#f85149">Error: ${esc(String(e))}</div>`;
2266
+ btn.textContent = 'retry';
2267
+ } finally {
2268
+ btn.disabled = false;
2269
+ }
2270
+}
2271
+
2272
+async function loadAIKnown() {
2273
+ const el = document.getElementById('ai-supported-list');
2274
+ try {
2275
+ const known = await api('GET', '/v1/llm/known');
2276
+ const native = known.filter(b => b.native);
2277
+ const compat = known.filter(b => !b.native);
2278
+ native.sort((a,b) => a.name.localeCompare(b.name));
2279
+ compat.sort((a,b) => a.name.localeCompare(b.name));
2280
+ el.innerHTML = `
2281
+ <div style="margin-bottom:12px">
2282
+ <div style="font-size:12px;color:#8b949e;font-weight:500;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px">native APIs</div>
2283
+ <div style="display:flex;flex-wrap:wrap;gap:4px">${native.map(b =>
2284
+ `<span style="background:#161b22;border:1px solid #30363d;border-radius:4px;padding:3px 8px;font-size:12px;color:#e6edf3">${esc(b.name)}</span>`
2285
+ ).join('')}</div>
2286
+ </div>
2287
+ <div>
2288
+ <div style="font-size:12px;color:#8b949e;font-weight:500;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px">OpenAI-compatible</div>
2289
+ <div style="display:flex;flex-wrap:wrap;gap:4px">${compat.map(b =>
2290
+ `<span style="background:#161b22;border:1px solid #30363d;border-radius:4px;padding:3px 8px;font-size:12px;color:#e6edf3" title="${esc(b.base_url)}">${esc(b.name)}</span>`
2291
+ ).join('')}</div>
2292
+ </div>`;
2293
+ } catch(e) {
2294
+ el.innerHTML = `<div class="empty-state" style="color:#f85149">${esc(String(e))}</div>`;
2295
+ }
2296
+}
2297
+
2298
+function showAIExample(e) {
2299
+ e.preventDefault();
2300
+ const card = document.getElementById('card-ai-example');
2301
+ card.style.display = '';
2302
+ card.scrollIntoView({ behavior: 'smooth', block: 'start' });
2303
+ // Expand it if collapsed.
2304
+ const body = card.querySelector('.card-body');
2305
+ if (body) body.style.display = '';
2306
+}
2307
+
2308
+// --- settings / policies ---
2309
+let currentPolicies = null;
2310
+let _llmBackendNames = []; // cached backend names for oracle dropdown
2311
+
2312
+async function loadSettings() {
2313
+ try {
2314
+ const [s, backends] = await Promise.all([
2315
+ api('GET', '/v1/settings'),
2316
+ api('GET', '/v1/llm/backends').catch(() => []),
2317
+ ]);
2318
+ _llmBackendNames = (backends || []).map(b => b.name);
2319
+ renderTLSStatus(s.tls);
2320
+ currentPolicies = s.policies;
2321
+ renderBehaviors(s.policies.behaviors || []);
2322
+ renderAgentPolicy(s.policies.agent_policy || {});
2323
+ renderBridgePolicy(s.policies.bridge || {});
2324
+ renderLoggingPolicy(s.policies.logging || {});
2325
+ loadAdmins();
2326
+ } catch(e) {
2327
+ document.getElementById('tls-badge').textContent = 'error';
2328
+ }
2329
+}
2330
+
2331
+function renderTLSStatus(tls) {
2332
+ const badge = document.getElementById('tls-badge');
2333
+ if (tls.enabled) {
2334
+ badge.textContent = 'TLS active';
2335
+ badge.style.background = '#3fb95022'; badge.style.color = '#3fb950'; badge.style.borderColor = '#3fb95044';
2336
+ } else {
2337
+ badge.textContent = 'HTTP only';
2338
+ }
2339
+ document.getElementById('tls-status-rows').innerHTML = `
2340
+ <div class="setting-row">
2341
+ <div class="setting-label">mode</div>
2342
+ <div class="setting-desc"></div>
2343
+ <code class="setting-val">${tls.enabled ? 'HTTPS (Let\'s Encrypt)' : 'HTTP'}</code>
2344
+ </div>
2345
+ ${tls.enabled ? `
2346
+ <div class="setting-row">
2347
+ <div class="setting-label">domain</div>
2348
+ <div class="setting-desc"></div>
2349
+ <code class="setting-val">${esc(tls.domain)}</code>
2350
+ </div>
2351
+ <div class="setting-row">
2352
+ <div class="setting-label">allow insecure</div>
2353
+ <div class="setting-desc">Plain HTTP also accepted.</div>
2354
+ <code class="setting-val">${tls.allow_insecure ? 'yes' : 'no'}</code>
2355
+ </div>` : ''}
2356
+ `;
2357
+}
2358
+
2359
+function renderBehaviors(behaviors) {
2360
+ const hasSchema = id => !!BEHAVIOR_SCHEMAS[id];
2361
+ document.getElementById('behaviors-list').innerHTML = behaviors.map(b => `
2362
+ <div>
2363
+ <div style="display:grid;grid-template-columns:20px 90px 1fr auto;align-items:center;gap:12px;padding:11px 16px;border-bottom:1px solid #21262d">
2364
+ <input type="checkbox" ${b.enabled?'checked':''} onchange="onBehaviorToggle('${esc(b.id)}',this.checked)" style="width:14px;height:14px;cursor:pointer;accent-color:#58a6ff">
2365
+ <strong style="font-size:13px;white-space:nowrap">${esc(b.name)}</strong>
2366
+ <span style="font-size:12px;color:#8b949e">${esc(b.description)}</span>
2367
+ <div style="display:flex;align-items:center;gap:8px;justify-content:flex-end">
2368
+ ${b.enabled ? `
2369
+ <label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#8b949e;cursor:pointer;white-space:nowrap">
2370
+ <input type="checkbox" ${b.join_all_channels?'checked':''} onchange="onBehaviorJoinAll('${esc(b.id)}',this.checked)" style="accent-color:#58a6ff">
2371
+ all channels
2372
+ </label>
2373
+ ${b.join_all_channels
2374
+ ? `<input type="text" placeholder="exclude #private, /regex/" style="width:160px;padding:3px 7px;font-size:11px" title="Exclude: comma-separated names or /regex/" value="${esc((b.exclude_channels||[]).join(', '))}" onchange="onBehaviorExclude('${esc(b.id)}',this.value)">`
2375
+ : `<input type="text" placeholder="#ops, /^#proj-.*/" style="width:160px;padding:3px 7px;font-size:11px" title="Join: comma-separated names or /regex/ patterns" value="${esc((b.required_channels||[]).join(', '))}" onchange="onBehaviorChannels('${esc(b.id)}',this.value)">`
2376
+ }
2377
+ ${hasSchema(b.id) ? `<button class="sm" id="beh-cfg-btn-${esc(b.id)}" onclick="toggleBehConfig('${esc(b.id)}')" style="font-size:11px;white-space:nowrap">configure ▾</button>` : ''}
2378
+ ` : ''}
2379
+ <span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span>
2380
+ </div>
2381
+ </div>
2382
+ ${b.enabled && hasSchema(b.id) ? renderBehConfig(b) : ''}
2383
+ </div>
2384
+ `).join('');
2385
+}
2386
+
2387
+function onBehaviorToggle(id, enabled) {
2388
+ const b = currentPolicies.behaviors.find(x => x.id === id);
2389
+ if (b) b.enabled = enabled;
2390
+ renderBehaviors(currentPolicies.behaviors);
2391
+}
2392
+function onBehaviorJoinAll(id, val) {
2393
+ const b = currentPolicies.behaviors.find(x => x.id === id);
2394
+ if (b) b.join_all_channels = val;
2395
+ renderBehaviors(currentPolicies.behaviors);
2396
+}
2397
+function onBehaviorExclude(id, val) {
2398
+ const b = currentPolicies.behaviors.find(x => x.id === id);
2399
+ if (b) b.exclude_channels = val.split(',').map(s=>s.trim()).filter(Boolean);
2400
+}
2401
+function onBehaviorChannels(id, val) {
2402
+ const b = currentPolicies.behaviors.find(x => x.id === id);
2403
+ if (b) b.required_channels = val.split(',').map(s=>s.trim()).filter(Boolean);
2404
+}
2405
+
2406
+function renderAgentPolicy(p) {
2407
+ document.getElementById('policy-checkin-enabled').checked = !!p.require_checkin;
2408
+ document.getElementById('policy-checkin-channel').value = p.checkin_channel || '';
2409
+ document.getElementById('policy-required-channels').value = (p.required_channels||[]).join(', ');
2410
+ toggleCheckinChannel();
2411
+}
2412
+function toggleCheckinChannel() {
2413
+ const on = document.getElementById('policy-checkin-enabled').checked;
2414
+ document.getElementById('policy-checkin-row').style.display = on ? '' : 'none';
2415
+}
2416
+
2417
+function renderBridgePolicy(p) {
2418
+ document.getElementById('policy-bridge-web-user-ttl').value = p.web_user_ttl_minutes || 5;
2419
+}
2420
+
2421
+function renderLoggingPolicy(l) {
2422
+ document.getElementById('policy-logging-enabled').checked = !!l.enabled;
2423
+ document.getElementById('policy-log-dir').value = l.dir || '';
2424
+ document.getElementById('policy-log-format').value = l.format || 'jsonl';
2425
+ document.getElementById('policy-log-rotation').value = l.rotation || 'none';
2426
+ document.getElementById('policy-log-max-size').value = l.max_size_mb || '';
2427
+ document.getElementById('policy-log-per-channel').checked = !!l.per_channel;
2428
+ document.getElementById('policy-log-max-age').value = l.max_age_days || '';
2429
+ toggleLogOptions();
2430
+ toggleRotationOptions();
2431
+}
2432
+function toggleLogOptions() {
2433
+ const on = document.getElementById('policy-logging-enabled').checked;
2434
+ document.getElementById('policy-log-options').style.display = on ? '' : 'none';
2435
+}
2436
+function toggleRotationOptions() {
2437
+ const rot = document.getElementById('policy-log-rotation').value;
2438
+ document.getElementById('policy-log-size-row').style.display = rot === 'size' ? '' : 'none';
2439
+}
2440
+
2441
+async function savePolicies() {
2442
+ if (!currentPolicies) return;
2443
+ const p = JSON.parse(JSON.stringify(currentPolicies)); // deep copy
2444
+ p.agent_policy = {
2445
+ require_checkin: document.getElementById('policy-checkin-enabled').checked,
2446
+ checkin_channel: document.getElementById('policy-checkin-channel').value.trim(),
2447
+ required_channels: document.getElementById('policy-required-channels').value.split(',').map(s=>s.trim()).filter(Boolean),
2448
+ };
2449
+ p.bridge = {
2450
+ web_user_ttl_minutes: parseInt(document.getElementById('policy-bridge-web-user-ttl').value, 10) || 5,
2451
+ };
2452
+ p.logging = {
2453
+ enabled: document.getElementById('policy-logging-enabled').checked,
2454
+ dir: document.getElementById('policy-log-dir').value.trim(),
2455
+ format: document.getElementById('policy-log-format').value,
2456
+ rotation: document.getElementById('policy-log-rotation').value,
2457
+ max_size_mb: parseInt(document.getElementById('policy-log-max-size').value, 10) || 0,
2458
+ per_channel: document.getElementById('policy-log-per-channel').checked,
2459
+ max_age_days: parseInt(document.getElementById('policy-log-max-age').value, 10) || 0,
2460
+ };
2461
+ const resultEl = document.getElementById('policies-save-result');
2462
+ try {
2463
+ currentPolicies = await api('PUT', '/v1/settings/policies', p);
2464
+ resultEl.style.display = 'block';
2465
+ resultEl.innerHTML = renderAlert('success', 'Settings saved.');
2466
+ setTimeout(() => { resultEl.style.display = 'none'; }, 3000);
3682467
} catch(e) {
3692468
resultEl.style.display = 'block';
3702469
resultEl.innerHTML = renderAlert('error', e.message);
3712470
}
3722471
}
3732472
374
-function showCredentials(nick, creds, payload, mode) {
375
- const resultEl = document.getElementById('register-result');
376
- resultEl.style.display = 'block';
377
-
378
- const passphrase = creds?.passphrase || creds?.Passphrase || '';
379
- const payloadSig = payload?.signature || '';
380
- const payloadEnc = payload ? JSON.stringify(payload.payload || payload, null, 2) : '';
381
-
382
- resultEl.innerHTML = renderAlert('success',
383
- `<strong>${mode === 'register' ? 'Agent registered' : 'Credentials rotated'}: ${esc(nick)}</strong>
384
- <div style="margin-top:10px" class="cred-box">
385
- <div class="cred-row">
386
- <span class="cred-key">nick</span>
387
- <span class="cred-val">${esc(creds?.nick || nick)}</span>
388
- <button class="small copy-btn" onclick="copyText('${esc(creds?.nick || nick)}', this)">copy</button>
389
- </div>
390
- <div class="cred-row">
391
- <span class="cred-key">passphrase</span>
392
- <span class="cred-val">${esc(passphrase)}</span>
393
- <button class="small copy-btn" onclick="copyText('${esc(passphrase)}', this)">copy</button>
394
- </div>
395
- ${payloadSig ? `<div class="cred-row">
396
- <span class="cred-key">sig</span>
397
- <span class="cred-val" style="font-size:11px">${esc(payloadSig)}</span>
398
- </div>` : ''}
399
- </div>
400
- <div style="margin-top:8px;font-size:11px;color:#7ee787">⚠ Save the passphrase now — it will not be shown again.</div>`
401
- );
402
-}
403
-
404
-// --- helpers ---
405
-function renderAlert(type, msg) {
406
- const icons = { info: 'ℹ', error: '✕', success: '✓' };
407
- return `<div class="alert ${type}"><span class="icon">${icons[type]}</span><div>${msg}</div></div>`;
408
-}
409
-function esc(s) {
410
- return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
411
-}
412
-function fmtTime(iso) {
413
- if (!iso) return '—';
414
- const d = new Date(iso);
415
- return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
416
-}
417
-
418
-// --- chat ---
419
-let chatChannel = null;
420
-let chatSSE = null;
421
-
422
-async function loadChannels() {
423
- if (!getToken()) return;
424
- try {
425
- const data = await api('GET', '/v1/channels');
426
- renderChannelList(data.channels || []);
427
- document.getElementById('chat-card').style.display = '';
428
- } catch(e) {
429
- // bridge disabled or error — keep chat card hidden
430
- }
431
-}
432
-
433
-function renderChannelList(channels) {
434
- const list = document.getElementById('chat-channel-list');
435
- // Remove old channel items (keep header div and join input div)
436
- Array.from(list.querySelectorAll('.chan-item')).forEach(el => el.remove());
437
- channels.sort().forEach(ch => {
438
- const el = document.createElement('div');
439
- el.className = 'chan-item' + (ch === chatChannel ? ' active' : '');
440
- el.textContent = ch;
441
- el.onclick = () => selectChannel(ch);
442
- list.appendChild(el);
443
- });
444
-}
445
-
446
-async function joinChannel() {
447
- let ch = document.getElementById('join-channel-input').value.trim();
448
- if (!ch) return;
449
- if (!ch.startsWith('#')) ch = '#' + ch;
450
- const slug = ch.replace(/^#/, '');
451
- try {
452
- await api('POST', `/v1/channels/${slug}/join`);
453
- document.getElementById('join-channel-input').value = '';
454
- await loadChannels();
455
- selectChannel(ch);
456
- } catch(e) {
457
- alert('Join failed: ' + e.message);
458
- }
459
-}
460
-
461
-document.getElementById('join-channel-input').addEventListener('keydown', e => {
462
- if (e.key === 'Enter') joinChannel();
463
-});
464
-
465
-async function selectChannel(ch) {
466
- chatChannel = ch;
467
- document.getElementById('chat-channel-badge').textContent = ch;
468
- document.getElementById('chat-channel-badge').style.display = '';
469
- document.getElementById('chat-placeholder').style.display = 'none';
470
- document.querySelectorAll('.chan-item').forEach(el => {
471
- el.classList.toggle('active', el.textContent === ch);
472
- });
473
-
474
- const area = document.getElementById('chat-messages');
475
- // clear previous messages (keep placeholder)
476
- Array.from(area.children).forEach(el => { if (!el.id) el.remove(); });
477
-
478
- try {
479
- const slug = ch.replace(/^#/, '');
480
- const data = await api('GET', `/v1/channels/${slug}/messages`);
481
- (data.messages || []).forEach(appendMessage);
482
- area.scrollTop = area.scrollHeight;
483
- } catch(e) {}
484
-
485
- if (chatSSE) { chatSSE.close(); chatSSE = null; }
486
- const slug = ch.replace(/^#/, '');
487
- const url = `/v1/channels/${slug}/stream?token=${encodeURIComponent(getToken())}`;
488
- const es = new EventSource(url);
489
- chatSSE = es;
490
- const status = document.getElementById('chat-stream-status');
491
- es.onopen = () => { status.textContent = '● live'; status.style.color = '#3fb950'; };
492
- es.onmessage = (e) => {
493
- try {
494
- const msg = JSON.parse(e.data);
495
- appendMessage(msg);
496
- area.scrollTop = area.scrollHeight;
497
- } catch(_) {}
498
- };
499
- es.onerror = () => { status.textContent = '○ reconnecting…'; status.style.color = '#8b949e'; };
500
-}
501
-
502
-function appendMessage(msg) {
503
- const area = document.getElementById('chat-messages');
504
- const t = new Date(msg.at);
505
- const timeStr = t.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
506
- const isBridge = msg.nick === 'bridge';
507
- const row = document.createElement('div');
508
- row.className = 'msg-row';
509
- row.innerHTML = `<span class="msg-time">${esc(timeStr)}</span>`
510
- + `<span class="msg-nick${isBridge ? ' bridge-nick' : ''}">${esc(msg.nick)}</span>`
511
- + `<span class="msg-text">${esc(msg.text)}</span>`;
512
- area.appendChild(row);
513
-}
514
-
515
-async function sendChatMessage() {
516
- if (!chatChannel) return;
517
- const input = document.getElementById('chat-text-input');
518
- const nick = document.getElementById('chat-nick-input').value.trim() || 'web';
519
- const text = input.value.trim();
520
- if (!text) return;
521
- input.disabled = true;
522
- document.getElementById('chat-send-btn').disabled = true;
523
- try {
524
- const slug = chatChannel.replace(/^#/, '');
525
- await api('POST', `/v1/channels/${slug}/messages`, { text, nick });
526
- input.value = '';
527
- } catch(e) {
528
- alert('Send failed: ' + e.message);
529
- } finally {
530
- input.disabled = false;
531
- document.getElementById('chat-send-btn').disabled = false;
532
- input.focus();
533
- }
534
-}
535
-
536
-document.getElementById('chat-text-input').addEventListener('keydown', e => {
537
- if (e.key === 'Enter') sendChatMessage();
538
-});
539
-
5402473
// --- init ---
541
-function loadAll() { loadStatus(); loadAgents(); loadChannels(); }
542
-updateTokenDisplay();
543
-loadAll();
544
-setInterval(loadStatus, 15000);
2474
+function loadAll() { loadStatus(); loadAgents(); loadSettings(); startMetricsPoll(); }
2475
+initAuth();
5452476
</script>
5462477
</body>
5472478
</html>
5482479
5492480
ADDED internal/auth/admin.go
5502481
ADDED internal/auth/admin_test.go
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -3,545 +3,2476 @@
3 <head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>scuttlebot</title>
7 <style>
8 * { box-sizing: border-box; margin: 0; padding: 0; }
9 body { font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace; background: #0d1117; color: #e6edf3; min-height: 100vh; }
10 header { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 24px; display: flex; align-items: center; gap: 16px; }
11 header h1 { font-size: 16px; color: #58a6ff; letter-spacing: 0.05em; }
12 header .tagline { font-size: 12px; color: #8b949e; }
13 header .spacer { flex: 1; }
14 .token-badge { font-size: 12px; color: #8b949e; display: flex; align-items: center; gap: 8px; }
15 .token-badge code { background: #21262d; border: 1px solid #30363d; border-radius: 4px; padding: 2px 8px; color: #a5d6ff; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
16 button { cursor: pointer; border: 1px solid #30363d; border-radius: 6px; padding: 6px 12px; font-size: 13px; font-family: inherit; background: #21262d; color: #e6edf3; transition: background 0.1s; }
17 button:hover { background: #30363d; }
18 button.primary { background: #1f6feb; border-color: #1f6feb; color: #fff; }
19 button.primary:hover { background: #388bfd; }
20 button.danger { background: #21262d; border-color: #f85149; color: #f85149; }
21 button.danger:hover { background: #3d1f1e; }
22 button.small { padding: 3px 8px; font-size: 12px; }
23 main { max-width: 960px; margin: 0 auto; padding: 24px; display: flex; flex-direction: column; gap: 24px; }
24 .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
25 .card-header { padding: 12px 16px; border-bottom: 1px solid #30363d; display: flex; align-items: center; gap: 8px; }
26 .card-header h2 { font-size: 14px; color: #e6edf3; font-weight: 600; }
27 .card-header .badge { background: #1f6feb22; border: 1px solid #1f6feb44; color: #58a6ff; border-radius: 999px; padding: 1px 8px; font-size: 12px; }
28 .card-body { padding: 16px; }
29 .status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
30 .stat { background: #0d1117; border: 1px solid #21262d; border-radius: 6px; padding: 12px 16px; }
31 .stat .label { font-size: 11px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; }
32 .stat .value { font-size: 20px; color: #58a6ff; font-weight: 600; }
33 .stat .sub { font-size: 11px; color: #8b949e; margin-top: 2px; }
34 .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
35 .dot.green { background: #3fb950; box-shadow: 0 0 6px #3fb950aa; }
36 .dot.red { background: #f85149; }
37 table { width: 100%; border-collapse: collapse; font-size: 13px; }
38 th { text-align: left; padding: 8px 12px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #8b949e; border-bottom: 1px solid #21262d; }
39 td { padding: 10px 12px; border-bottom: 1px solid #21262d; color: #e6edf3; vertical-align: top; }
40 tr:last-child td { border-bottom: none; }
41 tr:hover td { background: #1c2128; }
42 .tag { display: inline-block; background: #1f6feb22; border: 1px solid #1f6feb44; color: #79c0ff; border-radius: 4px; padding: 1px 6px; font-size: 11px; margin: 1px; }
43 .tag.type-operator { background: #db613622; border-color: #db613644; color: #ffa657; }
44 .tag.type-orchestrator { background: #8957e522; border-color: #8957e544; color: #d2a8ff; }
45 .tag.type-worker { background: #1f6feb22; border-color: #1f6feb44; color: #79c0ff; }
46 .tag.type-observer { background: #21262d; border-color: #30363d; color: #8b949e; }
47 .tag.revoked { background: #f8514922; border-color: #f8514944; color: #ff7b72; }
48 .actions { display: flex; gap: 6px; flex-wrap: wrap; }
49 .empty { color: #8b949e; font-size: 13px; text-align: center; padding: 24px; }
50 .chan-item { padding: 8px 12px; font-size: 13px; cursor: pointer; color: #8b949e; border-bottom: 1px solid #21262d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
51 .chan-item:hover { background: #1c2128; color: #e6edf3; }
52 .chan-item.active { background: #1f6feb22; color: #58a6ff; border-left: 2px solid #58a6ff; }
53 .msg-row { display: flex; gap: 8px; font-size: 13px; line-height: 1.6; padding: 1px 0; }
54 .msg-time { color: #8b949e; font-size: 11px; flex-shrink: 0; padding-top: 3px; min-width: 44px; }
55 .msg-nick { color: #58a6ff; font-weight: 600; flex-shrink: 0; min-width: 80px; text-align: right; }
56 .msg-nick.bridge-nick { color: #3fb950; }
57 .msg-text { color: #e6edf3; word-break: break-word; }
58 form { display: flex; flex-direction: column; gap: 14px; }
59 .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
60 label { display: block; font-size: 12px; color: #8b949e; margin-bottom: 4px; }
61 input, select, textarea { width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 8px 10px; font-size: 13px; font-family: inherit; color: #e6edf3; outline: none; transition: border-color 0.1s; }
62 input:focus, select:focus, textarea:focus { border-color: #58a6ff; }
63 select option { background: #161b22; }
64 textarea { resize: vertical; min-height: 60px; }
65 .hint { font-size: 11px; color: #8b949e; margin-top: 3px; }
66 .alert { border-radius: 6px; padding: 12px 14px; font-size: 13px; display: flex; gap: 10px; align-items: flex-start; }
67 .alert.info { background: #1f6feb1a; border: 1px solid #1f6feb44; color: #79c0ff; }
68 .alert.error { background: #f851491a; border: 1px solid #f8514944; color: #ff7b72; }
69 .alert.success { background: #3fb9501a; border: 1px solid #3fb95044; color: #7ee787; }
70 .alert .icon { flex-shrink: 0; font-size: 16px; line-height: 1.3; }
71 .cred-box { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 12px; font-size: 12px; }
72 .cred-box .cred-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
73 .cred-box .cred-row:last-child { margin-bottom: 0; }
74 .cred-box .cred-key { color: #8b949e; min-width: 90px; }
75 .cred-box .cred-val { color: #a5d6ff; word-break: break-all; flex: 1; }
76 .cred-box .copy-btn { flex-shrink: 0; }
77 .modal-overlay { display: none; position: fixed; inset: 0; background: #0d111788; z-index: 100; align-items: center; justify-content: center; }
78 .modal-overlay.open { display: flex; }
79 .modal { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 24px; width: 480px; max-width: 95vw; }
80 .modal h3 { font-size: 15px; margin-bottom: 16px; }
81 .modal .btn-row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82 </style>
 
83 </head>
84 <body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
86 <header>
87 <h1>⬡ scuttlebot</h1>
88 <span class="tagline">agent coordination backplane</span>
89 <div class="spacer"></div>
90 <div class="token-badge">
91 <span>token:</span>
92 <code id="token-display">not set</code>
93 <button class="small" onclick="openTokenModal()">set</button>
 
 
 
 
 
 
 
 
 
94 </div>
95 </header>
96
97 <main>
98 <div id="no-token-banner" class="alert info" style="display:none">
99 <span class="icon">ℹ</span>
100 <span>Paste your API token to continue. It was printed to stderr when scuttlebot started:<br><code style="color:#a5d6ff">level=INFO msg="api token" token=...</code></span>
101 </div>
102
103 <!-- Status -->
104 <div class="card">
105 <div class="card-header">
106 <span class="dot green" id="status-dot"></span>
107 <h2>status</h2>
108 </div>
109 <div class="card-body">
110 <div class="status-grid" id="status-grid">
111 <div class="stat"><div class="label">state</div><div class="value" id="stat-status">—</div></div>
112 <div class="stat"><div class="label">uptime</div><div class="value" id="stat-uptime">—</div></div>
113 <div class="stat"><div class="label">agents</div><div class="value" id="stat-agents">—</div></div>
114 <div class="stat"><div class="label">started</div><div class="value" style="font-size:13px" id="stat-started">—</div><div class="sub" id="stat-started-rel"></div></div>
115 </div>
116 <div id="status-error" style="margin-top:12px;display:none"></div>
117 </div>
118 </div>
119
120 <!-- Agents -->
121 <div class="card">
122 <div class="card-header">
123 <h2>agents</h2>
124 <span class="badge" id="agent-count">0</span>
125 <div class="spacer"></div>
126 <button class="small" onclick="loadAgents()">↻ refresh</button>
127 </div>
128 <div class="card-body" style="padding:0">
129 <div id="agents-container"></div>
130 </div>
131 </div>
132
133 <!-- Register -->
134 <div class="card">
135 <div class="card-header"><h2>register agent</h2></div>
136 <div class="card-body">
137 <form id="register-form" onsubmit="handleRegister(event)">
138 <div class="form-row">
139 <div>
140 <label>nick *</label>
141 <input type="text" id="reg-nick" placeholder="my-agent-01" required>
142 </div>
143 <div>
144 <label>type</label>
145 <select id="reg-type">
146 <option value="operator">operator — human, +o + full permissions</option>
147 <option value="worker">worker — gets +v in channels</option>
148 <option value="orchestrator">orchestrator — gets +o in channels</option>
149 <option value="observer">observer — read-only</option>
150 </select>
151 </div>
152 </div>
153 <div>
154 <label>channels</label>
155 <input type="text" id="reg-channels" placeholder="#fleet, #ops, #project.foo">
156 <div class="hint">comma-separated; must start with #</div>
157 </div>
158 <div>
159 <label>permissions</label>
160 <input type="text" id="reg-permissions" placeholder="task.create, task.update">
161 <div class="hint">comma-separated message types this agent is allowed to send</div>
162 </div>
163 <div id="register-result" style="display:none"></div>
164 <div style="display:flex;justify-content:flex-end">
165 <button type="submit" class="primary">register</button>
166 </div>
167 </form>
168 </div>
169 </div>
170
171 <!-- Chat -->
172 <div class="card" id="chat-card" style="display:none">
173 <div class="card-header">
174 <h2>chat</h2>
175 <span class="badge" id="chat-channel-badge" style="display:none"></span>
176 <div class="spacer"></div>
177 <span id="chat-stream-status" style="font-size:11px;color:#8b949e"></span>
178 </div>
179 <div style="display:flex;height:440px">
180 <div id="chat-channel-list" style="width:140px;border-right:1px solid #30363d;overflow-y:auto;flex-shrink:0;padding:8px 0;display:flex;flex-direction:column">
181 <div style="padding:6px 12px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#8b949e">channels</div>
182 <div style="padding:6px 8px;border-bottom:1px solid #21262d;display:flex;gap:4px">
183 <input type="text" id="join-channel-input" placeholder="#channel" style="flex:1;font-size:11px;padding:3px 6px" autocomplete="off">
184 <button class="small" onclick="joinChannel()" style="padding:3px 6px;font-size:11px">+</button>
185 </div>
186 </div>
187 <div style="display:flex;flex-direction:column;flex:1;min-width:0">
188 <div id="chat-messages" style="flex:1;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:2px">
189 <div class="empty" id="chat-placeholder">select a channel to view messages</div>
190 </div>
191 <div style="padding:10px 14px;border-top:1px solid #30363d;display:flex;gap:8px;align-items:center">
192 <input type="text" id="chat-nick-input" placeholder="your nick" style="width:110px;flex-shrink:0" autocomplete="off">
193 <input type="text" id="chat-text-input" placeholder="type a message…" style="flex:1" autocomplete="off">
194 <button class="primary small" id="chat-send-btn" onclick="sendChatMessage()">send</button>
195 </div>
196 </div>
197 </div>
198 </div>
199 </main>
200
201 <!-- Token modal -->
202 <div class="modal-overlay" id="token-modal">
203 <div class="modal">
204 <h3>set API token</h3>
205 <p style="font-size:13px;color:#8b949e;margin-bottom:14px">The token is printed to stderr when scuttlebot starts:<br><code style="color:#a5d6ff">level=INFO msg="api token" token=&lt;value&gt;</code></p>
206 <label>token</label>
207 <input type="text" id="token-input" placeholder="paste token here" autocomplete="off" spellcheck="false">
208 <div class="btn-row">
209 <button onclick="closeTokenModal()">cancel</button>
210 <button class="primary" onclick="saveToken()">save</button>
211 </div>
212 </div>
213 </div>
214
215 <script>
216 // --- token management ---
217 function getToken() { return localStorage.getItem('scuttlebot_token') || ''; }
218 function setToken(t) {
219 localStorage.setItem('scuttlebot_token', t);
220 updateTokenDisplay();
221 }
222 function updateTokenDisplay() {
223 const t = getToken();
224 const el = document.getElementById('token-display');
225 const banner = document.getElementById('no-token-banner');
226 if (t) {
227 el.textContent = t.slice(0, 8) + '…' + t.slice(-4);
228 banner.style.display = 'none';
229 } else {
230 el.textContent = 'not set';
231 banner.style.display = 'flex';
232 }
233 }
234 function openTokenModal() {
235 document.getElementById('token-input').value = getToken();
236 document.getElementById('token-modal').classList.add('open');
237 setTimeout(() => document.getElementById('token-input').focus(), 50);
238 }
239 function closeTokenModal() { document.getElementById('token-modal').classList.remove('open'); }
240 function saveToken() {
241 const v = document.getElementById('token-input').value.trim();
242 if (v) { setToken(v); closeTokenModal(); loadAll(); }
243 }
244 document.getElementById('token-modal').addEventListener('click', function(e) {
245 if (e.target === this) closeTokenModal();
246 });
247 document.addEventListener('keydown', function(e) {
248 if (e.key === 'Escape') closeTokenModal();
249 });
250
251 // --- API ---
252 async function api(method, path, body) {
253 const token = getToken();
254 const opts = { method, headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' } };
255 if (body !== undefined) opts.body = JSON.stringify(body);
256 const res = await fetch(path, opts);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257 if (res.status === 204) return null;
258 const data = await res.json().catch(() => ({ error: res.statusText }));
259 if (!res.ok) throw Object.assign(new Error(data.error || res.statusText), { status: res.status });
260 return data;
261 }
262
263 function copyText(text, btn) {
264 navigator.clipboard.writeText(text).then(() => {
265 const orig = btn.textContent;
266 btn.textContent = '✓';
267 setTimeout(() => { btn.textContent = orig; }, 1200);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268 });
269 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
271 // --- status ---
272 async function loadStatus() {
273 try {
274 const s = await api('GET', '/v1/status');
275 document.getElementById('stat-status').innerHTML = '<span class="dot green"></span>' + s.status;
 
 
 
 
 
276 document.getElementById('stat-uptime').textContent = s.uptime;
277 document.getElementById('stat-agents').textContent = s.agents;
278 const started = new Date(s.started);
279 document.getElementById('stat-started').textContent = started.toLocaleTimeString();
280 document.getElementById('stat-started-rel').textContent = started.toLocaleDateString();
281 document.getElementById('status-error').style.display = 'none';
282 } catch (e) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283 document.getElementById('stat-status').innerHTML = '<span class="dot red"></span>error';
284 const err = document.getElementById('status-error');
285 err.style.display = 'block';
286 err.innerHTML = renderAlert('error', e.message);
287 }
288 }
289
290 // --- agents ---
 
 
 
 
 
 
 
 
 
291 async function loadAgents() {
292 const container = document.getElementById('agents-container');
293 try {
294 const data = await api('GET', '/v1/agents');
295 const agents = data.agents || [];
296 document.getElementById('agent-count').textContent = agents.length;
297 if (agents.length === 0) {
298 container.innerHTML = '<div class="empty">no agents registered yet</div>';
299 return;
300 }
301 let rows = agents.map(a => {
302 const channels = (a.config?.channels || []).map(c => `<span class="tag">${esc(c)}</span>`).join('');
303 const perms = (a.config?.permissions || []).map(p => `<span class="tag">${esc(p)}</span>`).join('');
304 const revoked = a.revoked_at ? '<span class="tag revoked">revoked</span>' : '';
305 return `<tr>
306 <td><strong>${esc(a.nick)}</strong></td>
307 <td><span class="tag type-${a.type}">${esc(a.type)}</span>${revoked}</td>
308 <td>${channels || '<span style="color:#8b949e">—</span>'}</td>
309 <td>${perms || '<span style="color:#8b949e">—</span>'}</td>
310 <td>${fmtTime(a.created_at)}</td>
311 <td>
312 <div class="actions">
313 ${!a.revoked_at ? `<button class="small" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
314 <button class="small danger" onclick="revokeAgent('${esc(a.nick)}')">revoke</button>` : ''}
315 </div>
316 </td>
317 </tr>`;
318 }).join('');
319 container.innerHTML = `<table>
320 <thead><tr><th>nick</th><th>type</th><th>channels</th><th>permissions</th><th>registered</th><th>actions</th></tr></thead>
321 <tbody>${rows}</tbody>
322 </table>`;
323 } catch (e) {
324 container.innerHTML = renderAlert('error', e.message);
325 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326 }
327
328 async function revokeAgent(nick) {
329 if (!confirm(`Revoke agent "${nick}"? This cannot be undone.`)) return;
330 try {
331 await api('POST', `/v1/agents/${nick}/revoke`);
332 await loadAgents();
333 await loadStatus();
334 } catch(e) {
335 alert('Revoke failed: ' + e.message);
336 }
337 }
338
339 async function rotateAgent(nick) {
340 try {
341 const creds = await api('POST', `/v1/agents/${nick}/rotate`);
 
342 showCredentials(nick, creds, null, 'rotate');
343 } catch(e) {
344 alert('Rotate failed: ' + e.message);
345 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346 }
347
348 // --- register ---
349 async function handleRegister(e) {
350 e.preventDefault();
351 const nick = document.getElementById('reg-nick').value.trim();
352 const type = document.getElementById('reg-type').value;
353 const channelsRaw = document.getElementById('reg-channels').value;
354 const permsRaw = document.getElementById('reg-permissions').value;
355
356 const channels = channelsRaw.split(',').map(s => s.trim()).filter(Boolean);
357 const permissions = permsRaw.split(',').map(s => s.trim()).filter(Boolean);
358
359 const resultEl = document.getElementById('register-result');
360 resultEl.style.display = 'none';
361
362 try {
363 const res = await api('POST', '/v1/agents/register', { nick, type, channels, permissions });
364 showCredentials(nick, res.credentials, res.payload, 'register');
365 document.getElementById('register-form').reset();
366 await loadAgents();
367 await loadStatus();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368 } catch(e) {
369 resultEl.style.display = 'block';
370 resultEl.innerHTML = renderAlert('error', e.message);
371 }
372 }
373
374 function showCredentials(nick, creds, payload, mode) {
375 const resultEl = document.getElementById('register-result');
376 resultEl.style.display = 'block';
377
378 const passphrase = creds?.passphrase || creds?.Passphrase || '';
379 const payloadSig = payload?.signature || '';
380 const payloadEnc = payload ? JSON.stringify(payload.payload || payload, null, 2) : '';
381
382 resultEl.innerHTML = renderAlert('success',
383 `<strong>${mode === 'register' ? 'Agent registered' : 'Credentials rotated'}: ${esc(nick)}</strong>
384 <div style="margin-top:10px" class="cred-box">
385 <div class="cred-row">
386 <span class="cred-key">nick</span>
387 <span class="cred-val">${esc(creds?.nick || nick)}</span>
388 <button class="small copy-btn" onclick="copyText('${esc(creds?.nick || nick)}', this)">copy</button>
389 </div>
390 <div class="cred-row">
391 <span class="cred-key">passphrase</span>
392 <span class="cred-val">${esc(passphrase)}</span>
393 <button class="small copy-btn" onclick="copyText('${esc(passphrase)}', this)">copy</button>
394 </div>
395 ${payloadSig ? `<div class="cred-row">
396 <span class="cred-key">sig</span>
397 <span class="cred-val" style="font-size:11px">${esc(payloadSig)}</span>
398 </div>` : ''}
399 </div>
400 <div style="margin-top:8px;font-size:11px;color:#7ee787">⚠ Save the passphrase now — it will not be shown again.</div>`
401 );
402 }
403
404 // --- helpers ---
405 function renderAlert(type, msg) {
406 const icons = { info: 'ℹ', error: '✕', success: '✓' };
407 return `<div class="alert ${type}"><span class="icon">${icons[type]}</span><div>${msg}</div></div>`;
408 }
409 function esc(s) {
410 return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
411 }
412 function fmtTime(iso) {
413 if (!iso) return '—';
414 const d = new Date(iso);
415 return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
416 }
417
418 // --- chat ---
419 let chatChannel = null;
420 let chatSSE = null;
421
422 async function loadChannels() {
423 if (!getToken()) return;
424 try {
425 const data = await api('GET', '/v1/channels');
426 renderChannelList(data.channels || []);
427 document.getElementById('chat-card').style.display = '';
428 } catch(e) {
429 // bridge disabled or error — keep chat card hidden
430 }
431 }
432
433 function renderChannelList(channels) {
434 const list = document.getElementById('chat-channel-list');
435 // Remove old channel items (keep header div and join input div)
436 Array.from(list.querySelectorAll('.chan-item')).forEach(el => el.remove());
437 channels.sort().forEach(ch => {
438 const el = document.createElement('div');
439 el.className = 'chan-item' + (ch === chatChannel ? ' active' : '');
440 el.textContent = ch;
441 el.onclick = () => selectChannel(ch);
442 list.appendChild(el);
443 });
444 }
445
446 async function joinChannel() {
447 let ch = document.getElementById('join-channel-input').value.trim();
448 if (!ch) return;
449 if (!ch.startsWith('#')) ch = '#' + ch;
450 const slug = ch.replace(/^#/, '');
451 try {
452 await api('POST', `/v1/channels/${slug}/join`);
453 document.getElementById('join-channel-input').value = '';
454 await loadChannels();
455 selectChannel(ch);
456 } catch(e) {
457 alert('Join failed: ' + e.message);
458 }
459 }
460
461 document.getElementById('join-channel-input').addEventListener('keydown', e => {
462 if (e.key === 'Enter') joinChannel();
463 });
464
465 async function selectChannel(ch) {
466 chatChannel = ch;
467 document.getElementById('chat-channel-badge').textContent = ch;
468 document.getElementById('chat-channel-badge').style.display = '';
469 document.getElementById('chat-placeholder').style.display = 'none';
470 document.querySelectorAll('.chan-item').forEach(el => {
471 el.classList.toggle('active', el.textContent === ch);
472 });
473
474 const area = document.getElementById('chat-messages');
475 // clear previous messages (keep placeholder)
476 Array.from(area.children).forEach(el => { if (!el.id) el.remove(); });
477
478 try {
479 const slug = ch.replace(/^#/, '');
480 const data = await api('GET', `/v1/channels/${slug}/messages`);
481 (data.messages || []).forEach(appendMessage);
482 area.scrollTop = area.scrollHeight;
483 } catch(e) {}
484
485 if (chatSSE) { chatSSE.close(); chatSSE = null; }
486 const slug = ch.replace(/^#/, '');
487 const url = `/v1/channels/${slug}/stream?token=${encodeURIComponent(getToken())}`;
488 const es = new EventSource(url);
489 chatSSE = es;
490 const status = document.getElementById('chat-stream-status');
491 es.onopen = () => { status.textContent = '● live'; status.style.color = '#3fb950'; };
492 es.onmessage = (e) => {
493 try {
494 const msg = JSON.parse(e.data);
495 appendMessage(msg);
496 area.scrollTop = area.scrollHeight;
497 } catch(_) {}
498 };
499 es.onerror = () => { status.textContent = '○ reconnecting…'; status.style.color = '#8b949e'; };
500 }
501
502 function appendMessage(msg) {
503 const area = document.getElementById('chat-messages');
504 const t = new Date(msg.at);
505 const timeStr = t.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
506 const isBridge = msg.nick === 'bridge';
507 const row = document.createElement('div');
508 row.className = 'msg-row';
509 row.innerHTML = `<span class="msg-time">${esc(timeStr)}</span>`
510 + `<span class="msg-nick${isBridge ? ' bridge-nick' : ''}">${esc(msg.nick)}</span>`
511 + `<span class="msg-text">${esc(msg.text)}</span>`;
512 area.appendChild(row);
513 }
514
515 async function sendChatMessage() {
516 if (!chatChannel) return;
517 const input = document.getElementById('chat-text-input');
518 const nick = document.getElementById('chat-nick-input').value.trim() || 'web';
519 const text = input.value.trim();
520 if (!text) return;
521 input.disabled = true;
522 document.getElementById('chat-send-btn').disabled = true;
523 try {
524 const slug = chatChannel.replace(/^#/, '');
525 await api('POST', `/v1/channels/${slug}/messages`, { text, nick });
526 input.value = '';
527 } catch(e) {
528 alert('Send failed: ' + e.message);
529 } finally {
530 input.disabled = false;
531 document.getElementById('chat-send-btn').disabled = false;
532 input.focus();
533 }
534 }
535
536 document.getElementById('chat-text-input').addEventListener('keydown', e => {
537 if (e.key === 'Enter') sendChatMessage();
538 });
539
540 // --- init ---
541 function loadAll() { loadStatus(); loadAgents(); loadChannels(); }
542 updateTokenDisplay();
543 loadAll();
544 setInterval(loadStatus, 15000);
545 </script>
546 </body>
547 </html>
548
549 DDED internal/auth/admin.go
550 DDED internal/auth/admin_test.go
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -3,545 +3,2476 @@
3 <head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>scuttlebot</title>
7 <style>
8 * { box-sizing: border-box; margin: 0; padding: 0; }
9 body { font-family: ui-monospace,'Cascadia Code','Source Code Pro',monospace; background:#0d1117; color:#e6edf3; height:100vh; display:flex; flex-direction:column; overflow:hidden; }
10
11 /* header */
12 header { background:#161b22; border-bottom:1px solid #30363d; padding:0 20px; display:flex; align-items:stretch; flex-shrink:0; height:48px; }
13 .brand { display:flex; align-items:center; gap:8px; padding-right:20px; border-right:1px solid #30363d; margin-right:4px; }
14 .brand h1 { font-size:14px; color:#58a6ff; letter-spacing:.05em; }
15 .brand span { font-size:11px; color:#8b949e; }
16 nav { display:flex; align-items:stretch; flex:1; }
17 .nav-tab { display:flex; align-items:center; gap:6px; padding:0 14px; font-size:13px; color:#8b949e; cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; transition:color .1s; white-space:nowrap; }
18 .nav-tab:hover { color:#c9d1d9; }
19 .nav-tab.active { color:#e6edf3; border-bottom-color:#58a6ff; }
20 .header-right { display:flex; align-items:center; gap:8px; margin-left:auto; font-size:12px; color:#8b949e; }
21 .header-right code { background:#21262d; border:1px solid #30363d; border-radius:4px; padding:2px 7px; color:#a5d6ff; max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22
23 /* tab panes */
24 .tab-pane { display:none; flex:1; min-height:0; overflow-y:auto; }
25 .tab-pane.active { display:flex; flex-direction:column; }
26 .pane-scroll { flex:1; overflow-y:auto; }
27 .pane-inner { max-width:1000px; margin:0 auto; padding:24px; display:flex; flex-direction:column; gap:20px; }
28
29 /* cards */
30 .card { background:#161b22; border:1px solid #30363d; border-radius:8px; overflow:hidden; }
31 .card-header { padding:12px 16px; border-bottom:1px solid #30363d; display:flex; align-items:center; gap:8px; cursor:pointer; user-select:none; }
32 .card-header:hover { background:#1c2128; }
33 .card-header h2 { font-size:14px; font-weight:600; }
34 .card-header .collapse-icon { font-size:11px; color:#8b949e; margin-left:2px; transition:transform .15s; }
35 .card.collapsed .card-header { border-bottom:none; }
36 .card.collapsed .card-body { display:none; }
37 .card.collapsed .collapse-icon { transform:rotate(-90deg); }
38 .card-body { padding:16px; }
39 /* behavior config panel */
40 .beh-config { background:#0d1117; border-top:1px solid #21262d; padding:14px 16px 14px 42px; display:none; }
41 .beh-config.open { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:12px; }
42 .beh-field label { display:block; font-size:11px; color:#8b949e; margin-bottom:3px; }
43 .beh-field input[type=text],.beh-field input[type=number],.beh-field select { width:100%; }
44 .beh-field .hint { font-size:10px; color:#6e7681; margin-top:2px; }
45 .spacer { flex:1; }
46 .badge { background:#1f6feb22; border:1px solid #1f6feb44; color:#58a6ff; border-radius:999px; padding:1px 8px; font-size:12px; }
47
48 /* status */
49 .stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; }
50 .stat { background:#0d1117; border:1px solid #21262d; border-radius:6px; padding:12px 16px; }
51 .stat .lbl { font-size:11px; color:#8b949e; text-transform:uppercase; letter-spacing:.06em; margin-bottom:4px; }
52 .stat .val { font-size:20px; color:#58a6ff; font-weight:600; }
53 .stat .sub { font-size:11px; color:#8b949e; margin-top:2px; }
54 .dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:5px; }
55 .dot.green { background:#3fb950; box-shadow:0 0 6px #3fb950aa; }
56 .dot.red { background:#f85149; }
57
58 /* table */
59 table { width:100%; border-collapse:collapse; font-size:13px; }
60 th { text-align:left; padding:8px 12px; font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:#8b949e; border-bottom:1px solid #21262d; white-space:nowrap; }
61 td { padding:9px 12px; border-bottom:1px solid #21262d; color:#e6edf3; vertical-align:middle; }
62 tr:last-child td { border-bottom:none; }
63 tr:hover td { background:#1c2128; }
64
65 /* tags */
66 .tag { display:inline-block; border-radius:4px; padding:1px 6px; font-size:11px; margin:1px; border:1px solid; }
67 .tag.ch { background:#1f6feb22; border-color:#1f6feb44; color:#79c0ff; }
68 .tag.perm{ background:#21262d; border-color:#30363d; color:#8b949e; }
69 .tag.type-operator { background:#db613622; border-color:#db613644; color:#ffa657; }
70 .tag.type-orchestrator { background:#8957e522; border-color:#8957e544; color:#d2a8ff; }
71 .tag.type-worker { background:#1f6feb22; border-color:#1f6feb44; color:#79c0ff; }
72 .tag.type-observer { background:#21262d; border-color:#30363d; color:#8b949e; }
73 .tag.revoked { background:#f8514922; border-color:#f8514944; color:#ff7b72; }
74
75 /* buttons */
76 button { cursor:pointer; border:1px solid #30363d; border-radius:6px; padding:6px 12px; font-size:13px; font-family:inherit; background:#21262d; color:#e6edf3; transition:background .1s; }
77 button:hover:not(:disabled) { background:#30363d; }
78 button:disabled { opacity:.5; cursor:default; }
79 button.primary { background:#1f6feb; border-color:#1f6feb; color:#fff; }
80 button.primary:hover:not(:disabled) { background:#388bfd; }
81 button.danger { border-color:#f85149; color:#f85149; }
82 button.danger:hover:not(:disabled) { background:#3d1f1e; }
83 button.sm { padding:3px 8px; font-size:12px; }
84 .actions { display:flex; gap:6px; }
85
86 /* forms */
87 form { display:flex; flex-direction:column; gap:14px; }
88 .form-row { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
89 label { display:block; font-size:12px; color:#8b949e; margin-bottom:4px; }
90 input,select,textarea { width:100%; background:#0d1117; border:1px solid #30363d; border-radius:6px; padding:8px 10px; font-size:13px; font-family:inherit; color:#e6edf3; outline:none; transition:border-color .1s; }
91 input:focus,select:focus,textarea:focus { border-color:#58a6ff; }
92 select option { background:#161b22; }
93 .hint { font-size:11px; color:#8b949e; margin-top:3px; }
94
95 /* alerts */
96 .alert { border-radius:6px; padding:12px 14px; font-size:13px; display:flex; gap:10px; align-items:flex-start; }
97 .alert.info { background:#1f6feb1a; border:1px solid #1f6feb44; color:#79c0ff; }
98 .alert.error { background:#f851491a; border:1px solid #f8514944; color:#ff7b72; }
99 .alert.success { background:#3fb9501a; border:1px solid #3fb95044; color:#7ee787; }
100 .alert .icon { flex-shrink:0; font-size:15px; line-height:1.4; }
101 .cred-box { background:#0d1117; border:1px solid #30363d; border-radius:6px; padding:12px; font-size:12px; margin-top:10px; }
102 .cred-row { display:flex; align-items:baseline; gap:8px; margin-bottom:6px; }
103 .cred-row:last-child { margin-bottom:0; }
104 .cred-key { color:#8b949e; min-width:90px; }
105 .cred-val { color:#a5d6ff; word-break:break-all; flex:1; }
106
107 /* search/filter bar */
108 .filter-bar { display:flex; gap:8px; align-items:center; padding:10px 16px; border-bottom:1px solid #30363d; }
109 .filter-bar input { max-width:280px; padding:5px 10px; }
110
111 /* empty */
112 .empty { color:#8b949e; font-size:13px; text-align:center; padding:28px; }
113
114 /* drawer */
115 .drawer-overlay { display:none; position:fixed; inset:0; background:#0d111760; z-index:50; }
116 .drawer-overlay.open { display:block; }
117 .drawer { position:fixed; top:48px; right:0; bottom:0; width:480px; max-width:95vw; background:#161b22; border-left:1px solid #30363d; transform:translateX(100%); transition:transform .2s; z-index:51; display:flex; flex-direction:column; }
118 .drawer.open { transform:translateX(0); }
119 .drawer-header { padding:16px 20px; border-bottom:1px solid #30363d; display:flex; align-items:center; gap:8px; flex-shrink:0; }
120 .drawer-header h3 { font-size:14px; font-weight:600; flex:1; }
121 .drawer-body { flex:1; overflow-y:auto; padding:20px; }
122
123 /* chat */
124 #pane-chat { flex-direction:row; overflow:hidden; }
125 .chat-sidebar { width:180px; min-width:0; flex-shrink:0; border-right:1px solid #30363d; display:flex; flex-direction:column; background:#161b22; overflow:hidden; transition:width .15s; }
126 .chat-sidebar.collapsed { width:28px; }
127 .chat-sidebar.collapsed .chan-join,.chat-sidebar.collapsed .chan-list { display:none; }
128 .sidebar-head { padding:8px 12px; font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:#8b949e; border-bottom:1px solid #30363d; flex-shrink:0; display:flex; align-items:center; gap:4px; }
129 .sidebar-toggle { margin-left:auto; background:none; border:none; color:#8b949e; cursor:pointer; font-size:14px; padding:0 2px; line-height:1; }
130 .sidebar-toggle:hover { color:#e6edf3; }
131 .sidebar-resize { width:4px; flex-shrink:0; cursor:col-resize; background:transparent; transition:background .1s; z-index:10; }
132 .sidebar-resize:hover,.sidebar-resize.dragging { background:#58a6ff55; }
133 .chan-join { display:flex; gap:5px; padding:7px 9px; border-bottom:1px solid #21262d; flex-shrink:0; }
134 .chan-join input { flex:1; padding:4px 7px; font-size:12px; }
135 .chan-list { flex:1; overflow-y:auto; }
136 .chan-item { padding:7px 14px; font-size:13px; cursor:pointer; color:#8b949e; border-bottom:1px solid #21262d; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
137 .chan-item:hover { background:#1c2128; color:#e6edf3; }
138 .chan-item.active { background:#1f6feb22; color:#58a6ff; border-left:2px solid #58a6ff; padding-left:12px; }
139 .chat-main { flex:1; display:flex; flex-direction:column; min-width:0; }
140 .chat-topbar { padding:9px 16px; border-bottom:1px solid #30363d; display:flex; align-items:center; gap:10px; flex-shrink:0; background:#161b22; font-size:13px; }
141 .chat-ch-name { font-weight:600; color:#58a6ff; }
142 .stream-badge { font-size:11px; color:#8b949e; margin-left:auto; }
143 .chat-msgs { flex:1; overflow-y:auto; padding:10px 16px; display:flex; flex-direction:column; gap:1px; }
144 .msg-row { display:flex; gap:10px; font-size:13px; line-height:1.6; padding:1px 0; }
145 .msg-time { color:#8b949e; font-size:11px; flex-shrink:0; padding-top:3px; min-width:40px; }
146 .msg-nick { font-weight:600; flex-shrink:0; min-width:90px; text-align:right; }
147 .msg-grouped .msg-nick { visibility:hidden; }
148 .msg-grouped .msg-time { color:transparent; }
149 .msg-grouped:hover .msg-time { color:#8b949e; transition:color .1s; }
150 .chat-nicklist { width:148px; min-width:0; flex-shrink:0; border-left:1px solid #30363d; display:flex; flex-direction:column; background:#161b22; overflow-y:auto; overflow-x:hidden; transition:width .15s; }
151 .chat-nicklist.collapsed { width:28px; overflow:hidden; }
152 .chat-nicklist.collapsed #nicklist-users { display:none; }
153 .nicklist-head { padding:8px 12px; font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:#8b949e; border-bottom:1px solid #30363d; flex-shrink:0; display:flex; align-items:center; gap:4px; }
154 .nicklist-nick { padding:5px 12px; font-size:12px; color:#8b949e; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
155 .nicklist-nick.is-bot { color:#58a6ff; }
156 .nicklist-nick::before { content:"● "; font-size:8px; vertical-align:middle; }
157 .chat-new-banner { align-self:center; margin:4px auto 0; background:#1f6feb; color:#fff; border-radius:20px; padding:3px 14px; font-size:12px; cursor:pointer; display:inline-block; white-space:nowrap; }
158 /* login screen */
159 .login-screen { position:fixed; inset:0; background:#0d1117; z-index:200; display:flex; align-items:center; justify-content:center; flex-direction:column; }
160 .login-box { width:340px; }
161 .login-brand { text-align:center; margin-bottom:24px; }
162 .login-brand h1 { font-size:22px; color:#58a6ff; letter-spacing:.05em; }
163 .login-brand p { color:#8b949e; font-size:13px; margin-top:6px; }
164 /* unread badge on chat tab */
165 .nav-tab[data-unread]::after { content:attr(data-unread); background:#f85149; color:#fff; border-radius:999px; padding:1px 5px; font-size:10px; margin-left:5px; vertical-align:middle; }
166 .msg-text { color:#e6edf3; word-break:break-word; }
167 .chat-input { padding:9px 13px; border-top:1px solid #30363d; display:flex; gap:7px; flex-shrink:0; background:#161b22; }
168
169 /* channels tab */
170 .chan-card { display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; }
171 .chan-card:last-child { border-bottom:none; }
172 .chan-card:hover { background:#1c2128; }
173 .chan-name { font-size:14px; font-weight:600; color:#58a6ff; }
174 .chan-meta { font-size:12px; color:#8b949e; }
175
176 /* settings */
177 .setting-row { display:flex; align-items:center; gap:12px; padding:14px 0; border-bottom:1px solid #21262d; }
178 .setting-row:last-child { border-bottom:none; }
179 .setting-label { min-width:160px; font-size:13px; color:#c9d1d9; }
180 .setting-desc { font-size:12px; color:#8b949e; flex:1; }
181 .setting-val { font-size:12px; font-family:inherit; color:#a5d6ff; background:#0d1117; border:1px solid #30363d; border-radius:4px; padding:4px 10px; }
182
183 /* modal */
184 .modal-overlay { display:none; position:fixed; inset:0; background:#0d111788; z-index:100; align-items:center; justify-content:center; }
185 .modal-overlay.open { display:flex; }
186 .modal { background:#161b22; border:1px solid #30363d; border-radius:10px; padding:24px; width:480px; max-width:95vw; }
187 .modal h3 { font-size:15px; margin-bottom:16px; }
188 .modal .btn-row { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; }
189 /* charts */
190 .charts-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; }
191 .chart-card { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:14px 16px; }
192 .chart-label { font-size:11px; color:#8b949e; text-transform:uppercase; letter-spacing:.06em; margin-bottom:10px; display:flex; align-items:center; gap:6px; }
193 .chart-label .val { margin-left:auto; font-size:13px; color:#e6edf3; font-weight:600; letter-spacing:0; text-transform:none; }
194 canvas { display:block; width:100% !important; }
195 .bridge-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
196 </style>
197 <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
198 </head>
199 <body>
200
201 <!-- login screen — shown when unauthenticated -->
202 <div class="login-screen" id="login-screen" style="display:none">
203 <div class="login-box">
204 <div class="login-brand">
205 <h1>⬡ scuttlebot</h1>
206 <p>agent coordination backplane</p>
207 </div>
208 <div class="card">
209 <div class="card-body">
210 <form id="login-form" onsubmit="handleLogin(event)" style="gap:12px">
211 <div>
212 <label>username</label>
213 <input type="text" id="login-username" autocomplete="username">
214 </div>
215 <div>
216 <label>password</label>
217 <input type="password" id="login-password" autocomplete="current-password">
218 </div>
219 <div id="login-error" style="display:none"></div>
220 <button type="submit" class="primary" style="width:100%;margin-top:4px" id="login-btn">sign in</button>
221 </form>
222 <details style="margin-top:16px;border-top:1px solid #21262d;padding-top:14px">
223 <summary style="font-size:12px;color:#8b949e;cursor:pointer;user-select:none">use API token instead</summary>
224 <div style="display:flex;gap:8px;margin-top:10px">
225 <input type="text" id="token-login-input" placeholder="paste API token" style="flex:1;font-size:12px" autocomplete="off" spellcheck="false">
226 <button class="sm primary" onclick="saveTokenLogin()">apply</button>
227 </div>
228 <div class="hint" style="margin-top:4px">Token is printed to stderr at startup.</div>
229 </details>
230 </div>
231 </div>
232 <p style="text-align:center;font-size:11px;color:#6e7681;margin-top:14px">
233 <a href="https://scuttlebot.dev" target="_blank" rel="noopener" style="color:#58a6ff;text-decoration:none">ScuttleBot</a> · Powered by <a href="https://weareconflict.com" target="_blank" rel="noopener" style="color:#58a6ff;text-decoration:none">CONFLICT</a>
234 </p>
235 </div>
236 </div>
237
238 <header>
239 <div class="brand">
240 <h1>⬡ scuttlebot</h1>
241 <span>agent coordination backplane</span>
242 </div>
243 <nav>
244 <div class="nav-tab active" id="tab-status" onclick="switchTab('status')">◈ status</div>
245 <div class="nav-tab" id="tab-users" onclick="switchTab('users')">◉ users</div>
246 <div class="nav-tab" id="tab-agents" onclick="switchTab('agents')">◎ agents</div>
247 <div class="nav-tab" id="tab-channels" onclick="switchTab('channels')">◎ channels</div>
248 <div class="nav-tab" id="tab-chat" onclick="switchTab('chat')">◌ chat</div>
249 <div class="nav-tab" id="tab-ai" onclick="switchTab('ai')">✦ ai</div>
250 <div class="nav-tab" id="tab-settings" onclick="switchTab('settings')">⚙ settings</div>
251 </nav>
252 <div class="header-right">
253 <span id="header-user-display" style="font-size:12px;color:#8b949e"></span>
254 <button class="sm" onclick="logout()">sign out</button>
255 </div>
256 </header>
257
258 <!-- STATUS -->
259 <div class="tab-pane active" id="pane-status">
260 <div class="pane-inner">
261 <div id="no-token-banner" class="alert info" style="display:none">
262 <span class="icon">ℹ</span>
263 <span>Paste your API token to continue — printed to stderr at startup: <code style="color:#a5d6ff">level=INFO msg="api token" token=…</code></span>
264 </div>
265
266 <!-- server status card -->
267 <div class="card" id="card-status">
268 <div class="card-header" onclick="toggleCard('card-status',event)">
269 <span class="dot green" id="status-dot"></span><h2>server status</h2>
270 <span class="collapse-icon">▾</span>
271 <div class="spacer"></div>
272 <span style="font-size:11px;color:#8b949e" id="metrics-updated"></span>
273 <button class="sm" onclick="loadStatus()" title="refresh">↻</button>
274 </div>
275 <div class="card-body">
276 <div class="stat-grid">
277 <div class="stat"><div class="lbl">state</div><div class="val" id="stat-status">—</div></div>
278 <div class="stat"><div class="lbl">uptime</div><div class="val" id="stat-uptime">—</div></div>
279 <div class="stat"><div class="lbl">agents</div><div class="val" id="stat-agents">—</div></div>
280 <div class="stat"><div class="lbl">started</div><div class="val" style="font-size:13px" id="stat-started">—</div><div class="sub" id="stat-started-rel"></div></div>
281 </div>
282 <div id="status-error" style="margin-top:12px;display:none"></div>
283 </div>
284 </div>
285
286 <!-- runtime -->
287 <div class="card" id="card-runtime">
288 <div class="card-header" onclick="toggleCard('card-runtime',event)"><h2>runtime</h2><span class="collapse-icon">▾</span></div>
289 <div class="card-body" style="display:flex;flex-direction:column;gap:16px">
290 <div class="stat-grid">
291 <div class="stat"><div class="lbl">goroutines</div><div class="val" id="stat-goroutines">—</div></div>
292 <div class="stat"><div class="lbl">heap alloc</div><div class="val" id="stat-heap">—</div></div>
293 <div class="stat"><div class="lbl">heap sys</div><div class="val" id="stat-heapsys">—</div></div>
294 <div class="stat"><div class="lbl">GC runs</div><div class="val" id="stat-gc">—</div></div>
295 </div>
296 <div class="charts-grid">
297 <div class="chart-card">
298 <div class="chart-label">heap alloc <span class="val" id="chart-heap-val">—</span></div>
299 <canvas id="chart-heap" height="80"></canvas>
300 </div>
301 <div class="chart-card">
302 <div class="chart-label">goroutines <span class="val" id="chart-goroutines-val">—</span></div>
303 <canvas id="chart-goroutines" height="80"></canvas>
304 </div>
305 <div class="chart-card">
306 <div class="chart-label">messages total <span class="val" id="chart-messages-val">—</span></div>
307 <canvas id="chart-messages" height="80"></canvas>
308 </div>
309 </div>
310 </div>
311 </div>
312
313 <!-- bridge -->
314 <div class="card" id="bridge-card" style="display:none">
315 <div class="card-header" onclick="toggleCard('bridge-card',event)"><h2>bridge</h2><span class="collapse-icon">▾</span></div>
316 <div class="card-body">
317 <div class="bridge-grid">
318 <div class="stat"><div class="lbl">channels</div><div class="val" id="stat-bridge-channels">—</div></div>
319 <div class="stat"><div class="lbl">messages total</div><div class="val" id="stat-bridge-msgs">—</div></div>
320 <div class="stat"><div class="lbl">active streams</div><div class="val" id="stat-bridge-subs">—</div></div>
321 </div>
322 </div>
323 </div>
324
325 <!-- registry -->
326 <div class="card" id="card-registry">
327 <div class="card-header" onclick="toggleCard('card-registry',event)"><h2>registry</h2><span class="collapse-icon">▾</span></div>
328 <div class="card-body">
329 <div class="stat-grid">
330 <div class="stat"><div class="lbl">total</div><div class="val" id="stat-reg-total">—</div></div>
331 <div class="stat"><div class="lbl">active</div><div class="val" id="stat-reg-active">—</div></div>
332 <div class="stat"><div class="lbl">revoked</div><div class="val" id="stat-reg-revoked">—</div></div>
333 </div>
334 </div>
335 </div>
336
337 </div>
338 </div>
339
340 <!-- USERS -->
341 <div class="tab-pane" id="pane-users">
342 <div class="filter-bar">
343 <input type="text" id="user-search" placeholder="search by nick or channel…" oninput="renderUsersTable()" style="max-width:320px">
344 <div class="spacer"></div>
345 <span class="badge" id="user-count" style="margin-right:4px">0</span>
346 <button class="sm" onclick="loadAgents()">↻ refresh</button>
347 <button class="sm" onclick="openAdoptDrawer()">adopt existing user</button>
348 <button class="sm primary" onclick="openRegisterUserDrawer()">+ register user</button>
349 </div>
350 <div style="flex:1;overflow-y:auto">
351 <div id="users-container"></div>
352 </div>
353 </div>
354
355 <!-- AGENTS -->
356 <div class="tab-pane" id="pane-agents">
357 <div class="filter-bar">
358 <input type="text" id="agent-search" placeholder="search by nick, type, channel…" oninput="renderAgentTable()" style="max-width:320px">
359 <div class="spacer"></div>
360 <span class="badge" id="agent-count" style="margin-right:4px">0</span>
361 <button class="sm" onclick="loadAgents()">↻ refresh</button>
362 <button class="sm primary" onclick="openDrawer()">+ register agent</button>
363 </div>
364 <div style="flex:1;overflow-y:auto">
365 <div id="agents-container"></div>
366 </div>
367 </div>
368
369 <!-- CHANNELS -->
370 <div class="tab-pane" id="pane-channels">
371 <div class="pane-inner">
372 <div class="card">
373 <div class="card-header">
374 <h2>channels</h2>
375 <span class="badge" id="chan-count">0</span>
376 <div class="spacer"></div>
377 <div style="display:flex;gap:6px;align-items:center">
378 <input type="text" id="quick-join-input" placeholder="#channel" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
379 <button class="sm primary" onclick="quickJoin()">join</button>
380 </div>
381 </div>
382 <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
383 </div>
384 </div>
385 </div>
386
387 <!-- CHAT -->
388 <div class="tab-pane" id="pane-chat">
389 <div class="chat-sidebar" id="chat-sidebar-left">
390 <div class="sidebar-head">
391 <span id="sidebar-left-label">channels</span>
392 <button class="sidebar-toggle" id="sidebar-left-toggle" title="collapse" onclick="toggleSidebar('left')">‹</button>
393 </div>
394 <div class="chan-join">
395 <input type="text" id="join-channel-input" placeholder="#general" autocomplete="off">
396 <button class="sm" onclick="joinChannel()">+</button>
397 </div>
398 <div class="chan-list" id="chan-list"></div>
399 </div>
400 <div class="sidebar-resize" id="resize-left" title="drag to resize"></div>
401 <div class="chat-main">
402 <div class="chat-topbar">
403 <span class="chat-ch-name" id="chat-ch-name">select a channel</span>
404 <div class="spacer"></div>
405 <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
406 <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
407 <option value="">— pick a user —</option>
408 </select>
409 <span class="stream-badge" id="chat-stream-status" style="margin-left:8px"></span>
410 </div>
411 <div class="chat-msgs" id="chat-msgs">
412 <div class="empty" id="chat-placeholder">join a channel to start chatting</div>
413 </div>
414 <div class="chat-input">
415 <input type="text" id="chat-text-input" placeholder="type a message…" style="flex:1" autocomplete="off">
416 <button class="primary sm" id="chat-send-btn" onclick="sendMsg()">send</button>
417 </div>
418 </div>
419 <div class="sidebar-resize" id="resize-right" title="drag to resize"></div>
420 <div class="chat-nicklist" id="chat-nicklist">
421 <div class="nicklist-head">
422 <button class="sidebar-toggle" id="sidebar-right-toggle" title="collapse" onclick="toggleSidebar('right')">›</button>
423 <span id="sidebar-right-label">users</span>
424 </div>
425 <div id="nicklist-users"></div>
426 </div>
427 </div>
428
429 <!-- SETTINGS -->
430 <div class="tab-pane" id="pane-settings">
431 <div class="pane-inner">
432
433 <!-- connection -->
434 <div class="card" id="card-connection">
435 <div class="card-header" style="cursor:default"><h2>connection</h2></div>
436 <div class="card-body">
437 <div class="setting-row">
438 <div class="setting-label">signed in as</div>
439 <div class="setting-desc">Current admin session.</div>
440 <code class="setting-val" id="settings-username-display">—</code>
441 <button class="sm danger" onclick="logout()">sign out</button>
442 </div>
443 <div class="setting-row">
444 <div class="setting-label">API endpoint</div>
445 <div class="setting-desc">REST API base URL.</div>
446 <code class="setting-val" id="settings-api-url"></code>
447 </div>
448 <div class="setting-row">
449 <div class="setting-label">IRC network</div>
450 <div class="setting-desc">Ergo IRC server address.</div>
451 <code class="setting-val">localhost:6667</code>
452 </div>
453 <div class="setting-row">
454 <div class="setting-label">MCP server</div>
455 <div class="setting-desc">Model Context Protocol endpoint.</div>
456 <code class="setting-val">localhost:8081</code>
457 </div>
458 </div>
459 </div>
460
461 <!-- admin accounts -->
462 <div class="card" id="card-admins">
463 <div class="card-header" onclick="toggleCard('card-admins',event)"><h2>admin accounts</h2><span class="collapse-icon">▾</span></div>
464 <div id="admins-list-container"></div>
465 <div class="card-body" style="border-top:1px solid #21262d">
466 <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Add an admin account. Admins sign in at the login screen with username + password.</p>
467 <form id="add-admin-form" onsubmit="addAdmin(event)" style="flex-direction:row;align-items:flex-end;gap:10px;flex-wrap:wrap">
468 <div style="flex:1;min-width:130px"><label>username</label><input type="text" id="new-admin-username" autocomplete="off"></div>
469 <div style="flex:1;min-width:130px"><label>password</label><input type="password" id="new-admin-password" autocomplete="new-password"></div>
470 <button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button>
471 </form>
472 <div id="add-admin-result" style="margin-top:10px"></div>
473 </div>
474 </div>
475
476 <!-- tls -->
477 <div class="card" id="card-tls">
478 <div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div>
479 <div class="card-body">
480 <div id="tls-status-rows"></div>
481 <div class="alert info" style="margin-top:12px;font-size:12px">
482 <span class="icon">ℹ</span>
483 <span>TLS is configured in <code style="color:#a5d6ff">scuttlebot.yaml</code> under <code style="color:#a5d6ff">tls:</code>.
484 Set <code style="color:#a5d6ff">domain:</code> to enable Let's Encrypt. <code style="color:#a5d6ff">allow_insecure: true</code> keeps HTTP running alongside HTTPS.</span>
485 </div>
486 </div>
487 </div>
488
489 <!-- system behaviors -->
490 <div class="card" id="card-behaviors">
491 <div class="card-header" onclick="toggleCard('card-behaviors',event)">
492 <h2>system behaviors</h2><span class="collapse-icon">▾</span>
493 <div class="spacer"></div>
494 <button class="sm primary" onclick="savePolicies()">save</button>
495 </div>
496 <div class="card-body" style="padding:0">
497 <div id="behaviors-list"></div>
498 </div>
499 </div>
500
501 <!-- agent policy -->
502 <div class="card" id="card-agentpolicy">
503 <div class="card-header" onclick="toggleCard('card-agentpolicy',event)"><h2>agent policy</h2><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="savePolicies()">save</button></div>
504 <div class="card-body">
505 <div class="setting-row">
506 <div class="setting-label">require check-in</div>
507 <div class="setting-desc">Agents must join a coordination channel before others.</div>
508 <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
509 <input type="checkbox" id="policy-checkin-enabled" onchange="toggleCheckinChannel()">
510 <span style="font-size:12px">enabled</span>
511 </label>
512 </div>
513 <div class="setting-row" id="policy-checkin-row" style="display:none">
514 <div class="setting-label">check-in channel</div>
515 <div class="setting-desc">Channel all agents must join first.</div>
516 <input type="text" id="policy-checkin-channel" placeholder="#coordination" style="width:180px;padding:4px 8px;font-size:12px">
517 </div>
518 <div class="setting-row">
519 <div class="setting-label">required channels</div>
520 <div class="setting-desc">Channels every agent is added to automatically.</div>
521 <input type="text" id="policy-required-channels" placeholder="#fleet, #alerts" style="width:220px;padding:4px 8px;font-size:12px">
522 </div>
523 </div>
524 </div>
525
526 <!-- bridge -->
527 <div class="card" id="card-bridgepolicy">
528 <div class="card-header" onclick="toggleCard('card-bridgepolicy',event)"><h2>web bridge</h2><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="savePolicies()">save</button></div>
529 <div class="card-body">
530 <div class="setting-row">
531 <div class="setting-label">web user TTL</div>
532 <div class="setting-desc">How long HTTP-posted nicks stay visible in the channel user list after their last message.</div>
533 <div style="display:flex;align-items:center;gap:6px">
534 <input type="number" id="policy-bridge-web-user-ttl" placeholder="5" min="1" style="width:80px;padding:4px 8px;font-size:12px">
535 <span style="font-size:12px;color:#8b949e">minutes</span>
536 </div>
537 </div>
538 </div>
539 </div>
540
541 <!-- logging -->
542 <div class="card" id="card-logging">
543 <div class="card-header" onclick="toggleCard('card-logging',event)"><h2>message logging</h2><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="savePolicies()">save</button></div>
544 <div class="card-body">
545 <div class="setting-row">
546 <div class="setting-label">enabled</div>
547 <div class="setting-desc">Write every channel message to disk.</div>
548 <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
549 <input type="checkbox" id="policy-logging-enabled" onchange="toggleLogOptions()">
550 <span style="font-size:12px">enabled</span>
551 </label>
552 </div>
553 <div id="policy-log-options" style="display:none">
554 <div class="setting-row">
555 <div class="setting-label">log directory</div>
556 <div class="setting-desc">Directory to write log files into.</div>
557 <input type="text" id="policy-log-dir" placeholder="./data/logs" style="width:280px;padding:4px 8px;font-size:12px">
558 </div>
559 <div class="setting-row">
560 <div class="setting-label">format</div>
561 <div class="setting-desc">Output format for log lines.</div>
562 <select id="policy-log-format" style="width:160px;padding:4px 8px;font-size:12px">
563 <option value="jsonl">JSON Lines (.jsonl)</option>
564 <option value="csv">CSV (.csv)</option>
565 <option value="text">Plain text (.log)</option>
566 </select>
567 </div>
568 <div class="setting-row">
569 <div class="setting-label">rotation</div>
570 <div class="setting-desc">When to start a new log file.</div>
571 <select id="policy-log-rotation" style="width:160px;padding:4px 8px;font-size:12px" onchange="toggleRotationOptions()">
572 <option value="none">None</option>
573 <option value="daily">Daily</option>
574 <option value="weekly">Weekly</option>
575 <option value="monthly">Monthly</option>
576 <option value="yearly">Yearly</option>
577 <option value="size">By size</option>
578 </select>
579 </div>
580 <div class="setting-row" id="policy-log-size-row" style="display:none">
581 <div class="setting-label">max file size</div>
582 <div class="setting-desc">Rotate when file reaches this size.</div>
583 <div style="display:flex;align-items:center;gap:6px">
584 <input type="number" id="policy-log-max-size" placeholder="100" min="1" style="width:80px;padding:4px 8px;font-size:12px">
585 <span style="font-size:12px;color:#8b949e">MiB</span>
586 </div>
587 </div>
588 <div class="setting-row">
589 <div class="setting-label">per-channel files</div>
590 <div class="setting-desc">Write a separate file for each channel.</div>
591 <label style="display:flex;align-items:center;gap:6px;cursor:pointer">
592 <input type="checkbox" id="policy-log-per-channel">
593 <span style="font-size:12px">enabled</span>
594 </label>
595 </div>
596 <div class="setting-row">
597 <div class="setting-label">max age</div>
598 <div class="setting-desc">Delete rotated files older than N days. 0 = keep forever.</div>
599 <div style="display:flex;align-items:center;gap:6px">
600 <input type="number" id="policy-log-max-age" placeholder="0" min="0" style="width:80px;padding:4px 8px;font-size:12px">
601 <span style="font-size:12px;color:#8b949e">days</span>
602 </div>
603 </div>
604 </div>
605 </div>
606 </div>
607
608 <div id="policies-save-result" style="display:none"></div>
609
610 <!-- about -->
611 <div class="card">
612 <div class="card-header" style="cursor:default"><h2>about</h2></div>
613 <div class="card-body" style="font-size:13px;color:#8b949e;line-height:1.8">
614 <p><strong style="color:#e6edf3">ScuttleBot</strong> — agent coordination backplane over IRC.</p>
615 <p>Agents register, receive SASL credentials, and coordinate in IRC channels.</p>
616 <p>Everything is human observable: all activity is visible in the IRC channel log.</p>
617 <p style="margin-top:12px;font-size:11px;color:#6e7681">
618 Copyright &copy; 2026 CONFLICT LLC. All rights reserved.<br>
619 <a href="https://scuttlebot.dev" target="_blank" rel="noopener" style="color:#58a6ff;text-decoration:none">ScuttleBot</a> — Powered by <a href="https://weareconflict.com" target="_blank" rel="noopener" style="color:#58a6ff;text-decoration:none">CONFLICT</a>
620 </p>
621 </div>
622 </div>
623
624 </div>
625 </div>
626
627 <!-- AI -->
628 <div class="tab-pane" id="pane-ai">
629 <div class="pane-inner">
630
631 <!-- LLM backends -->
632 <div class="card" id="card-ai-backends">
633 <div class="card-header" style="cursor:default">
634 <h2>LLM backends</h2>
635 <div class="spacer"></div>
636 <button class="sm" onclick="loadAI()">↺ refresh</button>
637 <button class="sm primary" onclick="openAddBackend()">+ add backend</button>
638 </div>
639 <div class="card-body" style="padding:0">
640 <div id="ai-backends-list" style="padding:16px">
641 <div class="empty-state">loading…</div>
642 </div>
643 </div>
644 </div>
645
646 <!-- add/edit backend form (hidden until opened) -->
647 <div class="card" id="card-ai-form" style="display:none">
648 <div class="card-header" style="cursor:default">
649 <h2 id="ai-form-title">add backend</h2>
650 <div class="spacer"></div>
651 <button class="sm" onclick="closeBackendForm()">✕ cancel</button>
652 </div>
653 <div class="card-body">
654 <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
655 <div>
656 <label>name *</label>
657 <input type="text" id="bf-name" placeholder="openai-main" autocomplete="off">
658 <div class="hint">unique identifier — used in oracle's backend field</div>
659 </div>
660 <div>
661 <label>backend type *</label>
662 <select id="bf-backend" onchange="onBackendTypeChange()">
663 <option value="">— select type —</option>
664 <optgroup label="Native APIs">
665 <option value="anthropic">anthropic</option>
666 <option value="gemini">gemini</option>
667 <option value="bedrock">bedrock</option>
668 <option value="ollama">ollama</option>
669 </optgroup>
670 <optgroup label="OpenAI-compatible">
671 <option value="openai">openai</option>
672 <option value="openrouter">openrouter</option>
673 <option value="together">together</option>
674 <option value="groq">groq</option>
675 <option value="fireworks">fireworks</option>
676 <option value="mistral">mistral</option>
677 <option value="ai21">ai21</option>
678 <option value="huggingface">huggingface</option>
679 <option value="deepseek">deepseek</option>
680 <option value="cerebras">cerebras</option>
681 <option value="xai">xai</option>
682 <option value="litellm">litellm (local)</option>
683 <option value="lmstudio">lm studio (local)</option>
684 <option value="jan">jan (local)</option>
685 <option value="localai">localai (local)</option>
686 <option value="vllm">vllm (local)</option>
687 <option value="anythingllm">anythingllm (local)</option>
688 </optgroup>
689 </select>
690 </div>
691
692 <!-- shown for non-bedrock backends -->
693 <div id="bf-apikey-row">
694 <label>API key</label>
695 <input type="password" id="bf-apikey" placeholder="sk-…" autocomplete="new-password">
696 <div class="hint" id="bf-apikey-hint">Leave blank to use env var or instance role</div>
697 </div>
698
699 <!-- shown for ollama and OpenAI-compat local backends -->
700 <div id="bf-baseurl-row">
701 <label>base URL</label>
702 <input type="text" id="bf-baseurl" placeholder="http://localhost:11434" autocomplete="off">
703 <div class="hint">Override default endpoint for self-hosted backends</div>
704 </div>
705
706 <div>
707 <label>model</label>
708 <div style="display:flex;gap:6px;align-items:flex-start">
709 <div style="flex:1">
710 <select id="bf-model-select" onchange="onModelSelectChange()" style="width:100%">
711 <option value="">— select or load models —</option>
712 </select>
713 <input type="text" id="bf-model-custom" placeholder="model-id" autocomplete="off" style="display:none;margin-top:6px">
714 </div>
715 <button type="button" class="sm" id="bf-load-models-btn" onclick="loadLiveModels(this)" style="white-space:nowrap;margin-top:1px">↺ load models</button>
716 </div>
717 <div class="hint">Pick from list or load live from API. Leave blank to auto-select.</div>
718 </div>
719
720 <div style="display:flex;align-items:flex-end;gap:8px">
721 <label style="margin:0;cursor:pointer;display:flex;align-items:center;gap:6px">
722 <input type="checkbox" id="bf-default"> mark as default backend
723 </label>
724 </div>
725
726 <!-- Bedrock-specific fields -->
727 <div id="bf-bedrock-group" style="display:none;grid-column:1/-1">
728 <div style="font-size:12px;color:#8b949e;font-weight:500;text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px">AWS / Bedrock</div>
729 <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
730 <div>
731 <label>region *</label>
732 <input type="text" id="bf-region" placeholder="us-east-1" autocomplete="off">
733 </div>
734 <div>
735 <label>AWS key ID</label>
736 <input type="text" id="bf-aws-key-id" placeholder="AKIA… (or leave blank for IAM role)" autocomplete="off">
737 <div class="hint">Leave blank — scuttlebot will auto-detect IAM role (ECS/EC2/EKS)</div>
738 </div>
739 <div>
740 <label>AWS secret key</label>
741 <input type="password" id="bf-aws-secret" placeholder="(or leave blank for IAM role)" autocomplete="new-password">
742 </div>
743 </div>
744 </div>
745
746 <!-- allow/block filters -->
747 <div style="grid-column:1/-1">
748 <div style="font-size:12px;color:#8b949e;font-weight:500;text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px">Model filters (regex, one per line)</div>
749 <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
750 <div>
751 <label>allow list</label>
752 <textarea id="bf-allow" rows="3" placeholder="^gpt-4&#10;^claude-3" style="font-family:var(--font-mono);font-size:12px"></textarea>
753 <div class="hint">Only these models shown. Empty = all.</div>
754 </div>
755 <div>
756 <label>block list</label>
757 <textarea id="bf-block" rows="3" placeholder=".*-instruct$&#10;.*-preview$" style="font-family:var(--font-mono);font-size:12px"></textarea>
758 <div class="hint">Always hidden.</div>
759 </div>
760 </div>
761 </div>
762 </div>
763 <div id="ai-form-result" style="display:none;margin-top:12px"></div>
764 <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
765 <button class="sm" onclick="closeBackendForm()">cancel</button>
766 <button class="sm primary" id="bf-submit-btn" onclick="submitBackendForm()">add backend</button>
767 </div>
768 </div>
769 </div>
770
771 <!-- supported backends reference -->
772 <div class="card" id="card-ai-supported">
773 <div class="card-header" onclick="toggleCard('card-ai-supported',event)"><h2>supported backends</h2><span class="collapse-icon">▾</span></div>
774 <div class="card-body" id="ai-supported-list">
775 <div class="empty-state">loading…</div>
776 </div>
777 </div>
778
779 <!-- config example -->
780 <div class="card" id="card-ai-example" style="display:none">
781 <div class="card-header" onclick="toggleCard('card-ai-example',event)"><h2>YAML example</h2><span class="collapse-icon">▾</span></div>
782 <div class="card-body">
783 <pre style="font-size:12px;color:#a5d6ff;background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:12px;overflow-x:auto;white-space:pre">llm:
784 backends:
785 - name: openai-main
786 backend: openai
787 api_key: sk-...
788 model: gpt-4o-mini
789 block: [".*-instruct$"] # optional regex filter
790
791 - name: local-ollama
792 backend: ollama
793 base_url: http://localhost:11434
794 model: llama3.2
795 default: true
796
797 - name: anthropic-claude
798 backend: anthropic
799 api_key: sk-ant-...
800 model: claude-3-5-sonnet-20241022
801
802 - name: bedrock-us
803 backend: bedrock
804 region: us-east-1
805 aws_key_id: AKIA...
806 aws_secret_key: ...
807 allow: ["^anthropic\\."] # only Anthropic models
808
809 - name: groq-fast
810 backend: groq
811 api_key: gsk_...</pre>
812 <p style="font-size:12px;color:#8b949e;margin-top:8px">
813 Reference a backend from oracle's behavior config using the <code>backend</code> key.
814 </p>
815 </div>
816 </div>
817
818 </div>
819 </div>
820
821 <!-- Register drawer -->
822 <div class="drawer-overlay" id="drawer-overlay" onclick="closeDrawer()"></div>
823 <div class="drawer" id="register-drawer">
824 <div class="drawer-header">
825 <h3>register agent</h3>
826 <div class="spacer"></div>
827 <button class="sm" onclick="closeDrawer()">✕</button>
828 </div>
829 <div class="drawer-body">
830 <form id="register-form" onsubmit="handleRegister(event)">
831 <div class="form-row">
832 <div>
833 <label>nick *</label>
834 <input type="text" id="reg-nick" placeholder="my-agent-01" required autocomplete="off">
835 </div>
836 <div>
837 <label>type</label>
838 <select id="reg-type">
839 <option value="worker" selected>worker — +v</option>
840 <option value="orchestrator">orchestrator — +o</option>
841 <option value="observer">observer — read only</option>
842 </select>
843 </div>
844 </div>
845 <div>
846 <label>channels</label>
847 <input type="text" id="reg-channels" placeholder="#fleet, #ops, #project.foo" autocomplete="off">
848 <div class="hint">comma-separated; must start with #</div>
849 </div>
850 <div>
851 <label>permissions</label>
852 <input type="text" id="reg-permissions" placeholder="task.create, task.update" autocomplete="off">
853 <div class="hint">comma-separated message types this agent may send</div>
854 </div>
855 <div id="register-result" style="display:none"></div>
856 <div style="display:flex;justify-content:flex-end;gap:8px">
857 <button type="button" onclick="closeDrawer()">cancel</button>
858 <button type="submit" class="primary">register</button>
859 </div>
860 </form>
861 </div>
862 </div>
863
864 <!-- Register user drawer (operator with fresh credentials) -->
865 <div class="drawer-overlay" id="register-user-overlay" onclick="closeRegisterUserDrawer()"></div>
866 <div class="drawer" id="register-user-drawer">
867 <div class="drawer-header">
868 <h3>register user</h3>
869 <div class="spacer"></div>
870 <button class="sm" onclick="closeRegisterUserDrawer()">✕</button>
871 </div>
872 <div class="drawer-body">
873 <form id="register-user-form" onsubmit="handleRegisterUser(event)">
874 <div>
875 <label>nick *</label>
876 <input type="text" id="regu-nick" placeholder="alice" required autocomplete="off">
877 <div class="hint">new NickServ account will be created; credentials returned once</div>
878 </div>
879 <div>
880 <label>channels</label>
881 <input type="text" id="regu-channels" placeholder="#ops, #general" autocomplete="off">
882 <div class="hint">comma-separated</div>
883 </div>
884 <div id="register-user-result" style="display:none"></div>
885 <div style="display:flex;justify-content:flex-end;gap:8px">
886 <button type="button" onclick="closeRegisterUserDrawer()">cancel</button>
887 <button type="submit" class="primary">register</button>
888 </div>
889 </form>
890 </div>
891 </div>
892
893 <!-- Adopt user drawer (claim pre-existing NickServ account) -->
894 <div class="drawer-overlay" id="adopt-overlay" onclick="closeAdoptDrawer()"></div>
895 <div class="drawer" id="adopt-drawer">
896 <div class="drawer-header">
897 <h3>adopt existing user</h3>
898 <div class="spacer"></div>
899 <button class="sm" onclick="closeAdoptDrawer()">✕</button>
900 </div>
901 <div class="drawer-body">
902 <p style="font-size:12px;color:#8b949e;margin-bottom:16px">Adds a pre-existing NickServ account to the registry without changing its password. Use this for accounts already connected to IRC.</p>
903 <form id="adopt-form" onsubmit="handleAdopt(event)">
904 <div>
905 <label>nick *</label>
906 <input type="text" id="adopt-nick" placeholder="existing-irc-nick" required autocomplete="off">
907 </div>
908 <div>
909 <label>channels</label>
910 <input type="text" id="adopt-channels" placeholder="#ops, #general" autocomplete="off">
911 <div class="hint">comma-separated</div>
912 </div>
913 <div id="adopt-result" style="display:none"></div>
914 <div style="display:flex;justify-content:flex-end;gap:8px">
915 <button type="button" onclick="closeAdoptDrawer()">cancel</button>
916 <button type="submit" class="primary">adopt</button>
917 </div>
918 </form>
919 </div>
920 </div>
921
922
923 <script>
924 // --- tabs ---
925 const TAB_LOADERS = { status: loadStatus, users: loadAgents, agents: loadAgents, channels: loadChanTab, chat: () => { populateChatIdentity(); loadChannels(); }, ai: loadAI, settings: loadSettings };
926 function switchTab(name) {
927 document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
928 document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
929 document.getElementById('tab-' + name).classList.add('active');
930 document.getElementById('pane-' + name).classList.add('active');
931 if (name === 'chat') {
932 _chatUnread = 0;
933 delete document.getElementById('tab-chat').dataset.unread;
934 }
935 if (TAB_LOADERS[name]) TAB_LOADERS[name]();
936 }
937
938 // --- auth ---
939 function getToken() { return localStorage.getItem('sb_token') || ''; }
940 function getUsername() { return localStorage.getItem('sb_username') || ''; }
941
942 function showLoginScreen() {
943 document.getElementById('login-screen').style.display = 'flex';
944 setTimeout(() => document.getElementById('login-username')?.focus(), 80);
945 }
946 function hideLoginScreen() {
947 document.getElementById('login-screen').style.display = 'none';
948 }
949
950 function updateHeaderUser() {
951 const u = getUsername();
952 const t = getToken();
953 const label = u ? '@' + u : (t ? t.slice(0,8)+'…' : '');
954 document.getElementById('header-user-display').textContent = label;
955 const su = document.getElementById('settings-username-display');
956 if (su) su.textContent = u || 'token auth';
957 document.getElementById('settings-api-url').textContent = location.origin;
958 document.getElementById('no-token-banner').style.display = t ? 'none' : 'flex';
959 }
960
961 async function handleLogin(e) {
962 e.preventDefault();
963 const username = document.getElementById('login-username').value.trim();
964 const password = document.getElementById('login-password').value;
965 const btn = document.getElementById('login-btn');
966 const errEl = document.getElementById('login-error');
967 if (!username || !password) return;
968 btn.disabled = true; btn.textContent = 'signing in…';
969 errEl.style.display = 'none';
970 try {
971 const resp = await fetch('/login', {
972 method: 'POST',
973 headers: {'Content-Type':'application/json'},
974 body: JSON.stringify({username, password}),
975 });
976 const data = await resp.json().catch(() => ({}));
977 if (!resp.ok) throw new Error(data.error || 'Login failed');
978 localStorage.setItem('sb_token', data.token);
979 localStorage.setItem('sb_username', data.username || username);
980 hideLoginScreen();
981 updateHeaderUser();
982 loadAll();
983 } catch(err) {
984 errEl.style.display = 'block';
985 errEl.innerHTML = renderAlert('error', err.message);
986 btn.disabled = false; btn.textContent = 'sign in';
987 }
988 }
989
990 function saveTokenLogin() {
991 const v = document.getElementById('token-login-input').value.trim();
992 if (!v) return;
993 localStorage.setItem('sb_token', v);
994 localStorage.removeItem('sb_username');
995 hideLoginScreen();
996 updateHeaderUser();
997 loadAll();
998 }
999
1000 function logout() {
1001 localStorage.removeItem('sb_token');
1002 localStorage.removeItem('sb_username');
1003 location.reload();
1004 }
1005
1006 function initAuth() {
1007 if (!getToken()) { showLoginScreen(); return; }
1008 hideLoginScreen();
1009 updateHeaderUser();
1010 loadAll();
1011 }
1012
1013 document.addEventListener('keydown', e => { if(e.key==='Escape'){ closeDrawer(); closeRegisterUserDrawer(); closeAdoptDrawer(); } });
1014
1015 // --- API ---
1016 async function api(method, path, body) {
1017 const opts = { method, headers: { 'Authorization':'Bearer '+getToken(), 'Content-Type':'application/json' } };
1018 if (body !== undefined) opts.body = JSON.stringify(body);
1019 const res = await fetch(path, opts);
1020 if (res.status === 401) {
1021 localStorage.removeItem('sb_token');
1022 localStorage.removeItem('sb_username');
1023 showLoginScreen();
1024 throw new Error('Session expired — please sign in again');
1025 }
1026 if (res.status === 204) return null;
1027 const data = await res.json().catch(() => ({ error: res.statusText }));
1028 if (!res.ok) throw Object.assign(new Error(data.error || res.statusText), { status: res.status });
1029 return data;
1030 }
 
1031 function copyText(text, btn) {
1032 navigator.clipboard.writeText(text).then(() => { const o=btn.textContent; btn.textContent='✓'; setTimeout(()=>{btn.textContent=o;},1200); });
1033 }
1034
1035 // --- charts ---
1036 const CHART_POINTS = 60; // 5 min at 5s intervals
1037 const chartData = {
1038 labels: Array(CHART_POINTS).fill(''),
1039 heap: Array(CHART_POINTS).fill(null),
1040 goroutines: Array(CHART_POINTS).fill(null),
1041 messages: Array(CHART_POINTS).fill(null),
1042 };
1043 let charts = {};
1044
1045 function mkChart(id, label, color) {
1046 const ctx = document.getElementById(id).getContext('2d');
1047 return new Chart(ctx, {
1048 type: 'line',
1049 data: {
1050 labels: chartData.labels,
1051 datasets: [{
1052 label,
1053 data: chartData[id.replace('chart-','')],
1054 borderColor: color,
1055 backgroundColor: color+'22',
1056 borderWidth: 1.5,
1057 pointRadius: 0,
1058 tension: 0.3,
1059 fill: true,
1060 }],
1061 },
1062 options: {
1063 responsive: true,
1064 animation: false,
1065 plugins: { legend: { display: false } },
1066 scales: {
1067 x: { display: false },
1068 y: { display: true, grid: { color: '#21262d' }, ticks: { color: '#8b949e', font: { size: 10 }, maxTicksLimit: 4 } },
1069 },
1070 },
1071 });
1072 }
1073
1074 function initCharts() {
1075 if (charts.heap) return;
1076 charts.heap = mkChart('chart-heap', 'heap', '#58a6ff');
1077 charts.goroutines = mkChart('chart-goroutines', 'goroutines', '#3fb950');
1078 charts.messages = mkChart('chart-messages', 'messages', '#d2a8ff');
1079 }
1080
1081 function fmtBytes(b) {
1082 if (b == null) return '—';
1083 if (b < 1024) return b+'B';
1084 if (b < 1048576) return (b/1024).toFixed(1)+'KB';
1085 return (b/1048576).toFixed(1)+'MB';
1086 }
1087
1088 function pushMetrics(m) {
1089 chartData.heap.push(m.runtime.heap_alloc_bytes/1048576);
1090 chartData.heap.shift();
1091 chartData.goroutines.push(m.runtime.goroutines);
1092 chartData.goroutines.shift();
1093 const msgs = m.bridge ? m.bridge.messages_total : null;
1094 chartData.messages.push(msgs);
1095 chartData.messages.shift();
1096
1097 // Reassign dataset data arrays (shared reference, Chart.js reads them directly).
1098 charts.heap.data.datasets[0].data = chartData.heap;
1099 charts.goroutines.data.datasets[0].data = chartData.goroutines;
1100 charts.messages.data.datasets[0].data = chartData.messages;
1101 charts.heap.update('none');
1102 charts.goroutines.update('none');
1103 charts.messages.update('none');
1104 }
1105
1106 // --- status ---
1107 async function loadStatus() {
1108 try {
1109 const [s, m] = await Promise.all([
1110 api('GET', '/v1/status'),
1111 api('GET', '/v1/metrics'),
1112 ]);
1113
1114 // Status card.
1115 document.getElementById('stat-status').innerHTML = '<span class="dot green"></span>'+s.status;
1116 document.getElementById('stat-uptime').textContent = s.uptime;
1117 document.getElementById('stat-agents').textContent = s.agents;
1118 const d = new Date(s.started);
1119 document.getElementById('stat-started').textContent = d.toLocaleTimeString();
1120 document.getElementById('stat-started-rel').textContent = d.toLocaleDateString();
1121 document.getElementById('status-error').style.display = 'none';
1122 document.getElementById('metrics-updated').textContent = 'updated '+new Date().toLocaleTimeString();
1123
1124 // Runtime card.
1125 document.getElementById('stat-goroutines').textContent = m.runtime.goroutines;
1126 document.getElementById('stat-heap').textContent = fmtBytes(m.runtime.heap_alloc_bytes);
1127 document.getElementById('stat-heapsys').textContent = fmtBytes(m.runtime.heap_sys_bytes);
1128 document.getElementById('stat-gc').textContent = m.runtime.gc_runs;
1129 document.getElementById('chart-heap-val').textContent = fmtBytes(m.runtime.heap_alloc_bytes);
1130 document.getElementById('chart-goroutines-val').textContent = m.runtime.goroutines;
1131 document.getElementById('chart-messages-val').textContent = m.bridge ? m.bridge.messages_total : '—';
1132
1133 // Bridge card.
1134 if (m.bridge) {
1135 document.getElementById('bridge-card').style.display = '';
1136 document.getElementById('stat-bridge-channels').textContent = m.bridge.channels;
1137 document.getElementById('stat-bridge-msgs').textContent = m.bridge.messages_total;
1138 document.getElementById('stat-bridge-subs').textContent = m.bridge.active_subscribers;
1139 }
1140
1141 // Registry card.
1142 document.getElementById('stat-reg-total').textContent = m.registry.total;
1143 document.getElementById('stat-reg-active').textContent = m.registry.active;
1144 document.getElementById('stat-reg-revoked').textContent = m.registry.revoked;
1145
1146 // Push to charts.
1147 initCharts();
1148 pushMetrics(m);
1149 } catch(e) {
1150 document.getElementById('stat-status').innerHTML = '<span class="dot red"></span>error';
1151 const el = document.getElementById('status-error');
1152 el.style.display = 'block';
1153 el.innerHTML = renderAlert('error', e.message);
1154 }
1155 }
1156
1157 let metricsTimer = null;
1158 function startMetricsPoll() {
1159 if (metricsTimer) return;
1160 metricsTimer = setInterval(() => {
1161 if (document.getElementById('pane-status').classList.contains('active')) loadStatus();
1162 }, 5000);
1163 }
1164
1165 // --- agents + users (shared data) ---
1166 let allAgents = [];
1167 async function loadAgents() {
 
1168 try {
1169 const data = await api('GET', '/v1/agents');
1170 allAgents = data.agents || [];
1171 renderUsersTable();
1172 renderAgentTable();
1173 populateChatIdentity();
1174 } catch(e) {
1175 const msg = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1176 document.getElementById('agents-container').innerHTML = msg;
1177 document.getElementById('users-container').innerHTML = msg;
1178 }
1179 }
1180
1181 function renderTable(container, countEl, rows, emptyMsg, cols) {
1182 if (rows.length === 0) {
1183 document.getElementById(container).innerHTML = '<div class="empty">'+emptyMsg+'</div>';
1184 } else {
1185 const ths = cols.map(c=>`<th>${c}</th>`).join('');
1186 document.getElementById(container).innerHTML =
1187 `<table><thead><tr>${ths}</tr></thead><tbody>${rows.join('')}</tbody></table>`;
1188 }
1189 if (countEl) document.getElementById(countEl).textContent = rows.length;
1190 }
1191
1192 function renderUsersTable() {
1193 const q = (document.getElementById('user-search').value||'').toLowerCase();
1194 const users = allAgents.filter(a => a.type === 'operator' && (!q ||
1195 a.nick.toLowerCase().includes(q) ||
1196 (a.config?.channels||[]).some(c => c.toLowerCase().includes(q))));
1197 const rows = users.map(a => {
1198 const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
1199 const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1200 return `<tr>
1201 <td><strong>${esc(a.nick)}</strong></td>
1202 <td><span class="tag type-operator">operator</span>${rev}</td>
1203 <td>${chs||'<span style="color:#8b949e">—</span>'}</td>
1204 <td style="white-space:nowrap">${fmtTime(a.created_at)}</td>
1205 <td><div class="actions">${!a.revoked?`
1206 <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
1207 <button class="sm danger" onclick="revokeAgent('${esc(a.nick)}')">revoke</button>`:``}
1208 <button class="sm danger" onclick="deleteAgent('${esc(a.nick)}')">delete</button></div></td>
1209 </tr>`;
1210 });
1211 const all = allAgents.filter(a => a.type === 'operator');
1212 const countTxt = all.length + (rows.length !== all.length ? ' / '+rows.length+' shown' : '');
1213 document.getElementById('user-count').textContent = countTxt;
1214 renderTable('users-container', null, rows,
1215 all.length ? 'no users match the filter' : 'no users registered yet',
1216 ['nick','type','channels','registered','']);
1217 }
1218
1219 function renderAgentTable() {
1220 const q = (document.getElementById('agent-search').value||'').toLowerCase();
1221 const bots = allAgents.filter(a => a.type !== 'operator');
1222 const agents = bots.filter(a => !q ||
1223 a.nick.toLowerCase().includes(q) ||
1224 a.type.toLowerCase().includes(q) ||
1225 (a.config?.channels||[]).some(c => c.toLowerCase().includes(q)) ||
1226 (a.config?.permissions||[]).some(p => p.toLowerCase().includes(q)));
1227 document.getElementById('agent-count').textContent = bots.length + (agents.length !== bots.length ? ' / '+agents.length+' shown' : '');
1228 const rows = agents.map(a => {
1229 const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
1230 const perms = (a.config?.permissions||[]).map(p=>`<span class="tag perm">${esc(p)}</span>`).join('');
1231 const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1232 return `<tr>
1233 <td><strong>${esc(a.nick)}</strong></td>
1234 <td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td>
1235 <td>${chs||'<span style="color:#8b949e">—</span>'}</td>
1236 <td>${perms||'<span style="color:#8b949e">—</span>'}</td>
1237 <td style="white-space:nowrap">${fmtTime(a.created_at)}</td>
1238 <td><div class="actions">${!a.revoked?`
1239 <button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
1240 <button class="sm danger" onclick="revokeAgent('${esc(a.nick)}')">revoke</button>`:``}
1241 <button class="sm danger" onclick="deleteAgent('${esc(a.nick)}')">delete</button></div></td>
1242 </tr>`;
1243 });
1244 renderTable('agents-container', null, rows,
1245 bots.length ? 'no agents match the filter' : 'no agents registered yet',
1246 ['nick','type','channels','permissions','registered','']);
1247 }
1248
1249 async function revokeAgent(nick) {
1250 if (!confirm(`Revoke "${nick}"? This cannot be undone.`)) return;
1251 try { await api('POST', `/v1/agents/${nick}/revoke`); await loadAgents(); await loadStatus(); }
1252 catch(e) { alert('Revoke failed: '+e.message); }
1253 }
1254 async function deleteAgent(nick) {
1255 if (!confirm(`Delete "${nick}"? This permanently removes the agent from the registry.`)) return;
1256 try { await api('DELETE', `/v1/agents/${nick}`); await loadAgents(); await loadStatus(); }
1257 catch(e) { alert('Delete failed: '+e.message); }
1258 }
 
1259 async function rotateAgent(nick) {
1260 try {
1261 const creds = await api('POST', `/v1/agents/${nick}/rotate`);
1262 // Show result in whichever drawer is relevant.
1263 showCredentials(nick, creds, null, 'rotate');
1264 openDrawer();
 
1265 }
1266 catch(e) { alert('Rotate failed: '+e.message); }
1267 }
1268
1269 // --- users drawers ---
1270 function openRegisterUserDrawer() {
1271 document.getElementById('register-user-overlay').classList.add('open');
1272 document.getElementById('register-user-drawer').classList.add('open');
1273 setTimeout(() => document.getElementById('regu-nick').focus(), 100);
1274 }
1275 function closeRegisterUserDrawer() {
1276 document.getElementById('register-user-overlay').classList.remove('open');
1277 document.getElementById('register-user-drawer').classList.remove('open');
1278 }
1279 function openAdoptDrawer() {
1280 document.getElementById('adopt-overlay').classList.add('open');
1281 document.getElementById('adopt-drawer').classList.add('open');
1282 setTimeout(() => document.getElementById('adopt-nick').focus(), 100);
1283 }
1284 function closeAdoptDrawer() {
1285 document.getElementById('adopt-overlay').classList.remove('open');
1286 document.getElementById('adopt-drawer').classList.remove('open');
1287 }
1288
1289 async function handleRegisterUser(e) {
1290 e.preventDefault();
1291 const nick = document.getElementById('regu-nick').value.trim();
1292 const channels = document.getElementById('regu-channels').value.split(',').map(s=>s.trim()).filter(Boolean);
1293 const resultEl = document.getElementById('register-user-result');
1294 resultEl.style.display = 'none';
1295 try {
1296 const res = await api('POST', '/v1/agents/register', { nick, type: 'operator', channels, permissions: [] });
1297 resultEl.style.display = 'block';
1298 const pass = res.credentials?.passphrase || '';
1299 resultEl.innerHTML = renderAlert('success',
1300 `<div><strong>Registered: ${esc(nick)}</strong>
1301 <div class="cred-box">
1302 <div class="cred-row"><span class="cred-key">nick</span><span class="cred-val">${esc(nick)}</span><button class="sm" onclick="copyText('${esc(nick)}',this)">copy</button></div>
1303 <div class="cred-row"><span class="cred-key">passphrase</span><span class="cred-val">${esc(pass)}</span><button class="sm" onclick="copyText('${esc(pass)}',this)">copy</button></div>
1304 </div>
1305 <div style="margin-top:8px;font-size:11px;color:#7ee787">⚠ Save the passphrase — shown once only.</div></div>`);
1306 document.getElementById('register-user-form').reset();
1307 await loadAgents(); await loadStatus();
1308 } catch(e) { resultEl.style.display='block'; resultEl.innerHTML=renderAlert('error', e.message); }
1309 }
1310
1311 async function handleAdopt(e) {
1312 e.preventDefault();
1313 const nick = document.getElementById('adopt-nick').value.trim();
1314 const channels = document.getElementById('adopt-channels').value.split(',').map(s=>s.trim()).filter(Boolean);
1315 const resultEl = document.getElementById('adopt-result');
1316 resultEl.style.display = 'none';
1317 try {
1318 await api('POST', `/v1/agents/${nick}/adopt`, { type: 'operator', channels, permissions: [] });
1319 resultEl.style.display = 'block';
1320 resultEl.innerHTML = renderAlert('success',
1321 `<strong>${esc(nick)}</strong> adopted as operator — existing IRC session and passphrase unchanged.`);
1322 document.getElementById('adopt-form').reset();
1323 await loadAgents(); await loadStatus();
1324 } catch(e) { resultEl.style.display='block'; resultEl.innerHTML=renderAlert('error', e.message); }
1325 }
1326
1327 // --- drawer ---
1328 function openDrawer() {
1329 document.getElementById('drawer-overlay').classList.add('open');
1330 document.getElementById('register-drawer').classList.add('open');
1331 setTimeout(() => document.getElementById('reg-nick').focus(), 100);
1332 }
1333 function closeDrawer() {
1334 document.getElementById('drawer-overlay').classList.remove('open');
1335 document.getElementById('register-drawer').classList.remove('open');
1336 }
1337
1338 // --- register ---
1339 async function handleRegister(e) {
1340 e.preventDefault();
1341 const nick = document.getElementById('reg-nick').value.trim();
1342 const type = document.getElementById('reg-type').value;
1343 const channels = document.getElementById('reg-channels').value.split(',').map(s=>s.trim()).filter(Boolean);
1344 const permissions = document.getElementById('reg-permissions').value.split(',').map(s=>s.trim()).filter(Boolean);
1345 const resultEl = document.getElementById('register-result');
 
 
 
 
1346 resultEl.style.display = 'none';
 
1347 try {
1348 const res = await api('POST', '/v1/agents/register', { nick, type, channels, permissions });
1349 showCredentials(nick, res.credentials, res.payload, 'register');
1350 document.getElementById('register-form').reset();
1351 await loadAgents(); await loadStatus();
1352 } catch(e) { resultEl.style.display='block'; resultEl.innerHTML=renderAlert('error', e.message); }
1353 }
1354 function showCredentials(nick, creds, payload, mode) {
1355 const resultEl = document.getElementById('register-result');
1356 resultEl.style.display = 'block';
1357 const pass = creds?.passphrase || creds?.Passphrase || '';
1358 const sig = payload?.signature || '';
1359 resultEl.innerHTML = renderAlert('success',
1360 `<div><strong>${mode==='register'?'Registered':'Rotated'}: ${esc(nick)}</strong>
1361 <div class="cred-box">
1362 <div class="cred-row"><span class="cred-key">nick</span><span class="cred-val">${esc(creds?.nick||nick)}</span><button class="sm" onclick="copyText('${esc(creds?.nick||nick)}',this)">copy</button></div>
1363 <div class="cred-row"><span class="cred-key">passphrase</span><span class="cred-val">${esc(pass)}</span><button class="sm" onclick="copyText('${esc(pass)}',this)">copy</button></div>
1364 ${sig?`<div class="cred-row"><span class="cred-key">sig</span><span class="cred-val" style="font-size:11px">${esc(sig)}</span></div>`:''}
1365 </div>
1366 <div style="margin-top:8px;font-size:11px;color:#7ee787">⚠ Save the passphrase — shown once only.</div></div>`
1367 );
1368 }
1369
1370 // --- channels tab ---
1371 async function loadChanTab() {
1372 if (!getToken()) return;
1373 try {
1374 const data = await api('GET', '/v1/channels');
1375 const channels = data.channels || [];
1376 document.getElementById('chan-count').textContent = channels.length;
1377 if (channels.length === 0) {
1378 document.getElementById('channels-list').innerHTML = '<div class="empty">no channels joined yet — type a channel name above and click join</div>';
1379 return;
1380 }
1381 document.getElementById('channels-list').innerHTML = channels.sort().map(ch =>
1382 `<div class="chan-card">
1383 <div>
1384 <div class="chan-name">${esc(ch)}</div>
1385 <div class="chan-meta">joined</div>
1386 </div>
1387 <div class="spacer"></div>
1388 <button class="sm" onclick="switchTab('chat');setTimeout(()=>selectChannel('${esc(ch)}'),50)">open chat →</button>
1389 </div>`
1390 ).join('');
1391 } catch(e) {
1392 document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1393 }
1394 }
1395 async function quickJoin() {
1396 let ch = document.getElementById('quick-join-input').value.trim();
1397 if (!ch) return;
1398 if (!ch.startsWith('#')) ch = '#' + ch;
1399 const slug = ch.replace(/^#/,'');
1400 try {
1401 await api('POST', `/v1/channels/${slug}/join`);
1402 document.getElementById('quick-join-input').value = '';
1403 await loadChanTab();
1404 renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
1405 } catch(e) { alert('Join failed: '+e.message); }
1406 }
1407 document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
1408
1409 // --- chat ---
1410 let chatChannel = null, chatSSE = null;
1411
1412 async function loadChannels() {
1413 if (!getToken()) return;
1414 try {
1415 const data = await api('GET', '/v1/channels');
1416 renderChanSidebar(data.channels || []);
1417 } catch(e) {}
1418 }
1419 function renderChanSidebar(channels) {
1420 const list = document.getElementById('chan-list');
1421 list.innerHTML = '';
1422 channels.sort().forEach(ch => {
1423 const el = document.createElement('div');
1424 el.className = 'chan-item' + (ch===chatChannel?' active':'');
1425 el.textContent = ch;
1426 el.onclick = () => selectChannel(ch);
1427 list.appendChild(el);
1428 });
1429 }
1430 async function joinChannel() {
1431 let ch = document.getElementById('join-channel-input').value.trim();
1432 if (!ch) return;
1433 if (!ch.startsWith('#')) ch = '#' + ch;
1434 const slug = ch.replace(/^#/,'');
1435 try {
1436 await api('POST', `/v1/channels/${slug}/join`);
1437 document.getElementById('join-channel-input').value = '';
1438 const data = await api('GET', '/v1/channels');
1439 renderChanSidebar(data.channels||[]);
1440 selectChannel(ch);
1441 } catch(e) { alert('Join failed: '+e.message); }
1442 }
1443 document.getElementById('join-channel-input').addEventListener('keydown', e => { if(e.key==='Enter')joinChannel(); });
1444
1445 async function selectChannel(ch) {
1446 _lastMsgNick = null; _lastMsgAt = 0; _hideChatBanner(); _chatUnread = 0;
1447 chatChannel = ch;
1448 document.getElementById('chat-ch-name').textContent = ch;
1449 document.getElementById('chat-placeholder').style.display = 'none';
1450 document.querySelectorAll('.chan-item').forEach(el => el.classList.toggle('active', el.textContent===ch));
1451
1452 const area = document.getElementById('chat-msgs');
1453 Array.from(area.children).forEach(el => { if(!el.id) el.remove(); });
1454
1455 try {
1456 const slug = ch.replace(/^#/,'');
1457 const data = await api('GET', `/v1/channels/${slug}/messages`);
1458 (data.messages||[]).forEach(m => appendMsg(m, true));
1459 area.scrollTop = area.scrollHeight;
1460 } catch(e) {}
1461
1462 if (chatSSE) { chatSSE.close(); chatSSE = null; }
1463 const slug = ch.replace(/^#/,'');
1464 const es = new EventSource(`/v1/channels/${slug}/stream?token=${encodeURIComponent(getToken())}`);
1465 chatSSE = es;
1466 const badge = document.getElementById('chat-stream-status');
1467 es.onopen = () => { badge.textContent='● live'; badge.style.color='#3fb950'; };
1468 es.onmessage = ev => { try { appendMsg(JSON.parse(ev.data)); area.scrollTop=area.scrollHeight; } catch(_){} };
1469 es.onerror = () => { badge.textContent='○ reconnecting…'; badge.style.color='#8b949e'; };
1470
1471 loadNicklist(ch);
1472 if (_nicklistTimer) clearInterval(_nicklistTimer);
1473 _nicklistTimer = setInterval(() => loadNicklist(chatChannel), 10000);
1474 }
1475
1476 let _nicklistTimer = null;
1477 async function loadNicklist(ch) {
1478 if (!ch) return;
1479 try {
1480 const slug = ch.replace(/^#/,'');
1481 const data = await api('GET', `/v1/channels/${slug}/users`);
1482 renderNicklist(data.users || []);
1483 } catch(e) {}
1484 }
1485 function renderNicklist(users) {
1486 const el = document.getElementById('nicklist-users');
1487 const knownBots = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot','claude']);
1488 el.innerHTML = users.sort((a,b) => a.localeCompare(b)).map(nick => {
1489 const isBot = knownBots.has(nick.toLowerCase());
1490 return `<div class="nicklist-nick${isBot?' is-bot':''}" title="${esc(nick)}">${esc(nick)}</div>`;
1491 }).join('');
1492 }
1493 // Nick colors — deterministic hash over a palette
1494 const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353'];
1495 function nickColor(nick) {
1496 let h = 0;
1497 for (let i = 0; i < nick.length; i++) h = (h * 31 + nick.charCodeAt(i)) & 0xffff;
1498 return NICK_PALETTE[h % NICK_PALETTE.length];
1499 }
1500
1501 let _lastMsgNick = null, _lastMsgAt = 0;
1502 const GROUP_MS = 5 * 60 * 1000;
1503 let _chatNewBanner = null;
1504 let _chatUnread = 0;
1505
1506 function appendMsg(msg, isHistory) {
1507 const area = document.getElementById('chat-msgs');
1508
1509 // Parse "[nick] text" sent by the bridge bot on behalf of a web user
1510 let displayNick = msg.nick;
1511 let displayText = msg.text;
1512 if (msg.nick === 'bridge') {
1513 const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/);
1514 if (m) { displayNick = m[1]; displayText = m[2]; }
1515 }
1516
1517 const atMs = new Date(msg.at).getTime();
1518 const grouped = !isHistory && displayNick === _lastMsgNick && (atMs - _lastMsgAt) < GROUP_MS;
1519 _lastMsgNick = displayNick;
1520 _lastMsgAt = atMs;
1521
1522 const timeStr = new Date(msg.at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
1523 const color = nickColor(displayNick);
1524
1525 const row = document.createElement('div');
1526 row.className = 'msg-row' + (grouped ? ' msg-grouped' : '');
1527 row.innerHTML =
1528 `<span class="msg-time" title="${esc(new Date(msg.at).toLocaleString())}">${esc(timeStr)}</span>` +
1529 `<span class="msg-nick" style="color:${color}">${esc(displayNick)}</span>` +
1530 `<span class="msg-text">${esc(displayText)}</span>`;
1531 area.appendChild(row);
1532
1533 // Unread badge when chat tab not active
1534 if (!isHistory && !document.getElementById('tab-chat').classList.contains('active')) {
1535 _chatUnread++;
1536 document.getElementById('tab-chat').dataset.unread = _chatUnread > 9 ? '9+' : _chatUnread;
1537 }
1538
1539 if (isHistory) {
1540 area.scrollTop = area.scrollHeight;
1541 } else {
1542 const nearBottom = area.scrollHeight - area.clientHeight - area.scrollTop < 100;
1543 if (nearBottom) {
1544 area.scrollTop = area.scrollHeight;
1545 _hideChatBanner();
1546 } else {
1547 _showChatBanner(area);
1548 }
1549 }
1550 }
1551
1552 function _showChatBanner(area) {
1553 if (_chatNewBanner) return;
1554 _chatNewBanner = document.createElement('div');
1555 _chatNewBanner.className = 'chat-new-banner';
1556 _chatNewBanner.textContent = '↓ new messages';
1557 _chatNewBanner.onclick = () => { area.scrollTop = area.scrollHeight; _hideChatBanner(); };
1558 area.appendChild(_chatNewBanner);
1559 }
1560 function _hideChatBanner() {
1561 if (_chatNewBanner) { _chatNewBanner.remove(); _chatNewBanner = null; }
1562 }
1563 function saveChatIdentity() {
1564 localStorage.setItem('sb_chat_nick', document.getElementById('chat-identity').value);
1565 }
1566 function getChatNick() {
1567 return localStorage.getItem('sb_chat_nick') || '';
1568 }
1569 function populateChatIdentity() {
1570 const sel = document.getElementById('chat-identity');
1571 const current = getChatNick();
1572 // Operators + any registered nick can send (all types visible, operators first)
1573 const operators = allAgents.filter(a => a.type === 'operator' && !a.revoked);
1574 const bots = allAgents.filter(a => a.type !== 'operator' && !a.revoked);
1575 const options = [...operators, ...bots];
1576 sel.innerHTML = '<option value="">— pick a user —</option>' +
1577 options.map(a => `<option value="${esc(a.nick)}"${a.nick===current?' selected':''}>${esc(a.nick)} (${esc(a.type)})</option>`).join('');
1578 // Restore saved selection.
1579 if (current) sel.value = current;
1580 }
1581
1582 async function sendMsg() {
1583 if (!chatChannel) return;
1584 const input = document.getElementById('chat-text-input');
1585 const nick = document.getElementById('chat-identity').value.trim() || 'web';
1586 const text = input.value.trim();
1587 if (!text) return;
1588 input.disabled = true;
1589 document.getElementById('chat-send-btn').disabled = true;
1590 try {
1591 const slug = chatChannel.replace(/^#/,'');
1592 await api('POST', `/v1/channels/${slug}/messages`, { text, nick });
1593 input.value = '';
1594 } catch(e) { alert('Send failed: '+e.message); }
1595 finally { input.disabled=false; document.getElementById('chat-send-btn').disabled=false; input.focus(); }
1596 }
1597 // --- chat input: Enter to send, Tab for nick completion ---
1598 (function() {
1599 const input = document.getElementById('chat-text-input');
1600 let _tabCandidates = [];
1601 let _tabIdx = -1;
1602 let _tabPrefix = '';
1603
1604 input.addEventListener('keydown', e => {
1605 if (e.key === 'Enter') { e.preventDefault(); sendMsg(); return; }
1606 if (e.key === 'Tab') {
1607 e.preventDefault();
1608 const val = input.value;
1609 const cursor = input.selectionStart;
1610 // Find the word being typed up to cursor
1611 const before = val.slice(0, cursor);
1612 const wordStart = before.search(/\S+$/);
1613 const word = wordStart === -1 ? '' : before.slice(wordStart);
1614 if (!word) return;
1615
1616 // On first Tab press with this prefix, build candidate list
1617 if (_tabIdx === -1 || word.toLowerCase() !== _tabPrefix.toLowerCase()) {
1618 _tabPrefix = word;
1619 const nicks = Array.from(document.querySelectorAll('#nicklist-users .nicklist-nick'))
1620 .map(el => el.textContent.replace(/^●\s*/, '').trim())
1621 .filter(n => n.toLowerCase().startsWith(word.toLowerCase()));
1622 if (!nicks.length) return;
1623 _tabCandidates = nicks;
1624 _tabIdx = 0;
1625 } else {
1626 _tabIdx = (_tabIdx + 1) % _tabCandidates.length;
1627 }
1628
1629 const chosen = _tabCandidates[_tabIdx];
1630 // If at start of message, append ': '
1631 const suffix = (wordStart === 0 && before.slice(0, wordStart) === '') ? ': ' : ' ';
1632 const newVal = val.slice(0, wordStart === -1 ? 0 : wordStart) + chosen + suffix + val.slice(cursor);
1633 input.value = newVal;
1634 const newPos = (wordStart === -1 ? 0 : wordStart) + chosen.length + suffix.length;
1635 input.setSelectionRange(newPos, newPos);
1636 return;
1637 }
1638 // Any other key resets tab state
1639 _tabIdx = -1;
1640 _tabPrefix = '';
1641 });
1642 })();
1643
1644 // --- sidebar collapse toggles ---
1645 function toggleSidebar(side) {
1646 if (side === 'left') {
1647 const el = document.getElementById('chat-sidebar-left');
1648 const btn = document.getElementById('sidebar-left-toggle');
1649 const collapsed = el.classList.toggle('collapsed');
1650 btn.textContent = collapsed ? '›' : '‹';
1651 btn.title = collapsed ? 'expand' : 'collapse';
1652 } else {
1653 const el = document.getElementById('chat-nicklist');
1654 const btn = document.getElementById('sidebar-right-toggle');
1655 const collapsed = el.classList.toggle('collapsed');
1656 btn.textContent = collapsed ? '‹' : '›';
1657 btn.title = collapsed ? 'expand' : 'collapse';
1658 }
1659 }
1660
1661 // --- sidebar drag-to-resize ---
1662 (function() {
1663 function makeResizable(handleId, sidebarId, side) {
1664 const handle = document.getElementById(handleId);
1665 const sidebar = document.getElementById(sidebarId);
1666 if (!handle || !sidebar) return;
1667 let startX, startW;
1668 handle.addEventListener('mousedown', e => {
1669 e.preventDefault();
1670 startX = e.clientX;
1671 startW = sidebar.offsetWidth;
1672 handle.classList.add('dragging');
1673 const onMove = mv => {
1674 const delta = side === 'left' ? mv.clientX - startX : startX - mv.clientX;
1675 const w = Math.max(28, Math.min(400, startW + delta));
1676 sidebar.style.width = w + 'px';
1677 if (w <= 30) sidebar.classList.add('collapsed');
1678 else sidebar.classList.remove('collapsed');
1679 };
1680 const onUp = () => {
1681 handle.classList.remove('dragging');
1682 document.removeEventListener('mousemove', onMove);
1683 document.removeEventListener('mouseup', onUp);
1684 };
1685 document.addEventListener('mousemove', onMove);
1686 document.addEventListener('mouseup', onUp);
1687 });
1688 }
1689 makeResizable('resize-left', 'chat-sidebar-left', 'left');
1690 makeResizable('resize-right', 'chat-nicklist', 'right');
1691 })();
1692
1693 // --- helpers ---
1694 function renderAlert(type, msg) {
1695 return `<div class="alert ${type}"><span class="icon">${{info:'ℹ',error:'✕',success:'✓'}[type]}</span><div>${msg}</div></div>`;
1696 }
1697 function esc(s) {
1698 return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
1699 }
1700 function fmtTime(iso) {
1701 if (!iso) return '—';
1702 const d = new Date(iso);
1703 return d.toLocaleDateString()+' '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
1704 }
1705
1706 // --- collapsible cards ---
1707 function toggleCard(id, e) {
1708 // Don't collapse when clicking buttons inside the header.
1709 if (e && e.target.tagName === 'BUTTON') return;
1710 document.getElementById(id).classList.toggle('collapsed');
1711 }
1712
1713 // --- per-bot config schemas ---
1714 const BEHAVIOR_SCHEMAS = {
1715 snitch: [
1716 { key:'alert_channel', label:'Alert channel', type:'text', placeholder:'#ops', hint:'Channel to post alerts in' },
1717 { key:'alert_nicks', label:'Alert nicks', type:'text', placeholder:'alice, bob', hint:'Operators to DM (comma-separated)' },
1718 { key:'flood_messages', label:'Flood threshold', type:'number', placeholder:'10', hint:'Messages in window that triggers alert' },
1719 { key:'flood_window_s', label:'Flood window (s)', type:'number', placeholder:'5', hint:'Rolling window duration in seconds' },
1720 { key:'joinpart_threshold',label:'Join/part threshold', type:'number', placeholder:'5', hint:'Join+part events before cycling alert' },
1721 { key:'joinpart_window_s', label:'Join/part window (s)',type:'number', placeholder:'30' },
1722 ],
1723 warden: [
1724 { key:'flood_threshold', label:'Flood threshold', type:'number', placeholder:'10', hint:'Messages/window before action' },
1725 { key:'window_s', label:'Window (s)', type:'number', placeholder:'5' },
1726 { key:'warn_before_mute', label:'Warn before mute', type:'checkbox' },
1727 { key:'mute_duration_s', label:'Mute duration (s)', type:'number', placeholder:'300' },
1728 { key:'kick_after_mutes', label:'Kick after N mutes',type:'number', placeholder:'3' },
1729 ],
1730 oracle: [
1731 { key:'backend', label:'LLM backend', type:'llm-backend', hint:'Backend configured in the AI tab' },
1732 { key:'model', label:'Model override', type:'model-override', backendKey:'backend', hint:'Override the backend default model (optional)' },
1733 { key:'scribe_dir', label:'Scribe log dir', type:'text', placeholder:'./data/logs/scribe', hint:'Directory scribe writes to — oracle reads history from here' },
1734 { key:'max_messages', label:'Max messages', type:'number', placeholder:'50', hint:'Default message count for summaries' },
1735 ],
1736 scribe: [
1737 { key:'dir', label:'Log directory', type:'text', placeholder:'./data/logs', hint:'Directory to write log files into' },
1738 { key:'format', label:'Format', type:'select', options:['jsonl','csv','text'], hint:'jsonl=structured, csv=spreadsheet, text=human-readable' },
1739 { key:'rotation', label:'Rotation', type:'select', options:['none','daily','weekly','monthly','yearly','size'], hint:'When to start a new log file' },
1740 { key:'max_size_mb', label:'Max size (MiB)', type:'number', placeholder:'100', hint:'Only applies when rotation = size' },
1741 { key:'per_channel', label:'Per-channel files',type:'checkbox', hint:'Separate file per channel' },
1742 { key:'max_age_days', label:'Max age (days)', type:'number', placeholder:'0', hint:'Prune old rotated files; 0 = keep all' },
1743 ],
1744 herald: [
1745 { key:'webhook_path', label:'Webhook path', type:'text', placeholder:'/webhooks/herald', hint:'HTTP path that receives inbound events' },
1746 { key:'rate_limit', label:'Rate limit (msg/min)', type:'number', placeholder:'60' },
1747 ],
1748 scroll: [
1749 { key:'max_replay', label:'Max replay', type:'number', placeholder:'100', hint:'Max messages per request' },
1750 { key:'require_auth', label:'Require auth', type:'checkbox', hint:'Only registered agents can query history' },
1751 ],
1752 systembot: [
1753 { key:'log_joins', label:'Log joins', type:'checkbox' },
1754 { key:'log_parts', label:'Log parts/quits', type:'checkbox' },
1755 { key:'log_modes', label:'Log mode changes', type:'checkbox' },
1756 { key:'log_kicks', label:'Log kicks', type:'checkbox' },
1757 ],
1758 auditbot: [
1759 { key:'retention_days', label:'Retention (days)', type:'number', placeholder:'90', hint:'0 = keep forever' },
1760 { key:'log_path', label:'Log path', type:'text', placeholder:'/var/log/scuttlebot/audit.log' },
1761 ],
1762 sentinel: [
1763 { key:'backend', label:'LLM backend', type:'llm-backend', hint:'Backend configured in the AI tab' },
1764 { key:'model', label:'Model override', type:'model-override', backendKey:'backend', hint:'Override the backend default model (optional)' },
1765 { key:'mod_channel', label:'Mod channel', type:'text', placeholder:'#moderation', hint:'Channel where incident reports are posted' },
1766 { key:'dm_operators', label:'DM operators', type:'checkbox', hint:'Also send incident reports as DMs to operator nicks' },
1767 { key:'alert_nicks', label:'Operator nicks', type:'text', placeholder:'alice,bob', hint:'Comma-separated nicks to DM on incidents (requires DM operators)' },
1768 { key:'policy', label:'Policy', type:'text', placeholder:'Flag harassment, hate speech, spam and threats.', hint:'Plain-English description of what to flag' },
1769 { key:'min_severity', label:'Min severity', type:'select', options:['low','medium','high'], hint:'Minimum severity level to report' },
1770 { key:'window_size', label:'Window size', type:'number', placeholder:'20', hint:'Messages to buffer per channel before analysis' },
1771 { key:'window_age_sec', label:'Window age (s)', type:'number', placeholder:'300', hint:'Max seconds before a stale buffer is force-scanned' },
1772 { key:'cooldown_sec', label:'Cooldown (s)', type:'number', placeholder:'600', hint:'Min seconds between reports about the same nick' },
1773 ],
1774 steward: [
1775 { key:'mod_channel', label:'Mod channel', type:'text', placeholder:'#moderation', hint:'Channel steward watches for sentinel reports and posts action logs' },
1776 { key:'operator_nicks', label:'Operator nicks', type:'text', placeholder:'alice,bob', hint:'Comma-separated nicks allowed to issue direct commands via DM' },
1777 { key:'dm_on_action', label:'DM operators', type:'checkbox', hint:'Send a DM to operator nicks when steward takes action' },
1778 { key:'auto_act', label:'Auto-act', type:'checkbox', hint:'Automatically act on sentinel incident reports' },
1779 { key:'warn_on_low', label:'Warn on low', type:'checkbox', hint:'Send a warning notice for low-severity incidents' },
1780 { key:'mute_duration_sec', label:'Mute duration (s)',type:'number', placeholder:'600', hint:'How long medium-severity mutes last' },
1781 { key:'cooldown_sec', label:'Cooldown (s)', type:'number', placeholder:'300', hint:'Min seconds between automated actions on the same nick' },
1782 ],
1783 };
1784
1785 function renderBehConfig(b) {
1786 const schema = BEHAVIOR_SCHEMAS[b.id];
1787 if (!schema) return '';
1788 const cfg = b.config || {};
1789 const fields = schema.map(f => {
1790 const val = cfg[f.key];
1791 let input = '';
1792 if (f.type === 'checkbox') {
1793 input = `<input type="checkbox" ${val?'checked':''} style="accent-color:#58a6ff" onchange="onBehCfg('${esc(b.id)}','${f.key}',this.checked)">`;
1794 } else if (f.type === 'select') {
1795 input = `<select onchange="onBehCfg('${esc(b.id)}','${f.key}',this.value)">${(f.options||[]).map(o=>`<option ${val===o?'selected':''}>${esc(o)}</option>`).join('')}</select>`;
1796 } else if (f.type === 'llm-backend') {
1797 const opts = _llmBackendNames.map(n => `<option value="${esc(n)}" ${val===n?'selected':''}>${esc(n)}</option>`).join('');
1798 const noMatch = val && !_llmBackendNames.includes(val);
1799 input = `<select onchange="onBehCfg('${esc(b.id)}','${f.key}',this.value)">
1800 <option value="">— select backend —</option>
1801 ${opts}
1802 ${noMatch ? `<option value="${esc(val)}" selected>${esc(val)}</option>` : ''}
1803 </select>`;
1804 } else if (f.type === 'model-override') {
1805 const selId = `beh-msel-${esc(b.id)}`;
1806 const customId = `beh-mcustom-${esc(b.id)}`;
1807 const backendKey = f.backendKey || 'backend';
1808 const currentVal = val || '';
1809 input = `<div style="display:flex;gap:6px;align-items:flex-start">
1810 <div style="flex:1">
1811 <select id="${selId}" style="width:100%" onchange="onBehModelSelect('${esc(b.id)}','${f.key}','${selId}','${customId}')">
1812 <option value="">— none / auto-select —</option>
1813 ${currentVal ? `<option value="${esc(currentVal)}" selected>${esc(currentVal)}</option>` : ''}
1814 <option value="__other__">— other (type below) —</option>
1815 </select>
1816 <input type="text" id="${customId}" placeholder="model-id" autocomplete="off"
1817 style="display:none;margin-top:6px"
1818 onchange="onBehCfg('${esc(b.id)}','${f.key}',this.value)">
1819 </div>
1820 <button type="button" class="sm" style="white-space:nowrap;margin-top:1px"
1821 onclick="loadBehModels(this,'${esc(b.id)}','${backendKey}','${f.key}','${selId}','${customId}')">↺</button>
1822 </div>`;
1823 } else {
1824 input = `<input type="${f.type}" placeholder="${esc(f.placeholder||'')}" value="${esc(String(val??''))}" onchange="onBehCfg('${esc(b.id)}','${f.key}',this.value${f.type==='number'?'*1':''})">`;
1825 }
1826 return `<div class="beh-field"><label>${esc(f.label)}</label>${input}${f.hint?`<div class="hint">${esc(f.hint)}</div>`:''}</div>`;
1827 }).join('');
1828 return `<div class="beh-config" id="beh-cfg-${esc(b.id)}">${fields}</div>`;
1829 }
1830
1831 function toggleBehConfig(id) {
1832 const el = document.getElementById('beh-cfg-' + id);
1833 if (!el) return;
1834 el.classList.toggle('open');
1835 const btn = document.getElementById('beh-cfg-btn-' + id);
1836 if (btn) btn.textContent = el.classList.contains('open') ? 'configure ▴' : 'configure ▾';
1837 }
1838
1839 function onBehCfg(id, key, val) {
1840 const b = currentPolicies.behaviors.find(x => x.id === id);
1841 if (!b) return;
1842 if (!b.config) b.config = {};
1843 b.config[key] = val;
1844 }
1845
1846 function onBehModelSelect(botId, key, selId, customId) {
1847 const sel = document.getElementById(selId);
1848 const custom = document.getElementById(customId);
1849 if (!sel) return;
1850 custom.style.display = sel.value === '__other__' ? '' : 'none';
1851 if (sel.value !== '__other__') onBehCfg(botId, key, sel.value);
1852 }
1853
1854 async function loadBehModels(btn, botId, backendKey, modelKey, selId, customId) {
1855 const b = currentPolicies && currentPolicies.behaviors.find(x => x.id === botId);
1856 const backendName = b && b.config && b.config[backendKey];
1857 if (!backendName) {
1858 alert('Select a backend first, then click ↺ to load its models.');
1859 return;
1860 }
1861 const origText = btn.textContent;
1862 btn.textContent = '…';
1863 btn.disabled = true;
1864 try {
1865 const models = await api('GET', `/v1/llm/backends/${encodeURIComponent(backendName)}/models`);
1866 const sel = document.getElementById(selId);
1867 const custom = document.getElementById(customId);
1868 if (!sel) return;
1869 const current = (b.config && b.config[modelKey]) || '';
1870 sel.innerHTML = '<option value="">— none / auto-select —</option>';
1871 for (const m of (models || [])) {
1872 const id = typeof m === 'string' ? m : m.id;
1873 const label = (typeof m === 'object' && m.name && m.name !== id) ? `${id} — ${m.name}` : id;
1874 const opt = document.createElement('option');
1875 opt.value = id;
1876 opt.textContent = label;
1877 if (id === current) opt.selected = true;
1878 sel.appendChild(opt);
1879 }
1880 const other = document.createElement('option');
1881 other.value = '__other__';
1882 other.textContent = '— other (type below) —';
1883 const matched = (models || []).some(m => (typeof m === 'string' ? m : m.id) === current);
1884 if (current && !matched) {
1885 other.selected = true;
1886 if (custom) { custom.value = current; custom.style.display = ''; }
1887 }
1888 sel.appendChild(other);
1889 } catch(e) {
1890 alert('Model discovery failed: ' + e.message);
1891 } finally {
1892 btn.textContent = origText;
1893 btn.disabled = false;
1894 }
1895 }
1896
1897 // --- admin accounts ---
1898 async function loadAdmins() {
1899 try {
1900 const data = await api('GET', '/v1/admins');
1901 renderAdmins(data.admins || []);
1902 } catch(e) {
1903 // admins endpoint may not exist on token-only setups
1904 document.getElementById('admins-list-container').innerHTML = '';
1905 }
1906 }
1907
1908 function renderAdmins(admins) {
1909 const el = document.getElementById('admins-list-container');
1910 if (!admins.length) { el.innerHTML = ''; return; }
1911 const rows = admins.map(a => `<tr>
1912 <td><strong>${esc(a.username)}</strong></td>
1913 <td style="color:#8b949e;font-size:12px">${fmtTime(a.created)}</td>
1914 <td><div class="actions">
1915 <button class="sm" onclick="promptAdminPassword('${esc(a.username)}')">change password</button>
1916 <button class="sm danger" onclick="removeAdmin('${esc(a.username)}')">remove</button>
1917 </div></td>
1918 </tr>`).join('');
1919 el.innerHTML = `<table><thead><tr><th>username</th><th>created</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1920 }
1921
1922 async function addAdmin(e) {
1923 e.preventDefault();
1924 const username = document.getElementById('new-admin-username').value.trim();
1925 const password = document.getElementById('new-admin-password').value;
1926 const resultEl = document.getElementById('add-admin-result');
1927 if (!username || !password) return;
1928 try {
1929 await api('POST', '/v1/admins', { username, password });
1930 resultEl.innerHTML = renderAlert('success', `Admin <strong>${esc(username)}</strong> added.`);
1931 resultEl.style.display = 'block';
1932 document.getElementById('add-admin-form').reset();
1933 await loadAdmins();
1934 setTimeout(() => { resultEl.style.display = 'none'; }, 3000);
1935 } catch(e) {
1936 resultEl.innerHTML = renderAlert('error', e.message);
1937 resultEl.style.display = 'block';
1938 }
1939 }
1940
1941 async function removeAdmin(username) {
1942 if (!confirm(`Remove admin "${username}"?`)) return;
1943 try {
1944 await api('DELETE', `/v1/admins/${encodeURIComponent(username)}`);
1945 await loadAdmins();
1946 } catch(e) { alert('Remove failed: ' + e.message); }
1947 }
1948
1949 async function promptAdminPassword(username) {
1950 const pw = prompt(`New password for ${username}:`);
1951 if (!pw) return;
1952 try {
1953 await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw });
1954 alert('Password updated.');
1955 } catch(e) { alert('Failed: ' + e.message); }
1956 }
1957
1958 // --- AI / LLM tab ---
1959 async function loadAI() {
1960 await Promise.all([loadAIBackends(), loadAIKnown()]);
1961 }
1962
1963 async function loadAIBackends() {
1964 const el = document.getElementById('ai-backends-list');
1965 try {
1966 const backends = await api('GET', '/v1/llm/backends');
1967 if (!backends || backends.length === 0) {
1968 el.innerHTML = '<div class="empty-state">No LLM backends configured yet. Click <strong>+ add backend</strong> above or add them to <code>scuttlebot.yaml</code>.</div>';
1969 return;
1970 }
1971 el.innerHTML = backends.map(b => {
1972 const sid = CSS.escape(b.name);
1973 const editable = b.source === 'policy';
1974 const srcBadge = b.source === 'config'
1975 ? '<span class="badge" style="background:#21262d;color:#8b949e;border:1px solid #30363d">yaml</span>'
1976 : '<span class="badge" style="background:#21262d;color:#58a6ff;border:1px solid #1f6feb">ui</span>';
1977 return `<div class="setting-row" style="flex-wrap:wrap;gap:8px;align-items:center">
1978 <div style="flex:1;min-width:140px">
1979 <div style="font-weight:500;color:#e6edf3">${esc(b.name)} ${srcBadge}${b.default ? ' <span class="badge" style="background:#1f6feb">default</span>' : ''}</div>
1980 <div style="font-size:11px;color:#8b949e;margin-top:2px">${esc(b.backend)}${b.region ? ' · ' + esc(b.region) : ''}${b.base_url ? ' · ' + esc(b.base_url) : ''}</div>
1981 </div>
1982 <div style="min-width:100px;font-size:12px;color:#8b949e">${b.model ? 'model: <code style="color:#a5d6ff">' + esc(b.model) + '</code>' : '<span style="color:#6e7681">model: auto</span>'}</div>
1983 <div id="ai-models-${sid}" style="width:100%;display:none"></div>
1984 <button class="sm" onclick="discoverModels('${esc(b.name)}', this)">discover models</button>
1985 ${editable ? `<button class="sm" onclick="openEditBackend('${esc(b.name)}')">edit</button>
1986 <button class="sm danger" onclick="deleteBackend('${esc(b.name)}', this)">delete</button>` : ''}
1987 </div>`;
1988 }).join('<div style="height:1px;background:#21262d;margin:4px 0"></div>');
1989 } catch(e) {
1990 el.innerHTML = `<div class="empty-state" style="color:#f85149">${esc(String(e))}</div>`;
1991 }
1992 }
1993
1994 // --- backend form ---
1995
1996 let _editingBackend = null; // null = adding, string = name being edited
1997 let _backendList = []; // cached for edit lookups
1998
1999 async function openAddBackend() {
2000 _editingBackend = null;
2001 document.getElementById('ai-form-title').textContent = 'add backend';
2002 document.getElementById('bf-submit-btn').textContent = 'add backend';
2003 document.getElementById('bf-name').value = '';
2004 document.getElementById('bf-name').disabled = false;
2005 document.getElementById('bf-backend').value = '';
2006 document.getElementById('bf-apikey').value = '';
2007 document.getElementById('bf-baseurl').value = '';
2008 populateModelSelect([], '');
2009 document.getElementById('bf-model-custom').value = '';
2010 document.getElementById('bf-model-custom').style.display = 'none';
2011 document.getElementById('bf-load-models-btn').textContent = '↺ load models';
2012 document.getElementById('bf-default').checked = false;
2013 document.getElementById('bf-region').value = '';
2014 document.getElementById('bf-aws-key-id').value = '';
2015 document.getElementById('bf-aws-secret').value = '';
2016 document.getElementById('bf-allow').value = '';
2017 document.getElementById('bf-block').value = '';
2018 document.getElementById('ai-form-result').style.display = 'none';
2019 onBackendTypeChange();
2020 const card = document.getElementById('card-ai-form');
2021 card.style.display = '';
2022 card.scrollIntoView({ behavior: 'smooth', block: 'start' });
2023 }
2024
2025 async function openEditBackend(name) {
2026 let b;
2027 try {
2028 const backends = await api('GET', '/v1/llm/backends');
2029 b = backends.find(x => x.name === name);
2030 } catch(e) { return; }
2031 if (!b) return;
2032
2033 _editingBackend = name;
2034 document.getElementById('ai-form-title').textContent = 'edit backend — ' + esc(name);
2035 document.getElementById('bf-submit-btn').textContent = 'save changes';
2036 document.getElementById('bf-name').value = name;
2037 document.getElementById('bf-name').disabled = true; // name is immutable
2038 document.getElementById('bf-backend').value = b.backend || '';
2039 document.getElementById('bf-apikey').value = ''; // never pre-fill secrets
2040 document.getElementById('bf-baseurl').value = b.base_url || '';
2041 const curated = KNOWN_MODELS[b.backend] || [];
2042 populateModelSelect(curated, b.model || '');
2043 document.getElementById('bf-model-custom').style.display = 'none';
2044 document.getElementById('bf-load-models-btn').textContent = '↺ load models';
2045 document.getElementById('bf-default').checked = !!b.default;
2046 document.getElementById('bf-region').value = b.region || '';
2047 document.getElementById('bf-aws-key-id').value = ''; // never pre-fill
2048 document.getElementById('bf-aws-secret').value = '';
2049 document.getElementById('bf-allow').value = (b.allow || []).join('\n');
2050 document.getElementById('bf-block').value = (b.block || []).join('\n');
2051 document.getElementById('ai-form-result').style.display = 'none';
2052 onBackendTypeChange();
2053 const card = document.getElementById('card-ai-form');
2054 card.style.display = '';
2055 card.scrollIntoView({ behavior: 'smooth', block: 'start' });
2056 }
2057
2058 function closeBackendForm() {
2059 document.getElementById('card-ai-form').style.display = 'none';
2060 _editingBackend = null;
2061 }
2062
2063 // Curated model lists per backend — shown before live discovery.
2064 const KNOWN_MODELS = {
2065 anthropic: [
2066 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001',
2067 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229',
2068 ],
2069 gemini: [
2070 'gemini-2.0-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-flash',
2071 'gemini-1.5-flash-8b', 'gemini-1.5-pro',
2072 ],
2073 openai: [
2074 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'o1', 'o1-mini', 'o3-mini', 'gpt-3.5-turbo',
2075 ],
2076 bedrock: [
2077 'anthropic.claude-3-5-sonnet-20241022-v2:0', 'anthropic.claude-3-5-haiku-20241022-v1:0',
2078 'anthropic.claude-3-opus-20240229-v1:0',
2079 'amazon.nova-pro-v1:0', 'amazon.nova-lite-v1:0', 'amazon.nova-micro-v1:0',
2080 'meta.llama3-70b-instruct-v1:0', 'meta.llama3-8b-instruct-v1:0',
2081 'mistral.mistral-large-2402-v1:0',
2082 ],
2083 ollama: ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'gemma2', 'qwen2.5', 'phi3'],
2084 groq: [
2085 'llama-3.3-70b-versatile', 'llama-3.1-8b-instant',
2086 'mixtral-8x7b-32768', 'gemma2-9b-it',
2087 ],
2088 mistral: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest', 'open-mistral-nemo'],
2089 deepseek: ['deepseek-chat', 'deepseek-reasoner'],
2090 xai: ['grok-2', 'grok-2-mini', 'grok-beta'],
2091 cerebras: ['llama3.1-8b', 'llama3.1-70b', 'llama3.3-70b'],
2092 together: [
2093 'meta-llama/Llama-3.3-70B-Instruct-Turbo',
2094 'meta-llama/Llama-3.1-8B-Instruct-Turbo',
2095 'mistralai/Mixtral-8x7B-Instruct-v0.1',
2096 'Qwen/Qwen2.5-72B-Instruct-Turbo',
2097 ],
2098 fireworks: [
2099 'accounts/fireworks/models/llama-v3p3-70b-instruct',
2100 'accounts/fireworks/models/mixtral-8x7b-instruct',
2101 ],
2102 openrouter: [], // too varied — always load live
2103 huggingface: [
2104 'meta-llama/Llama-3.3-70B-Instruct',
2105 'mistralai/Mistral-7B-Instruct-v0.3',
2106 'Qwen/Qwen2.5-72B-Instruct',
2107 ],
2108 };
2109
2110 function populateModelSelect(models, currentVal) {
2111 const sel = document.getElementById('bf-model-select');
2112 sel.innerHTML = '<option value="">— none / auto-select —</option>';
2113 for (const m of models) {
2114 const id = typeof m === 'string' ? m : m.id;
2115 const label = (typeof m === 'object' && m.name && m.name !== id) ? `${id} — ${m.name}` : id;
2116 const opt = document.createElement('option');
2117 opt.value = id;
2118 opt.textContent = label;
2119 if (id === currentVal) opt.selected = true;
2120 sel.appendChild(opt);
2121 }
2122 const other = document.createElement('option');
2123 other.value = '__other__';
2124 other.textContent = '— other (type below) —';
2125 if (currentVal && !models.find(m => (typeof m === 'string' ? m : m.id) === currentVal)) {
2126 other.selected = true;
2127 document.getElementById('bf-model-custom').value = currentVal;
2128 document.getElementById('bf-model-custom').style.display = '';
2129 }
2130 sel.appendChild(other);
2131 }
2132
2133 function onModelSelectChange() {
2134 const sel = document.getElementById('bf-model-select');
2135 const custom = document.getElementById('bf-model-custom');
2136 custom.style.display = sel.value === '__other__' ? '' : 'none';
2137 }
2138
2139 function getModelValue() {
2140 const sel = document.getElementById('bf-model-select');
2141 if (sel.value === '__other__') return document.getElementById('bf-model-custom').value.trim();
2142 return sel.value || '';
2143 }
2144
2145 function onBackendTypeChange() {
2146 const t = document.getElementById('bf-backend').value;
2147 const isBedrock = t === 'bedrock';
2148 const isLocal = ['ollama','litellm','lmstudio','jan','localai','vllm','anythingllm'].includes(t);
2149 const hasKey = !isBedrock;
2150
2151 document.getElementById('bf-bedrock-group').style.display = isBedrock ? '' : 'none';
2152 document.getElementById('bf-apikey-row').style.display = hasKey ? '' : 'none';
2153 document.getElementById('bf-baseurl-row').style.display = (isLocal || isBedrock) ? 'none' : '';
2154
2155 const curated = KNOWN_MODELS[t] || [];
2156 populateModelSelect(curated, '');
2157 }
2158
2159 async function loadLiveModels(btn) {
2160 const t = document.getElementById('bf-backend').value;
2161 if (!t) { alert('Select a backend type first.'); return; }
2162
2163 btn.disabled = true;
2164 btn.textContent = 'loading…';
2165 try {
2166 const payload = {
2167 backend: t,
2168 api_key: document.getElementById('bf-apikey')?.value || '',
2169 base_url: document.getElementById('bf-baseurl')?.value.trim() || '',
2170 region: document.getElementById('bf-region')?.value.trim() || '',
2171 aws_key_id: document.getElementById('bf-aws-key-id')?.value.trim() || '',
2172 aws_secret_key: document.getElementById('bf-aws-secret')?.value || '',
2173 };
2174 const models = await api('POST', '/v1/llm/discover', payload);
2175 const current = getModelValue();
2176 populateModelSelect(models, current);
2177 btn.textContent = `↺ ${models.length} loaded`;
2178 } catch(e) {
2179 btn.textContent = '✕ failed';
2180 setTimeout(() => { btn.textContent = '↺ load models'; }, 2000);
2181 alert('Discovery failed: ' + String(e));
2182 } finally {
2183 btn.disabled = false;
2184 }
2185 }
2186
2187 async function submitBackendForm() {
2188 const name = document.getElementById('bf-name').value.trim();
2189 const backend = document.getElementById('bf-backend').value;
2190 if (!name || !backend) {
2191 showFormResult('name and backend type are required', 'error');
2192 return;
2193 }
2194
2195 const allow = document.getElementById('bf-allow').value.trim().split('\n').map(s=>s.trim()).filter(Boolean);
2196 const block = document.getElementById('bf-block').value.trim().split('\n').map(s=>s.trim()).filter(Boolean);
2197
2198 const payload = {
2199 name, backend,
2200 api_key: document.getElementById('bf-apikey').value || undefined,
2201 base_url: document.getElementById('bf-baseurl').value.trim() || undefined,
2202 model: getModelValue() || undefined,
2203 default: document.getElementById('bf-default').checked || undefined,
2204 region: document.getElementById('bf-region').value.trim() || undefined,
2205 aws_key_id: document.getElementById('bf-aws-key-id').value.trim() || undefined,
2206 aws_secret_key: document.getElementById('bf-aws-secret').value || undefined,
2207 allow: allow.length ? allow : undefined,
2208 block: block.length ? block : undefined,
2209 };
2210
2211 const btn = document.getElementById('bf-submit-btn');
2212 btn.disabled = true;
2213 try {
2214 if (_editingBackend) {
2215 await api('PUT', `/v1/llm/backends/${encodeURIComponent(_editingBackend)}`, payload);
2216 } else {
2217 await api('POST', '/v1/llm/backends', payload);
2218 }
2219 closeBackendForm();
2220 await loadAIBackends();
2221 } catch(e) {
2222 showFormResult(String(e), 'error');
2223 } finally {
2224 btn.disabled = false;
2225 }
2226 }
2227
2228 async function deleteBackend(name, btn) {
2229 btn.disabled = true;
2230 try {
2231 await api('DELETE', `/v1/llm/backends/${encodeURIComponent(name)}`);
2232 await loadAIBackends();
2233 } catch(e) {
2234 btn.disabled = false;
2235 alert('Delete failed: ' + String(e));
2236 }
2237 }
2238
2239 function showFormResult(msg, type) {
2240 const el = document.getElementById('ai-form-result');
2241 el.style.display = '';
2242 el.className = 'alert ' + (type === 'error' ? 'danger' : 'info');
2243 el.innerHTML = `<span class="icon">${type === 'error' ? '✕' : 'ℹ'}</span><span>${esc(msg)}</span>`;
2244 }
2245
2246 async function discoverModels(name, btn) {
2247 const el = document.getElementById('ai-models-' + name);
2248 if (!el) return;
2249 btn.disabled = true;
2250 btn.textContent = 'discovering…';
2251 try {
2252 const models = await api('GET', `/v1/llm/backends/${encodeURIComponent(name)}/models`);
2253 el.style.display = 'block';
2254 if (!models || models.length === 0) {
2255 el.innerHTML = '<div style="font-size:12px;color:#8b949e;padding:6px 0">No models found (check filters).</div>';
2256 } else {
2257 el.innerHTML = `<div style="font-size:12px;color:#8b949e;margin-bottom:6px">${models.length} model${models.length !== 1 ? 's' : ''} available:</div>
2258 <div style="display:flex;flex-wrap:wrap;gap:4px">${models.map(m =>
2259 `<code style="background:#161b22;border:1px solid #30363d;border-radius:4px;padding:2px 6px;font-size:11px;color:#a5d6ff">${esc(m.id)}${m.name && m.name !== m.id ? ' <span style="color:#6e7681">(' + esc(m.name) + ')</span>' : ''}</code>`
2260 ).join('')}</div>`;
2261 }
2262 btn.textContent = '↺ refresh';
2263 } catch(e) {
2264 el.style.display = 'block';
2265 el.innerHTML = `<div style="font-size:12px;color:#f85149">Error: ${esc(String(e))}</div>`;
2266 btn.textContent = 'retry';
2267 } finally {
2268 btn.disabled = false;
2269 }
2270 }
2271
2272 async function loadAIKnown() {
2273 const el = document.getElementById('ai-supported-list');
2274 try {
2275 const known = await api('GET', '/v1/llm/known');
2276 const native = known.filter(b => b.native);
2277 const compat = known.filter(b => !b.native);
2278 native.sort((a,b) => a.name.localeCompare(b.name));
2279 compat.sort((a,b) => a.name.localeCompare(b.name));
2280 el.innerHTML = `
2281 <div style="margin-bottom:12px">
2282 <div style="font-size:12px;color:#8b949e;font-weight:500;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px">native APIs</div>
2283 <div style="display:flex;flex-wrap:wrap;gap:4px">${native.map(b =>
2284 `<span style="background:#161b22;border:1px solid #30363d;border-radius:4px;padding:3px 8px;font-size:12px;color:#e6edf3">${esc(b.name)}</span>`
2285 ).join('')}</div>
2286 </div>
2287 <div>
2288 <div style="font-size:12px;color:#8b949e;font-weight:500;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px">OpenAI-compatible</div>
2289 <div style="display:flex;flex-wrap:wrap;gap:4px">${compat.map(b =>
2290 `<span style="background:#161b22;border:1px solid #30363d;border-radius:4px;padding:3px 8px;font-size:12px;color:#e6edf3" title="${esc(b.base_url)}">${esc(b.name)}</span>`
2291 ).join('')}</div>
2292 </div>`;
2293 } catch(e) {
2294 el.innerHTML = `<div class="empty-state" style="color:#f85149">${esc(String(e))}</div>`;
2295 }
2296 }
2297
2298 function showAIExample(e) {
2299 e.preventDefault();
2300 const card = document.getElementById('card-ai-example');
2301 card.style.display = '';
2302 card.scrollIntoView({ behavior: 'smooth', block: 'start' });
2303 // Expand it if collapsed.
2304 const body = card.querySelector('.card-body');
2305 if (body) body.style.display = '';
2306 }
2307
2308 // --- settings / policies ---
2309 let currentPolicies = null;
2310 let _llmBackendNames = []; // cached backend names for oracle dropdown
2311
2312 async function loadSettings() {
2313 try {
2314 const [s, backends] = await Promise.all([
2315 api('GET', '/v1/settings'),
2316 api('GET', '/v1/llm/backends').catch(() => []),
2317 ]);
2318 _llmBackendNames = (backends || []).map(b => b.name);
2319 renderTLSStatus(s.tls);
2320 currentPolicies = s.policies;
2321 renderBehaviors(s.policies.behaviors || []);
2322 renderAgentPolicy(s.policies.agent_policy || {});
2323 renderBridgePolicy(s.policies.bridge || {});
2324 renderLoggingPolicy(s.policies.logging || {});
2325 loadAdmins();
2326 } catch(e) {
2327 document.getElementById('tls-badge').textContent = 'error';
2328 }
2329 }
2330
2331 function renderTLSStatus(tls) {
2332 const badge = document.getElementById('tls-badge');
2333 if (tls.enabled) {
2334 badge.textContent = 'TLS active';
2335 badge.style.background = '#3fb95022'; badge.style.color = '#3fb950'; badge.style.borderColor = '#3fb95044';
2336 } else {
2337 badge.textContent = 'HTTP only';
2338 }
2339 document.getElementById('tls-status-rows').innerHTML = `
2340 <div class="setting-row">
2341 <div class="setting-label">mode</div>
2342 <div class="setting-desc"></div>
2343 <code class="setting-val">${tls.enabled ? 'HTTPS (Let\'s Encrypt)' : 'HTTP'}</code>
2344 </div>
2345 ${tls.enabled ? `
2346 <div class="setting-row">
2347 <div class="setting-label">domain</div>
2348 <div class="setting-desc"></div>
2349 <code class="setting-val">${esc(tls.domain)}</code>
2350 </div>
2351 <div class="setting-row">
2352 <div class="setting-label">allow insecure</div>
2353 <div class="setting-desc">Plain HTTP also accepted.</div>
2354 <code class="setting-val">${tls.allow_insecure ? 'yes' : 'no'}</code>
2355 </div>` : ''}
2356 `;
2357 }
2358
2359 function renderBehaviors(behaviors) {
2360 const hasSchema = id => !!BEHAVIOR_SCHEMAS[id];
2361 document.getElementById('behaviors-list').innerHTML = behaviors.map(b => `
2362 <div>
2363 <div style="display:grid;grid-template-columns:20px 90px 1fr auto;align-items:center;gap:12px;padding:11px 16px;border-bottom:1px solid #21262d">
2364 <input type="checkbox" ${b.enabled?'checked':''} onchange="onBehaviorToggle('${esc(b.id)}',this.checked)" style="width:14px;height:14px;cursor:pointer;accent-color:#58a6ff">
2365 <strong style="font-size:13px;white-space:nowrap">${esc(b.name)}</strong>
2366 <span style="font-size:12px;color:#8b949e">${esc(b.description)}</span>
2367 <div style="display:flex;align-items:center;gap:8px;justify-content:flex-end">
2368 ${b.enabled ? `
2369 <label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#8b949e;cursor:pointer;white-space:nowrap">
2370 <input type="checkbox" ${b.join_all_channels?'checked':''} onchange="onBehaviorJoinAll('${esc(b.id)}',this.checked)" style="accent-color:#58a6ff">
2371 all channels
2372 </label>
2373 ${b.join_all_channels
2374 ? `<input type="text" placeholder="exclude #private, /regex/" style="width:160px;padding:3px 7px;font-size:11px" title="Exclude: comma-separated names or /regex/" value="${esc((b.exclude_channels||[]).join(', '))}" onchange="onBehaviorExclude('${esc(b.id)}',this.value)">`
2375 : `<input type="text" placeholder="#ops, /^#proj-.*/" style="width:160px;padding:3px 7px;font-size:11px" title="Join: comma-separated names or /regex/ patterns" value="${esc((b.required_channels||[]).join(', '))}" onchange="onBehaviorChannels('${esc(b.id)}',this.value)">`
2376 }
2377 ${hasSchema(b.id) ? `<button class="sm" id="beh-cfg-btn-${esc(b.id)}" onclick="toggleBehConfig('${esc(b.id)}')" style="font-size:11px;white-space:nowrap">configure ▾</button>` : ''}
2378 ` : ''}
2379 <span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span>
2380 </div>
2381 </div>
2382 ${b.enabled && hasSchema(b.id) ? renderBehConfig(b) : ''}
2383 </div>
2384 `).join('');
2385 }
2386
2387 function onBehaviorToggle(id, enabled) {
2388 const b = currentPolicies.behaviors.find(x => x.id === id);
2389 if (b) b.enabled = enabled;
2390 renderBehaviors(currentPolicies.behaviors);
2391 }
2392 function onBehaviorJoinAll(id, val) {
2393 const b = currentPolicies.behaviors.find(x => x.id === id);
2394 if (b) b.join_all_channels = val;
2395 renderBehaviors(currentPolicies.behaviors);
2396 }
2397 function onBehaviorExclude(id, val) {
2398 const b = currentPolicies.behaviors.find(x => x.id === id);
2399 if (b) b.exclude_channels = val.split(',').map(s=>s.trim()).filter(Boolean);
2400 }
2401 function onBehaviorChannels(id, val) {
2402 const b = currentPolicies.behaviors.find(x => x.id === id);
2403 if (b) b.required_channels = val.split(',').map(s=>s.trim()).filter(Boolean);
2404 }
2405
2406 function renderAgentPolicy(p) {
2407 document.getElementById('policy-checkin-enabled').checked = !!p.require_checkin;
2408 document.getElementById('policy-checkin-channel').value = p.checkin_channel || '';
2409 document.getElementById('policy-required-channels').value = (p.required_channels||[]).join(', ');
2410 toggleCheckinChannel();
2411 }
2412 function toggleCheckinChannel() {
2413 const on = document.getElementById('policy-checkin-enabled').checked;
2414 document.getElementById('policy-checkin-row').style.display = on ? '' : 'none';
2415 }
2416
2417 function renderBridgePolicy(p) {
2418 document.getElementById('policy-bridge-web-user-ttl').value = p.web_user_ttl_minutes || 5;
2419 }
2420
2421 function renderLoggingPolicy(l) {
2422 document.getElementById('policy-logging-enabled').checked = !!l.enabled;
2423 document.getElementById('policy-log-dir').value = l.dir || '';
2424 document.getElementById('policy-log-format').value = l.format || 'jsonl';
2425 document.getElementById('policy-log-rotation').value = l.rotation || 'none';
2426 document.getElementById('policy-log-max-size').value = l.max_size_mb || '';
2427 document.getElementById('policy-log-per-channel').checked = !!l.per_channel;
2428 document.getElementById('policy-log-max-age').value = l.max_age_days || '';
2429 toggleLogOptions();
2430 toggleRotationOptions();
2431 }
2432 function toggleLogOptions() {
2433 const on = document.getElementById('policy-logging-enabled').checked;
2434 document.getElementById('policy-log-options').style.display = on ? '' : 'none';
2435 }
2436 function toggleRotationOptions() {
2437 const rot = document.getElementById('policy-log-rotation').value;
2438 document.getElementById('policy-log-size-row').style.display = rot === 'size' ? '' : 'none';
2439 }
2440
2441 async function savePolicies() {
2442 if (!currentPolicies) return;
2443 const p = JSON.parse(JSON.stringify(currentPolicies)); // deep copy
2444 p.agent_policy = {
2445 require_checkin: document.getElementById('policy-checkin-enabled').checked,
2446 checkin_channel: document.getElementById('policy-checkin-channel').value.trim(),
2447 required_channels: document.getElementById('policy-required-channels').value.split(',').map(s=>s.trim()).filter(Boolean),
2448 };
2449 p.bridge = {
2450 web_user_ttl_minutes: parseInt(document.getElementById('policy-bridge-web-user-ttl').value, 10) || 5,
2451 };
2452 p.logging = {
2453 enabled: document.getElementById('policy-logging-enabled').checked,
2454 dir: document.getElementById('policy-log-dir').value.trim(),
2455 format: document.getElementById('policy-log-format').value,
2456 rotation: document.getElementById('policy-log-rotation').value,
2457 max_size_mb: parseInt(document.getElementById('policy-log-max-size').value, 10) || 0,
2458 per_channel: document.getElementById('policy-log-per-channel').checked,
2459 max_age_days: parseInt(document.getElementById('policy-log-max-age').value, 10) || 0,
2460 };
2461 const resultEl = document.getElementById('policies-save-result');
2462 try {
2463 currentPolicies = await api('PUT', '/v1/settings/policies', p);
2464 resultEl.style.display = 'block';
2465 resultEl.innerHTML = renderAlert('success', 'Settings saved.');
2466 setTimeout(() => { resultEl.style.display = 'none'; }, 3000);
2467 } catch(e) {
2468 resultEl.style.display = 'block';
2469 resultEl.innerHTML = renderAlert('error', e.message);
2470 }
2471 }
2472
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2473 // --- init ---
2474 function loadAll() { loadStatus(); loadAgents(); loadSettings(); startMetricsPoll(); }
2475 initAuth();
 
 
2476 </script>
2477 </body>
2478 </html>
2479
2480 DDED internal/auth/admin.go
2481 DDED internal/auth/admin_test.go
--- a/internal/auth/admin.go
+++ b/internal/auth/admin.go
@@ -0,0 +1,67 @@
1
+// Package auth provides admin account management with bcrypt-hashed passwords.
2
+package auth
3
+
4
+import (
5
+ "encoding/json"
6
+ "fmt"
7
+ "os"
8
+ "sync"
9
+ "time"
10
+
11
+ "gscuttlebot/internal/store"
12
+)
13
+
14
+// Admin is a single admin account record.
15
+type Admin struct {
16
+ Username string `json:"username"`
17
+ Hash []byte `json:"hash"`
18
+ Created time.Time `json:"created"`
19
+}.
20
+type AdminStore struct {
21
+ mu sync.RWMutex
22
+ path string
23
+ data []Admin
24
+}
25
+
26
+// NewAdminStore loads (or creates) the admin store at the given path.
27
+func NewAdminStore(path string) (*AdminStore, error) {
28
+ s := &AdminStore{path: path}
29
+ if err := s.load(); err != nil {
30
+ return nil, eides admin account management with bcrypt-hashed passwords.
31
+package auth
32
+
33
+import (
34
+ "encoding/json"
35
+ "fmt"
36
+ "os"
37
+ "sync"
38
+ "time"
39
+
40
+ "golang.org/x/crypto/bcrypt"
41
+
42
+ "github.com/conflicthq/scuttlebot/internal/store"
43
+)
44
+
45
+// Admin is a single admin account record.
46
+type Admin struct {
47
+ Username string `json:"username"`
48
+ Hash []byte `json:"hash"`
49
+ Created time.Time `json:"created"`
50
+}
51
+
52
+// AdminStore persists admin accounts to a JSON file or database.
53
+type AdminStore struct {
54
+ mu sync.RWMutex
55
+ path string
56
+ data []Admin
57
+ db *store.Store // when non-nil, supersedes path
58
+}
59
+
60
+// NewAdminStore loads (or cr// Package auth provid, Admin{
61
+ %w", err)
62
+ }
63
+
64
+ a :
65
+ Hash: hash,
66
+ Crea time.Now().UTC(),
67
+ })6j@Xy,1G@ly,45@gu,Gn@ly,pIZ1H;
--- a/internal/auth/admin.go
+++ b/internal/auth/admin.go
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/auth/admin.go
+++ b/internal/auth/admin.go
@@ -0,0 +1,67 @@
1 // Package auth provides admin account management with bcrypt-hashed passwords.
2 package auth
3
4 import (
5 "encoding/json"
6 "fmt"
7 "os"
8 "sync"
9 "time"
10
11 "gscuttlebot/internal/store"
12 )
13
14 // Admin is a single admin account record.
15 type Admin struct {
16 Username string `json:"username"`
17 Hash []byte `json:"hash"`
18 Created time.Time `json:"created"`
19 }.
20 type AdminStore struct {
21 mu sync.RWMutex
22 path string
23 data []Admin
24 }
25
26 // NewAdminStore loads (or creates) the admin store at the given path.
27 func NewAdminStore(path string) (*AdminStore, error) {
28 s := &AdminStore{path: path}
29 if err := s.load(); err != nil {
30 return nil, eides admin account management with bcrypt-hashed passwords.
31 package auth
32
33 import (
34 "encoding/json"
35 "fmt"
36 "os"
37 "sync"
38 "time"
39
40 "golang.org/x/crypto/bcrypt"
41
42 "github.com/conflicthq/scuttlebot/internal/store"
43 )
44
45 // Admin is a single admin account record.
46 type Admin struct {
47 Username string `json:"username"`
48 Hash []byte `json:"hash"`
49 Created time.Time `json:"created"`
50 }
51
52 // AdminStore persists admin accounts to a JSON file or database.
53 type AdminStore struct {
54 mu sync.RWMutex
55 path string
56 data []Admin
57 db *store.Store // when non-nil, supersedes path
58 }
59
60 // NewAdminStore loads (or cr// Package auth provid, Admin{
61 %w", err)
62 }
63
64 a :
65 Hash: hash,
66 Crea time.Now().UTC(),
67 })6j@Xy,1G@ly,45@gu,Gn@ly,pIZ1H;
--- a/internal/auth/admin_test.go
+++ b/internal/auth/admin_test.go
@@ -0,0 +1,141 @@
1
+package auth_test
2
+
3
+import (
4
+ "path/filepath"
5
+ "testing"
6
+
7
+ "github.com/conflicthq/scuttlebot/internal/auth"
8
+)
9
+
10
+func newStore(t *testing.T) *auth.AdminStore {
11
+ t.Helper()
12
+ s, err := auth.NewAdminStore(filepath.Join(t.TempDir(), "admins.json"))
13
+ if err != nil {
14
+ t.Fatalf("NewAdminStore: %v", err)
15
+ }
16
+ return s
17
+}
18
+
19
+func TestIsEmptyInitially(t *testing.T) {
20
+ s := newStore(t)
21
+ if !s.IsEmpty() {
22
+ t.Error("expected empty store")
23
+ }
24
+}
25
+
26
+func TestAddAndAuthenticate(t *testing.T) {
27
+ s := newStore(t)
28
+ if err := s.Add("alice", "s3cr3t"); err != nil {
29
+ t.Fatalf("Add: %v", err)
30
+ }
31
+ if !s.Authenticate("alice", "s3cr3t") {
32
+ t.Error("expected Authenticate to return true for correct credentials")
33
+ }
34
+ if s.Authenticate("alice", "wrong") {
35
+ t.Error("expected Authenticate to return false for wrong password")
36
+ }
37
+ if s.Authenticate("nobody", "s3cr3t") {
38
+ t.Error("expected Authenticate to return false for unknown user")
39
+ }
40
+}
41
+
42
+func TestAddDuplicateReturnsError(t *testing.T) {
43
+ s := newStore(t)
44
+ if err := s.Add("alice", "pass1"); err != nil {
45
+ t.Fatalf("first Add: %v", err)
46
+ }
47
+ if err := s.Add("alice", "pass2"); err == nil {
48
+ t.Error("expected error on duplicate Add")
49
+ }
50
+}
51
+
52
+func TestIsEmptyAfterAdd(t *testing.T) {
53
+ s := newStore(t)
54
+ _ = s.Add("admin", "pw")
55
+ if s.IsEmpty() {
56
+ t.Error("expected non-empty store after Add")
57
+ }
58
+}
59
+
60
+func TestList(t *testing.T) {
61
+ s := newStore(t)
62
+ _ = s.Add("alice", "pw")
63
+ _ = s.Add("bob", "pw")
64
+
65
+ list := s.List()
66
+ if len(list) != 2 {
67
+ t.Fatalf("List: got %d, want 2", len(list))
68
+ }
69
+ names := map[string]bool{list[0].Username: true, list[1].Username: true}
70
+ if !names["alice"] || !names["bob"] {
71
+ t.Errorf("List: unexpected names %v", names)
72
+ }
73
+}
74
+
75
+func TestSetPassword(t *testing.T) {
76
+ s := newStore(t)
77
+ _ = s.Add("alice", "old")
78
+
79
+ if err := s.SetPassword("alice", "new"); err != nil {
80
+ t.Fatalf("SetPassword: %v", err)
81
+ }
82
+ if s.Authenticate("alice", "old") {
83
+ t.Error("old password should no longer work")
84
+ }
85
+ if !s.Authenticate("alice", "new") {
86
+ t.Error("new password should work")
87
+ }
88
+}
89
+
90
+func TestSetPasswordUnknownUser(t *testing.T) {
91
+ s := newStore(t)
92
+ if err := s.SetPassword("nobody", "pw"); err == nil {
93
+ t.Error("expected error setting password for unknown user")
94
+ }
95
+}
96
+
97
+func TestRemove(t *testing.T) {
98
+ s := newStore(t)
99
+ _ = s.Add("alice", "pw")
100
+ _ = s.Add("bob", "pw")
101
+
102
+ if err := s.Remove("alice"); err != nil {
103
+ t.Fatalf("Remove: %v", err)
104
+ }
105
+ if len(s.List()) != 1 {
106
+ t.Errorf("List after Remove: got %d, want 1", len(s.List()))
107
+ }
108
+ if s.List()[0].Username != "bob" {
109
+ t.Errorf("expected bob to remain, got %q", s.List()[0].Username)
110
+ }
111
+}
112
+
113
+func TestRemoveUnknown(t *testing.T) {
114
+ s := newStore(t)
115
+ if err := s.Remove("nobody"); err == nil {
116
+ t.Error("expected error removing unknown user")
117
+ }
118
+}
119
+
120
+func TestPersistence(t *testing.T) {
121
+ dir := t.TempDir()
122
+ path := filepath.Join(dir, "admins.json")
123
+
124
+ s1, err := auth.NewAdminStore(path)
125
+ if err != nil {
126
+ t.Fatalf("create: %v", err)
127
+ }
128
+ _ = s1.Add("alice", "s3cr3t")
129
+
130
+ // Load a new store from the same file.
131
+ s2, err := auth.NewAdminStore(path)
132
+ if err != nil {
133
+ t.Fatalf("reload: %v", err)
134
+ }
135
+ if s2.IsEmpty() {
136
+ t.Error("reloaded store should not be empty")
137
+ }
138
+ if !s2.Authenticate("alice", "s3cr3t") {
139
+ t.Error("reloaded store should authenticate alice")
140
+ }
141
+}
--- a/internal/auth/admin_test.go
+++ b/internal/auth/admin_test.go
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/auth/admin_test.go
+++ b/internal/auth/admin_test.go
@@ -0,0 +1,141 @@
1 package auth_test
2
3 import (
4 "path/filepath"
5 "testing"
6
7 "github.com/conflicthq/scuttlebot/internal/auth"
8 )
9
10 func newStore(t *testing.T) *auth.AdminStore {
11 t.Helper()
12 s, err := auth.NewAdminStore(filepath.Join(t.TempDir(), "admins.json"))
13 if err != nil {
14 t.Fatalf("NewAdminStore: %v", err)
15 }
16 return s
17 }
18
19 func TestIsEmptyInitially(t *testing.T) {
20 s := newStore(t)
21 if !s.IsEmpty() {
22 t.Error("expected empty store")
23 }
24 }
25
26 func TestAddAndAuthenticate(t *testing.T) {
27 s := newStore(t)
28 if err := s.Add("alice", "s3cr3t"); err != nil {
29 t.Fatalf("Add: %v", err)
30 }
31 if !s.Authenticate("alice", "s3cr3t") {
32 t.Error("expected Authenticate to return true for correct credentials")
33 }
34 if s.Authenticate("alice", "wrong") {
35 t.Error("expected Authenticate to return false for wrong password")
36 }
37 if s.Authenticate("nobody", "s3cr3t") {
38 t.Error("expected Authenticate to return false for unknown user")
39 }
40 }
41
42 func TestAddDuplicateReturnsError(t *testing.T) {
43 s := newStore(t)
44 if err := s.Add("alice", "pass1"); err != nil {
45 t.Fatalf("first Add: %v", err)
46 }
47 if err := s.Add("alice", "pass2"); err == nil {
48 t.Error("expected error on duplicate Add")
49 }
50 }
51
52 func TestIsEmptyAfterAdd(t *testing.T) {
53 s := newStore(t)
54 _ = s.Add("admin", "pw")
55 if s.IsEmpty() {
56 t.Error("expected non-empty store after Add")
57 }
58 }
59
60 func TestList(t *testing.T) {
61 s := newStore(t)
62 _ = s.Add("alice", "pw")
63 _ = s.Add("bob", "pw")
64
65 list := s.List()
66 if len(list) != 2 {
67 t.Fatalf("List: got %d, want 2", len(list))
68 }
69 names := map[string]bool{list[0].Username: true, list[1].Username: true}
70 if !names["alice"] || !names["bob"] {
71 t.Errorf("List: unexpected names %v", names)
72 }
73 }
74
75 func TestSetPassword(t *testing.T) {
76 s := newStore(t)
77 _ = s.Add("alice", "old")
78
79 if err := s.SetPassword("alice", "new"); err != nil {
80 t.Fatalf("SetPassword: %v", err)
81 }
82 if s.Authenticate("alice", "old") {
83 t.Error("old password should no longer work")
84 }
85 if !s.Authenticate("alice", "new") {
86 t.Error("new password should work")
87 }
88 }
89
90 func TestSetPasswordUnknownUser(t *testing.T) {
91 s := newStore(t)
92 if err := s.SetPassword("nobody", "pw"); err == nil {
93 t.Error("expected error setting password for unknown user")
94 }
95 }
96
97 func TestRemove(t *testing.T) {
98 s := newStore(t)
99 _ = s.Add("alice", "pw")
100 _ = s.Add("bob", "pw")
101
102 if err := s.Remove("alice"); err != nil {
103 t.Fatalf("Remove: %v", err)
104 }
105 if len(s.List()) != 1 {
106 t.Errorf("List after Remove: got %d, want 1", len(s.List()))
107 }
108 if s.List()[0].Username != "bob" {
109 t.Errorf("expected bob to remain, got %q", s.List()[0].Username)
110 }
111 }
112
113 func TestRemoveUnknown(t *testing.T) {
114 s := newStore(t)
115 if err := s.Remove("nobody"); err == nil {
116 t.Error("expected error removing unknown user")
117 }
118 }
119
120 func TestPersistence(t *testing.T) {
121 dir := t.TempDir()
122 path := filepath.Join(dir, "admins.json")
123
124 s1, err := auth.NewAdminStore(path)
125 if err != nil {
126 t.Fatalf("create: %v", err)
127 }
128 _ = s1.Add("alice", "s3cr3t")
129
130 // Load a new store from the same file.
131 s2, err := auth.NewAdminStore(path)
132 if err != nil {
133 t.Fatalf("reload: %v", err)
134 }
135 if s2.IsEmpty() {
136 t.Error("reloaded store should not be empty")
137 }
138 if !s2.Authenticate("alice", "s3cr3t") {
139 t.Error("reloaded store should authenticate alice")
140 }
141 }
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -240,10 +240,22 @@
240240
select {
241241
case b.joinCh <- channel:
242242
default:
243243
}
244244
}
245
+
246
+// LeaveChannel parts the bridge from a channel and removes its buffers.
247
+func (b *Bot) LeaveChannel(channel string) {
248
+ if b.client != nil {
249
+ b.client.Cmd.Part(channel)
250
+ }
251
+ b.mu.Lock()
252
+ delete(b.joined, channel)
253
+ delete(b.buffers, channel)
254
+ delete(b.subs, channel)
255
+ b.mu.Unlock()
256
+}
245257
246258
// Channels returns the list of channels currently joined.
247259
func (b *Bot) Channels() []string {
248260
b.mu.RLock()
249261
defer b.mu.RUnlock()
250262
251263
ADDED internal/bots/manager/manager.go
252264
ADDED internal/bots/manager/manager_test.go
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -240,10 +240,22 @@
240 select {
241 case b.joinCh <- channel:
242 default:
243 }
244 }
 
 
 
 
 
 
 
 
 
 
 
 
245
246 // Channels returns the list of channels currently joined.
247 func (b *Bot) Channels() []string {
248 b.mu.RLock()
249 defer b.mu.RUnlock()
250
251 DDED internal/bots/manager/manager.go
252 DDED internal/bots/manager/manager_test.go
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -240,10 +240,22 @@
240 select {
241 case b.joinCh <- channel:
242 default:
243 }
244 }
245
246 // LeaveChannel parts the bridge from a channel and removes its buffers.
247 func (b *Bot) LeaveChannel(channel string) {
248 if b.client != nil {
249 b.client.Cmd.Part(channel)
250 }
251 b.mu.Lock()
252 delete(b.joined, channel)
253 delete(b.buffers, channel)
254 delete(b.subs, channel)
255 b.mu.Unlock()
256 }
257
258 // Channels returns the list of channels currently joined.
259 func (b *Bot) Channels() []string {
260 b.mu.RLock()
261 defer b.mu.RUnlock()
262
263 DDED internal/bots/manager/manager.go
264 DDED internal/bots/manager/manager_test.go
--- a/internal/bots/manager/manager.go
+++ b/internal/bots/manager/manager.go
@@ -0,0 +1,377 @@
1
+// Package manager starts and stops system bots based on policy configuration.
2
+package manager
3
+
4
+import (
5
+ "context"
6
+ "crypto/rand"
7
+ "encoding/hex"
8
+ "encoding/json"
9
+ "fmt"
10
+ "log/slog"
11
+ "os"
12
+ "path/filepath"
13
+ "strings"
14
+ "time"
15
+
16
+ "github.com/conflicthq/scuttlebot/internal/bots/auditbot"
17
+ "github.com/conflicthq/scuttlebot/internal/bots/herald"
18
+ "github.com/conflicthq/scuttlebot/internal/bots/oracle"
19
+ "github.com/conflicthq/scuttlebot/internal/bots/scribe"
20
+ "github.com/conflicthq/scuttlebot/internal/bots/scroll"
21
+ "github.com/conflicthq/scuttlebot/internal/bots/sentinel"
22
+ "github.com/conflicthqnitchage manager starts and st// Pacage manager starts and stops system bots based on policy configuration.
23
+package manager
24
+
25
+import (
26
+ "context"
27
+ "crypto/rand"
28
+ "encoding/hex"
29
+ "encoding/json"
30
+ "fmt"
31
+ "log/slog"
32
+ "os"
33
+ "path/filepath"
34
+ "strings"
35
+ "time"
36
+
37
+ "github.com/conflicthq/scuttlebot/internal/bots/auditbot"
38
+ "github.com/conflicthq/scuttlebot/internal/bots/herald"
39
+ "github.com/conflicthq/scuttlebot/internal/bots/oracle"
40
+ "github.com/conflicthq/scuttlebot/internal/bots/scribe"
41
+ "github.com/conflicthq/scuttlebot/internal/bots/scroll"
42
+ "github.com/conflicthq/scuttlebot/internal/bots/sentinel"
43
+ "github.com/conflicthq/scuttlebot/internal/bots/shepherd"
44
+ "github.com/conflicthq/scuttlebot/internal/bots/snitch"
45
+ "github.com/conflicthq/scuttlebot/internal/bots/steward"
46
+ "github.com/conflicthq/scuttlebot/internal/bots/systembot"
47
+ "github.com/conflicthq/scuttlebot/internal/bots/warden"
48
+ "github.com/conflicthq/scuttlebot/internal/llm"
49
+)
50
+
51
+// scribeHistoryAdapter adapts scribe.FileStore to oracle.HistoryFetcher.
52
+type scribeHistoryAdapter struct {
53
+ store *scribe.FileStore
54
+}
55
+
56
+func (a *scribeHistoryAdapter) Query(channel string, limit int) ([]oracle.HistoryEntry, error) {
57
+ entries, err := a.store.Query(channel, limit)
58
+ if err != nil {
59
+ return nil, err
60
+ }
61
+ out := make([]oracle.HistoryEntry, len(entries))
62
+ for i, e := range entries {
63
+ out[i] = oracle.HistoryEntry{
64
+ Nick: e.Nick,
65
+ MessageType: e.MessageType,
66
+ Raw: e.Raw,
67
+ }
68
+ }
69
+ return out, nil
70
+}
71
+
72
+// BotSpec mirrors api.BehaviorConfig without importing the api package.
73
+type BotSpec struct {
74
+ ID string
75
+ Nick string
76
+ Enabled bool
77
+ JoinAllChannels bool
78
+ Requi iredChannels []string
79
+ Config map[string]any
80
+}
81
+
82
+// Provisioner can register and change passwords for IRC accounts.
83
+type Provisioner interface {
84
+ RegisterAccount(name, pass string) error
85
+ ChangePassword(name, pass string) error
86
+}
87
+
88
+// ChannelLister can enumerate active IRC channels.
89
+type ChannelLister interface {
90
+ ListChannels() ([]string, error)
91
+}
92
+
93
+// bot is the common interface all bots satisfy.
94
+type bot interface {
95
+ Start(ctx context.Context) error
96
+}
97
+
98
+// Manager starts and stops bots based on BotSpec slices.
99
+type Manager struct {
100
+ ircAddr string
101
+ dataDir string
102
+ prov Provisioner
103
+ channels ChannelLister
104
+ log *slog.Logger
105
+ passwords map[string]string // nick → password, persisted
106
+ running map[string]context.CancelFunc
107
+}
108
+
109
+// New creates a Manager.
110
+func New(ircAddr, dataDir string, prov Provisioner, channels ChannelLister, log *slog.Logger) *Manager {
111
+ m := &Manager{
112
+ ircAddr: ircAddr,
113
+ dataDir: dataDir,
114
+ prov: prov,
115
+ channels: channels,
116
+ log: log,
117
+ passwords: make(map[string]string),
118
+ running: make(map[string]context.CancelFunc),
119
+ }
120
+ _ = m.loadPasswords()
121
+ return m
122
+}
123
+
124
+// Running returns the nicks of currently running bots.
125
+func (m *Manager) Running() []string {
126
+ out := make([]string, 0, len(m.running))
127
+ for nick := range m.running {
128
+ out = append(out, nick)
129
+ }
130
+ return out
131
+}
132
+
133
+// Sync starts enabled+not-running bots and stops disabled+running bots.
134
+func (m *Manager) Sync(ctx context.Context, specs []BotSpec) {
135
+ desired := make(map[string]BotSpec, len(specs))
136
+ for _, s := range specs {
137
+ desired[s.Nick] = s
138
+ }
139
+
140
+ // Stop bots that are running but should be disabled.
141
+ for nick, cancel := range m.running {
142
+ spec, ok := desired[nick]
143
+ if !ok || !spec.Enabled {
144
+ m.log.Info("manager: stopping bot", "nick", nick)
145
+ cancel()
146
+ delete(m.running, nick)
147
+ }
148
+ }
149
+
150
+ // Start bots that are enabled but not running.
151
+ for _, spec := range specs {
152
+ if !spec.Enabled {
153
+ continue
154
+ }
155
+ if _, running := m.running[spec.Nick]; running {
156
+ continue
157
+ }
158
+
159
+ pass, err := m.ensurePassword(spec.Nick)
160
+ if err != nil {
161
+ m.log.Error("manager: ensure password", "nick", spec.Nick, "err", err)
162
+ continue
163
+ }
164
+
165
+ if err := m.ensureAccount(spec.Nick, pass); err != nil {
166
+ m.log.Error("manager: ensure account", "nick", spec.Nick, "err", err)
167
+ continue
168
+ }
169
+
170
+ channels, err := m.resolveChannels(spec)
171
+ if err != nil {
172
+ m.log.Warn("manager: list channels failed, using required", "nick", spec.Nick, "err", err)
173
+ }
174
+
175
+ b, err := m.buildBot(spec, pass, channels)
176
+ if err != nil {
177
+ m.log.Error("manager: build bot", "nick", spec.Nick, "err", err)
178
+ continue
179
+ }
180
+ if b == nil {
181
+ continue
182
+ }
183
+
184
+ botCtx, c := context.WithCancel(ctx)
185
+ m.running[spec.Nick] = cancel
186
+
187
+ go fun
188
+package manager
189
+
190
+import (
191
+ "context"
192
+ "crypto/rand"
193
+ "encoding/hex"
194
+ "encoding/json"
195
+ "fmt"
196
+ "log/slog"
197
+ "os"
198
+ "path/filepath"
199
+ "strings"
200
+ "time"
201
+
202
+ "github.com/conflicthq/scuttlebot/internal/bots/auditbot"
203
+ "// Package manager starts and stops system bots based on policy configuration.
204
+package manager
205
+
206
+import (
207
+ "context"
208
+ "crypto/rand"
209
+ "encoding/hex"
210
+ "encoding/json"
211
+ "fmt"
212
+ "log/slog"
213
+ "os"
214
+ "path/fc.RequiredChannels, err
215
+ }
216
+ return ch, nil
217
+ }
218
+ return spec.RequiredChannels, nil
219
+}
220
+
221
+func (m *Manager) ensurePassword(nick string) (string, error) {
222
+ if pass, ok := m.passwords[nick]; ok {
223
+ return pass, nil
224
+ }
225
+ pass, err := genPassword()
226
+ if err != nil {
227
+ return "", err
228
+ }
229
+ m.passwords[nick] = pass
230
+ if err := m.savePasswords(); err != nil {
231
+ return "", err
232
+ }
233
+ return pass, nil
234
+}
235
+
236
+func (m *Manager) ensureAccount(nick, pass string) error {
237
+ if err := m.prov.RegisterAccount(nick, pass); err != nil {
238
+ if strings.Contains(err.Error(), "ACCOUNT_EXISTS") {
239
+ return m.prov.ChangePassword(nick, pass)
240
+ }
241
+ return err
242
+ }
243
+ return nil
244
+}
245
+
246
+func (m *Manager) buildBot(spec BotSpec, pass string, channels []string) (bot, error) {
247
+ cfg := spec.Config
248
+ switch spec.ID {
249
+ case "scribe":
250
+ store := scribe.NewFileStore(scribe.FileStoreConfig{
251
+ Dir: cfgStr(cfg, "dir", filepath.Join(m.dataDir, "logs", "scribe")),
252
+ Format: cfgStr(cfg, "format", "jsonl"),
253
+ Rotation: cfgStr(cfg, "rotation", "none"),
254
+ MaxSizeMB: cfgInt(cfg, "max_size_mb", 0),
255
+ PerChannel: cfgBool(cfg, "per_channel", false),
256
+ MaxAgeDays: cfgInt(cfg, "max_age_days", 0),
257
+ })
258
+ return scribe.New(m.ircAddr, pass, channels, store, m.log), nil
259
+
260
+ case "au apiKey,
261
+ BaseURL: cfgStr(cfg, "base_url", ""),
262
+ Model: cfgStr(cfg, "model", ""),
263
+ Region: cfgStr(cfg, "region", ""),
264
+ AWSKeyID: cfgStr(cfg, "aws_key_id", ""),
265
+ AWSSecretKey: cfgStr(cfg, "aws_secret_key", ""),
266
+ }
267
+ provider, err := llm.New(llmCfg)
268
+ if err != nil {
269
+ return nil, fmt.Errorf("oracle: build llm provider: %w", err)
270
+ }
271
+
272
+ // Read from the same dir scribe writes to.
273
+ scribeDir := cfgStr(cfg, "scribe_dir", filepath.Join(m.dataDir, "logs", "scribe"))
274
+ fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: scribeDir, Format: "jsonl"})
275
+ history := &scribeHistoryAdapter{store: fs}
276
+
277
+ return oracle.New(m.ircAddr, pass, channels, history, provider, m.log), nil
278
+
279
+ case "sentinel":
280
+ apiKey := cfgStr(cfg, "api_key", "")
281
+ if apiKey == "" {
282
+ if env := cfgStr(cfg, "api_key_env", ""); env != "" {
283
+ apiKey = os.Getenv(env)
284
+ }
285
+ }
286
+ llmCfg := llm.BackendConfig{
287
+ Backend: cfgStr(cfg, "backend", "openai"),
288
+ APIKey: apiKey,
289
+ BaseURL: cfgStr(cfg, "base_url", ""),
290
+ Model: cfgStr(cfg, "model", ""),
291
+ Region: cfgStr(cfg, "region", ""),
292
+ AWSKeyID: cfgStr(cfg, "aws_key_id", ""),
293
+ AWSSecretKey: cfgStr(cfg, "aws_secret_key", ""),
294
+ }
295
+ provider, err := llm.New(llmCfg)
296
+ if err != nil {
297
+ return nil, fmt.Errorf}iKey,
298
+ BaseURL: cfgStr(cfgNew(sentinel.Config{
299
+ IRCAddr: m.ircAddr,
300
+ Nick: spec.Nick,
301
+ Password: pass,
302
+ ModChannel: cfgStr(cfg, "mod_channel", "#moderation"),
303
+ DMOperators: cfgBool(cfg, "dm_operators", false),
304
+ AlertNicks: splitCSV(cfgStr(cfg, "alert_nicks", "")),
305
+ Policy: cfgStr(cfg, "policy", ""),
306
+ WindowSize: cfgInt(cfg, "window_size", 20),
307
+ WindowAge: time.Duration(cfgInt(cfg, "window_age_sec", 300)) * time.Second,
308
+ CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 600)) * time.Second,
309
+ MinSeverity: cfgStr(cfg, "min_seve := context.WithC Channels: channels,
310
+ }, provider, m.log), nil
311
+
312
+ case "steward":
313
+ return steward.New(steward.Config{
314
+ IRCAddr: m.ircAddr,
315
+ Nick: spec.Nick,
316
+ Password: pass,
317
+ ModChannel: cfgStr(cfg, "mod_channel", "#moderation"),
318
+ OperatorNicks: splitCSV(cfgStr(cfg, "operator_nicks", "")),
319
+ DMOnAction: cfgBool(cfg, "dm_on_action", false),
320
+ AutoAct: cfgBool(cfg, "auto_act", true),
321
+ MuteDuration: time.Duration(cfgInt(cfg, "mute_duration_sec", 600)) * time.Second,
322
+ WarnOnLow: cfgBool(cfg, "warn_on_low", true),
323
+ CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 300)) * time.Second,
324
+ Channels: channels,
325
+ }, m.log), nil
326
+
327
+ case "shepherd":
328
+ apiKey := cfgStr(cfg, "api_key", "")
329
+ if apiKey == "" {
330
+ if env := cfgStr(cfg, "api_key_env", ""); env != "" {
331
+ apiKey = os.Getenv(env)
332
+ }
333
+ }
334
+ var provider shepherd.LLMProvider
335
+ if apiKey != "" {
336
+ llmCfg := llm.BackendConfig{
337
+ Backend: cfgStr(cfg, "backend", "openai"),
338
+ APIKey: apiKey,
339
+ BaseURL: cfgStr(cfg, "base_url", ""),
340
+ Model: cfgStr(cfg, "model", ""),
341
+ Region: cfgStr(cfg, "region", ""),
342
+ AWSKeyID: cfgStr(cfg, "aws_key_id", ""),
343
+ AWSSecretKey: cfgStr(cfg, "aws_secret_key", ""),
344
+ }
345
+ p, err := llm.New(llmCfg)
346
+ if err != nil {
347
+ return nil, fmt.Errorf("shepherd: build llm provider: %w", err)
348
+ }
349
+ provider = p
350
+ }
351
+ checkinSec := cfgInt(cfg, "checkin_interval_sec", 0)
352
+ return shepherd.New(shepherd.Config{
353
+ IRCAddr: m.ircAddr,
354
+ Nick: spec.Nick,
355
+ Password: pass,
356
+ Channels: channels,
357
+ ReportChannel: cfgStr(cfg, "report_channel", "#ops"),
358
+ CheckinInterval: time.Duration(checkinSec) * time.Second,
359
+ GoalSource: cfgStr(cfg, "goal_source", ""),
360
+ }, provider, m.log), nil
361
+
362
+ default:
363
+ return nil, fmt.Errorf("unknown bot ID %q", spec.ID)
364
+ }
365
+}
366
+
367
+// passwordsPath returns the path for the passwords file.
368
+func (m *Manager) passwordsPath() string {
369
+ return filepath.Join(m.dataDir, "bot_passwords.json")
370
+}
371
+
372
+func (m *Manager) loadPasswords() error {
373
+ raw, err := os.ReadFile(m.passwordsPath())
374
+ if os.IsNotExist(err) {
375
+ return nil
376
+ }
377
+ if err != n
--- a/internal/bots/manager/manager.go
+++ b/internal/bots/manager/manager.go
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/manager/manager.go
+++ b/internal/bots/manager/manager.go
@@ -0,0 +1,377 @@
1 // Package manager starts and stops system bots based on policy configuration.
2 package manager
3
4 import (
5 "context"
6 "crypto/rand"
7 "encoding/hex"
8 "encoding/json"
9 "fmt"
10 "log/slog"
11 "os"
12 "path/filepath"
13 "strings"
14 "time"
15
16 "github.com/conflicthq/scuttlebot/internal/bots/auditbot"
17 "github.com/conflicthq/scuttlebot/internal/bots/herald"
18 "github.com/conflicthq/scuttlebot/internal/bots/oracle"
19 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
20 "github.com/conflicthq/scuttlebot/internal/bots/scroll"
21 "github.com/conflicthq/scuttlebot/internal/bots/sentinel"
22 "github.com/conflicthqnitchage manager starts and st// Pacage manager starts and stops system bots based on policy configuration.
23 package manager
24
25 import (
26 "context"
27 "crypto/rand"
28 "encoding/hex"
29 "encoding/json"
30 "fmt"
31 "log/slog"
32 "os"
33 "path/filepath"
34 "strings"
35 "time"
36
37 "github.com/conflicthq/scuttlebot/internal/bots/auditbot"
38 "github.com/conflicthq/scuttlebot/internal/bots/herald"
39 "github.com/conflicthq/scuttlebot/internal/bots/oracle"
40 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
41 "github.com/conflicthq/scuttlebot/internal/bots/scroll"
42 "github.com/conflicthq/scuttlebot/internal/bots/sentinel"
43 "github.com/conflicthq/scuttlebot/internal/bots/shepherd"
44 "github.com/conflicthq/scuttlebot/internal/bots/snitch"
45 "github.com/conflicthq/scuttlebot/internal/bots/steward"
46 "github.com/conflicthq/scuttlebot/internal/bots/systembot"
47 "github.com/conflicthq/scuttlebot/internal/bots/warden"
48 "github.com/conflicthq/scuttlebot/internal/llm"
49 )
50
51 // scribeHistoryAdapter adapts scribe.FileStore to oracle.HistoryFetcher.
52 type scribeHistoryAdapter struct {
53 store *scribe.FileStore
54 }
55
56 func (a *scribeHistoryAdapter) Query(channel string, limit int) ([]oracle.HistoryEntry, error) {
57 entries, err := a.store.Query(channel, limit)
58 if err != nil {
59 return nil, err
60 }
61 out := make([]oracle.HistoryEntry, len(entries))
62 for i, e := range entries {
63 out[i] = oracle.HistoryEntry{
64 Nick: e.Nick,
65 MessageType: e.MessageType,
66 Raw: e.Raw,
67 }
68 }
69 return out, nil
70 }
71
72 // BotSpec mirrors api.BehaviorConfig without importing the api package.
73 type BotSpec struct {
74 ID string
75 Nick string
76 Enabled bool
77 JoinAllChannels bool
78 Requi iredChannels []string
79 Config map[string]any
80 }
81
82 // Provisioner can register and change passwords for IRC accounts.
83 type Provisioner interface {
84 RegisterAccount(name, pass string) error
85 ChangePassword(name, pass string) error
86 }
87
88 // ChannelLister can enumerate active IRC channels.
89 type ChannelLister interface {
90 ListChannels() ([]string, error)
91 }
92
93 // bot is the common interface all bots satisfy.
94 type bot interface {
95 Start(ctx context.Context) error
96 }
97
98 // Manager starts and stops bots based on BotSpec slices.
99 type Manager struct {
100 ircAddr string
101 dataDir string
102 prov Provisioner
103 channels ChannelLister
104 log *slog.Logger
105 passwords map[string]string // nick → password, persisted
106 running map[string]context.CancelFunc
107 }
108
109 // New creates a Manager.
110 func New(ircAddr, dataDir string, prov Provisioner, channels ChannelLister, log *slog.Logger) *Manager {
111 m := &Manager{
112 ircAddr: ircAddr,
113 dataDir: dataDir,
114 prov: prov,
115 channels: channels,
116 log: log,
117 passwords: make(map[string]string),
118 running: make(map[string]context.CancelFunc),
119 }
120 _ = m.loadPasswords()
121 return m
122 }
123
124 // Running returns the nicks of currently running bots.
125 func (m *Manager) Running() []string {
126 out := make([]string, 0, len(m.running))
127 for nick := range m.running {
128 out = append(out, nick)
129 }
130 return out
131 }
132
133 // Sync starts enabled+not-running bots and stops disabled+running bots.
134 func (m *Manager) Sync(ctx context.Context, specs []BotSpec) {
135 desired := make(map[string]BotSpec, len(specs))
136 for _, s := range specs {
137 desired[s.Nick] = s
138 }
139
140 // Stop bots that are running but should be disabled.
141 for nick, cancel := range m.running {
142 spec, ok := desired[nick]
143 if !ok || !spec.Enabled {
144 m.log.Info("manager: stopping bot", "nick", nick)
145 cancel()
146 delete(m.running, nick)
147 }
148 }
149
150 // Start bots that are enabled but not running.
151 for _, spec := range specs {
152 if !spec.Enabled {
153 continue
154 }
155 if _, running := m.running[spec.Nick]; running {
156 continue
157 }
158
159 pass, err := m.ensurePassword(spec.Nick)
160 if err != nil {
161 m.log.Error("manager: ensure password", "nick", spec.Nick, "err", err)
162 continue
163 }
164
165 if err := m.ensureAccount(spec.Nick, pass); err != nil {
166 m.log.Error("manager: ensure account", "nick", spec.Nick, "err", err)
167 continue
168 }
169
170 channels, err := m.resolveChannels(spec)
171 if err != nil {
172 m.log.Warn("manager: list channels failed, using required", "nick", spec.Nick, "err", err)
173 }
174
175 b, err := m.buildBot(spec, pass, channels)
176 if err != nil {
177 m.log.Error("manager: build bot", "nick", spec.Nick, "err", err)
178 continue
179 }
180 if b == nil {
181 continue
182 }
183
184 botCtx, c := context.WithCancel(ctx)
185 m.running[spec.Nick] = cancel
186
187 go fun
188 package manager
189
190 import (
191 "context"
192 "crypto/rand"
193 "encoding/hex"
194 "encoding/json"
195 "fmt"
196 "log/slog"
197 "os"
198 "path/filepath"
199 "strings"
200 "time"
201
202 "github.com/conflicthq/scuttlebot/internal/bots/auditbot"
203 "// Package manager starts and stops system bots based on policy configuration.
204 package manager
205
206 import (
207 "context"
208 "crypto/rand"
209 "encoding/hex"
210 "encoding/json"
211 "fmt"
212 "log/slog"
213 "os"
214 "path/fc.RequiredChannels, err
215 }
216 return ch, nil
217 }
218 return spec.RequiredChannels, nil
219 }
220
221 func (m *Manager) ensurePassword(nick string) (string, error) {
222 if pass, ok := m.passwords[nick]; ok {
223 return pass, nil
224 }
225 pass, err := genPassword()
226 if err != nil {
227 return "", err
228 }
229 m.passwords[nick] = pass
230 if err := m.savePasswords(); err != nil {
231 return "", err
232 }
233 return pass, nil
234 }
235
236 func (m *Manager) ensureAccount(nick, pass string) error {
237 if err := m.prov.RegisterAccount(nick, pass); err != nil {
238 if strings.Contains(err.Error(), "ACCOUNT_EXISTS") {
239 return m.prov.ChangePassword(nick, pass)
240 }
241 return err
242 }
243 return nil
244 }
245
246 func (m *Manager) buildBot(spec BotSpec, pass string, channels []string) (bot, error) {
247 cfg := spec.Config
248 switch spec.ID {
249 case "scribe":
250 store := scribe.NewFileStore(scribe.FileStoreConfig{
251 Dir: cfgStr(cfg, "dir", filepath.Join(m.dataDir, "logs", "scribe")),
252 Format: cfgStr(cfg, "format", "jsonl"),
253 Rotation: cfgStr(cfg, "rotation", "none"),
254 MaxSizeMB: cfgInt(cfg, "max_size_mb", 0),
255 PerChannel: cfgBool(cfg, "per_channel", false),
256 MaxAgeDays: cfgInt(cfg, "max_age_days", 0),
257 })
258 return scribe.New(m.ircAddr, pass, channels, store, m.log), nil
259
260 case "au apiKey,
261 BaseURL: cfgStr(cfg, "base_url", ""),
262 Model: cfgStr(cfg, "model", ""),
263 Region: cfgStr(cfg, "region", ""),
264 AWSKeyID: cfgStr(cfg, "aws_key_id", ""),
265 AWSSecretKey: cfgStr(cfg, "aws_secret_key", ""),
266 }
267 provider, err := llm.New(llmCfg)
268 if err != nil {
269 return nil, fmt.Errorf("oracle: build llm provider: %w", err)
270 }
271
272 // Read from the same dir scribe writes to.
273 scribeDir := cfgStr(cfg, "scribe_dir", filepath.Join(m.dataDir, "logs", "scribe"))
274 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: scribeDir, Format: "jsonl"})
275 history := &scribeHistoryAdapter{store: fs}
276
277 return oracle.New(m.ircAddr, pass, channels, history, provider, m.log), nil
278
279 case "sentinel":
280 apiKey := cfgStr(cfg, "api_key", "")
281 if apiKey == "" {
282 if env := cfgStr(cfg, "api_key_env", ""); env != "" {
283 apiKey = os.Getenv(env)
284 }
285 }
286 llmCfg := llm.BackendConfig{
287 Backend: cfgStr(cfg, "backend", "openai"),
288 APIKey: apiKey,
289 BaseURL: cfgStr(cfg, "base_url", ""),
290 Model: cfgStr(cfg, "model", ""),
291 Region: cfgStr(cfg, "region", ""),
292 AWSKeyID: cfgStr(cfg, "aws_key_id", ""),
293 AWSSecretKey: cfgStr(cfg, "aws_secret_key", ""),
294 }
295 provider, err := llm.New(llmCfg)
296 if err != nil {
297 return nil, fmt.Errorf}iKey,
298 BaseURL: cfgStr(cfgNew(sentinel.Config{
299 IRCAddr: m.ircAddr,
300 Nick: spec.Nick,
301 Password: pass,
302 ModChannel: cfgStr(cfg, "mod_channel", "#moderation"),
303 DMOperators: cfgBool(cfg, "dm_operators", false),
304 AlertNicks: splitCSV(cfgStr(cfg, "alert_nicks", "")),
305 Policy: cfgStr(cfg, "policy", ""),
306 WindowSize: cfgInt(cfg, "window_size", 20),
307 WindowAge: time.Duration(cfgInt(cfg, "window_age_sec", 300)) * time.Second,
308 CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 600)) * time.Second,
309 MinSeverity: cfgStr(cfg, "min_seve := context.WithC Channels: channels,
310 }, provider, m.log), nil
311
312 case "steward":
313 return steward.New(steward.Config{
314 IRCAddr: m.ircAddr,
315 Nick: spec.Nick,
316 Password: pass,
317 ModChannel: cfgStr(cfg, "mod_channel", "#moderation"),
318 OperatorNicks: splitCSV(cfgStr(cfg, "operator_nicks", "")),
319 DMOnAction: cfgBool(cfg, "dm_on_action", false),
320 AutoAct: cfgBool(cfg, "auto_act", true),
321 MuteDuration: time.Duration(cfgInt(cfg, "mute_duration_sec", 600)) * time.Second,
322 WarnOnLow: cfgBool(cfg, "warn_on_low", true),
323 CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 300)) * time.Second,
324 Channels: channels,
325 }, m.log), nil
326
327 case "shepherd":
328 apiKey := cfgStr(cfg, "api_key", "")
329 if apiKey == "" {
330 if env := cfgStr(cfg, "api_key_env", ""); env != "" {
331 apiKey = os.Getenv(env)
332 }
333 }
334 var provider shepherd.LLMProvider
335 if apiKey != "" {
336 llmCfg := llm.BackendConfig{
337 Backend: cfgStr(cfg, "backend", "openai"),
338 APIKey: apiKey,
339 BaseURL: cfgStr(cfg, "base_url", ""),
340 Model: cfgStr(cfg, "model", ""),
341 Region: cfgStr(cfg, "region", ""),
342 AWSKeyID: cfgStr(cfg, "aws_key_id", ""),
343 AWSSecretKey: cfgStr(cfg, "aws_secret_key", ""),
344 }
345 p, err := llm.New(llmCfg)
346 if err != nil {
347 return nil, fmt.Errorf("shepherd: build llm provider: %w", err)
348 }
349 provider = p
350 }
351 checkinSec := cfgInt(cfg, "checkin_interval_sec", 0)
352 return shepherd.New(shepherd.Config{
353 IRCAddr: m.ircAddr,
354 Nick: spec.Nick,
355 Password: pass,
356 Channels: channels,
357 ReportChannel: cfgStr(cfg, "report_channel", "#ops"),
358 CheckinInterval: time.Duration(checkinSec) * time.Second,
359 GoalSource: cfgStr(cfg, "goal_source", ""),
360 }, provider, m.log), nil
361
362 default:
363 return nil, fmt.Errorf("unknown bot ID %q", spec.ID)
364 }
365 }
366
367 // passwordsPath returns the path for the passwords file.
368 func (m *Manager) passwordsPath() string {
369 return filepath.Join(m.dataDir, "bot_passwords.json")
370 }
371
372 func (m *Manager) loadPasswords() error {
373 raw, err := os.ReadFile(m.passwordsPath())
374 if os.IsNotExist(err) {
375 return nil
376 }
377 if err != n
--- a/internal/bots/manager/manager_test.go
+++ b/internal/bots/manager/manager_test.go
@@ -0,0 +1,204 @@
1
+package manager_test
2
+
3
+import (
4
+ "context"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "testing"
9
+
10
+ "github.com/conflicthq/scuttlebot/internal/bots/manager"
11
+ "log/slog"
12
+)
13
+
14
+var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
15
+
16
+// stubProvisioner records RegisterAccount/ChangePassword calls.
17
+type stubProvisioner struct {
18
+ accounts map[string]string
19
+ failOn string // if set, RegisterAccount returns an error for this nick
20
+}
21
+
22
+func newStub() *stubProvisioner {
23
+ return &stubProvisioner{accounts: make(map[string]string)}
24
+}
25
+
26
+func (p *stubProvisioner) RegisterAccount(name, pass string) error {
27
+ if p.failOn == name {
28
+ return fmt.Errorf("ACCOUNT_EXISTS")
29
+ }
30
+ if _, ok := p.accounts[name]; ok {
31
+ return fmt.Errorf("ACCOUNT_EXISTS")
32
+ }
33
+ p.accounts[name] = pass
34
+ return nil
35
+}
36
+
37
+func (p *stubProvisioner) ChangePassword(name, pass string) error {
38
+ if _, ok := p.accounts[name]; !ok {
39
+ return fmt.Errorf("ACCOUNT_DOES_NOT_EXIST")
40
+ }
41
+ p.accounts[name] = pass
42
+ return nil
43
+}
44
+
45
+// stubChannels returns a fixed list of channels.
46
+type stubChannels struct {
47
+ channels []string
48
+ err error
49
+}
50
+
51
+func (c *stubChannels) ListChannels() ([]string, error) {
52
+ return c.channels, c.err
53
+}
54
+
55
+func newManager(t *testing.T) *manager.Manager {
56
+ t.Helper()
57
+ return manager.New(
58
+ "127.0.0.1:6667",
59
+ t.TempDir(),
60
+ newStub(),
61
+ &stubChannels{channels: []string{"#fleet", "#ops"}},
62
+ testLog,
63
+ )
64
+}
65
+
66
+// scribeSpec returns a minimal enabled scribe BotSpec.
67
+func scribeSpec() manager.BotSpec {
68
+ return manager.BotSpec{
69
+ ID: "scribe",
70
+ Nick: "scribe",
71
+ Enabled: true,
72
+ Config: map[string]any{"dir": "/tmp/scribe-test-logs"},
73
+ }
74
+}
75
+
76
+func TestSyncStartsEnabledBot(t *testing.T) {
77
+ m := newManager(t)
78
+ ctx, cancel := context.WithCancel(context.Background())
79
+ defer cancel()
80
+
81
+ m.Sync(ctx, []manager.BotSpec{scribeSpec()})
82
+
83
+ running := m.Running()
84
+ if len(running) != 1 || running[0] != "scribe" {
85
+ t.Errorf("expected [scribe] running, got %v", running)
86
+ }
87
+}
88
+
89
+func TestSyncDisabledBotNotStarted(t *testing.T) {
90
+ m := newManager(t)
91
+ ctx, cancel := context.WithCancel(context.Background())
92
+ defer cancel()
93
+
94
+ spec := scribeSpec()
95
+ spec.Enabled = false
96
+ m.Sync(ctx, []manager.BotSpec{spec})
97
+
98
+ if len(m.Running()) != 0 {
99
+ t.Errorf("expected no bots running, got %v", m.Running())
100
+ }
101
+}
102
+
103
+func TestSyncStopsDisabledBot(t *testing.T) {
104
+ m := newManager(t)
105
+ ctx, cancel := context.WithCancel(context.Background())
106
+ defer cancel()
107
+
108
+ // Start it.
109
+ m.Sync(ctx, []manager.BotSpec{scribeSpec()})
110
+ if len(m.Running()) != 1 {
111
+ t.Fatalf("bot should be running before disable")
112
+ }
113
+
114
+ // Disable it.
115
+ spec := scribeSpec()
116
+ spec.Enabled = false
117
+ m.Sync(ctx, []manager.BotSpec{spec})
118
+
119
+ if len(m.Running()) != 0 {
120
+ t.Errorf("expected bot stopped after disable, got %v", m.Running())
121
+ }
122
+}
123
+
124
+func TestSyncIdempotent(t *testing.T) {
125
+ m := newManager(t)
126
+ ctx, cancel := context.WithCancel(context.Background())
127
+ defer cancel()
128
+
129
+ spec := scribeSpec()
130
+ m.Sync(ctx, []manager.BotSpec{spec})
131
+ m.Sync(ctx, []manager.BotSpec{spec}) // second call — should not start a second copy
132
+
133
+ if len(m.Running()) != 1 {
134
+ t.Errorf("expected exactly 1 running bot, got %v", m.Running())
135
+ }
136
+}
137
+
138
+func TestPasswordPersistence(t *testing.T) {
139
+ dir := t.TempDir()
140
+ prov := newStub()
141
+ m1 := manager.New("127.0.0.1:6667", dir, prov, &stubChannels{}, testLog)
142
+
143
+ ctx, cancel := context.WithCancel(context.Background())
144
+ m1.Sync(ctx, []manager.BotSpec{scribeSpec()})
145
+ cancel()
146
+
147
+ // Passwords file should exist.
148
+ pwPath := filepath.Join(dir, "bot_passwords.json")
149
+ if _, err := os.Stat(pwPath); err != nil {
150
+ t.Fatalf("passwords file not created: %v", err)
151
+ }
152
+
153
+ // Load a second manager from the same dir — it should reuse the same password
154
+ // (ensureAccount will call ChangePassword, not RegisterAccount, because the stub
155
+ // already has the account from the first run).
156
+ m2 := manager.New("127.0.0.1:6667", dir, prov, &stubChannels{}, testLog)
157
+ ctx2, cancel2 := context.WithCancel(context.Background())
158
+ defer cancel2()
159
+
160
+ // Should not panic and should be able to start the bot.
161
+ m2.Sync(ctx2, []manager.BotSpec{scribeSpec()})
162
+ if len(m2.Running()) != 1 {
163
+ t.Errorf("second manager: expected 1 running bot, got %v", m2.Running())
164
+ }
165
+}
166
+
167
+func TestSyncOracleStarts(t *testing.T) {
168
+ // Oracle now starts with default config (no API key — it won't respond to
169
+ // summaries but the bot itself connects to IRC and runs).
170
+ m := newManager(t)
171
+ ctx, cancel := context.WithCancel(context.Background())
172
+ defer cancel()
173
+
174
+ spec := manager.BotSpec{ID: "oracle", Nick: "oracle", Enabled: true}
175
+ m.Sync(ctx, []manager.BotSpec{spec})
176
+
177
+ running := m.Running()
178
+ found := false
179
+ for _, nick := range running {
180
+ if nick == "oracle" {
181
+ found = true
182
+ }
183
+ }
184
+ if !found {
185
+ t.Errorf("expected oracle to be in Running, got %v", running)
186
+ }
187
+}
188
+
189
+func TestSyncMultipleBots(t *testing.T) {
190
+ m := newManager(t)
191
+ ctx, cancel := context.WithCancel(context.Background())
192
+ defer cancel()
193
+
194
+ specs := []manager.BotSpec{
195
+ scribeSpec(),
196
+ {ID: "snitch", Nick: "snitch", Enabled: true},
197
+ }
198
+ m.Sync(ctx, []manager.BotSpec{specs[0], specs[1]})
199
+
200
+ running := m.Running()
201
+ if len(running) != 2 {
202
+ t.Errorf("expected 2 running bots, got %v", running)
203
+ }
204
+}
--- a/internal/bots/manager/manager_test.go
+++ b/internal/bots/manager/manager_test.go
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/manager/manager_test.go
+++ b/internal/bots/manager/manager_test.go
@@ -0,0 +1,204 @@
1 package manager_test
2
3 import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "testing"
9
10 "github.com/conflicthq/scuttlebot/internal/bots/manager"
11 "log/slog"
12 )
13
14 var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
15
16 // stubProvisioner records RegisterAccount/ChangePassword calls.
17 type stubProvisioner struct {
18 accounts map[string]string
19 failOn string // if set, RegisterAccount returns an error for this nick
20 }
21
22 func newStub() *stubProvisioner {
23 return &stubProvisioner{accounts: make(map[string]string)}
24 }
25
26 func (p *stubProvisioner) RegisterAccount(name, pass string) error {
27 if p.failOn == name {
28 return fmt.Errorf("ACCOUNT_EXISTS")
29 }
30 if _, ok := p.accounts[name]; ok {
31 return fmt.Errorf("ACCOUNT_EXISTS")
32 }
33 p.accounts[name] = pass
34 return nil
35 }
36
37 func (p *stubProvisioner) ChangePassword(name, pass string) error {
38 if _, ok := p.accounts[name]; !ok {
39 return fmt.Errorf("ACCOUNT_DOES_NOT_EXIST")
40 }
41 p.accounts[name] = pass
42 return nil
43 }
44
45 // stubChannels returns a fixed list of channels.
46 type stubChannels struct {
47 channels []string
48 err error
49 }
50
51 func (c *stubChannels) ListChannels() ([]string, error) {
52 return c.channels, c.err
53 }
54
55 func newManager(t *testing.T) *manager.Manager {
56 t.Helper()
57 return manager.New(
58 "127.0.0.1:6667",
59 t.TempDir(),
60 newStub(),
61 &stubChannels{channels: []string{"#fleet", "#ops"}},
62 testLog,
63 )
64 }
65
66 // scribeSpec returns a minimal enabled scribe BotSpec.
67 func scribeSpec() manager.BotSpec {
68 return manager.BotSpec{
69 ID: "scribe",
70 Nick: "scribe",
71 Enabled: true,
72 Config: map[string]any{"dir": "/tmp/scribe-test-logs"},
73 }
74 }
75
76 func TestSyncStartsEnabledBot(t *testing.T) {
77 m := newManager(t)
78 ctx, cancel := context.WithCancel(context.Background())
79 defer cancel()
80
81 m.Sync(ctx, []manager.BotSpec{scribeSpec()})
82
83 running := m.Running()
84 if len(running) != 1 || running[0] != "scribe" {
85 t.Errorf("expected [scribe] running, got %v", running)
86 }
87 }
88
89 func TestSyncDisabledBotNotStarted(t *testing.T) {
90 m := newManager(t)
91 ctx, cancel := context.WithCancel(context.Background())
92 defer cancel()
93
94 spec := scribeSpec()
95 spec.Enabled = false
96 m.Sync(ctx, []manager.BotSpec{spec})
97
98 if len(m.Running()) != 0 {
99 t.Errorf("expected no bots running, got %v", m.Running())
100 }
101 }
102
103 func TestSyncStopsDisabledBot(t *testing.T) {
104 m := newManager(t)
105 ctx, cancel := context.WithCancel(context.Background())
106 defer cancel()
107
108 // Start it.
109 m.Sync(ctx, []manager.BotSpec{scribeSpec()})
110 if len(m.Running()) != 1 {
111 t.Fatalf("bot should be running before disable")
112 }
113
114 // Disable it.
115 spec := scribeSpec()
116 spec.Enabled = false
117 m.Sync(ctx, []manager.BotSpec{spec})
118
119 if len(m.Running()) != 0 {
120 t.Errorf("expected bot stopped after disable, got %v", m.Running())
121 }
122 }
123
124 func TestSyncIdempotent(t *testing.T) {
125 m := newManager(t)
126 ctx, cancel := context.WithCancel(context.Background())
127 defer cancel()
128
129 spec := scribeSpec()
130 m.Sync(ctx, []manager.BotSpec{spec})
131 m.Sync(ctx, []manager.BotSpec{spec}) // second call — should not start a second copy
132
133 if len(m.Running()) != 1 {
134 t.Errorf("expected exactly 1 running bot, got %v", m.Running())
135 }
136 }
137
138 func TestPasswordPersistence(t *testing.T) {
139 dir := t.TempDir()
140 prov := newStub()
141 m1 := manager.New("127.0.0.1:6667", dir, prov, &stubChannels{}, testLog)
142
143 ctx, cancel := context.WithCancel(context.Background())
144 m1.Sync(ctx, []manager.BotSpec{scribeSpec()})
145 cancel()
146
147 // Passwords file should exist.
148 pwPath := filepath.Join(dir, "bot_passwords.json")
149 if _, err := os.Stat(pwPath); err != nil {
150 t.Fatalf("passwords file not created: %v", err)
151 }
152
153 // Load a second manager from the same dir — it should reuse the same password
154 // (ensureAccount will call ChangePassword, not RegisterAccount, because the stub
155 // already has the account from the first run).
156 m2 := manager.New("127.0.0.1:6667", dir, prov, &stubChannels{}, testLog)
157 ctx2, cancel2 := context.WithCancel(context.Background())
158 defer cancel2()
159
160 // Should not panic and should be able to start the bot.
161 m2.Sync(ctx2, []manager.BotSpec{scribeSpec()})
162 if len(m2.Running()) != 1 {
163 t.Errorf("second manager: expected 1 running bot, got %v", m2.Running())
164 }
165 }
166
167 func TestSyncOracleStarts(t *testing.T) {
168 // Oracle now starts with default config (no API key — it won't respond to
169 // summaries but the bot itself connects to IRC and runs).
170 m := newManager(t)
171 ctx, cancel := context.WithCancel(context.Background())
172 defer cancel()
173
174 spec := manager.BotSpec{ID: "oracle", Nick: "oracle", Enabled: true}
175 m.Sync(ctx, []manager.BotSpec{spec})
176
177 running := m.Running()
178 found := false
179 for _, nick := range running {
180 if nick == "oracle" {
181 found = true
182 }
183 }
184 if !found {
185 t.Errorf("expected oracle to be in Running, got %v", running)
186 }
187 }
188
189 func TestSyncMultipleBots(t *testing.T) {
190 m := newManager(t)
191 ctx, cancel := context.WithCancel(context.Background())
192 defer cancel()
193
194 specs := []manager.BotSpec{
195 scribeSpec(),
196 {ID: "snitch", Nick: "snitch", Enabled: true},
197 }
198 m.Sync(ctx, []manager.BotSpec{specs[0], specs[1]})
199
200 running := m.Running()
201 if len(running) != 2 {
202 t.Errorf("expected 2 running bots, got %v", running)
203 }
204 }
--- internal/bots/oracle/providers.go
+++ internal/bots/oracle/providers.go
@@ -1,95 +1,8 @@
11
package oracle
22
3
-import (
4
- "bytes"
5
- "context"
6
- "encoding/json"
7
- "fmt"
8
- "io"
9
- "net/http"
10
- "os"
11
-)
12
-
13
-// OpenAIProvider calls any OpenAI-compatible chat completion API.
14
-// Works with OpenAI, Anthropic (via compatibility layer), local Ollama, etc.
15
-type OpenAIProvider struct {
16
- BaseURL string // e.g. "https://api.openai.com/v1"
17
- APIKey string
18
- Model string
19
- http *http.Client
20
-}
21
-
22
-// NewOpenAIProvider creates a provider from environment variables:
23
-//
24
-// ORACLE_OPENAI_BASE_URL (default: https://api.openai.com/v1)
25
-// ORACLE_OPENAI_API_KEY (required)
26
-// ORACLE_OPENAI_MODEL (default: gpt-4o-mini)
27
-func NewOpenAIProvider() *OpenAIProvider {
28
- baseURL := os.Getenv("ORACLE_OPENAI_BASE_URL")
29
- if baseURL == "" {
30
- baseURL = "https://api.openai.com/v1"
31
- }
32
- model := os.Getenv("ORACLE_OPENAI_MODEL")
33
- if model == "" {
34
- model = "gpt-4o-mini"
35
- }
36
- return &OpenAIProvider{
37
- BaseURL: baseURL,
38
- APIKey: os.Getenv("ORACLE_OPENAI_API_KEY"),
39
- Model: model,
40
- http: &http.Client{},
41
- }
42
-}
43
-
44
-// Summarize calls the chat completions endpoint with the given prompt.
45
-func (p *OpenAIProvider) Summarize(ctx context.Context, prompt string) (string, error) {
46
- if p.APIKey == "" {
47
- return "", fmt.Errorf("ORACLE_OPENAI_API_KEY is not set")
48
- }
49
-
50
- body, _ := json.Marshal(map[string]any{
51
- "model": p.Model,
52
- "messages": []map[string]string{
53
- {"role": "user", "content": prompt},
54
- },
55
- "max_tokens": 512,
56
- })
57
-
58
- req, err := http.NewRequestWithContext(ctx, "POST", p.BaseURL+"/chat/completions", bytes.NewReader(body))
59
- if err != nil {
60
- return "", err
61
- }
62
- req.Header.Set("Authorization", "Bearer "+p.APIKey)
63
- req.Header.Set("Content-Type", "application/json")
64
-
65
- resp, err := p.http.Do(req)
66
- if err != nil {
67
- return "", fmt.Errorf("openai request: %w", err)
68
- }
69
- defer resp.Body.Close()
70
-
71
- data, _ := io.ReadAll(resp.Body)
72
- if resp.StatusCode != http.StatusOK {
73
- return "", fmt.Errorf("openai error %d: %s", resp.StatusCode, string(data))
74
- }
75
-
76
- var result struct {
77
- Choices []struct {
78
- Message struct {
79
- Content string `json:"content"`
80
- } `json:"message"`
81
- } `json:"choices"`
82
- }
83
- if err := json.Unmarshal(data, &result); err != nil {
84
- return "", fmt.Errorf("openai parse: %w", err)
85
- }
86
- if len(result.Choices) == 0 {
87
- return "", fmt.Errorf("openai returned no choices")
88
- }
89
- return result.Choices[0].Message.Content, nil
90
-}
3
+import "context"
914
925
// StubProvider returns a fixed summary. Used in tests and when no LLM is configured.
936
type StubProvider struct {
947
Response string
958
Err error
969
--- internal/bots/oracle/providers.go
+++ internal/bots/oracle/providers.go
@@ -1,95 +1,8 @@
1 package oracle
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "os"
11 )
12
13 // OpenAIProvider calls any OpenAI-compatible chat completion API.
14 // Works with OpenAI, Anthropic (via compatibility layer), local Ollama, etc.
15 type OpenAIProvider struct {
16 BaseURL string // e.g. "https://api.openai.com/v1"
17 APIKey string
18 Model string
19 http *http.Client
20 }
21
22 // NewOpenAIProvider creates a provider from environment variables:
23 //
24 // ORACLE_OPENAI_BASE_URL (default: https://api.openai.com/v1)
25 // ORACLE_OPENAI_API_KEY (required)
26 // ORACLE_OPENAI_MODEL (default: gpt-4o-mini)
27 func NewOpenAIProvider() *OpenAIProvider {
28 baseURL := os.Getenv("ORACLE_OPENAI_BASE_URL")
29 if baseURL == "" {
30 baseURL = "https://api.openai.com/v1"
31 }
32 model := os.Getenv("ORACLE_OPENAI_MODEL")
33 if model == "" {
34 model = "gpt-4o-mini"
35 }
36 return &OpenAIProvider{
37 BaseURL: baseURL,
38 APIKey: os.Getenv("ORACLE_OPENAI_API_KEY"),
39 Model: model,
40 http: &http.Client{},
41 }
42 }
43
44 // Summarize calls the chat completions endpoint with the given prompt.
45 func (p *OpenAIProvider) Summarize(ctx context.Context, prompt string) (string, error) {
46 if p.APIKey == "" {
47 return "", fmt.Errorf("ORACLE_OPENAI_API_KEY is not set")
48 }
49
50 body, _ := json.Marshal(map[string]any{
51 "model": p.Model,
52 "messages": []map[string]string{
53 {"role": "user", "content": prompt},
54 },
55 "max_tokens": 512,
56 })
57
58 req, err := http.NewRequestWithContext(ctx, "POST", p.BaseURL+"/chat/completions", bytes.NewReader(body))
59 if err != nil {
60 return "", err
61 }
62 req.Header.Set("Authorization", "Bearer "+p.APIKey)
63 req.Header.Set("Content-Type", "application/json")
64
65 resp, err := p.http.Do(req)
66 if err != nil {
67 return "", fmt.Errorf("openai request: %w", err)
68 }
69 defer resp.Body.Close()
70
71 data, _ := io.ReadAll(resp.Body)
72 if resp.StatusCode != http.StatusOK {
73 return "", fmt.Errorf("openai error %d: %s", resp.StatusCode, string(data))
74 }
75
76 var result struct {
77 Choices []struct {
78 Message struct {
79 Content string `json:"content"`
80 } `json:"message"`
81 } `json:"choices"`
82 }
83 if err := json.Unmarshal(data, &result); err != nil {
84 return "", fmt.Errorf("openai parse: %w", err)
85 }
86 if len(result.Choices) == 0 {
87 return "", fmt.Errorf("openai returned no choices")
88 }
89 return result.Choices[0].Message.Content, nil
90 }
91
92 // StubProvider returns a fixed summary. Used in tests and when no LLM is configured.
93 type StubProvider struct {
94 Response string
95 Err error
96
--- internal/bots/oracle/providers.go
+++ internal/bots/oracle/providers.go
@@ -1,95 +1,8 @@
1 package oracle
2
3 import "context"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
5 // StubProvider returns a fixed summary. Used in tests and when no LLM is configured.
6 type StubProvider struct {
7 Response string
8 Err error
9
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -8,10 +8,12 @@
88
99
import (
1010
"context"
1111
"fmt"
1212
"log/slog"
13
+ "net"
14
+ "strconv"
1315
"strings"
1416
"time"
1517
1618
"github.com/lrstanley/girc"
1719
@@ -131,12 +133,15 @@
131133
b.log.Error("scribe: failed to write log entry", "err", err)
132134
}
133135
}
134136
135137
func splitHostPort(addr string) (string, int, error) {
136
- var host string
137
- var port int
138
- if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
138
+ host, portStr, err := net.SplitHostPort(addr)
139
+ if err != nil {
139140
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
140141
}
142
+ port, err := strconv.Atoi(portStr)
143
+ if err != nil {
144
+ return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
145
+ }
141146
return host, port, nil
142147
}
143148
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -8,10 +8,12 @@
8
9 import (
10 "context"
11 "fmt"
12 "log/slog"
 
 
13 "strings"
14 "time"
15
16 "github.com/lrstanley/girc"
17
@@ -131,12 +133,15 @@
131 b.log.Error("scribe: failed to write log entry", "err", err)
132 }
133 }
134
135 func splitHostPort(addr string) (string, int, error) {
136 var host string
137 var port int
138 if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port); err != nil {
139 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
140 }
 
 
 
 
141 return host, port, nil
142 }
143
--- internal/bots/scribe/scribe.go
+++ internal/bots/scribe/scribe.go
@@ -8,10 +8,12 @@
8
9 import (
10 "context"
11 "fmt"
12 "log/slog"
13 "net"
14 "strconv"
15 "strings"
16 "time"
17
18 "github.com/lrstanley/girc"
19
@@ -131,12 +133,15 @@
133 b.log.Error("scribe: failed to write log entry", "err", err)
134 }
135 }
136
137 func splitHostPort(addr string) (string, int, error) {
138 host, portStr, err := net.SplitHostPort(addr)
139 if err != nil {
 
140 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
141 }
142 port, err := strconv.Atoi(portStr)
143 if err != nil {
144 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
145 }
146 return host, port, nil
147 }
148
--- internal/bots/scribe/scribe_test.go
+++ internal/bots/scribe/scribe_test.go
@@ -157,5 +157,123 @@
157157
}
158158
if got.MessageType != entry.MessageType {
159159
t.Errorf("MessageType: got %q, want %q", got.MessageType, entry.MessageType)
160160
}
161161
}
162
+
163
+func TestFileStoreJSONL(t *testing.T) {
164
+ dir := t.TempDir()
165
+ fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl"})
166
+ defer fs.Close()
167
+
168
+ entries := []scribe.Entry{
169
+ {At: time.Now(), Channel: "#fleet", Nick: "alice", Kind: scribe.EntryKindRaw, Raw: "hello"},
170
+ {At: time.Now(), Channel: "#fleet", Nick: "bob", Kind: scribe.EntryKindRaw, Raw: "world"},
171
+ {At: time.Now(), Channel: "#ops", Nick: "alice", Kind: scribe.EntryKindRaw, Raw: "other"},
172
+ }
173
+ for _, e := range entries {
174
+ if err := fs.Append(e); err != nil {
175
+ t.Fatalf("Append: %v", err)
176
+ }
177
+ }
178
+
179
+ got, err := fs.Query("#fleet", 0)
180
+ if err != nil {
181
+ t.Fatalf("Query: %v", err)
182
+ }
183
+ if len(got) != 2 {
184
+ t.Errorf("Query #fleet: got %d, want 2", len(got))
185
+ }
186
+}
187
+
188
+func TestFileStorePerChannel(t *testing.T) {
189
+ dir := t.TempDir()
190
+ fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl", PerChannel: true})
191
+ defer fs.Close()
192
+
193
+ _ = fs.Append(scribe.Entry{At: time.Now(), Channel: "#fleet", Nick: "a", Kind: scribe.EntryKindRaw, Raw: "msg"})
194
+ _ = fs.Append(scribe.Entry{At: time.Now(), Channel: "#ops", Nick: "a", Kind: scribe.EntryKindRaw, Raw: "msg"})
195
+
196
+ entries, err := fs.Query("#fleet", 0)
197
+ if err != nil {
198
+ t.Fatalf("Query: %v", err)
199
+ }
200
+ if len(entries) != 1 {
201
+ t.Errorf("per-channel: got %d entries for #fleet, want 1", len(entries))
202
+ }
203
+}
204
+
205
+func TestFileStoreSizeRotation(t *testing.T) {
206
+ dir := t.TempDir()
207
+ // Set threshold to 1 byte so every write triggers rotation.
208
+ fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "text", Rotation: "size", MaxSizeMB: 0})
209
+ _ = fs // rotation with MaxSizeMB=0 means no limit; just ensure no panic
210
+ fs2 := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl", Rotation: "size", MaxSizeMB: 1})
211
+ defer fs2.Close()
212
+ for i := 0; i < 3; i++ {
213
+ if err := fs2.Append(scribe.Entry{At: time.Now(), Channel: "#test", Nick: "x", Kind: scribe.EntryKindRaw, Raw: "line"}); err != nil {
214
+ t.Fatalf("Append: %v", err)
215
+ }
216
+ }
217
+}
218
+
219
+func TestFileStoreCSVFormat(t *testing.T) {
220
+ dir := t.TempDir()
221
+ fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "csv"})
222
+ defer fs.Close()
223
+
224
+ err := fs.Append(scribe.Entry{At: time.Now(), Channel: "#fleet", Nick: "alice", Kind: scribe.EntryKindRaw, Raw: `say "hi"`})
225
+ if err != nil {
226
+ t.Fatalf("Append csv: %v", err)
227
+ }
228
+ // Query returns nil for non-jsonl formats — just check no error on Append.
229
+ got, err := fs.Query("#fleet", 0)
230
+ if err != nil {
231
+ t.Fatalf("Query csv: %v", err)
232
+ }
233
+ if got != nil {
234
+ t.Errorf("expected nil from Query on csv format")
235
+ }
236
+}
237
+
238
+func TestFileStorePruneOld(t *testing.T) {
239
+ dir := t.TempDir()
240
+ fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl", MaxAgeDays: 1})
241
+ defer fs.Close()
242
+
243
+ // Write a file and manually backdate it.
244
+ _ = fs.Append(scribe.Entry{At: time.Now(), Channel: "#fleet", Nick: "a", Kind: scribe.EntryKindRaw, Raw: "x"})
245
+
246
+ if err := fs.PruneOld(); err != nil {
247
+ t.Fatalf("PruneOld: %v", err)
248
+ }
249
+}
250
+
251
+func TestFileStoreJSONRoundTrip(t *testing.T) {
252
+ dir := t.TempDir()
253
+ fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl"})
254
+ defer fs.Close()
255
+
256
+ orig := scribe.Entry{
257
+ At: time.Now().Truncate(time.Millisecond),
258
+ Channel: "#fleet",
259
+ Nick: "claude-01",
260
+ Kind: scribe.EntryKindEnvelope,
261
+ MessageType: "task.create",
262
+ MessageID: "01HX123",
263
+ Raw: `{"v":1}`,
264
+ }
265
+ _ = fs.Append(orig)
266
+
267
+ got, err := fs.Query("#fleet", 1)
268
+ if err != nil {
269
+ t.Fatalf("Query: %v", err)
270
+ }
271
+ if len(got) != 1 {
272
+ t.Fatalf("want 1 entry, got %d", len(got))
273
+ }
274
+ b1, _ := json.Marshal(orig)
275
+ b2, _ := json.Marshal(got[0])
276
+ if string(b1) != string(b2) {
277
+ t.Errorf("round-trip mismatch:\n want %s\n got %s", b1, b2)
278
+ }
279
+}
162280
--- internal/bots/scribe/scribe_test.go
+++ internal/bots/scribe/scribe_test.go
@@ -157,5 +157,123 @@
157 }
158 if got.MessageType != entry.MessageType {
159 t.Errorf("MessageType: got %q, want %q", got.MessageType, entry.MessageType)
160 }
161 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
--- internal/bots/scribe/scribe_test.go
+++ internal/bots/scribe/scribe_test.go
@@ -157,5 +157,123 @@
157 }
158 if got.MessageType != entry.MessageType {
159 t.Errorf("MessageType: got %q, want %q", got.MessageType, entry.MessageType)
160 }
161 }
162
163 func TestFileStoreJSONL(t *testing.T) {
164 dir := t.TempDir()
165 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl"})
166 defer fs.Close()
167
168 entries := []scribe.Entry{
169 {At: time.Now(), Channel: "#fleet", Nick: "alice", Kind: scribe.EntryKindRaw, Raw: "hello"},
170 {At: time.Now(), Channel: "#fleet", Nick: "bob", Kind: scribe.EntryKindRaw, Raw: "world"},
171 {At: time.Now(), Channel: "#ops", Nick: "alice", Kind: scribe.EntryKindRaw, Raw: "other"},
172 }
173 for _, e := range entries {
174 if err := fs.Append(e); err != nil {
175 t.Fatalf("Append: %v", err)
176 }
177 }
178
179 got, err := fs.Query("#fleet", 0)
180 if err != nil {
181 t.Fatalf("Query: %v", err)
182 }
183 if len(got) != 2 {
184 t.Errorf("Query #fleet: got %d, want 2", len(got))
185 }
186 }
187
188 func TestFileStorePerChannel(t *testing.T) {
189 dir := t.TempDir()
190 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl", PerChannel: true})
191 defer fs.Close()
192
193 _ = fs.Append(scribe.Entry{At: time.Now(), Channel: "#fleet", Nick: "a", Kind: scribe.EntryKindRaw, Raw: "msg"})
194 _ = fs.Append(scribe.Entry{At: time.Now(), Channel: "#ops", Nick: "a", Kind: scribe.EntryKindRaw, Raw: "msg"})
195
196 entries, err := fs.Query("#fleet", 0)
197 if err != nil {
198 t.Fatalf("Query: %v", err)
199 }
200 if len(entries) != 1 {
201 t.Errorf("per-channel: got %d entries for #fleet, want 1", len(entries))
202 }
203 }
204
205 func TestFileStoreSizeRotation(t *testing.T) {
206 dir := t.TempDir()
207 // Set threshold to 1 byte so every write triggers rotation.
208 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "text", Rotation: "size", MaxSizeMB: 0})
209 _ = fs // rotation with MaxSizeMB=0 means no limit; just ensure no panic
210 fs2 := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl", Rotation: "size", MaxSizeMB: 1})
211 defer fs2.Close()
212 for i := 0; i < 3; i++ {
213 if err := fs2.Append(scribe.Entry{At: time.Now(), Channel: "#test", Nick: "x", Kind: scribe.EntryKindRaw, Raw: "line"}); err != nil {
214 t.Fatalf("Append: %v", err)
215 }
216 }
217 }
218
219 func TestFileStoreCSVFormat(t *testing.T) {
220 dir := t.TempDir()
221 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "csv"})
222 defer fs.Close()
223
224 err := fs.Append(scribe.Entry{At: time.Now(), Channel: "#fleet", Nick: "alice", Kind: scribe.EntryKindRaw, Raw: `say "hi"`})
225 if err != nil {
226 t.Fatalf("Append csv: %v", err)
227 }
228 // Query returns nil for non-jsonl formats — just check no error on Append.
229 got, err := fs.Query("#fleet", 0)
230 if err != nil {
231 t.Fatalf("Query csv: %v", err)
232 }
233 if got != nil {
234 t.Errorf("expected nil from Query on csv format")
235 }
236 }
237
238 func TestFileStorePruneOld(t *testing.T) {
239 dir := t.TempDir()
240 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl", MaxAgeDays: 1})
241 defer fs.Close()
242
243 // Write a file and manually backdate it.
244 _ = fs.Append(scribe.Entry{At: time.Now(), Channel: "#fleet", Nick: "a", Kind: scribe.EntryKindRaw, Raw: "x"})
245
246 if err := fs.PruneOld(); err != nil {
247 t.Fatalf("PruneOld: %v", err)
248 }
249 }
250
251 func TestFileStoreJSONRoundTrip(t *testing.T) {
252 dir := t.TempDir()
253 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl"})
254 defer fs.Close()
255
256 orig := scribe.Entry{
257 At: time.Now().Truncate(time.Millisecond),
258 Channel: "#fleet",
259 Nick: "claude-01",
260 Kind: scribe.EntryKindEnvelope,
261 MessageType: "task.create",
262 MessageID: "01HX123",
263 Raw: `{"v":1}`,
264 }
265 _ = fs.Append(orig)
266
267 got, err := fs.Query("#fleet", 1)
268 if err != nil {
269 t.Fatalf("Query: %v", err)
270 }
271 if len(got) != 1 {
272 t.Fatalf("want 1 entry, got %d", len(got))
273 }
274 b1, _ := json.Marshal(orig)
275 b2, _ := json.Marshal(got[0])
276 if string(b1) != string(b2) {
277 t.Errorf("round-trip mismatch:\n want %s\n got %s", b1, b2)
278 }
279 }
280
--- internal/bots/scribe/store.go
+++ internal/bots/scribe/store.go
@@ -1,8 +1,14 @@
11
package scribe
22
33
import (
4
+ "bufio"
5
+ "encoding/json"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
410
"sync"
511
"time"
612
)
713
814
// EntryKind describes how a log entry was parsed.
@@ -65,5 +71,303 @@
6571
defer s.mu.RUnlock()
6672
out := make([]Entry, len(s.entries))
6773
copy(out, s.entries)
6874
return out
6975
}
76
+
77
+// ---------------------------------------------------------------------------
78
+// FileStore
79
+// ---------------------------------------------------------------------------
80
+
81
+// FileStoreConfig controls FileStore behaviour.
82
+type FileStoreConfig struct {
83
+ Dir string // base directory; created on first write if absent
84
+ Format string // "jsonl" (default) | "csv" | "text"
85
+ Rotation string // "none" (default) | "daily" | "weekly" | "size"
86
+ MaxSizeMB int // size rotation threshold in MiB; 0 = no limit
87
+ PerChannel bool // true: one file per channel; false: single combined file
88
+ MaxAgeDays int // prune rotated files older than N days; 0 = keep all
89
+}
90
+
91
+// FileStore writes log entries to rotating files on disk.
92
+// It is safe for concurrent use.
93
+type FileStore struct {
94
+ cfg FileStoreConfig
95
+ mu sync.Mutex
96
+ files map[string]*openFile // key: sanitized channel name or "_all"
97
+}
98
+
99
+type openFile struct {
100
+ f *os.File
101
+ size int64
102
+ bucket string // date bucket for time-based rotation ("YYYY-MM-DD", "YYYY-Www")
103
+ path string // absolute path of the current file (for size rotation)
104
+}
105
+
106
+// NewFileStore creates a FileStore with the given config.
107
+// Defaults: Format="jsonl", Rotation="none".
108
+func NewFileStore(cfg FileStoreConfig) *FileStore {
109
+ if cfg.Format == "" {
110
+ cfg.Format = "jsonl"
111
+ }
112
+ if cfg.Rotation == "" {
113
+ cfg.Rotation = "none"
114
+ }
115
+ return &FileStore{
116
+ cfg: cfg,
117
+ files: make(map[string]*openFile),
118
+ }
119
+}
120
+
121
+func (s *FileStore) Append(entry Entry) error {
122
+ s.mu.Lock()
123
+ defer s.mu.Unlock()
124
+
125
+ if err := os.MkdirAll(s.cfg.Dir, 0755); err != nil {
126
+ return fmt.Errorf("scribe filestore: mkdir %s: %w", s.cfg.Dir, err)
127
+ }
128
+
129
+ key := "_all"
130
+ if s.cfg.PerChannel {
131
+ key = sanitizeChannel(entry.Channel)
132
+ }
133
+
134
+ of, err := s.getFile(key, entry.At)
135
+ if err != nil {
136
+ return err
137
+ }
138
+
139
+ line, err := s.formatEntry(entry)
140
+ if err != nil {
141
+ return err
142
+ }
143
+
144
+ n, err := fmt.Fprintln(of.f, line)
145
+ if err != nil {
146
+ return fmt.Errorf("scribe filestore: write: %w", err)
147
+ }
148
+ of.size += int64(n)
149
+ return nil
150
+}
151
+
152
+// getFile returns the open file for the given key, rotating if necessary.
153
+// Caller must hold s.mu.
154
+func (s *FileStore) getFile(key string, now time.Time) (*openFile, error) {
155
+ of := s.files[key]
156
+ bucket := s.timeBucket(now)
157
+
158
+ if of != nil {
159
+ needRotate := false
160
+ switch s.cfg.Rotation {
161
+ case "daily", "weekly":
162
+ needRotate = of.bucket != bucket
163
+ case "size":
164
+ if s.cfg.MaxSizeMB > 0 {
165
+ needRotate = of.size >= int64(s.cfg.MaxSizeMB)*1024*1024
166
+ }
167
+ }
168
+ if needRotate {
169
+ _ = of.f.Close()
170
+ if s.cfg.Rotation == "size" {
171
+ s.shiftSizeBackups(of.path)
172
+ }
173
+ of = nil
174
+ delete(s.files, key)
175
+ }
176
+ }
177
+
178
+ if of == nil {
179
+ path := s.filePath(key, now)
180
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
181
+ if err != nil {
182
+ return nil, fmt.Errorf("scribe filestore: open %s: %w", path, err)
183
+ }
184
+ var size int64
185
+ if fi, err := f.Stat(); err == nil {
186
+ size = fi.Size()
187
+ }
188
+ of = &openFile{f: f, size: size, bucket: bucket, path: path}
189
+ s.files[key] = of
190
+ }
191
+ return of, nil
192
+}
193
+
194
+// shiftSizeBackups renames path → path.1, path.1 → path.2 … up to .10.
195
+func (s *FileStore) shiftSizeBackups(path string) {
196
+ for i := 9; i >= 1; i-- {
197
+ _ = os.Rename(fmt.Sprintf("%s.%d", path, i), fmt.Sprintf("%s.%d", path, i+1))
198
+ }
199
+ _ = os.Rename(path, path+".1")
200
+}
201
+
202
+func (s *FileStore) timeBucket(t time.Time) string {
203
+ switch s.cfg.Rotation {
204
+ case "daily":
205
+ return t.Format("2006-01-02")
206
+ case "weekly":
207
+ year, week := t.ISOWeek()
208
+ return fmt.Sprintf("%04d-W%02d", year, week)
209
+ case "monthly":
210
+ return t.Format("2006-01")
211
+ case "yearly":
212
+ return t.Format("2006")
213
+ default:
214
+ return "current"
215
+ }
216
+}
217
+
218
+func (s *FileStore) filePath(key string, now time.Time) string {
219
+ extMap := map[string]string{"jsonl": ".jsonl", "csv": ".csv", "text": ".log"}
220
+ ext, ok := extMap[s.cfg.Format]
221
+ if !ok {
222
+ ext = ".jsonl"
223
+ }
224
+
225
+ var suffix string
226
+ switch s.cfg.Rotation {
227
+ case "daily":
228
+ suffix = "-" + now.Format("2006-01-02")
229
+ case "weekly":
230
+ year, week := now.ISOWeek()
231
+ suffix = fmt.Sprintf("-%04d-W%02d", year, week)
232
+ case "monthly":
233
+ suffix = "-" + now.Format("2006-01")
234
+ case "yearly":
235
+ suffix = "-" + now.Format("2006")
236
+ }
237
+ return filepath.Join(s.cfg.Dir, key+suffix+ext)
238
+}
239
+
240
+func (s *FileStore) formatEntry(e Entry) (string, error) {
241
+ switch s.cfg.Format {
242
+ case "csv":
243
+ return strings.Join([]string{
244
+ e.At.Format(time.RFC3339),
245
+ csvField(e.Channel),
246
+ csvField(e.Nick),
247
+ string(e.Kind),
248
+ csvField(e.MessageType),
249
+ csvField(e.MessageID),
250
+ csvField(e.Raw),
251
+ }, ","), nil
252
+ case "text":
253
+ return fmt.Sprintf("%s %s <%s> %s",
254
+ e.At.Format("2006-01-02T15:04:05"),
255
+ e.Channel, e.Nick, e.Raw), nil
256
+ default: // jsonl
257
+ b, err := json.Marshal(e)
258
+ return string(b), err
259
+ }
260
+}
261
+
262
+// Query returns the most recent entries from the current log file.
263
+// Only supported for "jsonl" format; other formats return nil, nil.
264
+func (s *FileStore) Query(channel string, limit int) ([]Entry, error) {
265
+ if s.cfg.Format != "jsonl" {
266
+ return nil, nil
267
+ }
268
+ s.mu.Lock()
269
+ path := s.filePath(keyFor(s.cfg.PerChannel, channel), time.Now())
270
+ s.mu.Unlock()
271
+
272
+ f, err := os.Open(path)
273
+ if os.IsNotExist(err) {
274
+ return nil, nil
275
+ }
276
+ if err != nil {
277
+ return nil, err
278
+ }
279
+ defer f.Close()
280
+
281
+ var entries []Entry
282
+ scanner := bufio.NewScanner(f)
283
+ for scanner.Scan() {
284
+ line := scanner.Bytes()
285
+ if len(line) == 0 {
286
+ continue
287
+ }
288
+ var e Entry
289
+ if json.Unmarshal(line, &e) != nil {
290
+ continue
291
+ }
292
+ if channel == "" || e.Channel == channel {
293
+ entries = append(entries, e)
294
+ }
295
+ }
296
+ if err := scanner.Err(); err != nil {
297
+ return nil, err
298
+ }
299
+ if limit > 0 && len(entries) > limit {
300
+ entries = entries[len(entries)-limit:]
301
+ }
302
+ return entries, nil
303
+}
304
+
305
+// Close flushes and closes all open file handles.
306
+func (s *FileStore) Close() {
307
+ s.mu.Lock()
308
+ defer s.mu.Unlock()
309
+ for key, of := range s.files {
310
+ _ = of.f.Close()
311
+ delete(s.files, key)
312
+ }
313
+}
314
+
315
+// PruneOld removes log files whose modification time is older than MaxAgeDays.
316
+// No-op if MaxAgeDays is 0 or Dir is empty.
317
+func (s *FileStore) PruneOld() error {
318
+ if s.cfg.MaxAgeDays <= 0 || s.cfg.Dir == "" {
319
+ return nil
320
+ }
321
+ cutoff := time.Now().AddDate(0, 0, -s.cfg.MaxAgeDays)
322
+ entries, err := os.ReadDir(s.cfg.Dir)
323
+ if err != nil {
324
+ if os.IsNotExist(err) {
325
+ return nil
326
+ }
327
+ return err
328
+ }
329
+ for _, de := range entries {
330
+ if de.IsDir() {
331
+ continue
332
+ }
333
+ info, err := de.Info()
334
+ if err != nil {
335
+ continue
336
+ }
337
+ if info.ModTime().Before(cutoff) {
338
+ _ = os.Remove(filepath.Join(s.cfg.Dir, de.Name()))
339
+ }
340
+ }
341
+ return nil
342
+}
343
+
344
+// ---------------------------------------------------------------------------
345
+// helpers
346
+// ---------------------------------------------------------------------------
347
+
348
+func keyFor(perChannel bool, channel string) string {
349
+ if perChannel && channel != "" {
350
+ return sanitizeChannel(channel)
351
+ }
352
+ return "_all"
353
+}
354
+
355
+// sanitizeChannel strips "#" and replaces filesystem-unsafe characters.
356
+func sanitizeChannel(ch string) string {
357
+ ch = strings.TrimPrefix(ch, "#")
358
+ return strings.Map(func(r rune) rune {
359
+ switch r {
360
+ case '/', '\\', ':', '*', '?', '"', '<', '>', '|':
361
+ return '_'
362
+ }
363
+ return r
364
+ }, ch)
365
+}
366
+
367
+// csvField wraps a field in double-quotes if it contains a comma, quote, or newline.
368
+func csvField(s string) string {
369
+ if strings.ContainsAny(s, "\",\n") {
370
+ return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
371
+ }
372
+ return s
373
+}
70374
71375
ADDED internal/bots/sentinel/sentinel.go
72376
ADDED internal/bots/snitch/nickwindow_test.go
73377
ADDED internal/bots/snitch/snitch.go
74378
ADDED internal/bots/snitch/snitch_test.go
75379
ADDED internal/bots/steward/steward.go
--- internal/bots/scribe/store.go
+++ internal/bots/scribe/store.go
@@ -1,8 +1,14 @@
1 package scribe
2
3 import (
 
 
 
 
 
 
4 "sync"
5 "time"
6 )
7
8 // EntryKind describes how a log entry was parsed.
@@ -65,5 +71,303 @@
65 defer s.mu.RUnlock()
66 out := make([]Entry, len(s.entries))
67 copy(out, s.entries)
68 return out
69 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
71 DDED internal/bots/sentinel/sentinel.go
72 DDED internal/bots/snitch/nickwindow_test.go
73 DDED internal/bots/snitch/snitch.go
74 DDED internal/bots/snitch/snitch_test.go
75 DDED internal/bots/steward/steward.go
--- internal/bots/scribe/store.go
+++ internal/bots/scribe/store.go
@@ -1,8 +1,14 @@
1 package scribe
2
3 import (
4 "bufio"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "time"
12 )
13
14 // EntryKind describes how a log entry was parsed.
@@ -65,5 +71,303 @@
71 defer s.mu.RUnlock()
72 out := make([]Entry, len(s.entries))
73 copy(out, s.entries)
74 return out
75 }
76
77 // ---------------------------------------------------------------------------
78 // FileStore
79 // ---------------------------------------------------------------------------
80
81 // FileStoreConfig controls FileStore behaviour.
82 type FileStoreConfig struct {
83 Dir string // base directory; created on first write if absent
84 Format string // "jsonl" (default) | "csv" | "text"
85 Rotation string // "none" (default) | "daily" | "weekly" | "size"
86 MaxSizeMB int // size rotation threshold in MiB; 0 = no limit
87 PerChannel bool // true: one file per channel; false: single combined file
88 MaxAgeDays int // prune rotated files older than N days; 0 = keep all
89 }
90
91 // FileStore writes log entries to rotating files on disk.
92 // It is safe for concurrent use.
93 type FileStore struct {
94 cfg FileStoreConfig
95 mu sync.Mutex
96 files map[string]*openFile // key: sanitized channel name or "_all"
97 }
98
99 type openFile struct {
100 f *os.File
101 size int64
102 bucket string // date bucket for time-based rotation ("YYYY-MM-DD", "YYYY-Www")
103 path string // absolute path of the current file (for size rotation)
104 }
105
106 // NewFileStore creates a FileStore with the given config.
107 // Defaults: Format="jsonl", Rotation="none".
108 func NewFileStore(cfg FileStoreConfig) *FileStore {
109 if cfg.Format == "" {
110 cfg.Format = "jsonl"
111 }
112 if cfg.Rotation == "" {
113 cfg.Rotation = "none"
114 }
115 return &FileStore{
116 cfg: cfg,
117 files: make(map[string]*openFile),
118 }
119 }
120
121 func (s *FileStore) Append(entry Entry) error {
122 s.mu.Lock()
123 defer s.mu.Unlock()
124
125 if err := os.MkdirAll(s.cfg.Dir, 0755); err != nil {
126 return fmt.Errorf("scribe filestore: mkdir %s: %w", s.cfg.Dir, err)
127 }
128
129 key := "_all"
130 if s.cfg.PerChannel {
131 key = sanitizeChannel(entry.Channel)
132 }
133
134 of, err := s.getFile(key, entry.At)
135 if err != nil {
136 return err
137 }
138
139 line, err := s.formatEntry(entry)
140 if err != nil {
141 return err
142 }
143
144 n, err := fmt.Fprintln(of.f, line)
145 if err != nil {
146 return fmt.Errorf("scribe filestore: write: %w", err)
147 }
148 of.size += int64(n)
149 return nil
150 }
151
152 // getFile returns the open file for the given key, rotating if necessary.
153 // Caller must hold s.mu.
154 func (s *FileStore) getFile(key string, now time.Time) (*openFile, error) {
155 of := s.files[key]
156 bucket := s.timeBucket(now)
157
158 if of != nil {
159 needRotate := false
160 switch s.cfg.Rotation {
161 case "daily", "weekly":
162 needRotate = of.bucket != bucket
163 case "size":
164 if s.cfg.MaxSizeMB > 0 {
165 needRotate = of.size >= int64(s.cfg.MaxSizeMB)*1024*1024
166 }
167 }
168 if needRotate {
169 _ = of.f.Close()
170 if s.cfg.Rotation == "size" {
171 s.shiftSizeBackups(of.path)
172 }
173 of = nil
174 delete(s.files, key)
175 }
176 }
177
178 if of == nil {
179 path := s.filePath(key, now)
180 f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
181 if err != nil {
182 return nil, fmt.Errorf("scribe filestore: open %s: %w", path, err)
183 }
184 var size int64
185 if fi, err := f.Stat(); err == nil {
186 size = fi.Size()
187 }
188 of = &openFile{f: f, size: size, bucket: bucket, path: path}
189 s.files[key] = of
190 }
191 return of, nil
192 }
193
194 // shiftSizeBackups renames path → path.1, path.1 → path.2 … up to .10.
195 func (s *FileStore) shiftSizeBackups(path string) {
196 for i := 9; i >= 1; i-- {
197 _ = os.Rename(fmt.Sprintf("%s.%d", path, i), fmt.Sprintf("%s.%d", path, i+1))
198 }
199 _ = os.Rename(path, path+".1")
200 }
201
202 func (s *FileStore) timeBucket(t time.Time) string {
203 switch s.cfg.Rotation {
204 case "daily":
205 return t.Format("2006-01-02")
206 case "weekly":
207 year, week := t.ISOWeek()
208 return fmt.Sprintf("%04d-W%02d", year, week)
209 case "monthly":
210 return t.Format("2006-01")
211 case "yearly":
212 return t.Format("2006")
213 default:
214 return "current"
215 }
216 }
217
218 func (s *FileStore) filePath(key string, now time.Time) string {
219 extMap := map[string]string{"jsonl": ".jsonl", "csv": ".csv", "text": ".log"}
220 ext, ok := extMap[s.cfg.Format]
221 if !ok {
222 ext = ".jsonl"
223 }
224
225 var suffix string
226 switch s.cfg.Rotation {
227 case "daily":
228 suffix = "-" + now.Format("2006-01-02")
229 case "weekly":
230 year, week := now.ISOWeek()
231 suffix = fmt.Sprintf("-%04d-W%02d", year, week)
232 case "monthly":
233 suffix = "-" + now.Format("2006-01")
234 case "yearly":
235 suffix = "-" + now.Format("2006")
236 }
237 return filepath.Join(s.cfg.Dir, key+suffix+ext)
238 }
239
240 func (s *FileStore) formatEntry(e Entry) (string, error) {
241 switch s.cfg.Format {
242 case "csv":
243 return strings.Join([]string{
244 e.At.Format(time.RFC3339),
245 csvField(e.Channel),
246 csvField(e.Nick),
247 string(e.Kind),
248 csvField(e.MessageType),
249 csvField(e.MessageID),
250 csvField(e.Raw),
251 }, ","), nil
252 case "text":
253 return fmt.Sprintf("%s %s <%s> %s",
254 e.At.Format("2006-01-02T15:04:05"),
255 e.Channel, e.Nick, e.Raw), nil
256 default: // jsonl
257 b, err := json.Marshal(e)
258 return string(b), err
259 }
260 }
261
262 // Query returns the most recent entries from the current log file.
263 // Only supported for "jsonl" format; other formats return nil, nil.
264 func (s *FileStore) Query(channel string, limit int) ([]Entry, error) {
265 if s.cfg.Format != "jsonl" {
266 return nil, nil
267 }
268 s.mu.Lock()
269 path := s.filePath(keyFor(s.cfg.PerChannel, channel), time.Now())
270 s.mu.Unlock()
271
272 f, err := os.Open(path)
273 if os.IsNotExist(err) {
274 return nil, nil
275 }
276 if err != nil {
277 return nil, err
278 }
279 defer f.Close()
280
281 var entries []Entry
282 scanner := bufio.NewScanner(f)
283 for scanner.Scan() {
284 line := scanner.Bytes()
285 if len(line) == 0 {
286 continue
287 }
288 var e Entry
289 if json.Unmarshal(line, &e) != nil {
290 continue
291 }
292 if channel == "" || e.Channel == channel {
293 entries = append(entries, e)
294 }
295 }
296 if err := scanner.Err(); err != nil {
297 return nil, err
298 }
299 if limit > 0 && len(entries) > limit {
300 entries = entries[len(entries)-limit:]
301 }
302 return entries, nil
303 }
304
305 // Close flushes and closes all open file handles.
306 func (s *FileStore) Close() {
307 s.mu.Lock()
308 defer s.mu.Unlock()
309 for key, of := range s.files {
310 _ = of.f.Close()
311 delete(s.files, key)
312 }
313 }
314
315 // PruneOld removes log files whose modification time is older than MaxAgeDays.
316 // No-op if MaxAgeDays is 0 or Dir is empty.
317 func (s *FileStore) PruneOld() error {
318 if s.cfg.MaxAgeDays <= 0 || s.cfg.Dir == "" {
319 return nil
320 }
321 cutoff := time.Now().AddDate(0, 0, -s.cfg.MaxAgeDays)
322 entries, err := os.ReadDir(s.cfg.Dir)
323 if err != nil {
324 if os.IsNotExist(err) {
325 return nil
326 }
327 return err
328 }
329 for _, de := range entries {
330 if de.IsDir() {
331 continue
332 }
333 info, err := de.Info()
334 if err != nil {
335 continue
336 }
337 if info.ModTime().Before(cutoff) {
338 _ = os.Remove(filepath.Join(s.cfg.Dir, de.Name()))
339 }
340 }
341 return nil
342 }
343
344 // ---------------------------------------------------------------------------
345 // helpers
346 // ---------------------------------------------------------------------------
347
348 func keyFor(perChannel bool, channel string) string {
349 if perChannel && channel != "" {
350 return sanitizeChannel(channel)
351 }
352 return "_all"
353 }
354
355 // sanitizeChannel strips "#" and replaces filesystem-unsafe characters.
356 func sanitizeChannel(ch string) string {
357 ch = strings.TrimPrefix(ch, "#")
358 return strings.Map(func(r rune) rune {
359 switch r {
360 case '/', '\\', ':', '*', '?', '"', '<', '>', '|':
361 return '_'
362 }
363 return r
364 }, ch)
365 }
366
367 // csvField wraps a field in double-quotes if it contains a comma, quote, or newline.
368 func csvField(s string) string {
369 if strings.ContainsAny(s, "\",\n") {
370 return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
371 }
372 return s
373 }
374
375 DDED internal/bots/sentinel/sentinel.go
376 DDED internal/bots/snitch/nickwindow_test.go
377 DDED internal/bots/snitch/snitch.go
378 DDED internal/bots/snitch/snitch_test.go
379 DDED internal/bots/steward/steward.go
--- a/internal/bots/sentinel/sentinel.go
+++ b/internal/bots/sentinel/sentinel.go
@@ -0,0 +1,59 @@
1
+// Package sentinel implements the sentinel bot — an LLM-powered channel
2
+// observer that detects policy violations and posts structured incident
3
+// reports to a moderation channel.
4
+//
5
+// Sentinel never takes enforcement action. It watches, judges, and reports.
6
+// All reports are human-readable and posted to a configured mod channel
7
+// (e.g. #moderation) so the full audit trail is IRC-native and observable.
8
+//
9
+// Reports have the form:
10
+//
11
+// [sentinel] incident in #channel | nick: <who> | severity: high | reason: <llm judgment>
12
+package sentinel
13
+
14
+import (
15
+ "context"
16
+ "fmt"
17
+ "log/slog"
18
+ "net"
19
+ "strconv"
20
+ "strings"
21
+ "sync"
22
+ "time"
23
+
24
+ "github.com/lrstanley/girc"
25
+)
26
+
27
+const defaultNick = "sentinel"
28
+
29
+// LLMProvider calls a language model to evaluate channel content.
30
+type LLMProvider interface {
31
+ Summarize(ctx context.Context, prompt string) (string, error)
32
+}
33
+ time.Ti// Nick is the IRC nick. Default: "sentinel".
34
+ Nick string
35
+ // Password is the SASL PLAIN passphrase.
36
+ Password string
37
+
38
+ // ModChannel is where incident reports are posted (e.g. "#moderation").
39
+ ModChannel string
40
+ // DMOperators, when true, also sends incident reports as DMs to AlertNicks.
41
+ DMOperators bool
42
+ // AlertNicks is the list of operator nicks to DM on incidents.
43
+ AlertNicks []string
44
+
45
+ // Policy is a plain-English description of what sentinel should flag.
46
+ // Example: "Flag harassment, hate speech, spam, and coordinated manipulation."
47
+ Policy string
48
+
49
+ // WindowSize is how many messages to buffer per channel before analysis.
50
+ // Default: 20.
51
+ WindowSize int
52
+ // WindowAge is the maximum age of buffered messages before a scan is forced.
53
+ // Default: 5 minutes.
54
+ WindowAge time.Duration
55
+ // CooldownPerNick is the minimum time between reports about the same nichost,
56
+ Port: port,
57
+ Nick:0 minutes.
58
+ CooldownPerNib.cfg.Nick,
59
+ Name:ity cont
--- a/internal/bots/sentinel/sentinel.go
+++ b/internal/bots/sentinel/sentinel.go
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/sentinel/sentinel.go
+++ b/internal/bots/sentinel/sentinel.go
@@ -0,0 +1,59 @@
1 // Package sentinel implements the sentinel bot — an LLM-powered channel
2 // observer that detects policy violations and posts structured incident
3 // reports to a moderation channel.
4 //
5 // Sentinel never takes enforcement action. It watches, judges, and reports.
6 // All reports are human-readable and posted to a configured mod channel
7 // (e.g. #moderation) so the full audit trail is IRC-native and observable.
8 //
9 // Reports have the form:
10 //
11 // [sentinel] incident in #channel | nick: <who> | severity: high | reason: <llm judgment>
12 package sentinel
13
14 import (
15 "context"
16 "fmt"
17 "log/slog"
18 "net"
19 "strconv"
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/lrstanley/girc"
25 )
26
27 const defaultNick = "sentinel"
28
29 // LLMProvider calls a language model to evaluate channel content.
30 type LLMProvider interface {
31 Summarize(ctx context.Context, prompt string) (string, error)
32 }
33 time.Ti// Nick is the IRC nick. Default: "sentinel".
34 Nick string
35 // Password is the SASL PLAIN passphrase.
36 Password string
37
38 // ModChannel is where incident reports are posted (e.g. "#moderation").
39 ModChannel string
40 // DMOperators, when true, also sends incident reports as DMs to AlertNicks.
41 DMOperators bool
42 // AlertNicks is the list of operator nicks to DM on incidents.
43 AlertNicks []string
44
45 // Policy is a plain-English description of what sentinel should flag.
46 // Example: "Flag harassment, hate speech, spam, and coordinated manipulation."
47 Policy string
48
49 // WindowSize is how many messages to buffer per channel before analysis.
50 // Default: 20.
51 WindowSize int
52 // WindowAge is the maximum age of buffered messages before a scan is forced.
53 // Default: 5 minutes.
54 WindowAge time.Duration
55 // CooldownPerNick is the minimum time between reports about the same nichost,
56 Port: port,
57 Nick:0 minutes.
58 CooldownPerNib.cfg.Nick,
59 Name:ity cont
--- a/internal/bots/snitch/nickwindow_test.go
+++ b/internal/bots/snitch/nickwindow_test.go
@@ -0,0 +1,189 @@
1
+// Internal tests for the nickWindow sliding-window logic.
2
+// In package snitch (not snitch_test) to access unexported types.
3
+package snitch
4
+
5
+import (
6
+ "testing"
7
+ "time"
8
+)
9
+
10
+func TestNickWindowTrimRemovesOldMsgs(t *testing.T) {
11
+ now := time.Now()
12
+ nw := &nickWindow{
13
+ msgs: []time.Time{
14
+ now.Add(-10 * time.Second), // old — should be trimmed
15
+ now.Add(-1 * time.Second), // recent — should stay
16
+ },
17
+ }
18
+ nw.trim(now, 5*time.Second, 30*time.Second)
19
+ if len(nw.msgs) != 1 {
20
+ t.Errorf("expected 1 msg after trim, got %d", len(nw.msgs))
21
+ }
22
+}
23
+
24
+func TestNickWindowTrimKeepsAllRecent(t *testing.T) {
25
+ now := time.Now()
26
+ nw := &nickWindow{
27
+ msgs: []time.Time{
28
+ now.Add(-1 * time.Second),
29
+ now.Add(-2 * time.Second),
30
+ now.Add(-3 * time.Second),
31
+ },
32
+ }
33
+ nw.trim(now, 10*time.Second, 30*time.Second)
34
+ if len(nw.msgs) != 3 {
35
+ t.Errorf("expected 3 msgs after trim, got %d", len(nw.msgs))
36
+ }
37
+}
38
+
39
+func TestNickWindowTrimRemovesOldJoinParts(t *testing.T) {
40
+ now := time.Now()
41
+ nw := &nickWindow{
42
+ joinPart: []time.Time{
43
+ now.Add(-60 * time.Second), // too old
44
+ now.Add(-5 * time.Second), // recent
45
+ },
46
+ }
47
+ nw.trim(now, 5*time.Second, 30*time.Second)
48
+ if len(nw.joinPart) != 1 {
49
+ t.Errorf("expected 1 join/part after trim, got %d", len(nw.joinPart))
50
+ }
51
+}
52
+
53
+func TestNickWindowTrimEmptyNoop(t *testing.T) {
54
+ nw := &nickWindow{}
55
+ // Should not panic on empty slices.
56
+ nw.trim(time.Now(), 5*time.Second, 30*time.Second)
57
+ if len(nw.msgs) != 0 || len(nw.joinPart) != 0 {
58
+ t.Error("expected empty after trimming empty window")
59
+ }
60
+}
61
+
62
+func TestNickWindowTrimAllOld(t *testing.T) {
63
+ now := time.Now()
64
+ nw := &nickWindow{
65
+ msgs: []time.Time{
66
+ now.Add(-100 * time.Second),
67
+ now.Add(-200 * time.Second),
68
+ },
69
+ joinPart: []time.Time{
70
+ now.Add(-90 * time.Second),
71
+ },
72
+ }
73
+ nw.trim(now, 5*time.Second, 30*time.Second)
74
+ if len(nw.msgs) != 0 {
75
+ t.Errorf("expected 0 msgs after trimming all-old, got %d", len(nw.msgs))
76
+ }
77
+ if len(nw.joinPart) != 0 {
78
+ t.Errorf("expected 0 join/parts after trimming all-old, got %d", len(nw.joinPart))
79
+ }
80
+}
81
+
82
+// Test the flood detection path at the Bot level. We reach into the Bot's
83
+// internal window map by calling recordMsg directly, which is the same path
84
+// a real PRIVMSG would trigger. This validates the counting logic without
85
+// requiring an IRC connection.
86
+
87
+func TestFloodDetectionCounting(t *testing.T) {
88
+ cfg := Config{
89
+ IRCAddr: "127.0.0.1:6667",
90
+ Nick: "snitch",
91
+ FloodMessages: 3,
92
+ FloodWindow: 10 * time.Second,
93
+ }
94
+ cfg.setDefaults()
95
+
96
+ b := &Bot{
97
+ cfg: cfg,
98
+ windows: make(map[string]map[string]*nickWindow),
99
+ alerted: make(map[string]time.Time),
100
+ }
101
+
102
+ // Record 2 messages — below threshold.
103
+ b.recordMsg("#fleet", "spammer")
104
+ b.recordMsg("#fleet", "spammer")
105
+ w := b.window("#fleet", "spammer")
106
+ if len(w.msgs) != 2 {
107
+ t.Errorf("expected 2 msgs in window, got %d", len(w.msgs))
108
+ }
109
+
110
+ // Record a third — at threshold.
111
+ b.recordMsg("#fleet", "spammer")
112
+ w = b.window("#fleet", "spammer")
113
+ if len(w.msgs) != 3 {
114
+ t.Errorf("expected 3 msgs in window, got %d", len(w.msgs))
115
+ }
116
+}
117
+
118
+func TestJoinPartCounting(t *testing.T) {
119
+ cfg := Config{
120
+ IRCAddr: "127.0.0.1:6667",
121
+ Nick: "snitch",
122
+ JoinPartThreshold: 3,
123
+ JoinPartWindow: 30 * time.Second,
124
+ }
125
+ cfg.setDefaults()
126
+
127
+ b := &Bot{
128
+ cfg: cfg,
129
+ windows: make(map[string]map[string]*nickWindow),
130
+ alerted: make(map[string]time.Time),
131
+ }
132
+
133
+ // 2 join/part events — below threshold.
134
+ b.recordJoinPart("#fleet", "cycler")
135
+ b.recordJoinPart("#fleet", "cycler")
136
+ w := b.window("#fleet", "cycler")
137
+ if len(w.joinPart) != 2 {
138
+ t.Errorf("expected 2 join/parts before threshold, got %d", len(w.joinPart))
139
+ }
140
+
141
+ // 3rd event hits threshold — window is reset to nil after alert fires.
142
+ b.recordJoinPart("#fleet", "cycler")
143
+ w = b.window("#fleet", "cycler")
144
+ if len(w.joinPart) != 0 {
145
+ t.Errorf("expected joinPart reset to 0 after threshold hit, got %d", len(w.joinPart))
146
+ }
147
+}
148
+
149
+func TestWindowIsolatedPerNick(t *testing.T) {
150
+ cfg := Config{IRCAddr: "127.0.0.1:6667", FloodMessages: 5, FloodWindow: 10 * time.Second}
151
+ cfg.setDefaults()
152
+ b := &Bot{
153
+ cfg: cfg,
154
+ windows: make(map[string]map[string]*nickWindow),
155
+ alerted: make(map[string]time.Time),
156
+ }
157
+
158
+ b.recordMsg("#fleet", "alice")
159
+ b.recordMsg("#fleet", "alice")
160
+ b.recordMsg("#fleet", "bob")
161
+
162
+ wa := b.window("#fleet", "alice")
163
+ wb := b.window("#fleet", "bob")
164
+ if len(wa.msgs) != 2 {
165
+ t.Errorf("alice: expected 2, got %d", len(wa.msgs))
166
+ }
167
+ if len(wb.msgs) != 1 {
168
+ t.Errorf("bob: expected 1, got %d", len(wb.msgs))
169
+ }
170
+}
171
+
172
+func TestWindowIsolatedPerChannel(t *testing.T) {
173
+ cfg := Config{IRCAddr: "127.0.0.1:6667"}
174
+ cfg.setDefaults()
175
+ b := &Bot{
176
+ cfg: cfg,
177
+ windows: make(map[string]map[string]*nickWindow),
178
+ alerted: make(map[string]time.Time),
179
+ }
180
+
181
+ b.recordMsg("#fleet", "alice")
182
+ b.recordMsg("#ops", "alice")
183
+
184
+ wf := b.window("#fleet", "alice")
185
+ wo := b.window("#ops", "alice")
186
+ if len(wf.msgs) != 1 || len(wo.msgs) != 1 {
187
+ t.Errorf("expected 1 msg per channel, fleet=%d ops=%d", len(wf.msgs), len(wo.msgs))
188
+ }
189
+}
--- a/internal/bots/snitch/nickwindow_test.go
+++ b/internal/bots/snitch/nickwindow_test.go
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/snitch/nickwindow_test.go
+++ b/internal/bots/snitch/nickwindow_test.go
@@ -0,0 +1,189 @@
1 // Internal tests for the nickWindow sliding-window logic.
2 // In package snitch (not snitch_test) to access unexported types.
3 package snitch
4
5 import (
6 "testing"
7 "time"
8 )
9
10 func TestNickWindowTrimRemovesOldMsgs(t *testing.T) {
11 now := time.Now()
12 nw := &nickWindow{
13 msgs: []time.Time{
14 now.Add(-10 * time.Second), // old — should be trimmed
15 now.Add(-1 * time.Second), // recent — should stay
16 },
17 }
18 nw.trim(now, 5*time.Second, 30*time.Second)
19 if len(nw.msgs) != 1 {
20 t.Errorf("expected 1 msg after trim, got %d", len(nw.msgs))
21 }
22 }
23
24 func TestNickWindowTrimKeepsAllRecent(t *testing.T) {
25 now := time.Now()
26 nw := &nickWindow{
27 msgs: []time.Time{
28 now.Add(-1 * time.Second),
29 now.Add(-2 * time.Second),
30 now.Add(-3 * time.Second),
31 },
32 }
33 nw.trim(now, 10*time.Second, 30*time.Second)
34 if len(nw.msgs) != 3 {
35 t.Errorf("expected 3 msgs after trim, got %d", len(nw.msgs))
36 }
37 }
38
39 func TestNickWindowTrimRemovesOldJoinParts(t *testing.T) {
40 now := time.Now()
41 nw := &nickWindow{
42 joinPart: []time.Time{
43 now.Add(-60 * time.Second), // too old
44 now.Add(-5 * time.Second), // recent
45 },
46 }
47 nw.trim(now, 5*time.Second, 30*time.Second)
48 if len(nw.joinPart) != 1 {
49 t.Errorf("expected 1 join/part after trim, got %d", len(nw.joinPart))
50 }
51 }
52
53 func TestNickWindowTrimEmptyNoop(t *testing.T) {
54 nw := &nickWindow{}
55 // Should not panic on empty slices.
56 nw.trim(time.Now(), 5*time.Second, 30*time.Second)
57 if len(nw.msgs) != 0 || len(nw.joinPart) != 0 {
58 t.Error("expected empty after trimming empty window")
59 }
60 }
61
62 func TestNickWindowTrimAllOld(t *testing.T) {
63 now := time.Now()
64 nw := &nickWindow{
65 msgs: []time.Time{
66 now.Add(-100 * time.Second),
67 now.Add(-200 * time.Second),
68 },
69 joinPart: []time.Time{
70 now.Add(-90 * time.Second),
71 },
72 }
73 nw.trim(now, 5*time.Second, 30*time.Second)
74 if len(nw.msgs) != 0 {
75 t.Errorf("expected 0 msgs after trimming all-old, got %d", len(nw.msgs))
76 }
77 if len(nw.joinPart) != 0 {
78 t.Errorf("expected 0 join/parts after trimming all-old, got %d", len(nw.joinPart))
79 }
80 }
81
82 // Test the flood detection path at the Bot level. We reach into the Bot's
83 // internal window map by calling recordMsg directly, which is the same path
84 // a real PRIVMSG would trigger. This validates the counting logic without
85 // requiring an IRC connection.
86
87 func TestFloodDetectionCounting(t *testing.T) {
88 cfg := Config{
89 IRCAddr: "127.0.0.1:6667",
90 Nick: "snitch",
91 FloodMessages: 3,
92 FloodWindow: 10 * time.Second,
93 }
94 cfg.setDefaults()
95
96 b := &Bot{
97 cfg: cfg,
98 windows: make(map[string]map[string]*nickWindow),
99 alerted: make(map[string]time.Time),
100 }
101
102 // Record 2 messages — below threshold.
103 b.recordMsg("#fleet", "spammer")
104 b.recordMsg("#fleet", "spammer")
105 w := b.window("#fleet", "spammer")
106 if len(w.msgs) != 2 {
107 t.Errorf("expected 2 msgs in window, got %d", len(w.msgs))
108 }
109
110 // Record a third — at threshold.
111 b.recordMsg("#fleet", "spammer")
112 w = b.window("#fleet", "spammer")
113 if len(w.msgs) != 3 {
114 t.Errorf("expected 3 msgs in window, got %d", len(w.msgs))
115 }
116 }
117
118 func TestJoinPartCounting(t *testing.T) {
119 cfg := Config{
120 IRCAddr: "127.0.0.1:6667",
121 Nick: "snitch",
122 JoinPartThreshold: 3,
123 JoinPartWindow: 30 * time.Second,
124 }
125 cfg.setDefaults()
126
127 b := &Bot{
128 cfg: cfg,
129 windows: make(map[string]map[string]*nickWindow),
130 alerted: make(map[string]time.Time),
131 }
132
133 // 2 join/part events — below threshold.
134 b.recordJoinPart("#fleet", "cycler")
135 b.recordJoinPart("#fleet", "cycler")
136 w := b.window("#fleet", "cycler")
137 if len(w.joinPart) != 2 {
138 t.Errorf("expected 2 join/parts before threshold, got %d", len(w.joinPart))
139 }
140
141 // 3rd event hits threshold — window is reset to nil after alert fires.
142 b.recordJoinPart("#fleet", "cycler")
143 w = b.window("#fleet", "cycler")
144 if len(w.joinPart) != 0 {
145 t.Errorf("expected joinPart reset to 0 after threshold hit, got %d", len(w.joinPart))
146 }
147 }
148
149 func TestWindowIsolatedPerNick(t *testing.T) {
150 cfg := Config{IRCAddr: "127.0.0.1:6667", FloodMessages: 5, FloodWindow: 10 * time.Second}
151 cfg.setDefaults()
152 b := &Bot{
153 cfg: cfg,
154 windows: make(map[string]map[string]*nickWindow),
155 alerted: make(map[string]time.Time),
156 }
157
158 b.recordMsg("#fleet", "alice")
159 b.recordMsg("#fleet", "alice")
160 b.recordMsg("#fleet", "bob")
161
162 wa := b.window("#fleet", "alice")
163 wb := b.window("#fleet", "bob")
164 if len(wa.msgs) != 2 {
165 t.Errorf("alice: expected 2, got %d", len(wa.msgs))
166 }
167 if len(wb.msgs) != 1 {
168 t.Errorf("bob: expected 1, got %d", len(wb.msgs))
169 }
170 }
171
172 func TestWindowIsolatedPerChannel(t *testing.T) {
173 cfg := Config{IRCAddr: "127.0.0.1:6667"}
174 cfg.setDefaults()
175 b := &Bot{
176 cfg: cfg,
177 windows: make(map[string]map[string]*nickWindow),
178 alerted: make(map[string]time.Time),
179 }
180
181 b.recordMsg("#fleet", "alice")
182 b.recordMsg("#ops", "alice")
183
184 wf := b.window("#fleet", "alice")
185 wo := b.window("#ops", "alice")
186 if len(wf.msgs) != 1 || len(wo.msgs) != 1 {
187 t.Errorf("expected 1 msg per channel, fleet=%d ops=%d", len(wf.msgs), len(wo.msgs))
188 }
189 }
--- a/internal/bots/snitch/snitch.go
+++ b/internal/bots/snitch/snitch.go
@@ -0,0 +1,88 @@
1
+t at runtime.
2
+fun// Package snitch implements a s a surveillance bot that watches })
3
+
4
+ c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
5
+ if ch := e.Last(); strings.HasPrefix(ch, "#") {
6
+ cl.Cmd.Join(ch)
7
+ }
8
+ })
9
+
10
+ c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
11
+ if len(e.Param return
12
+ }
13
+ b.recordJoinPart(e.Params[0], e.Source.Name)
14
+ })
15
+
16
+ c.Handlers.AddBg(girc.PART, fun len(e.Params) < 1 || e.Source == nil {
17
+ return
18
+ }
19
+ b.recordJoinPart(e.Param cl.Cmd.Join(ch)
20
+ }
21
+ PRIVMSG, func(_ *gircif len(e.Params) < 1 || e.Sourceply.Text)
22
+ return
23
+ }
24
+ channel := e.Params[0]
25
+ nick := e.Source.Name
26
+ if nick == b.cfg.Nick {
27
+ return
28
+ }
29
+ b.recordMsg(channel, nick)
30
+ b.checkFlood(c, channel, nick)
31
+ })
32
+
33
+ b.client = c
34
+
35
+ errCh := make(chan error, 1)
36
+ go func() {
37
+ if err := c.Connect(); err != nil && ctx.Err() == nil {
38
+ errCh <- err
39
+ }
40
+ }()
41
+
42
+ select {
43
+ case <-ctx.Done():
44
+ c.Close()
45
+ return nil
46
+ case err := <-errCh:
47
+ return fmt.Errorf("snitch: irc: %w", err)
48
+ }
49
+}
50
+
51
+func (b *Bot) JoinChannel(channel string) {
52
+ if b.client != nil {
53
+ b.client.Cmd.Join(channel)
54
+ }
55
+}
56
+
57
+// MonitorAdd adds nicks toif len(e.Params) < 1 ||)
58
+ }
59
+ .Event) {
60
+ nc(_ *girc.Client, e girc.Es) < 1 || e.Sourceply.Text)
61
+ return
62
+ }
63
+ channel := e.Params[0]
64
+ nick := e.Source.Name
65
+ if nick == b.cfg.Nick {
66
+ return
67
+ }
68
+ b.recordMsg(channel, nick)
69
+ b.checkFlood(c, channel, nick)
70
+ })
71
+
72
+ b.client = c
73
+
74
+ errCh := make(chan erhost,
75
+ Port: port,
76
+ Nick:) {
77
+ if err := c.Connectb.cfg.Nick,
78
+ Name:= nil {rrCh <- err
79
+ }
80
+ }()
81
+
82
+ select {nnel string) {
83
+ if b.client != nil {
84
+ b.client.Cmd.Join(channel)
85
+ }
86
+}
87
+
88
+// MonitorAdd adds nicks to
--- a/internal/bots/snitch/snitch.go
+++ b/internal/bots/snitch/snitch.go
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/snitch/snitch.go
+++ b/internal/bots/snitch/snitch.go
@@ -0,0 +1,88 @@
1 t at runtime.
2 fun// Package snitch implements a s a surveillance bot that watches })
3
4 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
5 if ch := e.Last(); strings.HasPrefix(ch, "#") {
6 cl.Cmd.Join(ch)
7 }
8 })
9
10 c.Handlers.AddBg(girc.JOIN, func(_ *girc.Client, e girc.Event) {
11 if len(e.Param return
12 }
13 b.recordJoinPart(e.Params[0], e.Source.Name)
14 })
15
16 c.Handlers.AddBg(girc.PART, fun len(e.Params) < 1 || e.Source == nil {
17 return
18 }
19 b.recordJoinPart(e.Param cl.Cmd.Join(ch)
20 }
21 PRIVMSG, func(_ *gircif len(e.Params) < 1 || e.Sourceply.Text)
22 return
23 }
24 channel := e.Params[0]
25 nick := e.Source.Name
26 if nick == b.cfg.Nick {
27 return
28 }
29 b.recordMsg(channel, nick)
30 b.checkFlood(c, channel, nick)
31 })
32
33 b.client = c
34
35 errCh := make(chan error, 1)
36 go func() {
37 if err := c.Connect(); err != nil && ctx.Err() == nil {
38 errCh <- err
39 }
40 }()
41
42 select {
43 case <-ctx.Done():
44 c.Close()
45 return nil
46 case err := <-errCh:
47 return fmt.Errorf("snitch: irc: %w", err)
48 }
49 }
50
51 func (b *Bot) JoinChannel(channel string) {
52 if b.client != nil {
53 b.client.Cmd.Join(channel)
54 }
55 }
56
57 // MonitorAdd adds nicks toif len(e.Params) < 1 ||)
58 }
59 .Event) {
60 nc(_ *girc.Client, e girc.Es) < 1 || e.Sourceply.Text)
61 return
62 }
63 channel := e.Params[0]
64 nick := e.Source.Name
65 if nick == b.cfg.Nick {
66 return
67 }
68 b.recordMsg(channel, nick)
69 b.checkFlood(c, channel, nick)
70 })
71
72 b.client = c
73
74 errCh := make(chan erhost,
75 Port: port,
76 Nick:) {
77 if err := c.Connectb.cfg.Nick,
78 Name:= nil {rrCh <- err
79 }
80 }()
81
82 select {nnel string) {
83 if b.client != nil {
84 b.client.Cmd.Join(channel)
85 }
86 }
87
88 // MonitorAdd adds nicks to
--- a/internal/bots/snitch/snitch_test.go
+++ b/internal/bots/snitch/snitch_test.go
@@ -0,0 +1,39 @@
1
+package snitch_test
2
+
3
+import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/conflicthq/scuttlebot/internal/bots/snitch"
8
+)
9
+
10
+func TestNewBotDefaults(t *testing.T) {
11
+ b := snitch.New(snitch.Config{IRCAddr: "127.0.0.1:6667"}, nil)
12
+ if b == nil {
13
+ t.Fatal("New returned nil")
14
+ }
15
+}
16
+
17
+func TestNewBotCustomThresholds(t *testing.T) {
18
+ cfg := snitch.Config{
19
+ IRCAddr: "127.0.0.1:6667",
20
+ Nick: "snitch",
21
+ Password: "pw",
22
+ FloodMessages: 5,
23
+ FloodWindow: 2 * time.Second,
24
+ JoinPartThreshold: 3,
25
+ JoinPartWindow: 10 * time.Second,
26
+ }
27
+ b := snitch.New(cfg, nil)
28
+ if b == nil {
29
+ t.Fatal("New returned nil")
30
+ }
31
+}
32
+
33
+func TestMultipleBotInstancesAreDistinct(t *testing.T) {
34
+ b1 := snitch.New(snitch.Config{IRCAddr: "127.0.0.1:6667", Nick: "snitch1"}, nil)
35
+ b2 := snitch.New(snitch.Config{IRCAddr: "127.0.0.1:6667", Nick: "snitch2"}, nil)
36
+ if b1 == b2 {
37
+ t.Error("expected distinct bot instances")
38
+ }
39
+}
--- a/internal/bots/snitch/snitch_test.go
+++ b/internal/bots/snitch/snitch_test.go
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/snitch/snitch_test.go
+++ b/internal/bots/snitch/snitch_test.go
@@ -0,0 +1,39 @@
1 package snitch_test
2
3 import (
4 "testing"
5 "time"
6
7 "github.com/conflicthq/scuttlebot/internal/bots/snitch"
8 )
9
10 func TestNewBotDefaults(t *testing.T) {
11 b := snitch.New(snitch.Config{IRCAddr: "127.0.0.1:6667"}, nil)
12 if b == nil {
13 t.Fatal("New returned nil")
14 }
15 }
16
17 func TestNewBotCustomThresholds(t *testing.T) {
18 cfg := snitch.Config{
19 IRCAddr: "127.0.0.1:6667",
20 Nick: "snitch",
21 Password: "pw",
22 FloodMessages: 5,
23 FloodWindow: 2 * time.Second,
24 JoinPartThreshold: 3,
25 JoinPartWindow: 10 * time.Second,
26 }
27 b := snitch.New(cfg, nil)
28 if b == nil {
29 t.Fatal("New returned nil")
30 }
31 }
32
33 func TestMultipleBotInstancesAreDistinct(t *testing.T) {
34 b1 := snitch.New(snitch.Config{IRCAddr: "127.0.0.1:6667", Nick: "snitch1"}, nil)
35 b2 := snitch.New(snitch.Config{IRCAddr: "127.0.0.1:6667", Nick: "snitch2"}, nil)
36 if b1 == b2 {
37 t.Error("expected distinct bot instances")
38 }
39 }
--- a/internal/bots/steward/steward.go
+++ b/internal/bots/steward/steward.go
@@ -0,0 +1,2 @@
1
+// Package steward implements the steward bot — a moderation action bot that
2
+// watches for sentinel incident reports and takes proportional IRC action.
--- a/internal/bots/steward/steward.go
+++ b/internal/bots/steward/steward.go
@@ -0,0 +1,2 @@
 
 
--- a/internal/bots/steward/steward.go
+++ b/internal/bots/steward/steward.go
@@ -0,0 +1,2 @@
1 // Package steward implements the steward bot — a moderation action bot that
2 // watches for sentinel incident reports and takes proportional IRC action.
--- internal/config/config.go
+++ internal/config/config.go
@@ -11,19 +11,95 @@
1111
// Config is the top-level scuttlebot configuration.
1212
type Config struct {
1313
Ergo ErgoConfig `yaml:"ergo"`
1414
Datastore DatastoreConfig `yaml:"datastore"`
1515
Bridge BridgeConfig `yaml:"bridge"`
16
+ TLS TLSConfig `yaml:"tls"`
17
+ LLM LLMConfig `yaml:"llm"`
1618
1719
// APIAddr is the address for scuttlebot's own HTTP management API.
20
+ // Ignored when TLS.Domain is set (HTTPS runs on :443, HTTP on :80).
1821
// Default: ":8080"
1922
APIAddr string `yaml:"api_addr"`
2023
2124
// MCPAddr is the address for the MCP server.
2225
// Default: ":8081"
2326
MCPAddr string `yaml:"mcp_addr"`
2427
}
28
+
29
+// LLMConfig configures the omnibus LLM gateway used by oracle and any other
30
+// bot or service that needs language model access.
31
+type LLMConfig struct {
32
+ // Backends is the list of configured LLM backends.
33
+ // Each backend has a unique Name used to reference it from bot configs.
34
+ Backends []LLMBackendConfig `yaml:"backends"`
35
+}
36
+
37
+// LLMBackendConfig configures a single LLM backend instance.
38
+type LLMBackendConfig struct {
39
+ // Name is a unique identifier for this backend (e.g. "openai-main", "local-ollama").
40
+ // Used when referencing the backend from bot configs.
41
+ Name string `yaml:"name"`
42
+
43
+ // Backend is the provider type. Supported values:
44
+ // Native: anthropic, gemini, bedrock, ollama
45
+ // OpenAI-compatible: openai, openrouter, together, groq, fireworks, mistral,
46
+ // ai21, huggingface, deepseek, cerebras, xai,
47
+ // litellm, lmstudio, jan, localai, vllm, anythingllm
48
+ Backend string `yaml:"backend"`
49
+
50
+ // APIKey is the authentication key for cloud backends.
51
+ APIKey string `yaml:"api_key"`
52
+
53
+ // BaseURL overrides the default base URL for OpenAI-compatible backends.
54
+ // Required for custom self-hosted endpoints without a known default.
55
+ BaseURL string `yaml:"base_url"`
56
+
57
+ // Model is the default model ID. If empty, the first discovered model
58
+ // that passes the allow/block filter is used.
59
+ Model string `yaml:"model"`
60
+
61
+ // Region is the AWS region (e.g. "us-east-1"). Bedrock only.
62
+ Region string `yaml:"region"`
63
+
64
+ // AWSKeyID is the AWS access key ID. Bedrock only.
65
+ AWSKeyID string `yaml:"aws_key_id"`
66
+
67
+ // AWSSecretKey is the AWS secret access key. Bedrock only.
68
+ AWSSecretKey string `yaml:"aws_secret_key"`
69
+
70
+ // Allow is a list of regex patterns. If non-empty, only model IDs matching
71
+ // at least one pattern are returned by model discovery.
72
+ Allow []string `yaml:"allow"`
73
+
74
+ // Block is a list of regex patterns. Matching model IDs are excluded
75
+ // from model discovery results.
76
+ Block []string `yaml:"block"`
77
+
78
+ // Default marks this backend as the one used when no backend is specified
79
+ // in a bot's config. Only one backend should have Default: true.
80
+ Default bool `yaml:"default"`
81
+}
82
+
83
+// TLSConfig configures automatic HTTPS via Let's Encrypt.
84
+type TLSConfig struct {
85
+ // Domain enables TLS. When set, scuttlebot obtains a certificate from
86
+ // Let's Encrypt for this domain and serves HTTPS on :443.
87
+ Domain string `yaml:"domain"`
88
+
89
+ // Email is sent to Let's Encrypt for certificate expiry notifications.
90
+ Email string `yaml:"email"`
91
+
92
+ // CertDir is the directory for the certificate cache.
93
+ // Default: {Ergo.DataDir}/certs
94
+ CertDir string `yaml:"cert_dir"`
95
+
96
+ // AllowInsecure keeps plain HTTP running on :80 alongside HTTPS.
97
+ // The ACME HTTP-01 challenge always runs on :80 regardless.
98
+ // Default: true
99
+ AllowInsecure bool `yaml:"allow_insecure"`
100
+}
25101
26102
// ErgoConfig holds settings for the managed Ergo IRC server.
27103
type ErgoConfig struct {
28104
// External disables subprocess management. When true, scuttlebot expects
29105
// ergo to already be running and reachable at APIAddr and IRCAddr.
@@ -97,10 +173,14 @@
97173
// Channels is the list of IRC channels the bridge joins on startup.
98174
Channels []string `yaml:"channels"`
99175
100176
// BufferSize is the number of messages to keep per channel. Default: 200.
101177
BufferSize int `yaml:"buffer_size"`
178
+
179
+ // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain visible
180
+ // in the channel user list after their last post. Default: 5.
181
+ WebUserTTLMinutes int `yaml:"web_user_ttl_minutes"`
102182
}
103183
104184
// DatastoreConfig configures scuttlebot's own state store (separate from Ergo).
105185
type DatastoreConfig struct {
106186
// Driver is "sqlite" or "postgres". Default: "sqlite".
@@ -145,15 +225,21 @@
145225
c.MCPAddr = ":8081"
146226
}
147227
if !c.Bridge.Enabled && c.Bridge.Nick == "" {
148228
c.Bridge.Enabled = true // enabled by default
149229
}
230
+ if c.TLS.Domain != "" && !c.TLS.AllowInsecure {
231
+ c.TLS.AllowInsecure = true // HTTP always on by default
232
+ }
150233
if c.Bridge.Nick == "" {
151234
c.Bridge.Nick = "bridge"
152235
}
153236
if c.Bridge.BufferSize == 0 {
154237
c.Bridge.BufferSize = 200
238
+ }
239
+ if c.Bridge.WebUserTTLMinutes == 0 {
240
+ c.Bridge.WebUserTTLMinutes = 5
155241
}
156242
}
157243
158244
func envStr(key string) string { return os.Getenv(key) }
159245
160246
161247
ADDED internal/llm/anthropic.go
162248
ADDED internal/llm/anthropic_test.go
163249
ADDED internal/llm/bedrock.go
164250
ADDED internal/llm/bedrock_test.go
165251
ADDED internal/llm/config.go
166252
ADDED internal/llm/factory.go
167253
ADDED internal/llm/factory_test.go
168254
ADDED internal/llm/filter.go
169255
ADDED internal/llm/gemini.go
170256
ADDED internal/llm/gemini_test.go
171257
ADDED internal/llm/ollama.go
172258
ADDED internal/llm/ollama_test.go
173259
ADDED internal/llm/openai.go
174260
ADDED internal/llm/openai_test.go
175261
ADDED internal/llm/provider.go
176262
ADDED internal/llm/stub.go
--- internal/config/config.go
+++ internal/config/config.go
@@ -11,19 +11,95 @@
11 // Config is the top-level scuttlebot configuration.
12 type Config struct {
13 Ergo ErgoConfig `yaml:"ergo"`
14 Datastore DatastoreConfig `yaml:"datastore"`
15 Bridge BridgeConfig `yaml:"bridge"`
 
 
16
17 // APIAddr is the address for scuttlebot's own HTTP management API.
 
18 // Default: ":8080"
19 APIAddr string `yaml:"api_addr"`
20
21 // MCPAddr is the address for the MCP server.
22 // Default: ":8081"
23 MCPAddr string `yaml:"mcp_addr"`
24 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
26 // ErgoConfig holds settings for the managed Ergo IRC server.
27 type ErgoConfig struct {
28 // External disables subprocess management. When true, scuttlebot expects
29 // ergo to already be running and reachable at APIAddr and IRCAddr.
@@ -97,10 +173,14 @@
97 // Channels is the list of IRC channels the bridge joins on startup.
98 Channels []string `yaml:"channels"`
99
100 // BufferSize is the number of messages to keep per channel. Default: 200.
101 BufferSize int `yaml:"buffer_size"`
 
 
 
 
102 }
103
104 // DatastoreConfig configures scuttlebot's own state store (separate from Ergo).
105 type DatastoreConfig struct {
106 // Driver is "sqlite" or "postgres". Default: "sqlite".
@@ -145,15 +225,21 @@
145 c.MCPAddr = ":8081"
146 }
147 if !c.Bridge.Enabled && c.Bridge.Nick == "" {
148 c.Bridge.Enabled = true // enabled by default
149 }
 
 
 
150 if c.Bridge.Nick == "" {
151 c.Bridge.Nick = "bridge"
152 }
153 if c.Bridge.BufferSize == 0 {
154 c.Bridge.BufferSize = 200
 
 
 
155 }
156 }
157
158 func envStr(key string) string { return os.Getenv(key) }
159
160
161 DDED internal/llm/anthropic.go
162 DDED internal/llm/anthropic_test.go
163 DDED internal/llm/bedrock.go
164 DDED internal/llm/bedrock_test.go
165 DDED internal/llm/config.go
166 DDED internal/llm/factory.go
167 DDED internal/llm/factory_test.go
168 DDED internal/llm/filter.go
169 DDED internal/llm/gemini.go
170 DDED internal/llm/gemini_test.go
171 DDED internal/llm/ollama.go
172 DDED internal/llm/ollama_test.go
173 DDED internal/llm/openai.go
174 DDED internal/llm/openai_test.go
175 DDED internal/llm/provider.go
176 DDED internal/llm/stub.go
--- internal/config/config.go
+++ internal/config/config.go
@@ -11,19 +11,95 @@
11 // Config is the top-level scuttlebot configuration.
12 type Config struct {
13 Ergo ErgoConfig `yaml:"ergo"`
14 Datastore DatastoreConfig `yaml:"datastore"`
15 Bridge BridgeConfig `yaml:"bridge"`
16 TLS TLSConfig `yaml:"tls"`
17 LLM LLMConfig `yaml:"llm"`
18
19 // APIAddr is the address for scuttlebot's own HTTP management API.
20 // Ignored when TLS.Domain is set (HTTPS runs on :443, HTTP on :80).
21 // Default: ":8080"
22 APIAddr string `yaml:"api_addr"`
23
24 // MCPAddr is the address for the MCP server.
25 // Default: ":8081"
26 MCPAddr string `yaml:"mcp_addr"`
27 }
28
29 // LLMConfig configures the omnibus LLM gateway used by oracle and any other
30 // bot or service that needs language model access.
31 type LLMConfig struct {
32 // Backends is the list of configured LLM backends.
33 // Each backend has a unique Name used to reference it from bot configs.
34 Backends []LLMBackendConfig `yaml:"backends"`
35 }
36
37 // LLMBackendConfig configures a single LLM backend instance.
38 type LLMBackendConfig struct {
39 // Name is a unique identifier for this backend (e.g. "openai-main", "local-ollama").
40 // Used when referencing the backend from bot configs.
41 Name string `yaml:"name"`
42
43 // Backend is the provider type. Supported values:
44 // Native: anthropic, gemini, bedrock, ollama
45 // OpenAI-compatible: openai, openrouter, together, groq, fireworks, mistral,
46 // ai21, huggingface, deepseek, cerebras, xai,
47 // litellm, lmstudio, jan, localai, vllm, anythingllm
48 Backend string `yaml:"backend"`
49
50 // APIKey is the authentication key for cloud backends.
51 APIKey string `yaml:"api_key"`
52
53 // BaseURL overrides the default base URL for OpenAI-compatible backends.
54 // Required for custom self-hosted endpoints without a known default.
55 BaseURL string `yaml:"base_url"`
56
57 // Model is the default model ID. If empty, the first discovered model
58 // that passes the allow/block filter is used.
59 Model string `yaml:"model"`
60
61 // Region is the AWS region (e.g. "us-east-1"). Bedrock only.
62 Region string `yaml:"region"`
63
64 // AWSKeyID is the AWS access key ID. Bedrock only.
65 AWSKeyID string `yaml:"aws_key_id"`
66
67 // AWSSecretKey is the AWS secret access key. Bedrock only.
68 AWSSecretKey string `yaml:"aws_secret_key"`
69
70 // Allow is a list of regex patterns. If non-empty, only model IDs matching
71 // at least one pattern are returned by model discovery.
72 Allow []string `yaml:"allow"`
73
74 // Block is a list of regex patterns. Matching model IDs are excluded
75 // from model discovery results.
76 Block []string `yaml:"block"`
77
78 // Default marks this backend as the one used when no backend is specified
79 // in a bot's config. Only one backend should have Default: true.
80 Default bool `yaml:"default"`
81 }
82
83 // TLSConfig configures automatic HTTPS via Let's Encrypt.
84 type TLSConfig struct {
85 // Domain enables TLS. When set, scuttlebot obtains a certificate from
86 // Let's Encrypt for this domain and serves HTTPS on :443.
87 Domain string `yaml:"domain"`
88
89 // Email is sent to Let's Encrypt for certificate expiry notifications.
90 Email string `yaml:"email"`
91
92 // CertDir is the directory for the certificate cache.
93 // Default: {Ergo.DataDir}/certs
94 CertDir string `yaml:"cert_dir"`
95
96 // AllowInsecure keeps plain HTTP running on :80 alongside HTTPS.
97 // The ACME HTTP-01 challenge always runs on :80 regardless.
98 // Default: true
99 AllowInsecure bool `yaml:"allow_insecure"`
100 }
101
102 // ErgoConfig holds settings for the managed Ergo IRC server.
103 type ErgoConfig struct {
104 // External disables subprocess management. When true, scuttlebot expects
105 // ergo to already be running and reachable at APIAddr and IRCAddr.
@@ -97,10 +173,14 @@
173 // Channels is the list of IRC channels the bridge joins on startup.
174 Channels []string `yaml:"channels"`
175
176 // BufferSize is the number of messages to keep per channel. Default: 200.
177 BufferSize int `yaml:"buffer_size"`
178
179 // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain visible
180 // in the channel user list after their last post. Default: 5.
181 WebUserTTLMinutes int `yaml:"web_user_ttl_minutes"`
182 }
183
184 // DatastoreConfig configures scuttlebot's own state store (separate from Ergo).
185 type DatastoreConfig struct {
186 // Driver is "sqlite" or "postgres". Default: "sqlite".
@@ -145,15 +225,21 @@
225 c.MCPAddr = ":8081"
226 }
227 if !c.Bridge.Enabled && c.Bridge.Nick == "" {
228 c.Bridge.Enabled = true // enabled by default
229 }
230 if c.TLS.Domain != "" && !c.TLS.AllowInsecure {
231 c.TLS.AllowInsecure = true // HTTP always on by default
232 }
233 if c.Bridge.Nick == "" {
234 c.Bridge.Nick = "bridge"
235 }
236 if c.Bridge.BufferSize == 0 {
237 c.Bridge.BufferSize = 200
238 }
239 if c.Bridge.WebUserTTLMinutes == 0 {
240 c.Bridge.WebUserTTLMinutes = 5
241 }
242 }
243
244 func envStr(key string) string { return os.Getenv(key) }
245
246
247 DDED internal/llm/anthropic.go
248 DDED internal/llm/anthropic_test.go
249 DDED internal/llm/bedrock.go
250 DDED internal/llm/bedrock_test.go
251 DDED internal/llm/config.go
252 DDED internal/llm/factory.go
253 DDED internal/llm/factory_test.go
254 DDED internal/llm/filter.go
255 DDED internal/llm/gemini.go
256 DDED internal/llm/gemini_test.go
257 DDED internal/llm/ollama.go
258 DDED internal/llm/ollama_test.go
259 DDED internal/llm/openai.go
260 DDED internal/llm/openai_test.go
261 DDED internal/llm/provider.go
262 DDED internal/llm/stub.go
--- a/internal/llm/anthropic.go
+++ b/internal/llm/anthropic.go
@@ -0,0 +1,100 @@
1
+package llm
2
+
3
+import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+)
11
+
12
+const anthropicAPIBase = "https://api.anthropic.com"
13
+const anthropicVersion = "2023-06-01"
14
+
15
+// anthropicModels is a curated static list — Anthropic has no public list API.
16
+var anthropicModels = []ModelInfo{
17
+ {ID: "claude-opus-4-6", Name: "Claude Opus 4.6"},
18
+ {ID: "claude-sonnet-4-6", Name: "Claude Sonnet 4.6"},
19
+ {ID: "claude-haiku-4-5-20251001", Name: "Claude Haiku 4.5"},
20
+ {ID: "claude-3-5-sonnet-20241022", Name: "Claude 3.5 Sonnet"},
21
+ {ID: "claude-3-5-haiku-20241022", Name: "Claude 3.5 Haiku"},
22
+ {ID: "claude-3-opus-20240229", Name: "Claude 3 Opus"},
23
+ {ID: "claude-3-sonnet-20240229", Name: "Claude 3 Sonnet"},
24
+ {ID: "claude-3-haiku-20240307", Name: "Claude 3 Haiku"},
25
+}
26
+
27
+type anthropicProvider struct {
28
+ apiKey string
29
+ model string
30
+ baseURL string
31
+ http *http.Client
32
+}
33
+
34
+func newAnthropicProvider(cfg BackendConfig, hc *http.Client) *anthropicProvider {
35
+ model := cfg.Model
36
+ if model == "" {
37
+ model = "claude-3-5-sonnet-20241022"
38
+ }
39
+ baseURL := cfg.BaseURL
40
+ if baseURL == "" {
41
+ baseURL = anthropicAPIBase
42
+ }
43
+ return &anthropicProvider{
44
+ apiKey: cfg.APIKey,
45
+ model: model,
46
+ baseURL: baseURL,
47
+ http: hc,
48
+ }
49
+}
50
+
51
+func (p *anthropicProvider) Summarize(ctx context.Context, prompt string) (string, error) {
52
+ body, _ := json.Marshal(map[string]any{
53
+ "model": p.model,
54
+ "max_tokens": 512,
55
+ "messages": []map[string]string{
56
+ {"role": "user", "content": prompt},
57
+ },
58
+ })
59
+ req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/v1/messages", bytes.NewReader(body))
60
+ if err != nil {
61
+ return "", err
62
+ }
63
+ req.Header.Set("x-api-key", p.apiKey)
64
+ req.Header.Set("anthropic-version", anthropicVersion)
65
+ req.Header.Set("Content-Type", "application/json")
66
+
67
+ resp, err := p.http.Do(req)
68
+ if err != nil {
69
+ return "", fmt.Errorf("anthropic request: %w", err)
70
+ }
71
+ defer resp.Body.Close()
72
+
73
+ data, _ := io.ReadAll(resp.Body)
74
+ if resp.StatusCode != http.StatusOK {
75
+ return "", fmt.Errorf("anthropic error %d: %s", resp.StatusCode, string(data))
76
+ }
77
+
78
+ var result struct {
79
+ Content []struct {
80
+ Type string `json:"type"`
81
+ Text string `json:"text"`
82
+ } `json:"content"`
83
+ }
84
+ if err := json.Unmarshal(data, &result); err != nil {
85
+ return "", fmt.Errorf("anthropic parse: %w", err)
86
+ }
87
+ for _, c := range result.Content {
88
+ if c.Type == "text" {
89
+ return c.Text, nil
90
+ }
91
+ }
92
+ return "", fmt.Errorf("anthropic returned no text content")
93
+}
94
+
95
+// DiscoverModels returns a curated static list (Anthropic has no public list API).
96
+func (p *anthropicProvider) DiscoverModels(_ context.Context) ([]ModelInfo, error) {
97
+ models := make([]ModelInfo, len(anthropicModels))
98
+ copy(models, anthropicModels)
99
+ return models, nil
100
+}
--- a/internal/llm/anthropic.go
+++ b/internal/llm/anthropic.go
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/anthropic.go
+++ b/internal/llm/anthropic.go
@@ -0,0 +1,100 @@
1 package llm
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 )
11
12 const anthropicAPIBase = "https://api.anthropic.com"
13 const anthropicVersion = "2023-06-01"
14
15 // anthropicModels is a curated static list — Anthropic has no public list API.
16 var anthropicModels = []ModelInfo{
17 {ID: "claude-opus-4-6", Name: "Claude Opus 4.6"},
18 {ID: "claude-sonnet-4-6", Name: "Claude Sonnet 4.6"},
19 {ID: "claude-haiku-4-5-20251001", Name: "Claude Haiku 4.5"},
20 {ID: "claude-3-5-sonnet-20241022", Name: "Claude 3.5 Sonnet"},
21 {ID: "claude-3-5-haiku-20241022", Name: "Claude 3.5 Haiku"},
22 {ID: "claude-3-opus-20240229", Name: "Claude 3 Opus"},
23 {ID: "claude-3-sonnet-20240229", Name: "Claude 3 Sonnet"},
24 {ID: "claude-3-haiku-20240307", Name: "Claude 3 Haiku"},
25 }
26
27 type anthropicProvider struct {
28 apiKey string
29 model string
30 baseURL string
31 http *http.Client
32 }
33
34 func newAnthropicProvider(cfg BackendConfig, hc *http.Client) *anthropicProvider {
35 model := cfg.Model
36 if model == "" {
37 model = "claude-3-5-sonnet-20241022"
38 }
39 baseURL := cfg.BaseURL
40 if baseURL == "" {
41 baseURL = anthropicAPIBase
42 }
43 return &anthropicProvider{
44 apiKey: cfg.APIKey,
45 model: model,
46 baseURL: baseURL,
47 http: hc,
48 }
49 }
50
51 func (p *anthropicProvider) Summarize(ctx context.Context, prompt string) (string, error) {
52 body, _ := json.Marshal(map[string]any{
53 "model": p.model,
54 "max_tokens": 512,
55 "messages": []map[string]string{
56 {"role": "user", "content": prompt},
57 },
58 })
59 req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/v1/messages", bytes.NewReader(body))
60 if err != nil {
61 return "", err
62 }
63 req.Header.Set("x-api-key", p.apiKey)
64 req.Header.Set("anthropic-version", anthropicVersion)
65 req.Header.Set("Content-Type", "application/json")
66
67 resp, err := p.http.Do(req)
68 if err != nil {
69 return "", fmt.Errorf("anthropic request: %w", err)
70 }
71 defer resp.Body.Close()
72
73 data, _ := io.ReadAll(resp.Body)
74 if resp.StatusCode != http.StatusOK {
75 return "", fmt.Errorf("anthropic error %d: %s", resp.StatusCode, string(data))
76 }
77
78 var result struct {
79 Content []struct {
80 Type string `json:"type"`
81 Text string `json:"text"`
82 } `json:"content"`
83 }
84 if err := json.Unmarshal(data, &result); err != nil {
85 return "", fmt.Errorf("anthropic parse: %w", err)
86 }
87 for _, c := range result.Content {
88 if c.Type == "text" {
89 return c.Text, nil
90 }
91 }
92 return "", fmt.Errorf("anthropic returned no text content")
93 }
94
95 // DiscoverModels returns a curated static list (Anthropic has no public list API).
96 func (p *anthropicProvider) DiscoverModels(_ context.Context) ([]ModelInfo, error) {
97 models := make([]ModelInfo, len(anthropicModels))
98 copy(models, anthropicModels)
99 return models, nil
100 }
--- a/internal/llm/anthropic_test.go
+++ b/internal/llm/anthropic_test.go
@@ -0,0 +1,77 @@
1
+package llm
2
+
3
+import (
4
+ "context"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "testing"
9
+)
10
+
11
+func TestAnthropicSummarize(t *testing.T) {
12
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13
+ if r.Method != "POST" {
14
+ t.Errorf("expected POST request, got %s", r.Method)
15
+ }
16
+ if r.URL.Path != "/v1/messages" {
17
+ t.Errorf("unexpected path: %s", r.URL.Path)
18
+ }
19
+ if r.Header.Get("x-api-key") != "test-api-key" {
20
+ t.Errorf("expected api key test-api-key, got %s", r.Header.Get("x-api-key"))
21
+ }
22
+ if r.Header.Get("anthropic-version") != "2023-06-01" {
23
+ t.Errorf("expected anthropic-version 2023-06-01, got %s", r.Header.Get("anthropic-version"))
24
+ }
25
+
26
+ resp := map[string]any{
27
+ "content": []map[string]any{
28
+ {
29
+ "type": "text",
30
+ "text": "anthropic response",
31
+ },
32
+ },
33
+ }
34
+ _ = json.NewEncoder(w).Encode(resp)
35
+ }))
36
+ defer srv.Close()
37
+
38
+ p := newAnthropicProvider(BackendConfig{
39
+ Backend: "anthropic",
40
+ APIKey: "test-api-key",
41
+ BaseURL: srv.URL,
42
+ }, srv.Client())
43
+
44
+ got, err := p.Summarize(context.Background(), "test prompt")
45
+ if err != nil {
46
+ t.Fatalf("Summarize failed: %v", err)
47
+ }
48
+ if got != "anthropic response" {
49
+ t.Errorf("got %q, want %q", got, "anthropic response")
50
+ }
51
+}
52
+
53
+func TestAnthropicDiscoverModels(t *testing.T) {
54
+ p := newAnthropicProvider(BackendConfig{
55
+ Backend: "anthropic",
56
+ APIKey: "test-api-key",
57
+ }, http.DefaultClient)
58
+
59
+ models, err := p.DiscoverModels(context.Background())
60
+ if err != nil {
61
+ t.Fatalf("DiscoverModels failed: %v", err)
62
+ }
63
+
64
+ if len(models) == 0 {
65
+ t.Error("expected non-empty model list")
66
+ }
67
+ found := false
68
+ for _, m := range models {
69
+ if m.ID == "claude-3-5-sonnet-20241022" {
70
+ found = true
71
+ break
72
+ }
73
+ }
74
+ if !found {
75
+ t.Error("expected to find claude-3-5-sonnet-20241022 in model list")
76
+ }
77
+}
--- a/internal/llm/anthropic_test.go
+++ b/internal/llm/anthropic_test.go
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/anthropic_test.go
+++ b/internal/llm/anthropic_test.go
@@ -0,0 +1,77 @@
1 package llm
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9 )
10
11 func TestAnthropicSummarize(t *testing.T) {
12 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13 if r.Method != "POST" {
14 t.Errorf("expected POST request, got %s", r.Method)
15 }
16 if r.URL.Path != "/v1/messages" {
17 t.Errorf("unexpected path: %s", r.URL.Path)
18 }
19 if r.Header.Get("x-api-key") != "test-api-key" {
20 t.Errorf("expected api key test-api-key, got %s", r.Header.Get("x-api-key"))
21 }
22 if r.Header.Get("anthropic-version") != "2023-06-01" {
23 t.Errorf("expected anthropic-version 2023-06-01, got %s", r.Header.Get("anthropic-version"))
24 }
25
26 resp := map[string]any{
27 "content": []map[string]any{
28 {
29 "type": "text",
30 "text": "anthropic response",
31 },
32 },
33 }
34 _ = json.NewEncoder(w).Encode(resp)
35 }))
36 defer srv.Close()
37
38 p := newAnthropicProvider(BackendConfig{
39 Backend: "anthropic",
40 APIKey: "test-api-key",
41 BaseURL: srv.URL,
42 }, srv.Client())
43
44 got, err := p.Summarize(context.Background(), "test prompt")
45 if err != nil {
46 t.Fatalf("Summarize failed: %v", err)
47 }
48 if got != "anthropic response" {
49 t.Errorf("got %q, want %q", got, "anthropic response")
50 }
51 }
52
53 func TestAnthropicDiscoverModels(t *testing.T) {
54 p := newAnthropicProvider(BackendConfig{
55 Backend: "anthropic",
56 APIKey: "test-api-key",
57 }, http.DefaultClient)
58
59 models, err := p.DiscoverModels(context.Background())
60 if err != nil {
61 t.Fatalf("DiscoverModels failed: %v", err)
62 }
63
64 if len(models) == 0 {
65 t.Error("expected non-empty model list")
66 }
67 found := false
68 for _, m := range models {
69 if m.ID == "claude-3-5-sonnet-20241022" {
70 found = true
71 break
72 }
73 }
74 if !found {
75 t.Error("expected to find claude-3-5-sonnet-20241022 in model list")
76 }
77 }
--- a/internal/llm/bedrock.go
+++ b/internal/llm/bedrock.go
@@ -0,0 +1,349 @@
1
+package llm
2
+
3
+import (
4
+ "bytes"
5
+ "context"
6
+ "crypto/hmac"
7
+ "crypto/sha256"
8
+ "encoding/hex"
9
+ "encoding/json"
10
+ "fmt"
11
+ "io"
12
+ "net/http"
13
+ "os"
14
+ "sort"
15
+ "strings"
16
+ "sync"
17
+ "time"
18
+)
19
+
20
+// awsCreds holds a resolved set of AWS credentials (static or temporary).
21
+type awsCreds struct {
22
+ KeyID string
23
+ SecretKey string
24
+ SessionToken string // non-empty for temporary credentials from IAM roles
25
+ Expiry time.Time // zero for static credentials
26
+}
27
+
28
+// credCache caches resolved credentials to avoid hitting the metadata endpoint
29
+// on every request. Refreshes when credentials are within 30s of expiry.
30
+type credCache struct {
31
+ mu sync.Mutex
32
+ creds *awsCreds
33
+}
34
+
35
+func (c *credCache) get() *awsCreds {
36
+ c.mu.Lock()
37
+ defer c.mu.Unlock()
38
+ if c.creds == nil {
39
+ return nil
40
+ }
41
+ if c.creds.Expiry.IsZero() {
42
+ return c.creds // static creds never expire
43
+ }
44
+ if time.Now().Before(c.creds.Expiry.Add(-30 * time.Second)) {
45
+ return c.creds
46
+ }
47
+ return nil // expired or about to expire
48
+}
49
+
50
+func (c *credCache) set(creds *awsCreds) {
51
+ c.mu.Lock()
52
+ defer c.mu.Unlock()
53
+ c.creds = creds
54
+}
55
+
56
+type bedrockProvider struct {
57
+ region string
58
+ modelID string
59
+ baseURL string // for testing
60
+ cfg BackendConfig
61
+ cache credCache
62
+ http *http.Client
63
+}
64
+
65
+func newBedrockProvider(cfg BackendConfig, hc *http.Client) (*bedrockProvider, error) {
66
+ if cfg.Region == "" {
67
+ return nil, fmt.Errorf("llm: bedrock requires region")
68
+ }
69
+ model := cfg.Model
70
+ if model == "" {
71
+ model = "anthropic.claude-3-5-sonnet-20241022-v2:0"
72
+ }
73
+ return &bedrockProvider{
74
+ region: cfg.Region,
75
+ modelID: model,
76
+ baseURL: cfg.BaseURL,
77
+ cfg: cfg,
78
+ http: hc,
79
+ }, nil
80
+}
81
+
82
+// Summarize calls the Bedrock Converse API, which provides a unified interface
83
+// across all Bedrock-hosted models.
84
+func (p *bedrockProvider) Summarize(ctx context.Context, prompt string) (string, error) {
85
+ url := p.baseURL
86
+ if url == "" {
87
+ url = fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com", p.region)
88
+ }
89
+ url = fmt.Sprintf("%s/model/%s/converse", url, p.modelID)
90
+
91
+ body, _ := json.Marshal(map[string]any{
92
+ "messages": []map[string]any{
93
+ {
94
+ "role": "user",
95
+ "content": []map[string]string{
96
+ {"type": "text", "text": prompt},
97
+ },
98
+ },
99
+ },
100
+ "inferenceConfig": map[string]any{
101
+ "maxTokens": 512,
102
+ },
103
+ })
104
+
105
+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
106
+ if err != nil {
107
+ return "", err
108
+ }
109
+ req.Header.Set("Content-Type", "application/json")
110
+ if err := p.signRequest(ctx, req, body); err != nil {
111
+ return "", fmt.Errorf("bedrock sign: %w", err)
112
+ }
113
+
114
+ resp, err := p.http.Do(req)
115
+ if err != nil {
116
+ return "", fmt.Errorf("bedrock request: %w", err)
117
+ }
118
+ defer resp.Body.Close()
119
+
120
+ data, _ := io.ReadAll(resp.Body)
121
+ if resp.StatusCode != http.StatusOK {
122
+ return "", fmt.Errorf("bedrock error %d: %s", resp.StatusCode, string(data))
123
+ }
124
+
125
+ var result struct {
126
+ Output struct {
127
+ Message struct {
128
+ Content []struct {
129
+ Text string `json:"text"`
130
+ } `json:"content"`
131
+ } `json:"message"`
132
+ } `json:"output"`
133
+ }
134
+ if err := json.Unmarshal(data, &result); err != nil {
135
+ return "", fmt.Errorf("bedrock parse: %w", err)
136
+ }
137
+ if len(result.Output.Message.Content) == 0 {
138
+ return "", fmt.Errorf("bedrock returned no content")
139
+ }
140
+ return result.Output.Message.Content[0].Text, nil
141
+}
142
+
143
+// DiscoverModels lists Bedrock foundation models available in the configured region.
144
+func (p *bedrockProvider) DiscoverModels(ctx context.Context) ([]ModelInfo, error) {
145
+ url := p.baseURL
146
+ if url == "" {
147
+ url = fmt.Sprintf("https://bedrock.%s.amazonaws.com", p.region)
148
+ }
149
+ url = fmt.Sprintf("%s/foundation-models", url)
150
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
151
+ if err != nil {
152
+ return nil, err
153
+ }
154
+ if err := p.signRequest(ctx, req, nil); err != nil {
155
+ return nil, fmt.Errorf("bedrock sign: %w", err)
156
+ }
157
+
158
+ resp, err := p.http.Do(req)
159
+ if err != nil {
160
+ return nil, fmt.Errorf("bedrock models request: %w", err)
161
+ }
162
+ defer resp.Body.Close()
163
+
164
+ data, _ := io.ReadAll(resp.Body)
165
+ if resp.StatusCode != http.StatusOK {
166
+ return nil, fmt.Errorf("bedrock models error %d: %s", resp.StatusCode, string(data))
167
+ }
168
+
169
+ var result struct {
170
+ ModelSummaries []struct {
171
+ ModelID string `json:"modelId"`
172
+ ModelName string `json:"modelName"`
173
+ } `json:"modelSummaries"`
174
+ }
175
+ if err := json.Unmarshal(data, &result); err != nil {
176
+ return nil, fmt.Errorf("bedrock models parse: %w", err)
177
+ }
178
+
179
+ models := make([]ModelInfo, len(result.ModelSummaries))
180
+ for i, m := range result.ModelSummaries {
181
+ models[i] = ModelInfo{ID: m.ModelID, Name: m.ModelName}
182
+ }
183
+ return models, nil
184
+}
185
+
186
+// signRequest resolves credentials (with caching) and applies SigV4 headers.
187
+func (p *bedrockProvider) signRequest(ctx context.Context, r *http.Request, body []byte) error {
188
+ creds := p.cache.get()
189
+ if creds == nil {
190
+ var err error
191
+ creds, err = resolveAWSCreds(ctx, p.cfg, p.http)
192
+ if err != nil {
193
+ return fmt.Errorf("resolve credentials: %w", err)
194
+ }
195
+ p.cache.set(creds)
196
+ }
197
+ return signSigV4(r, body, creds, p.region, "bedrock")
198
+}
199
+
200
+// --- AWS credential resolution chain ---
201
+
202
+// resolveAWSCreds resolves credentials using the standard AWS chain:
203
+// 1. Static credentials in BackendConfig (AWSKeyID + AWSSecretKey)
204
+// 2. AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN env vars
205
+// 3. ECS task role via AWS_CONTAINER_CREDENTIALS_RELATIVE_URI or _FULL_URI
206
+// 4. EC2/EKS instance profile via IMDSv2
207
+func resolveAWSCreds(ctx context.Context, cfg BackendConfig, hc *http.Client) (*awsCreds, error) {
208
+ // 1. Static config credentials.
209
+ if cfg.AWSKeyID != "" && cfg.AWSSecretKey != "" {
210
+ return &awsCreds{KeyID: cfg.AWSKeyID, SecretKey: cfg.AWSSecretKey}, nil
211
+ }
212
+
213
+ // 2. Environment variables.
214
+ if id := os.Getenv("AWS_ACCESS_KEY_ID"); id != "" {
215
+ return &awsCreds{
216
+ KeyID: id,
217
+ SecretKey: os.Getenv("AWS_SECRET_ACCESS_KEY"),
218
+ SessionToken: os.Getenv("AWS_SESSION_TOKEN"),
219
+ }, nil
220
+ }
221
+
222
+ // 3. ECS container credentials.
223
+ if rel := os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"); rel != "" {
224
+ return fetchContainerCreds(ctx, "http://169.254.170.2"+rel, "", hc)
225
+ }
226
+ if full := os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI"); full != "" {
227
+ token := os.Getenv("AWS_CONTAINER_AUTHORIZATION_TOKEN")
228
+ return fetchContainerCreds(ctx, full, token, hc)
229
+ }
230
+
231
+ // 4. EC2 / EKS instance metadata (IMDSv2).
232
+ return fetchIMDSCreds(ctx, hc)
233
+}
234
+
235
+// fetchContainerCreds fetches temporary credentials from the ECS task metadata endpoint.
236
+func fetchContainerCreds(ctx context.Context, url, token string, hc *http.Client) (*awsCreds, error) {
237
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
238
+ if err != nil {
239
+ return nil, fmt.Errorf("bedrock ecs creds: %w", err)
240
+ }
241
+ if token != "" {
242
+ req.Header.Set("Authorization", token)
243
+ }
244
+ return parseTempCreds(hc, req, "ECS container credentials")
245
+}
246
+
247
+// fetchIMDSCreds fetches temporary credentials via EC2 IMDSv2 (also works for EKS).
248
+func fetchIMDSCreds(ctx context.Context, hc *http.Client) (*awsCreds, error) {
249
+ const imdsBase = "http://169.254.169.254/latest"
250
+
251
+ // Step 1: obtain IMDSv2 session token.
252
+ tokenReq, err := http.NewRequestWithContext(ctx, "PUT", imdsBase+"/api/token", nil)
253
+ if err != nil {
254
+ return nil, fmt.Errorf("bedrock imds token request: %w", err)
255
+ }
256
+ tokenReq.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "21600")
257
+ tokenResp, err := hc.Do(tokenReq)
258
+ if err != nil {
259
+ return nil, fmt.Errorf("bedrock imds: not running on EC2/EKS or IMDS unreachable: %w", err)
260
+ }
261
+ defer tokenResp.Body.Close()
262
+ tokenBytes, _ := io.ReadAll(tokenResp.Body)
263
+ if tokenResp.StatusCode != http.StatusOK {
264
+ return nil, fmt.Errorf("bedrock imds: token request failed (%d)", tokenResp.StatusCode)
265
+ }
266
+ imdsToken := strings.TrimSpace(string(tokenBytes))
267
+
268
+ // Step 2: get the IAM role name.
269
+ roleReq, _ := http.NewRequestWithContext(ctx, "GET", imdsBase+"/meta-data/iam/security-credentials/", nil)
270
+ roleReq.Header.Set("X-aws-ec2-metadata-token", imdsToken)
271
+ roleResp, err := hc.Do(roleReq)
272
+ if err != nil {
273
+ return nil, fmt.Errorf("bedrock imds: get role name: %w", err)
274
+ }
275
+ defer roleResp.Body.Close()
276
+ roleBytes, _ := io.ReadAll(roleResp.Body)
277
+ if roleResp.StatusCode != http.StatusOK {
278
+ return nil, fmt.Errorf("bedrock imds: no IAM role attached to instance")
279
+ }
280
+ role := strings.TrimSpace(string(roleBytes))
281
+
282
+ // Step 3: fetch credentials for the role.
283
+ credsReq, _ := http.NewRequestWithContext(ctx, "GET", imdsBase+"/meta-data/iam/security-credentials/"+role, nil)
284
+ credsReq.Header.Set("X-aws-ec2-metadata-token", imdsToken)
285
+ return parseTempCreds(hc, credsReq, "EC2 instance metadata")
286
+}
287
+
288
+func parseTempCreds(hc *http.Client, req *http.Request, source string) (*awsCreds, error) {
289
+ resp, err := hc.Do(req)
290
+ if err != nil {
291
+ return nil, fmt.Errorf("bedrock %s: %w", source, err)
292
+ }
293
+ defer resp.Body.Close()
294
+ data, _ := io.ReadAll(resp.Body)
295
+ if resp.StatusCode != http.StatusOK {
296
+ return nil, fmt.Errorf("bedrock %s error %d: %s", source, resp.StatusCode, string(data))
297
+ }
298
+ var result struct {
299
+ AccessKeyID string `json:"AccessKeyId"`
300
+ SecretAccessKey string `json:"SecretAccessKey"`
301
+ Token string `json:"Token"`
302
+ Expiration string `json:"Expiration"`
303
+ }
304
+ if err := json.Unmarshal(data, &result); err != nil {
305
+ return nil, fmt.Errorf("bedrock %s parse: %w", source, err)
306
+ }
307
+ creds := &awsCreds{
308
+ KeyID: result.AccessKeyID,
309
+ SecretKey: result.SecretAccessKey,
310
+ SessionToken: result.Token,
311
+ }
312
+ if result.Expiration != "" {
313
+ if t, err := time.Parse(time.RFC3339, result.Expiration); err == nil {
314
+ creds.Expiry = t
315
+ }
316
+ }
317
+ return creds, nil
318
+}
319
+
320
+// --- SigV4 signing ---
321
+
322
+// signSigV4 adds AWS Signature Version 4 authentication headers to r.
323
+// Both bedrock.*.amazonaws.com and bedrock-runtime.*.amazonaws.com use service "bedrock".
324
+func signSigV4(r *http.Request, body []byte, creds *awsCreds, region, service string) error {
325
+ now := time.Now().UTC()
326
+ dateTime := now.Format("20060102T150405Z")
327
+ date := now.Format("20060102")
328
+
329
+ var bodyBytes []byte
330
+ if body != nil {
331
+ bodyBytes = body
332
+ }
333
+ bodyHash := sha256Hex(bodyBytes)
334
+
335
+ r.Header.Set("x-amz-date", dateTime)
336
+ r.Header.Set("x-amz-content-sha256", bodyHash)
337
+ if creds.SessionToken != "" {
338
+ r.Header.Set("x-amz-security-token", creds.SessionToken)
339
+ }
340
+ if r.Host == "" {
341
+ r.Host = r.URL.Host
342
+ }
343
+
344
+ canonHeaders, signedHeaders := buildHeaders(r)
345
+ path := r.URL.Path
346
+ if path == "" {
347
+ path = "/"
348
+ }
349
+ cano
--- a/internal/llm/bedrock.go
+++ b/internal/llm/bedrock.go
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/bedrock.go
+++ b/internal/llm/bedrock.go
@@ -0,0 +1,349 @@
1 package llm
2
3 import (
4 "bytes"
5 "context"
6 "crypto/hmac"
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "fmt"
11 "io"
12 "net/http"
13 "os"
14 "sort"
15 "strings"
16 "sync"
17 "time"
18 )
19
20 // awsCreds holds a resolved set of AWS credentials (static or temporary).
21 type awsCreds struct {
22 KeyID string
23 SecretKey string
24 SessionToken string // non-empty for temporary credentials from IAM roles
25 Expiry time.Time // zero for static credentials
26 }
27
28 // credCache caches resolved credentials to avoid hitting the metadata endpoint
29 // on every request. Refreshes when credentials are within 30s of expiry.
30 type credCache struct {
31 mu sync.Mutex
32 creds *awsCreds
33 }
34
35 func (c *credCache) get() *awsCreds {
36 c.mu.Lock()
37 defer c.mu.Unlock()
38 if c.creds == nil {
39 return nil
40 }
41 if c.creds.Expiry.IsZero() {
42 return c.creds // static creds never expire
43 }
44 if time.Now().Before(c.creds.Expiry.Add(-30 * time.Second)) {
45 return c.creds
46 }
47 return nil // expired or about to expire
48 }
49
50 func (c *credCache) set(creds *awsCreds) {
51 c.mu.Lock()
52 defer c.mu.Unlock()
53 c.creds = creds
54 }
55
56 type bedrockProvider struct {
57 region string
58 modelID string
59 baseURL string // for testing
60 cfg BackendConfig
61 cache credCache
62 http *http.Client
63 }
64
65 func newBedrockProvider(cfg BackendConfig, hc *http.Client) (*bedrockProvider, error) {
66 if cfg.Region == "" {
67 return nil, fmt.Errorf("llm: bedrock requires region")
68 }
69 model := cfg.Model
70 if model == "" {
71 model = "anthropic.claude-3-5-sonnet-20241022-v2:0"
72 }
73 return &bedrockProvider{
74 region: cfg.Region,
75 modelID: model,
76 baseURL: cfg.BaseURL,
77 cfg: cfg,
78 http: hc,
79 }, nil
80 }
81
82 // Summarize calls the Bedrock Converse API, which provides a unified interface
83 // across all Bedrock-hosted models.
84 func (p *bedrockProvider) Summarize(ctx context.Context, prompt string) (string, error) {
85 url := p.baseURL
86 if url == "" {
87 url = fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com", p.region)
88 }
89 url = fmt.Sprintf("%s/model/%s/converse", url, p.modelID)
90
91 body, _ := json.Marshal(map[string]any{
92 "messages": []map[string]any{
93 {
94 "role": "user",
95 "content": []map[string]string{
96 {"type": "text", "text": prompt},
97 },
98 },
99 },
100 "inferenceConfig": map[string]any{
101 "maxTokens": 512,
102 },
103 })
104
105 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
106 if err != nil {
107 return "", err
108 }
109 req.Header.Set("Content-Type", "application/json")
110 if err := p.signRequest(ctx, req, body); err != nil {
111 return "", fmt.Errorf("bedrock sign: %w", err)
112 }
113
114 resp, err := p.http.Do(req)
115 if err != nil {
116 return "", fmt.Errorf("bedrock request: %w", err)
117 }
118 defer resp.Body.Close()
119
120 data, _ := io.ReadAll(resp.Body)
121 if resp.StatusCode != http.StatusOK {
122 return "", fmt.Errorf("bedrock error %d: %s", resp.StatusCode, string(data))
123 }
124
125 var result struct {
126 Output struct {
127 Message struct {
128 Content []struct {
129 Text string `json:"text"`
130 } `json:"content"`
131 } `json:"message"`
132 } `json:"output"`
133 }
134 if err := json.Unmarshal(data, &result); err != nil {
135 return "", fmt.Errorf("bedrock parse: %w", err)
136 }
137 if len(result.Output.Message.Content) == 0 {
138 return "", fmt.Errorf("bedrock returned no content")
139 }
140 return result.Output.Message.Content[0].Text, nil
141 }
142
143 // DiscoverModels lists Bedrock foundation models available in the configured region.
144 func (p *bedrockProvider) DiscoverModels(ctx context.Context) ([]ModelInfo, error) {
145 url := p.baseURL
146 if url == "" {
147 url = fmt.Sprintf("https://bedrock.%s.amazonaws.com", p.region)
148 }
149 url = fmt.Sprintf("%s/foundation-models", url)
150 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
151 if err != nil {
152 return nil, err
153 }
154 if err := p.signRequest(ctx, req, nil); err != nil {
155 return nil, fmt.Errorf("bedrock sign: %w", err)
156 }
157
158 resp, err := p.http.Do(req)
159 if err != nil {
160 return nil, fmt.Errorf("bedrock models request: %w", err)
161 }
162 defer resp.Body.Close()
163
164 data, _ := io.ReadAll(resp.Body)
165 if resp.StatusCode != http.StatusOK {
166 return nil, fmt.Errorf("bedrock models error %d: %s", resp.StatusCode, string(data))
167 }
168
169 var result struct {
170 ModelSummaries []struct {
171 ModelID string `json:"modelId"`
172 ModelName string `json:"modelName"`
173 } `json:"modelSummaries"`
174 }
175 if err := json.Unmarshal(data, &result); err != nil {
176 return nil, fmt.Errorf("bedrock models parse: %w", err)
177 }
178
179 models := make([]ModelInfo, len(result.ModelSummaries))
180 for i, m := range result.ModelSummaries {
181 models[i] = ModelInfo{ID: m.ModelID, Name: m.ModelName}
182 }
183 return models, nil
184 }
185
186 // signRequest resolves credentials (with caching) and applies SigV4 headers.
187 func (p *bedrockProvider) signRequest(ctx context.Context, r *http.Request, body []byte) error {
188 creds := p.cache.get()
189 if creds == nil {
190 var err error
191 creds, err = resolveAWSCreds(ctx, p.cfg, p.http)
192 if err != nil {
193 return fmt.Errorf("resolve credentials: %w", err)
194 }
195 p.cache.set(creds)
196 }
197 return signSigV4(r, body, creds, p.region, "bedrock")
198 }
199
200 // --- AWS credential resolution chain ---
201
202 // resolveAWSCreds resolves credentials using the standard AWS chain:
203 // 1. Static credentials in BackendConfig (AWSKeyID + AWSSecretKey)
204 // 2. AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN env vars
205 // 3. ECS task role via AWS_CONTAINER_CREDENTIALS_RELATIVE_URI or _FULL_URI
206 // 4. EC2/EKS instance profile via IMDSv2
207 func resolveAWSCreds(ctx context.Context, cfg BackendConfig, hc *http.Client) (*awsCreds, error) {
208 // 1. Static config credentials.
209 if cfg.AWSKeyID != "" && cfg.AWSSecretKey != "" {
210 return &awsCreds{KeyID: cfg.AWSKeyID, SecretKey: cfg.AWSSecretKey}, nil
211 }
212
213 // 2. Environment variables.
214 if id := os.Getenv("AWS_ACCESS_KEY_ID"); id != "" {
215 return &awsCreds{
216 KeyID: id,
217 SecretKey: os.Getenv("AWS_SECRET_ACCESS_KEY"),
218 SessionToken: os.Getenv("AWS_SESSION_TOKEN"),
219 }, nil
220 }
221
222 // 3. ECS container credentials.
223 if rel := os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"); rel != "" {
224 return fetchContainerCreds(ctx, "http://169.254.170.2"+rel, "", hc)
225 }
226 if full := os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI"); full != "" {
227 token := os.Getenv("AWS_CONTAINER_AUTHORIZATION_TOKEN")
228 return fetchContainerCreds(ctx, full, token, hc)
229 }
230
231 // 4. EC2 / EKS instance metadata (IMDSv2).
232 return fetchIMDSCreds(ctx, hc)
233 }
234
235 // fetchContainerCreds fetches temporary credentials from the ECS task metadata endpoint.
236 func fetchContainerCreds(ctx context.Context, url, token string, hc *http.Client) (*awsCreds, error) {
237 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
238 if err != nil {
239 return nil, fmt.Errorf("bedrock ecs creds: %w", err)
240 }
241 if token != "" {
242 req.Header.Set("Authorization", token)
243 }
244 return parseTempCreds(hc, req, "ECS container credentials")
245 }
246
247 // fetchIMDSCreds fetches temporary credentials via EC2 IMDSv2 (also works for EKS).
248 func fetchIMDSCreds(ctx context.Context, hc *http.Client) (*awsCreds, error) {
249 const imdsBase = "http://169.254.169.254/latest"
250
251 // Step 1: obtain IMDSv2 session token.
252 tokenReq, err := http.NewRequestWithContext(ctx, "PUT", imdsBase+"/api/token", nil)
253 if err != nil {
254 return nil, fmt.Errorf("bedrock imds token request: %w", err)
255 }
256 tokenReq.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "21600")
257 tokenResp, err := hc.Do(tokenReq)
258 if err != nil {
259 return nil, fmt.Errorf("bedrock imds: not running on EC2/EKS or IMDS unreachable: %w", err)
260 }
261 defer tokenResp.Body.Close()
262 tokenBytes, _ := io.ReadAll(tokenResp.Body)
263 if tokenResp.StatusCode != http.StatusOK {
264 return nil, fmt.Errorf("bedrock imds: token request failed (%d)", tokenResp.StatusCode)
265 }
266 imdsToken := strings.TrimSpace(string(tokenBytes))
267
268 // Step 2: get the IAM role name.
269 roleReq, _ := http.NewRequestWithContext(ctx, "GET", imdsBase+"/meta-data/iam/security-credentials/", nil)
270 roleReq.Header.Set("X-aws-ec2-metadata-token", imdsToken)
271 roleResp, err := hc.Do(roleReq)
272 if err != nil {
273 return nil, fmt.Errorf("bedrock imds: get role name: %w", err)
274 }
275 defer roleResp.Body.Close()
276 roleBytes, _ := io.ReadAll(roleResp.Body)
277 if roleResp.StatusCode != http.StatusOK {
278 return nil, fmt.Errorf("bedrock imds: no IAM role attached to instance")
279 }
280 role := strings.TrimSpace(string(roleBytes))
281
282 // Step 3: fetch credentials for the role.
283 credsReq, _ := http.NewRequestWithContext(ctx, "GET", imdsBase+"/meta-data/iam/security-credentials/"+role, nil)
284 credsReq.Header.Set("X-aws-ec2-metadata-token", imdsToken)
285 return parseTempCreds(hc, credsReq, "EC2 instance metadata")
286 }
287
288 func parseTempCreds(hc *http.Client, req *http.Request, source string) (*awsCreds, error) {
289 resp, err := hc.Do(req)
290 if err != nil {
291 return nil, fmt.Errorf("bedrock %s: %w", source, err)
292 }
293 defer resp.Body.Close()
294 data, _ := io.ReadAll(resp.Body)
295 if resp.StatusCode != http.StatusOK {
296 return nil, fmt.Errorf("bedrock %s error %d: %s", source, resp.StatusCode, string(data))
297 }
298 var result struct {
299 AccessKeyID string `json:"AccessKeyId"`
300 SecretAccessKey string `json:"SecretAccessKey"`
301 Token string `json:"Token"`
302 Expiration string `json:"Expiration"`
303 }
304 if err := json.Unmarshal(data, &result); err != nil {
305 return nil, fmt.Errorf("bedrock %s parse: %w", source, err)
306 }
307 creds := &awsCreds{
308 KeyID: result.AccessKeyID,
309 SecretKey: result.SecretAccessKey,
310 SessionToken: result.Token,
311 }
312 if result.Expiration != "" {
313 if t, err := time.Parse(time.RFC3339, result.Expiration); err == nil {
314 creds.Expiry = t
315 }
316 }
317 return creds, nil
318 }
319
320 // --- SigV4 signing ---
321
322 // signSigV4 adds AWS Signature Version 4 authentication headers to r.
323 // Both bedrock.*.amazonaws.com and bedrock-runtime.*.amazonaws.com use service "bedrock".
324 func signSigV4(r *http.Request, body []byte, creds *awsCreds, region, service string) error {
325 now := time.Now().UTC()
326 dateTime := now.Format("20060102T150405Z")
327 date := now.Format("20060102")
328
329 var bodyBytes []byte
330 if body != nil {
331 bodyBytes = body
332 }
333 bodyHash := sha256Hex(bodyBytes)
334
335 r.Header.Set("x-amz-date", dateTime)
336 r.Header.Set("x-amz-content-sha256", bodyHash)
337 if creds.SessionToken != "" {
338 r.Header.Set("x-amz-security-token", creds.SessionToken)
339 }
340 if r.Host == "" {
341 r.Host = r.URL.Host
342 }
343
344 canonHeaders, signedHeaders := buildHeaders(r)
345 path := r.URL.Path
346 if path == "" {
347 path = "/"
348 }
349 cano
--- a/internal/llm/bedrock_test.go
+++ b/internal/llm/bedrock_test.go
@@ -0,0 +1,87 @@
1
+package llm
2
+
3
+import (
4
+ "context"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "testing"
9
+)
10
+
11
+func TestBedrockSummarize(t *testing.T) {
12
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13
+ if r.Method != "POST" {
14
+ t.Errorf("expected POST request, got %s", r.Method)
15
+ }
16
+ // Path: /model/{modelID}/converse
17
+ if r.URL.Path != "/model/test-model/converse" {
18
+ t.Errorf("unexpected path: %s", r.URL.Path)
19
+ }
20
+
21
+ resp := map[string]any{
22
+ "output": map[string]any{
23
+ "message": map[string]any{
24
+ "content": []map[string]any{
25
+ {"text": "bedrock response"},
26
+ },
27
+ },
28
+ },
29
+ }
30
+ _ = json.NewEncoder(w).Encode(resp)
31
+ }))
32
+ defer srv.Close()
33
+
34
+ p, _ := newBedrockProvider(BackendConfig{
35
+ Backend: "bedrock",
36
+ Region: "us-east-1",
37
+ Model: "test-model",
38
+ BaseURL: srv.URL,
39
+ AWSKeyID: "test-key",
40
+ AWSSecretKey: "test-secret",
41
+ }, srv.Client())
42
+
43
+ got, err := p.Summarize(context.Background(), "test prompt")
44
+ if err != nil {
45
+ t.Fatalf("Summarize failed: %v", err)
46
+ }
47
+ if got != "bedrock response" {
48
+ t.Errorf("got %q, want %q", got, "bedrock response")
49
+ }
50
+}
51
+
52
+func TestBedrockDiscoverModels(t *testing.T) {
53
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
54
+ if r.Method != "GET" {
55
+ t.Errorf("expected GET request, got %s", r.Method)
56
+ }
57
+ if r.URL.Path != "/foundation-models" {
58
+ t.Errorf("unexpected path: %s", r.URL.Path)
59
+ }
60
+
61
+ resp := map[string]any{
62
+ "modelSummaries": []map[string]any{
63
+ {"modelId": "m1", "modelName": "Model 1"},
64
+ {"modelId": "m2", "modelName": "Model 2"},
65
+ },
66
+ }
67
+ _ = json.NewEncoder(w).Encode(resp)
68
+ }))
69
+ defer srv.Close()
70
+
71
+ p, _ := newBedrockProvider(BackendConfig{
72
+ Backend: "bedrock",
73
+ Region: "us-east-1",
74
+ BaseURL: srv.URL,
75
+ AWSKeyID: "test-key",
76
+ AWSSecretKey: "test-secret",
77
+ }, srv.Client())
78
+
79
+ models, err := p.DiscoverModels(context.Background())
80
+ if err != nil {
81
+ t.Fatalf("DiscoverModels failed: %v", err)
82
+ }
83
+
84
+ if len(models) != 2 {
85
+ t.Errorf("got %d models, want 2", len(models))
86
+ }
87
+}
--- a/internal/llm/bedrock_test.go
+++ b/internal/llm/bedrock_test.go
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/bedrock_test.go
+++ b/internal/llm/bedrock_test.go
@@ -0,0 +1,87 @@
1 package llm
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9 )
10
11 func TestBedrockSummarize(t *testing.T) {
12 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13 if r.Method != "POST" {
14 t.Errorf("expected POST request, got %s", r.Method)
15 }
16 // Path: /model/{modelID}/converse
17 if r.URL.Path != "/model/test-model/converse" {
18 t.Errorf("unexpected path: %s", r.URL.Path)
19 }
20
21 resp := map[string]any{
22 "output": map[string]any{
23 "message": map[string]any{
24 "content": []map[string]any{
25 {"text": "bedrock response"},
26 },
27 },
28 },
29 }
30 _ = json.NewEncoder(w).Encode(resp)
31 }))
32 defer srv.Close()
33
34 p, _ := newBedrockProvider(BackendConfig{
35 Backend: "bedrock",
36 Region: "us-east-1",
37 Model: "test-model",
38 BaseURL: srv.URL,
39 AWSKeyID: "test-key",
40 AWSSecretKey: "test-secret",
41 }, srv.Client())
42
43 got, err := p.Summarize(context.Background(), "test prompt")
44 if err != nil {
45 t.Fatalf("Summarize failed: %v", err)
46 }
47 if got != "bedrock response" {
48 t.Errorf("got %q, want %q", got, "bedrock response")
49 }
50 }
51
52 func TestBedrockDiscoverModels(t *testing.T) {
53 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
54 if r.Method != "GET" {
55 t.Errorf("expected GET request, got %s", r.Method)
56 }
57 if r.URL.Path != "/foundation-models" {
58 t.Errorf("unexpected path: %s", r.URL.Path)
59 }
60
61 resp := map[string]any{
62 "modelSummaries": []map[string]any{
63 {"modelId": "m1", "modelName": "Model 1"},
64 {"modelId": "m2", "modelName": "Model 2"},
65 },
66 }
67 _ = json.NewEncoder(w).Encode(resp)
68 }))
69 defer srv.Close()
70
71 p, _ := newBedrockProvider(BackendConfig{
72 Backend: "bedrock",
73 Region: "us-east-1",
74 BaseURL: srv.URL,
75 AWSKeyID: "test-key",
76 AWSSecretKey: "test-secret",
77 }, srv.Client())
78
79 models, err := p.DiscoverModels(context.Background())
80 if err != nil {
81 t.Fatalf("DiscoverModels failed: %v", err)
82 }
83
84 if len(models) != 2 {
85 t.Errorf("got %d models, want 2", len(models))
86 }
87 }
--- a/internal/llm/config.go
+++ b/internal/llm/config.go
@@ -0,0 +1,44 @@
1
+package llm
2
+
3
+// BackendConfig holds configuration for a single LLM backend instance.
4
+type BackendConfig struct {
5
+ // Backend is the provider type. Supported values:
6
+ //
7
+ // Native APIs:
8
+ // anthropic, gemini, bedrock, ollama
9
+ //
10
+ // OpenAI-compatible (Bearer token auth, /v1/models discovery):
11
+ // openai, openrouter, together, groq, fireworks, mistral, ai21,
12
+ // huggingface, deepseek, cerebras, xai,
13
+ // litellm, lmstudio, jan, localai, vllm, anythingllm
14
+ Backend string
15
+
16
+ // APIKey is the authentication key or token for cloud backends.
17
+ APIKey string
18
+
19
+ // BaseURL overrides the default base URL for OpenAI-compatible backends.
20
+ // For named backends (e.g. "openai"), this defaults from KnownBackends.
21
+ // Required for custom/self-hosted OpenAI-compatible endpoints.
22
+ BaseURL string
23
+
24
+ // Model is the model ID to use. If empty, the first discovered model
25
+ // that passes the allow/block filter is used.
26
+ Model string
27
+
28
+ // Region is the AWS region (e.g. "us-east-1"). Bedrock only.
29
+ Region string
30
+
31
+ // AWSKeyID is the AWS access key ID. Bedrock only.
32
+ AWSKeyID string
33
+
34
+ // AWSSecretKey is the AWS secret access key. Bedrock only.
35
+ AWSSecretKey string
36
+
37
+ // Allow is a list of regex patterns. If non-empty, only model IDs matching
38
+ // at least one pattern are returned by DiscoverModels.
39
+ Allow []string
40
+
41
+ // Block is a list of regex patterns. Model IDs matching any pattern are
42
+ // excluded from DiscoverModels results.
43
+ Block []string
44
+}
--- a/internal/llm/config.go
+++ b/internal/llm/config.go
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/config.go
+++ b/internal/llm/config.go
@@ -0,0 +1,44 @@
1 package llm
2
3 // BackendConfig holds configuration for a single LLM backend instance.
4 type BackendConfig struct {
5 // Backend is the provider type. Supported values:
6 //
7 // Native APIs:
8 // anthropic, gemini, bedrock, ollama
9 //
10 // OpenAI-compatible (Bearer token auth, /v1/models discovery):
11 // openai, openrouter, together, groq, fireworks, mistral, ai21,
12 // huggingface, deepseek, cerebras, xai,
13 // litellm, lmstudio, jan, localai, vllm, anythingllm
14 Backend string
15
16 // APIKey is the authentication key or token for cloud backends.
17 APIKey string
18
19 // BaseURL overrides the default base URL for OpenAI-compatible backends.
20 // For named backends (e.g. "openai"), this defaults from KnownBackends.
21 // Required for custom/self-hosted OpenAI-compatible endpoints.
22 BaseURL string
23
24 // Model is the model ID to use. If empty, the first discovered model
25 // that passes the allow/block filter is used.
26 Model string
27
28 // Region is the AWS region (e.g. "us-east-1"). Bedrock only.
29 Region string
30
31 // AWSKeyID is the AWS access key ID. Bedrock only.
32 AWSKeyID string
33
34 // AWSSecretKey is the AWS secret access key. Bedrock only.
35 AWSSecretKey string
36
37 // Allow is a list of regex patterns. If non-empty, only model IDs matching
38 // at least one pattern are returned by DiscoverModels.
39 Allow []string
40
41 // Block is a list of regex patterns. Model IDs matching any pattern are
42 // excluded from DiscoverModels results.
43 Block []string
44 }
--- a/internal/llm/factory.go
+++ b/internal/llm/factory.go
@@ -0,0 +1,39 @@
1
+package llm
2
+
3
+import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+)
8
+
9
+// KnownBackends maps OpenAI-compatible backend names to their default base URLs.
10
+var KnownBackends = map[string]string{
11
+ "openai": "https://api.openai.com/v1",
12
+ "openrouter": "https://openrouter.ai/api/v1",
13
+ "together": "https://api.together.xyz/v1",
14
+ "groq": "https://api.groq.com/openai/v1",
15
+ "fireworks": "https://api.fireworks.ai/inference/v1",
16
+ "mistral": "https://api.mistral.ai/v1",
17
+ "ai21": "https://api.ai21.com/studio/v1",
18
+ "huggingface": "https://api-inference.huggingface.co/v1",
19
+ "deepseek": "https://api.deepseek.com/v1",
20
+ "cerebras": "https://api.cerebras.ai/v1",
21
+ "xai": "https://api.x.ai/v1",
22
+ // Local / self-hosted (defaults — override with base_url)
23
+ "litellm": :8000/v1",
24
+ "anyth4000/v1",
25
+ "lmstudio": "http://localhost:1234/v1",
26
+ "jan/localhost:8080/v1",
27
+ "vllm" "http://localhos:8000/v1",
28
+ "anythai": "http://localhos:8000/v1",
29
+ "anyth8000/v1",
30
+ "anythingllm": "http://localhost:3001/v1",
31
+}
32
+
33
+// New creates a Provider from the given config. The returned value may also
34
+// implement ModelDiscoverer — check with a type assertion before calling
35
+// DiscoverModels. Allow/block filters in cfg are applied transparently by
36
+// wrapping the discoverer.
37
+func New(cfg BackendConfig) (Provider, error) {
38
+ hc := &http.Client{}
39
+ switc
--- a/internal/llm/factory.go
+++ b/internal/llm/factory.go
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/factory.go
+++ b/internal/llm/factory.go
@@ -0,0 +1,39 @@
1 package llm
2
3 import (
4 "context"
5 "fmt"
6 "net/http"
7 )
8
9 // KnownBackends maps OpenAI-compatible backend names to their default base URLs.
10 var KnownBackends = map[string]string{
11 "openai": "https://api.openai.com/v1",
12 "openrouter": "https://openrouter.ai/api/v1",
13 "together": "https://api.together.xyz/v1",
14 "groq": "https://api.groq.com/openai/v1",
15 "fireworks": "https://api.fireworks.ai/inference/v1",
16 "mistral": "https://api.mistral.ai/v1",
17 "ai21": "https://api.ai21.com/studio/v1",
18 "huggingface": "https://api-inference.huggingface.co/v1",
19 "deepseek": "https://api.deepseek.com/v1",
20 "cerebras": "https://api.cerebras.ai/v1",
21 "xai": "https://api.x.ai/v1",
22 // Local / self-hosted (defaults — override with base_url)
23 "litellm": :8000/v1",
24 "anyth4000/v1",
25 "lmstudio": "http://localhost:1234/v1",
26 "jan/localhost:8080/v1",
27 "vllm" "http://localhos:8000/v1",
28 "anythai": "http://localhos:8000/v1",
29 "anyth8000/v1",
30 "anythingllm": "http://localhost:3001/v1",
31 }
32
33 // New creates a Provider from the given config. The returned value may also
34 // implement ModelDiscoverer — check with a type assertion before calling
35 // DiscoverModels. Allow/block filters in cfg are applied transparently by
36 // wrapping the discoverer.
37 func New(cfg BackendConfig) (Provider, error) {
38 hc := &http.Client{}
39 switc
--- a/internal/llm/factory_test.go
+++ b/internal/llm/factory_test.go
@@ -0,0 +1,70 @@
1
+package llm
2
+
3
+import (
4
+ "testing"
5
+)
6
+
7
+func TestNew(t *testing.T) {
8
+ tests := []struct {
9
+ name string
10
+ cfg BackendConfig
11
+ wantErr bool
12
+ }{
13
+ {
14
+ name: "openai",
15
+ cfg: BackendConfig{Backend: "openai", APIKey: "key"},
16
+ },
17
+ {
18
+ name: "anthropic",
19
+ cfg: BackendConfig{Backend: "anthropic", APIKey: "key"},
20
+ },
21
+ {
22
+ name: "gemini",
23
+ cfg: BackendConfig{Backend: "gemini", APIKey: "key"},
24
+ },
25
+ {
26
+ name: "ollama",
27
+ cfg: BackendConfig{Backend: "ollama", BaseURL: "http://localhost:11434"},
28
+ },
29
+ {
30
+ name: "bedrock",
31
+ cfg: BackendConfig{Backend: "bedrock", Region: "us-east-1", AWSKeyID: "key", AWSSecretKey: "secret"},
32
+ },
33
+ {
34
+ name: "unknown",
35
+ cfg: BackendConfig{Backend: "unknown"},
36
+ wantErr: true,
37
+ },
38
+ }
39
+
40
+ for _, tt := range tests {
41
+ t.Run(tt.name, func(t *testing.T) {
42
+ _, err := New(tt.cfg)
43
+ if (err != nil) != tt.wantErr {
44
+ t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
45
+ }
46
+ })
47
+ }
48
+}
49
+
50
+func TestBackendNames(t *testing.T) {
51
+ names := BackendNames()
52
+ if len(names) == 0 {
53
+ t.Error("expected non-empty backend names")
54
+ }
55
+
56
+ foundGemini := false
57
+ for _, n := range names {
58
+ if n == "gemini" {
59
+ foundGemini = true
60
+ break
61
+ }
62
+ }
63
+ if !foundGemini {
64
+ t.Error("expected gemini in backend names")
65
+ }
66
+}
67
+
68
+func TestKnownBackends(t *testing.T) {
69
+ if _, ok := KnownBackends["openai"]; !ok {
70
+ t.Error("expected opena
--- a/internal/llm/factory_test.go
+++ b/internal/llm/factory_test.go
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/factory_test.go
+++ b/internal/llm/factory_test.go
@@ -0,0 +1,70 @@
1 package llm
2
3 import (
4 "testing"
5 )
6
7 func TestNew(t *testing.T) {
8 tests := []struct {
9 name string
10 cfg BackendConfig
11 wantErr bool
12 }{
13 {
14 name: "openai",
15 cfg: BackendConfig{Backend: "openai", APIKey: "key"},
16 },
17 {
18 name: "anthropic",
19 cfg: BackendConfig{Backend: "anthropic", APIKey: "key"},
20 },
21 {
22 name: "gemini",
23 cfg: BackendConfig{Backend: "gemini", APIKey: "key"},
24 },
25 {
26 name: "ollama",
27 cfg: BackendConfig{Backend: "ollama", BaseURL: "http://localhost:11434"},
28 },
29 {
30 name: "bedrock",
31 cfg: BackendConfig{Backend: "bedrock", Region: "us-east-1", AWSKeyID: "key", AWSSecretKey: "secret"},
32 },
33 {
34 name: "unknown",
35 cfg: BackendConfig{Backend: "unknown"},
36 wantErr: true,
37 },
38 }
39
40 for _, tt := range tests {
41 t.Run(tt.name, func(t *testing.T) {
42 _, err := New(tt.cfg)
43 if (err != nil) != tt.wantErr {
44 t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
45 }
46 })
47 }
48 }
49
50 func TestBackendNames(t *testing.T) {
51 names := BackendNames()
52 if len(names) == 0 {
53 t.Error("expected non-empty backend names")
54 }
55
56 foundGemini := false
57 for _, n := range names {
58 if n == "gemini" {
59 foundGemini = true
60 break
61 }
62 }
63 if !foundGemini {
64 t.Error("expected gemini in backend names")
65 }
66 }
67
68 func TestKnownBackends(t *testing.T) {
69 if _, ok := KnownBackends["openai"]; !ok {
70 t.Error("expected opena
--- a/internal/llm/filter.go
+++ b/internal/llm/filter.go
@@ -0,0 +1,68 @@
1
+package llm
2
+
3
+import (
4
+ "fmt"
5
+ "regexp"
6
+)
7
+
8
+// ModelFilter applies allow/block regex patterns to a slice of ModelInfo.
9
+// Patterns are matched against model ID.
10
+type ModelFilter struct {
11
+ allowlist []*regexp.Regexp
12
+ blocklist []*regexp.Regexp
13
+}
14
+
15
+// NewModelFilter compiles regex allow/block patterns.
16
+// Returns an error if any pattern is invalid.
17
+func NewModelFilter(allow, block []string) (*ModelFilter, error) {
18
+ f := &ModelFilter{}
19
+ for _, pat := range allow {
20
+ r, err := regexp.Compile(pat)
21
+ if err != nil {
22
+ return nil, fmt.Errorf("llm: allow pattern %q: %w", pat, err)
23
+ }
24
+ f.allowlist = append(f.allowlist, r)
25
+ }
26
+ for _, pat := range block {
27
+ r, err := regexp.Compile(pat)
28
+ if err != nil {
29
+ return nil, fmt.Errorf("llm: block pattern %q: %w", pat, err)
30
+ }
31
+ f.blocklist = append(f.blocklist, r)
32
+ }
33
+ return f, nil
34
+}
35
+
36
+// Apply filters models: removes those matching any blocklist pattern, then
37
+// (if allowlist is non-empty) keeps only those matching at least one allowlist pattern.
38
+func (f *ModelFilter) Apply(models []ModelInfo) []ModelInfo {
39
+ out := make([]ModelInfo, 0, len(models))
40
+ for _, m := range models {
41
+ if f.blocked(m.ID) {
42
+ continue
43
+ }
44
+ if len(f.allowlist) > 0 && !f.allowed(m.ID) {
45
+ continue
46
+ }
47
+ out = append(out, m)
48
+ }
49
+ return out
50
+}
51
+
52
+func (f *ModelFilter) allowed(id string) bool {
53
+ for _, r := range f.allowlist {
54
+ if r.MatchString(id) {
55
+ return true
56
+ }
57
+ }
58
+ return false
59
+}
60
+
61
+func (f *ModelFilter) blocked(id string) bool {
62
+ for _, r := range f.blocklist {
63
+ if r.MatchString(id) {
64
+ return true
65
+ }
66
+ }
67
+ return false
68
+}
--- a/internal/llm/filter.go
+++ b/internal/llm/filter.go
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/filter.go
+++ b/internal/llm/filter.go
@@ -0,0 +1,68 @@
1 package llm
2
3 import (
4 "fmt"
5 "regexp"
6 )
7
8 // ModelFilter applies allow/block regex patterns to a slice of ModelInfo.
9 // Patterns are matched against model ID.
10 type ModelFilter struct {
11 allowlist []*regexp.Regexp
12 blocklist []*regexp.Regexp
13 }
14
15 // NewModelFilter compiles regex allow/block patterns.
16 // Returns an error if any pattern is invalid.
17 func NewModelFilter(allow, block []string) (*ModelFilter, error) {
18 f := &ModelFilter{}
19 for _, pat := range allow {
20 r, err := regexp.Compile(pat)
21 if err != nil {
22 return nil, fmt.Errorf("llm: allow pattern %q: %w", pat, err)
23 }
24 f.allowlist = append(f.allowlist, r)
25 }
26 for _, pat := range block {
27 r, err := regexp.Compile(pat)
28 if err != nil {
29 return nil, fmt.Errorf("llm: block pattern %q: %w", pat, err)
30 }
31 f.blocklist = append(f.blocklist, r)
32 }
33 return f, nil
34 }
35
36 // Apply filters models: removes those matching any blocklist pattern, then
37 // (if allowlist is non-empty) keeps only those matching at least one allowlist pattern.
38 func (f *ModelFilter) Apply(models []ModelInfo) []ModelInfo {
39 out := make([]ModelInfo, 0, len(models))
40 for _, m := range models {
41 if f.blocked(m.ID) {
42 continue
43 }
44 if len(f.allowlist) > 0 && !f.allowed(m.ID) {
45 continue
46 }
47 out = append(out, m)
48 }
49 return out
50 }
51
52 func (f *ModelFilter) allowed(id string) bool {
53 for _, r := range f.allowlist {
54 if r.MatchString(id) {
55 return true
56 }
57 }
58 return false
59 }
60
61 func (f *ModelFilter) blocked(id string) bool {
62 for _, r := range f.blocklist {
63 if r.MatchString(id) {
64 return true
65 }
66 }
67 return false
68 }
--- a/internal/llm/gemini.go
+++ b/internal/llm/gemini.go
@@ -0,0 +1,84 @@
1
+package llm
2
+
3
+import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+ "strings"
11
+)
12
+
13
+const geminiAPIBase = "https://generativelanguage.googleapis.com"
14
+
15
+type geminiProvider struct {
16
+ apiKey string
17
+ model string
18
+ baseURL string
19
+ http *http.Client
20
+}
21
+
22
+func newGeminiProvider(cfg BackendConfig, hc *http.Client) *geminiProvider {
23
+ model := cfg.Model
24
+ if model == "" {
25
+ model = "gemini-1.5-flash"
26
+ }
27
+ baseURL := cfg.BaseURL
28
+ if baseURL == "" {
29
+ baseURL = geminiAPIBase
30
+ }
31
+ return &geminiProvider{
32
+ apiKey: cfg.APIKey,
33
+ model: model,
34
+ baseURL: baseURL,
35
+ http: hc,
36
+ }
37
+}
38
+
39
+func (p *geminiProvider) Summarize(ctx context.Context, prompt string) (string, error) {
40
+ url := fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s", p.baseURL, p.model, p.apiKey)
41
+ body, _ := json.Marshal(map[string]any{
42
+ "contents": [] {
43
+ "parts":name"`
44
+ DisplayName: []map[string]any{
45
+ {"text": prompt},
46
+ },
47
+ },
48
+ {
49
+ "parts":
50
+ },
51
+ },
52
+ "generationConfig": map[string]any{
53
+ "maxOutputTokens": 512,
54
+ },
55
+ })
56
+
57
+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
58
+ if err != nil {
59
+ return "", err
60
+ }
61
+ req.Header.Set("Content-Type", "application/json")
62
+
63
+ resp, err := p.http.Do(req)
64
+ if err != nil {
65
+ return "", fmt.Errorf("gemini request: %w", err)
66
+ }
67
+ defer resp.Body.Close()
68
+
69
+ data, _ := io.ReadAll(resp.Body)
70
+ if resp.StatusCode != http.StatusOK {
71
+ return "", fmt.Errorf("gemini error %d: %s", resp.StatusCode, string(data))
72
+ }
73
+
74
+ var result struct {
75
+ Candidates []struct {
76
+ Content struct {
77
+ Parts []struct {
78
+ Text string `json:"text"`
79
+ } `json:"parts"`
80
+ } `json:"content"`
81
+ } `json:"candidates"`
82
+ }
83
+ if err := json.Unmarshal(data, &result); err != nil {
84
+ return "", fm
--- a/internal/llm/gemini.go
+++ b/internal/llm/gemini.go
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/gemini.go
+++ b/internal/llm/gemini.go
@@ -0,0 +1,84 @@
1 package llm
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "strings"
11 )
12
13 const geminiAPIBase = "https://generativelanguage.googleapis.com"
14
15 type geminiProvider struct {
16 apiKey string
17 model string
18 baseURL string
19 http *http.Client
20 }
21
22 func newGeminiProvider(cfg BackendConfig, hc *http.Client) *geminiProvider {
23 model := cfg.Model
24 if model == "" {
25 model = "gemini-1.5-flash"
26 }
27 baseURL := cfg.BaseURL
28 if baseURL == "" {
29 baseURL = geminiAPIBase
30 }
31 return &geminiProvider{
32 apiKey: cfg.APIKey,
33 model: model,
34 baseURL: baseURL,
35 http: hc,
36 }
37 }
38
39 func (p *geminiProvider) Summarize(ctx context.Context, prompt string) (string, error) {
40 url := fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s", p.baseURL, p.model, p.apiKey)
41 body, _ := json.Marshal(map[string]any{
42 "contents": [] {
43 "parts":name"`
44 DisplayName: []map[string]any{
45 {"text": prompt},
46 },
47 },
48 {
49 "parts":
50 },
51 },
52 "generationConfig": map[string]any{
53 "maxOutputTokens": 512,
54 },
55 })
56
57 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
58 if err != nil {
59 return "", err
60 }
61 req.Header.Set("Content-Type", "application/json")
62
63 resp, err := p.http.Do(req)
64 if err != nil {
65 return "", fmt.Errorf("gemini request: %w", err)
66 }
67 defer resp.Body.Close()
68
69 data, _ := io.ReadAll(resp.Body)
70 if resp.StatusCode != http.StatusOK {
71 return "", fmt.Errorf("gemini error %d: %s", resp.StatusCode, string(data))
72 }
73
74 var result struct {
75 Candidates []struct {
76 Content struct {
77 Parts []struct {
78 Text string `json:"text"`
79 } `json:"parts"`
80 } `json:"content"`
81 } `json:"candidates"`
82 }
83 if err := json.Unmarshal(data, &result); err != nil {
84 return "", fm
--- a/internal/llm/gemini_test.go
+++ b/internal/llm/gemini_test.go
@@ -0,0 +1,34 @@
1
+package llm
2
+
3
+import (
4
+ "context"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "testing"
9
+)
10
+
11
+func TestGeminiSummarize(t *testing.T) {
12
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13
+ if r.Method != "POST" {
14
+ t.Errorf("expected POST request, got %s", r.Method)
15
+ }
16
+ if r.URL.Path != "/v1beta/models/gemini-1.5-flash:generateContent" {
17
+ t.Errorf("unexpected path: %s", r.URL.Path)
18
+ }
19
+ if r.URL.Query().Get("key") != "test-api-key" {
20
+ t.Errorf("expected api key test-api-key, got %s", r.URL.Query().Get("key"))
21
+ }
22
+
23
+ resp := map[string]any{
24
+ "candidates": []map[string]any{
25
+ {
26
+ "content": map[string]any{
27
+ "parts": []map[string]any{
28
+ {"text": "gemini response"},
29
+ },
30
+ },
31
+ },
32
+ },
33
+ }
34
+ _ =
--- a/internal/llm/gemini_test.go
+++ b/internal/llm/gemini_test.go
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/gemini_test.go
+++ b/internal/llm/gemini_test.go
@@ -0,0 +1,34 @@
1 package llm
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9 )
10
11 func TestGeminiSummarize(t *testing.T) {
12 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13 if r.Method != "POST" {
14 t.Errorf("expected POST request, got %s", r.Method)
15 }
16 if r.URL.Path != "/v1beta/models/gemini-1.5-flash:generateContent" {
17 t.Errorf("unexpected path: %s", r.URL.Path)
18 }
19 if r.URL.Query().Get("key") != "test-api-key" {
20 t.Errorf("expected api key test-api-key, got %s", r.URL.Query().Get("key"))
21 }
22
23 resp := map[string]any{
24 "candidates": []map[string]any{
25 {
26 "content": map[string]any{
27 "parts": []map[string]any{
28 {"text": "gemini response"},
29 },
30 },
31 },
32 },
33 }
34 _ =
--- a/internal/llm/ollama.go
+++ b/internal/llm/ollama.go
@@ -0,0 +1,94 @@
1
+package llm
2
+
3
+import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+)
11
+
12
+type ollamaProvider struct {
13
+ baseURL string
14
+ model string
15
+ http *http.Client
16
+}
17
+
18
+func newOllamaProvider(cfg BackendConfig, baseURL string, hc *http.Client) *ollamaProvider {
19
+ model := cfg.Model
20
+ if model == "" {
21
+ model = "llama3.2"
22
+ }
23
+ return &ollamaProvider{
24
+ baseURL: baseURL,
25
+ model: model,
26
+ http: hc,
27
+ }
28
+}
29
+
30
+func (p *ollamaProvider) Summarize(ctx context.Context, prompt string) (string, error) {
31
+ body, _ := json.Marshal(map[string]any{
32
+ "model": p.model,
33
+ "prompt": prompt,
34
+ "stream": false,
35
+ })
36
+ req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/api/generate", bytes.NewReader(body))
37
+ if err != nil {
38
+ return "", err
39
+ }
40
+ req.Header.Set("Content-Type", "application/json")
41
+
42
+ resp, err := p.http.Do(req)
43
+ if err != nil {
44
+ return "", fmt.Errorf("ollama request: %w", err)
45
+ }
46
+ defer resp.Body.Close()
47
+
48
+ data, _ := io.ReadAll(resp.Body)
49
+ if resp.StatusCode != http.StatusOK {
50
+ return "", fmt.Errorf("ollama error %d: %s", resp.StatusCode, string(data))
51
+ }
52
+
53
+ var result struct {
54
+ Response string `json:"response"`
55
+ }
56
+ if err := json.Unmarshal(data, &result); err != nil {
57
+ return "", fmt.Errorf("ollama parse: %w", err)
58
+ }
59
+ return result.Response, nil
60
+}
61
+
62
+// DiscoverModels calls the Ollama /api/tags endpoint to list installed models.
63
+func (p *ollamaProvider) DiscoverModels(ctx context.Context) ([]ModelInfo, error) {
64
+ req, err := http.NewRequestWithContext(ctx, "GET", p.baseURL+"/api/tags", nil)
65
+ if err != nil {
66
+ return nil, err
67
+ }
68
+
69
+ resp, err := p.http.Do(req)
70
+ if err != nil {
71
+ return nil, fmt.Errorf("ollama models request: %w", err)
72
+ }
73
+ defer resp.Body.Close()
74
+
75
+ data, _ := io.ReadAll(resp.Body)
76
+ if resp.StatusCode != http.StatusOK {
77
+ return nil, fmt.Errorf("ollama models error %d: %s", resp.StatusCode, string(data))
78
+ }
79
+
80
+ var result struct {
81
+ Models []struct {
82
+ Name string `json:"name"`
83
+ } `json:"models"`
84
+ }
85
+ if err := json.Unmarshal(data, &result); err != nil {
86
+ return nil, fmt.Errorf("ollama models parse: %w", err)
87
+ }
88
+
89
+ models := make([]ModelInfo, len(result.Models))
90
+ for i, m := range result.Models {
91
+ models[i] = ModelInfo{ID: m.Name, Name: m.Name}
92
+ }
93
+ return models, nil
94
+}
--- a/internal/llm/ollama.go
+++ b/internal/llm/ollama.go
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/ollama.go
+++ b/internal/llm/ollama.go
@@ -0,0 +1,94 @@
1 package llm
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 )
11
12 type ollamaProvider struct {
13 baseURL string
14 model string
15 http *http.Client
16 }
17
18 func newOllamaProvider(cfg BackendConfig, baseURL string, hc *http.Client) *ollamaProvider {
19 model := cfg.Model
20 if model == "" {
21 model = "llama3.2"
22 }
23 return &ollamaProvider{
24 baseURL: baseURL,
25 model: model,
26 http: hc,
27 }
28 }
29
30 func (p *ollamaProvider) Summarize(ctx context.Context, prompt string) (string, error) {
31 body, _ := json.Marshal(map[string]any{
32 "model": p.model,
33 "prompt": prompt,
34 "stream": false,
35 })
36 req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/api/generate", bytes.NewReader(body))
37 if err != nil {
38 return "", err
39 }
40 req.Header.Set("Content-Type", "application/json")
41
42 resp, err := p.http.Do(req)
43 if err != nil {
44 return "", fmt.Errorf("ollama request: %w", err)
45 }
46 defer resp.Body.Close()
47
48 data, _ := io.ReadAll(resp.Body)
49 if resp.StatusCode != http.StatusOK {
50 return "", fmt.Errorf("ollama error %d: %s", resp.StatusCode, string(data))
51 }
52
53 var result struct {
54 Response string `json:"response"`
55 }
56 if err := json.Unmarshal(data, &result); err != nil {
57 return "", fmt.Errorf("ollama parse: %w", err)
58 }
59 return result.Response, nil
60 }
61
62 // DiscoverModels calls the Ollama /api/tags endpoint to list installed models.
63 func (p *ollamaProvider) DiscoverModels(ctx context.Context) ([]ModelInfo, error) {
64 req, err := http.NewRequestWithContext(ctx, "GET", p.baseURL+"/api/tags", nil)
65 if err != nil {
66 return nil, err
67 }
68
69 resp, err := p.http.Do(req)
70 if err != nil {
71 return nil, fmt.Errorf("ollama models request: %w", err)
72 }
73 defer resp.Body.Close()
74
75 data, _ := io.ReadAll(resp.Body)
76 if resp.StatusCode != http.StatusOK {
77 return nil, fmt.Errorf("ollama models error %d: %s", resp.StatusCode, string(data))
78 }
79
80 var result struct {
81 Models []struct {
82 Name string `json:"name"`
83 } `json:"models"`
84 }
85 if err := json.Unmarshal(data, &result); err != nil {
86 return nil, fmt.Errorf("ollama models parse: %w", err)
87 }
88
89 models := make([]ModelInfo, len(result.Models))
90 for i, m := range result.Models {
91 models[i] = ModelInfo{ID: m.Name, Name: m.Name}
92 }
93 return models, nil
94 }
--- a/internal/llm/ollama_test.go
+++ b/internal/llm/ollama_test.go
@@ -0,0 +1,81 @@
1
+package llm
2
+
3
+import (
4
+ "context"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "testing"
9
+)
10
+
11
+func TestOllamaSummarize(t *testing.T) {
12
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13
+ if r.Method != "POST" {
14
+ t.Errorf("expected POST request, got %s", r.Method)
15
+ }
16
+ if r.URL.Path != "/api/generate" {
17
+ t.Errorf("unexpected path: %s", r.URL.Path)
18
+ }
19
+
20
+ var req struct {
21
+ Model string `json:"model"`
22
+ Prompt string `json:"prompt"`
23
+ Stream bool `json:"stream"`
24
+ }
25
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
26
+ t.Fatalf("decode request: %v", err)
27
+ }
28
+
29
+ resp := map[string]any{
30
+ "response": "ollama response",
31
+ }
32
+ _ = json.NewEncoder(w).Encode(resp)
33
+ }))
34
+ defer srv.Close()
35
+
36
+ p := newOllamaProvider(BackendConfig{
37
+ Backend: "ollama",
38
+ Model: "test-model",
39
+ }, srv.URL, srv.Client())
40
+
41
+ got, err := p.Summarize(context.Background(), "test prompt")
42
+ if err != nil {
43
+ t.Fatalf("Summarize failed: %v", err)
44
+ }
45
+ if got != "ollama response" {
46
+ t.Errorf("got %q, want %q", got, "ollama response")
47
+ }
48
+}
49
+
50
+func TestOllamaDiscoverModels(t *testing.T) {
51
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52
+ if r.Method != "GET" {
53
+ t.Errorf("expected GET request, got %s", r.Method)
54
+ }
55
+ if r.URL.Path != "/api/tags" {
56
+ t.Errorf("unexpected path: %s", r.URL.Path)
57
+ }
58
+
59
+ resp := map[string]any{
60
+ "models": []map[string]any{
61
+ {"name": "model1"},
62
+ {"name": "model2"},
63
+ },
64
+ }
65
+ _ = json.NewEncoder(w).Encode(resp)
66
+ }))
67
+ defer srv.Close()
68
+
69
+ p := newOllamaProvider(BackendConfig{
70
+ Backend: "ollama",
71
+ }, srv.URL, srv.Client())
72
+
73
+ models, err := p.DiscoverModels(context.Background())
74
+ if err != nil {
75
+ t.Fatalf("DiscoverModels failed: %v", err)
76
+ }
77
+
78
+ if len(models) != 2 {
79
+ t.Errorf("got %d models, want 2", len(models))
80
+ }
81
+}
--- a/internal/llm/ollama_test.go
+++ b/internal/llm/ollama_test.go
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/ollama_test.go
+++ b/internal/llm/ollama_test.go
@@ -0,0 +1,81 @@
1 package llm
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9 )
10
11 func TestOllamaSummarize(t *testing.T) {
12 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13 if r.Method != "POST" {
14 t.Errorf("expected POST request, got %s", r.Method)
15 }
16 if r.URL.Path != "/api/generate" {
17 t.Errorf("unexpected path: %s", r.URL.Path)
18 }
19
20 var req struct {
21 Model string `json:"model"`
22 Prompt string `json:"prompt"`
23 Stream bool `json:"stream"`
24 }
25 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
26 t.Fatalf("decode request: %v", err)
27 }
28
29 resp := map[string]any{
30 "response": "ollama response",
31 }
32 _ = json.NewEncoder(w).Encode(resp)
33 }))
34 defer srv.Close()
35
36 p := newOllamaProvider(BackendConfig{
37 Backend: "ollama",
38 Model: "test-model",
39 }, srv.URL, srv.Client())
40
41 got, err := p.Summarize(context.Background(), "test prompt")
42 if err != nil {
43 t.Fatalf("Summarize failed: %v", err)
44 }
45 if got != "ollama response" {
46 t.Errorf("got %q, want %q", got, "ollama response")
47 }
48 }
49
50 func TestOllamaDiscoverModels(t *testing.T) {
51 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52 if r.Method != "GET" {
53 t.Errorf("expected GET request, got %s", r.Method)
54 }
55 if r.URL.Path != "/api/tags" {
56 t.Errorf("unexpected path: %s", r.URL.Path)
57 }
58
59 resp := map[string]any{
60 "models": []map[string]any{
61 {"name": "model1"},
62 {"name": "model2"},
63 },
64 }
65 _ = json.NewEncoder(w).Encode(resp)
66 }))
67 defer srv.Close()
68
69 p := newOllamaProvider(BackendConfig{
70 Backend: "ollama",
71 }, srv.URL, srv.Client())
72
73 models, err := p.DiscoverModels(context.Background())
74 if err != nil {
75 t.Fatalf("DiscoverModels failed: %v", err)
76 }
77
78 if len(models) != 2 {
79 t.Errorf("got %d models, want 2", len(models))
80 }
81 }
--- a/internal/llm/openai.go
+++ b/internal/llm/openai.go
@@ -0,0 +1,139 @@
1
+package llm
2
+
3
+import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+ "strings"
11
+)
12
+
13
+// openAIProvider implements Provider and ModelDiscoverer for any OpenAI-compatible API.
14
+type openAIProvider struct {
15
+ baseURL string
16
+ apiKey string
17
+ model string
18
+ http *http.Client
19
+}
20
+
21
+func newOpenAIProvider(apiKey, baseURL, model string, hc *http.Client) *openAIProvider {
22
+ return &openAIProvider{
23
+ baseURL: baseURL,
24
+ apiKey: apiKey,
25
+ model: model,
26
+ http: hc,
27
+ }
28
+}
29
+
30
+func (p *openAIProvider) Summarize(ctx context.Context, prompt string) (string, error) {
31
+ text, status, data, err := p.summarizeWithTokenField(ctx, prompt, "max_tokens")
32
+ if err == nil {
33
+ return text, nil
34
+ }
35
+ if shouldRetryWithMaxCompletionTokens(status, data) {
36
+ text, _, _, err := p.summarizeWithTokenField(ctx, prompt, "max_completion_tokens")
37
+ return text, err
38
+ }
39
+ return "", err
40
+}
41
+
42
+func (p *openAIProvider) summarizeWithTokenField(ctx context.Context, prompt, tokenField string) (string, int, []byte, error) {
43
+ body, _ := json.Marshal(map[string]any{
44
+ "model": p.model,
45
+ "messages": []map[string]string{
46
+ {"role": "user", "content": prompt},
47
+ },
48
+ tokenField: 512,
49
+ })
50
+ req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/chat/completions", bytes.NewReader(body))
51
+ if err != nil {
52
+ return "", 0, nil, err
53
+ }
54
+ if p.apiKey != "" {
55
+ req.Header.Set("Authorization", "Bearer "+p.apiKey)
56
+ }
57
+ req.Header.Set("Content-Type", "application/json")
58
+
59
+ resp, err := p.http.Do(req)
60
+ if err != nil {
61
+ return "", 0, nil, fmt.Errorf("openai request: %w", err)
62
+ }
63
+ defer resp.Body.Close()
64
+
65
+ data, _ := io.ReadAll(resp.Body)
66
+ if resp.StatusCode != http.StatusOK {
67
+ return "", resp.StatusCode, data, fmt.Errorf("openai error %d: %s", resp.StatusCode, string(data))
68
+ }
69
+
70
+ var result struct {
71
+ Choices []struct {
72
+ Message struct {
73
+ Content string `json:"content"`
74
+ } `json:"message"`
75
+ } `json:"choices"`
76
+ }
77
+ if err := json.Unmarshal(data, &result); err != nil {
78
+ return "", resp.StatusCode, data, fmt.Errorf("openai parse: %w", err)
79
+ }
80
+ if len(result.Choices) == 0 {
81
+ return "", resp.StatusCode, data, fmt.Errorf("openai returned no choices")
82
+ }
83
+ return result.Choices[0].Message.Content, resp.StatusCode, data, nil
84
+}
85
+
86
+func shouldRetryWithMaxCompletionTokens(status int, data []byte) bool {
87
+ if status != http.StatusBadRequest {
88
+ return false
89
+ }
90
+ var result struct {
91
+ Error struct {
92
+ Message string `json:"message"`
93
+ Param string `json:"param"`
94
+ } `json:"error"`
95
+ }
96
+ if err := json.Unmarshal(data, &result); err == nil {
97
+ if result.Error.Param == "max_tokens" && strings.Contains(strings.ToLower(result.Error.Message), "not supported") {
98
+ return true
99
+ }
100
+ }
101
+ lower := strings.ToLower(string(data))
102
+ return strings.Contains(lower, "unsupported parameter") && strings.Contains(lower, "max_tokens")
103
+}
104
+
105
+func (p *openAIProvider) DiscoverModels(ctx context.Context) ([]ModelInfo, error) {
106
+ req, err := http.NewRequestWithContext(ctx, "GET", p.baseURL+"/models", nil)
107
+ if err != nil {
108
+ return nil, err
109
+ }
110
+ if p.apiKey != "" {
111
+ req.Header.Set("Authorization", "Bearer "+p.apiKey)
112
+ }
113
+
114
+ resp, err := p.http.Do(req)
115
+ if err != nil {
116
+ return nil, fmt.Errorf("models request: %w", err)
117
+ }
118
+ defer resp.Body.Close()
119
+
120
+ data, _ := io.ReadAll(resp.Body)
121
+ if resp.StatusCode != http.StatusOK {
122
+ return nil, fmt.Errorf("models error %d: %s", resp.StatusCode, string(data))
123
+ }
124
+
125
+ var result struct {
126
+ Data []struct {
127
+ ID string `json:"id"`
128
+ } `json:"data"`
129
+ }
130
+ if err := json.Unmarshal(data, &result); err != nil {
131
+ return nil, fmt.Errorf("models parse: %w", err)
132
+ }
133
+
134
+ models := make([]ModelInfo, len(result.Data))
135
+ for i, m := range result.Data {
136
+ models[i] = ModelInfo{ID: m.ID}
137
+ }
138
+ return models, nil
139
+}
--- a/internal/llm/openai.go
+++ b/internal/llm/openai.go
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/openai.go
+++ b/internal/llm/openai.go
@@ -0,0 +1,139 @@
1 package llm
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "strings"
11 )
12
13 // openAIProvider implements Provider and ModelDiscoverer for any OpenAI-compatible API.
14 type openAIProvider struct {
15 baseURL string
16 apiKey string
17 model string
18 http *http.Client
19 }
20
21 func newOpenAIProvider(apiKey, baseURL, model string, hc *http.Client) *openAIProvider {
22 return &openAIProvider{
23 baseURL: baseURL,
24 apiKey: apiKey,
25 model: model,
26 http: hc,
27 }
28 }
29
30 func (p *openAIProvider) Summarize(ctx context.Context, prompt string) (string, error) {
31 text, status, data, err := p.summarizeWithTokenField(ctx, prompt, "max_tokens")
32 if err == nil {
33 return text, nil
34 }
35 if shouldRetryWithMaxCompletionTokens(status, data) {
36 text, _, _, err := p.summarizeWithTokenField(ctx, prompt, "max_completion_tokens")
37 return text, err
38 }
39 return "", err
40 }
41
42 func (p *openAIProvider) summarizeWithTokenField(ctx context.Context, prompt, tokenField string) (string, int, []byte, error) {
43 body, _ := json.Marshal(map[string]any{
44 "model": p.model,
45 "messages": []map[string]string{
46 {"role": "user", "content": prompt},
47 },
48 tokenField: 512,
49 })
50 req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/chat/completions", bytes.NewReader(body))
51 if err != nil {
52 return "", 0, nil, err
53 }
54 if p.apiKey != "" {
55 req.Header.Set("Authorization", "Bearer "+p.apiKey)
56 }
57 req.Header.Set("Content-Type", "application/json")
58
59 resp, err := p.http.Do(req)
60 if err != nil {
61 return "", 0, nil, fmt.Errorf("openai request: %w", err)
62 }
63 defer resp.Body.Close()
64
65 data, _ := io.ReadAll(resp.Body)
66 if resp.StatusCode != http.StatusOK {
67 return "", resp.StatusCode, data, fmt.Errorf("openai error %d: %s", resp.StatusCode, string(data))
68 }
69
70 var result struct {
71 Choices []struct {
72 Message struct {
73 Content string `json:"content"`
74 } `json:"message"`
75 } `json:"choices"`
76 }
77 if err := json.Unmarshal(data, &result); err != nil {
78 return "", resp.StatusCode, data, fmt.Errorf("openai parse: %w", err)
79 }
80 if len(result.Choices) == 0 {
81 return "", resp.StatusCode, data, fmt.Errorf("openai returned no choices")
82 }
83 return result.Choices[0].Message.Content, resp.StatusCode, data, nil
84 }
85
86 func shouldRetryWithMaxCompletionTokens(status int, data []byte) bool {
87 if status != http.StatusBadRequest {
88 return false
89 }
90 var result struct {
91 Error struct {
92 Message string `json:"message"`
93 Param string `json:"param"`
94 } `json:"error"`
95 }
96 if err := json.Unmarshal(data, &result); err == nil {
97 if result.Error.Param == "max_tokens" && strings.Contains(strings.ToLower(result.Error.Message), "not supported") {
98 return true
99 }
100 }
101 lower := strings.ToLower(string(data))
102 return strings.Contains(lower, "unsupported parameter") && strings.Contains(lower, "max_tokens")
103 }
104
105 func (p *openAIProvider) DiscoverModels(ctx context.Context) ([]ModelInfo, error) {
106 req, err := http.NewRequestWithContext(ctx, "GET", p.baseURL+"/models", nil)
107 if err != nil {
108 return nil, err
109 }
110 if p.apiKey != "" {
111 req.Header.Set("Authorization", "Bearer "+p.apiKey)
112 }
113
114 resp, err := p.http.Do(req)
115 if err != nil {
116 return nil, fmt.Errorf("models request: %w", err)
117 }
118 defer resp.Body.Close()
119
120 data, _ := io.ReadAll(resp.Body)
121 if resp.StatusCode != http.StatusOK {
122 return nil, fmt.Errorf("models error %d: %s", resp.StatusCode, string(data))
123 }
124
125 var result struct {
126 Data []struct {
127 ID string `json:"id"`
128 } `json:"data"`
129 }
130 if err := json.Unmarshal(data, &result); err != nil {
131 return nil, fmt.Errorf("models parse: %w", err)
132 }
133
134 models := make([]ModelInfo, len(result.Data))
135 for i, m := range result.Data {
136 models[i] = ModelInfo{ID: m.ID}
137 }
138 return models, nil
139 }
--- a/internal/llm/openai_test.go
+++ b/internal/llm/openai_test.go
@@ -0,0 +1,67 @@
1
+package llm
2
+
3
+import (
4
+ "context"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "testing"
9
+)
10
+
11
+func TestOpenAISummarizeRetriesWithMaxCompletionTokens(t *testing.T) {
12
+ t.Helper()
13
+
14
+ requests := 0
15
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16
+ requests++
17
+
18
+ var body map[string]any
19
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
20
+ t.Fatalf("decode request: %v", err)
21
+ }
22
+
23
+ switch requests {
24
+ case 1:
25
+ if _, ok := body["max_tokens"]; !ok {
26
+ t.Fatalf("first request missing max_tokens: %#v", body)
27
+ }
28
+ w.WriteHeader(http.StatusBadRequest)
29
+ _, _ = w.Write([]byte(`{"error":{"message":"Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.","param":"max_tokens"}}`))
30
+ case 2:
31
+ if _, ok := body["max_completion_tokens"]; !ok {
32
+ t.Fatalf("second request missing max_completion_tokens: %#v", body)
33
+ }
34
+ if _, ok := body["max_tokens"]; ok {
35
+ t.Fatalf("second request still included max_tokens: %#v", body)
36
+ }
37
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"relay smoke test succeeded"}}]}`))
38
+ default:
39
+ t.Fatalf("unexpected extra request %d", requests)
40
+ }
41
+ }))
42
+ defer srv.Close()
43
+
44
+ p := newOpenAIProvider("test-key", srv.URL, "gpt-5.4-mini", srv.Client())
45
+ got, err := p.Summarize(context.Background(), "test prompt")
46
+ if err != nil {
47
+ t.Fatalf("Summarize returned error: %v", err)
48
+ }
49
+ if got != "relay smoke test succeeded" {
50
+ t.Fatalf("Summarize = %q, want %q", got, "relay smoke test succeeded")
51
+ }
52
+ if requests != 2 {
53
+ t.Fatalf("request count = %d, want 2", requests)
54
+ }
55
+}
56
+
57
+func TestShouldRetryWithMaxCompletionTokens(t *testing.T) {
58
+ t.Helper()
59
+
60
+ body := []byte(`{"error":{"message":"Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.","param":"max_tokens"}}`)
61
+ if !shouldRetryWithMaxCompletionTokens(http.StatusBadRequest, body) {
62
+ t.Fatalf("expected retry to be enabled")
63
+ }
64
+ if shouldRetryWithMaxCompletionTokens(http.StatusUnauthorized, body) {
65
+ t.Fatalf("unexpected retry on unauthorized response")
66
+ }
67
+}
--- a/internal/llm/openai_test.go
+++ b/internal/llm/openai_test.go
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/openai_test.go
+++ b/internal/llm/openai_test.go
@@ -0,0 +1,67 @@
1 package llm
2
3 import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9 )
10
11 func TestOpenAISummarizeRetriesWithMaxCompletionTokens(t *testing.T) {
12 t.Helper()
13
14 requests := 0
15 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16 requests++
17
18 var body map[string]any
19 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
20 t.Fatalf("decode request: %v", err)
21 }
22
23 switch requests {
24 case 1:
25 if _, ok := body["max_tokens"]; !ok {
26 t.Fatalf("first request missing max_tokens: %#v", body)
27 }
28 w.WriteHeader(http.StatusBadRequest)
29 _, _ = w.Write([]byte(`{"error":{"message":"Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.","param":"max_tokens"}}`))
30 case 2:
31 if _, ok := body["max_completion_tokens"]; !ok {
32 t.Fatalf("second request missing max_completion_tokens: %#v", body)
33 }
34 if _, ok := body["max_tokens"]; ok {
35 t.Fatalf("second request still included max_tokens: %#v", body)
36 }
37 _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"relay smoke test succeeded"}}]}`))
38 default:
39 t.Fatalf("unexpected extra request %d", requests)
40 }
41 }))
42 defer srv.Close()
43
44 p := newOpenAIProvider("test-key", srv.URL, "gpt-5.4-mini", srv.Client())
45 got, err := p.Summarize(context.Background(), "test prompt")
46 if err != nil {
47 t.Fatalf("Summarize returned error: %v", err)
48 }
49 if got != "relay smoke test succeeded" {
50 t.Fatalf("Summarize = %q, want %q", got, "relay smoke test succeeded")
51 }
52 if requests != 2 {
53 t.Fatalf("request count = %d, want 2", requests)
54 }
55 }
56
57 func TestShouldRetryWithMaxCompletionTokens(t *testing.T) {
58 t.Helper()
59
60 body := []byte(`{"error":{"message":"Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.","param":"max_tokens"}}`)
61 if !shouldRetryWithMaxCompletionTokens(http.StatusBadRequest, body) {
62 t.Fatalf("expected retry to be enabled")
63 }
64 if shouldRetryWithMaxCompletionTokens(http.StatusUnauthorized, body) {
65 t.Fatalf("unexpected retry on unauthorized response")
66 }
67 }
--- a/internal/llm/provider.go
+++ b/internal/llm/provider.go
@@ -0,0 +1,35 @@
1
+// Package llm is the omnibus LLM gateway — any bot or service can use it to
2
+// call language models without depending on a specific provider's SDK.
3
+//
4
+// Usage:
5
+//
6
+// p, err := llm.New(llm.BackendConfig{Backend: "anthropic", APIKey: "sk-ant-..."})
7
+// text, err := p.Summarize(ctx, prompt)
8
+//
9
+// Model discovery (if the provider implements ModelDiscoverer):
10
+//
11
+// if d, ok := p.(llm.ModelDiscoverer); ok {
12
+// models, err := d.DiscoverModels(ctx)
13
+// }
14
+package llm
15
+
16
+import "context"
17
+
18
+// Provider calls a language model to generate text.
19
+// All provider implementations satisfy this interface.
20
+type Provider interface {
21
+ Summarize(ctx context.Context, prompt string) (string, error)
22
+}
23
+
24
+// ModelInfo describes a model returned by discovery.
25
+type ModelInfo struct {
26
+ ID string `json:"id"`
27
+ Name string `json:"name,omitempty"`
28
+ Description string `json:"description,omitempty"`
29
+}
30
+
31
+// ModelDiscoverer can enumerate available models for a backend.
32
+// Providers that support live model listing implement this interface.
33
+type ModelDiscoverer interface {
34
+ DiscoverModels(ctx context.Context) ([]ModelInfo, error)
35
+}
--- a/internal/llm/provider.go
+++ b/internal/llm/provider.go
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/provider.go
+++ b/internal/llm/provider.go
@@ -0,0 +1,35 @@
1 // Package llm is the omnibus LLM gateway — any bot or service can use it to
2 // call language models without depending on a specific provider's SDK.
3 //
4 // Usage:
5 //
6 // p, err := llm.New(llm.BackendConfig{Backend: "anthropic", APIKey: "sk-ant-..."})
7 // text, err := p.Summarize(ctx, prompt)
8 //
9 // Model discovery (if the provider implements ModelDiscoverer):
10 //
11 // if d, ok := p.(llm.ModelDiscoverer); ok {
12 // models, err := d.DiscoverModels(ctx)
13 // }
14 package llm
15
16 import "context"
17
18 // Provider calls a language model to generate text.
19 // All provider implementations satisfy this interface.
20 type Provider interface {
21 Summarize(ctx context.Context, prompt string) (string, error)
22 }
23
24 // ModelInfo describes a model returned by discovery.
25 type ModelInfo struct {
26 ID string `json:"id"`
27 Name string `json:"name,omitempty"`
28 Description string `json:"description,omitempty"`
29 }
30
31 // ModelDiscoverer can enumerate available models for a backend.
32 // Providers that support live model listing implement this interface.
33 type ModelDiscoverer interface {
34 DiscoverModels(ctx context.Context) ([]ModelInfo, error)
35 }
--- a/internal/llm/stub.go
+++ b/internal/llm/stub.go
@@ -0,0 +1,13 @@
1
+package llm
2
+
3
+import "context"
4
+
5
+// StubProvider returns a fixed response. Useful in tests and as a no-op placeholder.
6
+type StubProvider struct {
7
+ Response string
8
+ Err error
9
+}
10
+
11
+func (s *StubProvider) Summarize(_ context.Context, _ string) (string, error) {
12
+ return s.Response, s.Err
13
+}
--- a/internal/llm/stub.go
+++ b/internal/llm/stub.go
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/llm/stub.go
+++ b/internal/llm/stub.go
@@ -0,0 +1,13 @@
1 package llm
2
3 import "context"
4
5 // StubProvider returns a fixed response. Useful in tests and as a no-op placeholder.
6 type StubProvider struct {
7 Response string
8 Err error
9 }
10
11 func (s *StubProvider) Summarize(_ context.Context, _ string) (string, error) {
12 return s.Response, s.Err
13 }
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -10,10 +10,12 @@
1010
"crypto/rand"
1111
"crypto/sha256"
1212
"encoding/hex"
1313
"encoding/json"
1414
"fmt"
15
+ "os"
16
+ "strings"
1517
"sync"
1618
"time"
1719
)
1820
1921
// AgentType describes an agent's role and authority level.
@@ -70,20 +72,64 @@
7072
type Registry struct {
7173
mu sync.RWMutex
7274
agents map[string]*Agent // keyed by nick
7375
provisioner AccountProvisioner
7476
signingKey []byte
77
+ dataPath string // path to persist agents JSON; empty = no persistence
7578
}
7679
7780
// New creates a new Registry with the given provisioner and HMAC signing key.
81
+// Call SetDataPath to enable persistence before registering any agents.
7882
func New(provisioner AccountProvisioner, signingKey []byte) *Registry {
7983
return &Registry{
8084
agents: make(map[string]*Agent),
8185
provisioner: provisioner,
8286
signingKey: signingKey,
8387
}
8488
}
89
+
90
+// SetDataPath enables persistence. The registry is loaded from path immediately
91
+// (non-fatal if the file doesn't exist yet) and saved there after every mutation.
92
+func (r *Registry) SetDataPath(path string) error {
93
+ r.mu.Lock()
94
+ defer r.mu.Unlock()
95
+ r.dataPath = path
96
+ return r.load()
97
+}
98
+
99
+func (r *Registry) load() error {
100
+ data, err := os.ReadFile(r.dataPath)
101
+ if os.IsNotExist(err) {
102
+ return nil
103
+ }
104
+ if err != nil {
105
+ return fmt.Errorf("registry: load: %w", err)
106
+ }
107
+ var agents []*Agent
108
+ if err := json.Unmarshal(data, &agents); err != nil {
109
+ return fmt.Errorf("registry: load: %w", err)
110
+ }
111
+ for _, a := range agents {
112
+ r.agents[a.Nick] = a
113
+ }
114
+ return nil
115
+}
116
+
117
+func (r *Registry) save() {
118
+ if r.dataPath == "" {
119
+ return
120
+ }
121
+ agents := make([]*Agent, 0, len(r.agents))
122
+ for _, a := range r.agents {
123
+ agents = append(agents, a)
124
+ }
125
+ data, err := json.MarshalIndent(agents, "", " ")
126
+ if err != nil {
127
+ return
128
+ }
129
+ _ = os.WriteFile(r.dataPath, data, 0600)
130
+}
85131
86132
// Register creates a new agent, provisions its Ergo account, and returns
87133
// credentials and a signed rules-of-engagement payload.
88134
// cfg is validated before any provisioning occurs.
89135
func (r *Registry) Register(nick string, agentType AgentType, cfg EngagementConfig) (*Credentials, *SignedPayload, error) {
@@ -102,11 +148,53 @@
102148
if err != nil {
103149
return nil, nil, fmt.Errorf("registry: generate passphrase: %w", err)
104150
}
105151
106152
if err := r.provisioner.RegisterAccount(nick, passphrase); err != nil {
107
- return nil, nil, fmt.Errorf("registry: provision account: %w", err)
153
+ // Account exists in NickServ from a previous run — sync the password.
154
+ if strings.Contains(err.Error(), "ACCOUNT_EXISTS") {
155
+ if err2 := r.provisioner.ChangePassword(nick, passphrase); err2 != nil {
156
+ return nil, nil, fmt.Errorf("registry: provision account: %w", err2)
157
+ }
158
+ } else {
159
+ return nil, nil, fmt.Errorf("registry: provision account: %w", err)
160
+ }
161
+ }
162
+
163
+ agent := &Agent{
164
+ Nick: nick,
165
+ Type: agentType,
166
+ Channels: cfg.Channels,
167
+ Permissions: cfg.Permissions,
168
+ Config: cfg,
169
+ CreatedAt: time.Now(),
170
+ }
171
+ r.agents[nick] = agent
172
+ r.save()
173
+
174
+ payload, err := r.signPayload(agent)
175
+ if err != nil {
176
+ return nil, nil, fmt.Errorf("registry: sign payload: %w", err)
177
+ }
178
+
179
+ return &Credentials{Nick: nick, Passphrase: passphrase}, payload, nil
180
+}
181
+
182
+// Adopt adds a pre-existing NickServ account to the registry without touching
183
+// its password. The caller is responsible for knowing their own passphrase.
184
+// Returns a signed payload; no Credentials are returned since the password
185
+// is not changed.
186
+func (r *Registry) Adopt(nick string, agentType AgentType, cfg EngagementConfig) (*SignedPayload, error) {
187
+ if err := cfg.Validate(); err != nil {
188
+ return nil, fmt.Errorf("registry: invalid engagement config: %w", err)
189
+ }
190
+
191
+ r.mu.Lock()
192
+ defer r.mu.Unlock()
193
+
194
+ if existing, ok := r.agents[nick]; ok && !existing.Revoked {
195
+ return nil, fmt.Errorf("registry: agent %q already registered", nick)
108196
}
109197
110198
agent := &Agent{
111199
Nick: nick,
112200
Type: agentType,
@@ -114,26 +202,21 @@
114202
Permissions: cfg.Permissions,
115203
Config: cfg,
116204
CreatedAt: time.Now(),
117205
}
118206
r.agents[nick] = agent
207
+ r.save()
119208
120
- payload, err := r.signPayload(agent)
121
- if err != nil {
122
- return nil, nil, fmt.Errorf("registry: sign payload: %w", err)
123
- }
124
-
125
- return &Credentials{Nick: nick, Passphrase: passphrase}, payload, nil
209
+ return r.signPayload(agent)
126210
}
127211
128212
// Rotate generates a new passphrase for an agent and updates Ergo.
129213
func (r *Registry) Rotate(nick string) (*Credentials, error) {
130214
r.mu.Lock()
131215
defer r.mu.Unlock()
132216
133
- agent, err := r.get(nick)
134
- if err != nil {
217
+ if _, err := r.get(nick); err != nil {
135218
return nil, err
136219
}
137220
138221
passphrase, err := generatePassphrase()
139222
if err != nil {
@@ -142,11 +225,11 @@
142225
143226
if err := r.provisioner.ChangePassword(nick, passphrase); err != nil {
144227
return nil, fmt.Errorf("registry: rotate credentials: %w", err)
145228
}
146229
147
- _ = agent // agent exists, credentials rotated
230
+ r.save()
148231
return &Credentials{Nick: nick, Passphrase: passphrase}, nil
149232
}
150233
151234
// Revoke locks an agent out by rotating to an unguessable passphrase and
152235
// marking it revoked in the registry.
@@ -167,10 +250,39 @@
167250
if err := r.provisioner.ChangePassword(nick, lockout); err != nil {
168251
return fmt.Errorf("registry: revoke credentials: %w", err)
169252
}
170253
171254
agent.Revoked = true
255
+ r.save()
256
+ return nil
257
+}
258
+
259
+// Delete fully removes an agent from the registry. The Ergo NickServ account
260
+// is locked out first (password rotated to an unguessable value) so the agent
261
+// can no longer connect, then the entry is removed from the registry. If the
262
+// agent is already revoked the lockout step is skipped.
263
+func (r *Registry) Delete(nick string) error {
264
+ r.mu.Lock()
265
+ defer r.mu.Unlock()
266
+
267
+ agent, ok := r.agents[nick]
268
+ if !ok {
269
+ return fmt.Errorf("registry: agent %q not found", nick)
270
+ }
271
+
272
+ if !agent.Revoked {
273
+ lockout, err := generatePassphrase()
274
+ if err != nil {
275
+ return fmt.Errorf("registry: generate lockout passphrase: %w", err)
276
+ }
277
+ if err := r.provisioner.ChangePassword(nick, lockout); err != nil {
278
+ return fmt.Errorf("registry: delete lockout: %w", err)
279
+ }
280
+ }
281
+
282
+ delete(r.agents, nick)
283
+ r.save()
172284
return nil
173285
}
174286
175287
// Get returns the agent with the given nick.
176288
func (r *Registry) Get(nick string) (*Agent, error) {
177289
178290
ADDED pkg/agentrelay/relay.go
179291
ADDED run.sh
180292
ADDED scuttlectl
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -10,10 +10,12 @@
10 "crypto/rand"
11 "crypto/sha256"
12 "encoding/hex"
13 "encoding/json"
14 "fmt"
 
 
15 "sync"
16 "time"
17 )
18
19 // AgentType describes an agent's role and authority level.
@@ -70,20 +72,64 @@
70 type Registry struct {
71 mu sync.RWMutex
72 agents map[string]*Agent // keyed by nick
73 provisioner AccountProvisioner
74 signingKey []byte
 
75 }
76
77 // New creates a new Registry with the given provisioner and HMAC signing key.
 
78 func New(provisioner AccountProvisioner, signingKey []byte) *Registry {
79 return &Registry{
80 agents: make(map[string]*Agent),
81 provisioner: provisioner,
82 signingKey: signingKey,
83 }
84 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
86 // Register creates a new agent, provisions its Ergo account, and returns
87 // credentials and a signed rules-of-engagement payload.
88 // cfg is validated before any provisioning occurs.
89 func (r *Registry) Register(nick string, agentType AgentType, cfg EngagementConfig) (*Credentials, *SignedPayload, error) {
@@ -102,11 +148,53 @@
102 if err != nil {
103 return nil, nil, fmt.Errorf("registry: generate passphrase: %w", err)
104 }
105
106 if err := r.provisioner.RegisterAccount(nick, passphrase); err != nil {
107 return nil, nil, fmt.Errorf("registry: provision account: %w", err)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108 }
109
110 agent := &Agent{
111 Nick: nick,
112 Type: agentType,
@@ -114,26 +202,21 @@
114 Permissions: cfg.Permissions,
115 Config: cfg,
116 CreatedAt: time.Now(),
117 }
118 r.agents[nick] = agent
 
119
120 payload, err := r.signPayload(agent)
121 if err != nil {
122 return nil, nil, fmt.Errorf("registry: sign payload: %w", err)
123 }
124
125 return &Credentials{Nick: nick, Passphrase: passphrase}, payload, nil
126 }
127
128 // Rotate generates a new passphrase for an agent and updates Ergo.
129 func (r *Registry) Rotate(nick string) (*Credentials, error) {
130 r.mu.Lock()
131 defer r.mu.Unlock()
132
133 agent, err := r.get(nick)
134 if err != nil {
135 return nil, err
136 }
137
138 passphrase, err := generatePassphrase()
139 if err != nil {
@@ -142,11 +225,11 @@
142
143 if err := r.provisioner.ChangePassword(nick, passphrase); err != nil {
144 return nil, fmt.Errorf("registry: rotate credentials: %w", err)
145 }
146
147 _ = agent // agent exists, credentials rotated
148 return &Credentials{Nick: nick, Passphrase: passphrase}, nil
149 }
150
151 // Revoke locks an agent out by rotating to an unguessable passphrase and
152 // marking it revoked in the registry.
@@ -167,10 +250,39 @@
167 if err := r.provisioner.ChangePassword(nick, lockout); err != nil {
168 return fmt.Errorf("registry: revoke credentials: %w", err)
169 }
170
171 agent.Revoked = true
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172 return nil
173 }
174
175 // Get returns the agent with the given nick.
176 func (r *Registry) Get(nick string) (*Agent, error) {
177
178 DDED pkg/agentrelay/relay.go
179 DDED run.sh
180 DDED scuttlectl
--- internal/registry/registry.go
+++ internal/registry/registry.go
@@ -10,10 +10,12 @@
10 "crypto/rand"
11 "crypto/sha256"
12 "encoding/hex"
13 "encoding/json"
14 "fmt"
15 "os"
16 "strings"
17 "sync"
18 "time"
19 )
20
21 // AgentType describes an agent's role and authority level.
@@ -70,20 +72,64 @@
72 type Registry struct {
73 mu sync.RWMutex
74 agents map[string]*Agent // keyed by nick
75 provisioner AccountProvisioner
76 signingKey []byte
77 dataPath string // path to persist agents JSON; empty = no persistence
78 }
79
80 // New creates a new Registry with the given provisioner and HMAC signing key.
81 // Call SetDataPath to enable persistence before registering any agents.
82 func New(provisioner AccountProvisioner, signingKey []byte) *Registry {
83 return &Registry{
84 agents: make(map[string]*Agent),
85 provisioner: provisioner,
86 signingKey: signingKey,
87 }
88 }
89
90 // SetDataPath enables persistence. The registry is loaded from path immediately
91 // (non-fatal if the file doesn't exist yet) and saved there after every mutation.
92 func (r *Registry) SetDataPath(path string) error {
93 r.mu.Lock()
94 defer r.mu.Unlock()
95 r.dataPath = path
96 return r.load()
97 }
98
99 func (r *Registry) load() error {
100 data, err := os.ReadFile(r.dataPath)
101 if os.IsNotExist(err) {
102 return nil
103 }
104 if err != nil {
105 return fmt.Errorf("registry: load: %w", err)
106 }
107 var agents []*Agent
108 if err := json.Unmarshal(data, &agents); err != nil {
109 return fmt.Errorf("registry: load: %w", err)
110 }
111 for _, a := range agents {
112 r.agents[a.Nick] = a
113 }
114 return nil
115 }
116
117 func (r *Registry) save() {
118 if r.dataPath == "" {
119 return
120 }
121 agents := make([]*Agent, 0, len(r.agents))
122 for _, a := range r.agents {
123 agents = append(agents, a)
124 }
125 data, err := json.MarshalIndent(agents, "", " ")
126 if err != nil {
127 return
128 }
129 _ = os.WriteFile(r.dataPath, data, 0600)
130 }
131
132 // Register creates a new agent, provisions its Ergo account, and returns
133 // credentials and a signed rules-of-engagement payload.
134 // cfg is validated before any provisioning occurs.
135 func (r *Registry) Register(nick string, agentType AgentType, cfg EngagementConfig) (*Credentials, *SignedPayload, error) {
@@ -102,11 +148,53 @@
148 if err != nil {
149 return nil, nil, fmt.Errorf("registry: generate passphrase: %w", err)
150 }
151
152 if err := r.provisioner.RegisterAccount(nick, passphrase); err != nil {
153 // Account exists in NickServ from a previous run — sync the password.
154 if strings.Contains(err.Error(), "ACCOUNT_EXISTS") {
155 if err2 := r.provisioner.ChangePassword(nick, passphrase); err2 != nil {
156 return nil, nil, fmt.Errorf("registry: provision account: %w", err2)
157 }
158 } else {
159 return nil, nil, fmt.Errorf("registry: provision account: %w", err)
160 }
161 }
162
163 agent := &Agent{
164 Nick: nick,
165 Type: agentType,
166 Channels: cfg.Channels,
167 Permissions: cfg.Permissions,
168 Config: cfg,
169 CreatedAt: time.Now(),
170 }
171 r.agents[nick] = agent
172 r.save()
173
174 payload, err := r.signPayload(agent)
175 if err != nil {
176 return nil, nil, fmt.Errorf("registry: sign payload: %w", err)
177 }
178
179 return &Credentials{Nick: nick, Passphrase: passphrase}, payload, nil
180 }
181
182 // Adopt adds a pre-existing NickServ account to the registry without touching
183 // its password. The caller is responsible for knowing their own passphrase.
184 // Returns a signed payload; no Credentials are returned since the password
185 // is not changed.
186 func (r *Registry) Adopt(nick string, agentType AgentType, cfg EngagementConfig) (*SignedPayload, error) {
187 if err := cfg.Validate(); err != nil {
188 return nil, fmt.Errorf("registry: invalid engagement config: %w", err)
189 }
190
191 r.mu.Lock()
192 defer r.mu.Unlock()
193
194 if existing, ok := r.agents[nick]; ok && !existing.Revoked {
195 return nil, fmt.Errorf("registry: agent %q already registered", nick)
196 }
197
198 agent := &Agent{
199 Nick: nick,
200 Type: agentType,
@@ -114,26 +202,21 @@
202 Permissions: cfg.Permissions,
203 Config: cfg,
204 CreatedAt: time.Now(),
205 }
206 r.agents[nick] = agent
207 r.save()
208
209 return r.signPayload(agent)
 
 
 
 
 
210 }
211
212 // Rotate generates a new passphrase for an agent and updates Ergo.
213 func (r *Registry) Rotate(nick string) (*Credentials, error) {
214 r.mu.Lock()
215 defer r.mu.Unlock()
216
217 if _, err := r.get(nick); err != nil {
 
218 return nil, err
219 }
220
221 passphrase, err := generatePassphrase()
222 if err != nil {
@@ -142,11 +225,11 @@
225
226 if err := r.provisioner.ChangePassword(nick, passphrase); err != nil {
227 return nil, fmt.Errorf("registry: rotate credentials: %w", err)
228 }
229
230 r.save()
231 return &Credentials{Nick: nick, Passphrase: passphrase}, nil
232 }
233
234 // Revoke locks an agent out by rotating to an unguessable passphrase and
235 // marking it revoked in the registry.
@@ -167,10 +250,39 @@
250 if err := r.provisioner.ChangePassword(nick, lockout); err != nil {
251 return fmt.Errorf("registry: revoke credentials: %w", err)
252 }
253
254 agent.Revoked = true
255 r.save()
256 return nil
257 }
258
259 // Delete fully removes an agent from the registry. The Ergo NickServ account
260 // is locked out first (password rotated to an unguessable value) so the agent
261 // can no longer connect, then the entry is removed from the registry. If the
262 // agent is already revoked the lockout step is skipped.
263 func (r *Registry) Delete(nick string) error {
264 r.mu.Lock()
265 defer r.mu.Unlock()
266
267 agent, ok := r.agents[nick]
268 if !ok {
269 return fmt.Errorf("registry: agent %q not found", nick)
270 }
271
272 if !agent.Revoked {
273 lockout, err := generatePassphrase()
274 if err != nil {
275 return fmt.Errorf("registry: generate lockout passphrase: %w", err)
276 }
277 if err := r.provisioner.ChangePassword(nick, lockout); err != nil {
278 return fmt.Errorf("registry: delete lockout: %w", err)
279 }
280 }
281
282 delete(r.agents, nick)
283 r.save()
284 return nil
285 }
286
287 // Get returns the agent with the given nick.
288 func (r *Registry) Get(nick string) (*Agent, error) {
289
290 DDED pkg/agentrelay/relay.go
291 DDED run.sh
292 DDED scuttlectl
--- a/pkg/agentrelay/relay.go
+++ b/pkg/agentrelay/relay.go
@@ -0,0 +1,190 @@
1
+// Package agentrelay lets any agent post status to a scuttlebot IRC channel
2
+// and receive human instructions mid-work.
3
+//
4
+// Typical usage:
5
+//
6
+// relay, err := agentrelay.New(agentrelay.Config{
7
+// ServerURL: "http://localhost:8080",
8
+// Token: os.Getenv("SCUTTLEBOT_TOKEN"),
9
+// Nick: "my-agent",
10
+// Channel: "#fleet",
11
+// })
12
+// relay.Post("starting task: rewrite auth module")
13
+//
14
+// // between steps, check for human instructions
15
+// if msg, ok := relay.Poll(); ok {
16
+// // msg.Text is what the human said — incorporate or surface to agent
17
+// }
18
+//
19
+// // or block waiting for approval
20
+// relay.Post("about to drop table users — approve?")
21
+// if err := relay.WaitFor("yes", 2*time.Minute); err != nil {
22
+// // timed out or got "no" — abort
23
+// }
24
+package agentrelay
25
+
26
+import (
27
+ "bufio"
28
+ "bytes"
29
+ "context"
30
+ "encoding/json"
31
+ "fmt"
32
+ "io"
33
+ "net/http"
34
+ "strings"
35
+ "sync"
36
+ "time"
37
+)
38
+
39
+// Config configures a Relay.
40
+type Config struct {
41
+ // ServerURL is the scuttlebot HTTP API base URL.
42
+ ServerURL string
43
+ // Token is the scuttlebot bearer token.
44
+ Token string
45
+ // Nick is this agent's IRC nick — used to filter out its own messages.
46
+ Nick string
47
+ // Channel is the IRC channel to post to and listen on.
48
+ Channel string
49
+}
50
+
51
+// Message is an inbound message from the channel.
52
+type Message struct {
53
+ At time.Time `json:"at"`
54
+ Nick string `json:"nick"`
55
+ Text string `json:"text"`
56
+ Channel string `json:"channel"`
57
+}
58
+
59
+// Relay posts status messages to an IRC channel and surfaces inbound
60
+// human messages to the running agent. It is safe for concurrent use.
61
+type Relay struct {
62
+ cfg fig
63
+ http *http.Client
64
+
65
+ []Message // buffered inbound messages not yet consumed
66
+ cancel context.CancelFunc
67
+}
68
+
69
+// New creates a Relay and starts listening for inbound messages via SSE.
70
+// Call Close when done.
71
+func New(cfg Config) (*Relay, error) {
72
+ if cfg.ServerURL == "" || cfg.Token == "" || cfg.Nick == "" || cfg.Channel == "" {
73
+ return nil, fmt.Errorf("agentrelay: ServerURL, Token, Nick, and Channel are required")
74
+ }
75
+ r := &Relay{
76
+ cfg: cfg,
77
+ http: &http.Client{Timeout: 0}, // no timeout for SSE
78
+ }
79
+ ctx, cancel := context.WithCancel(context.Background())
80
+ r.cancel = cancel
81
+ go r.streamLoop(ctx)
82
+ return r, nil
83
+}
84
+
85
+// Post sends a status message to the channel. Non-blocking.
86
+func (r *Relay) Post(text string) error {
87
+ body, _ := json.Marshal(map[string]string{
88
+ "text": text,
89
+ "nick": r.cfg.Nick,
90
+ })
91
+ slug := strings.TrimPrefix(r.cfg.Channel, "#")
92
+ req, err := http.NewRequest("POST", r.cfg.ServerURL+"/v1/channels/"+slug+"/messages", bytes.NewReader(body))
93
+ if err != nil {
94
+ return err
95
+ }
96
+ req.Header.Set("Content-Type", "application/json")
97
+ req.Header.Set("Authorization", "Bearer "+r.cfg.Token)
98
+ resp, err := r.http.Do(req)
99
+ if err != nil {
100
+ return fmt.Errorf("agentrelay post: %w", err)
101
+ }
102
+ resp.Body.Close()
103
+ return nil
104
+}
105
+
106
+// Poll returns the oldest unread inbound message, if any.
107
+// Returns false if there are no pending messages.
108
+func (r *Relay) Poll() (Message, bool) {
109
+ r.mu.Lock()
110
+ defer r.mu.Unlock()
111
+ if len(r.inbox) == 0 {
112
+ return Message{}, false
113
+ }
114
+ msg := r.inbox[0]
115
+ r.inbox = r.inbox[1:]
116
+ return msg, true
117
+}
118
+
119
+// Drain returns all buffered inbound messages and clears the inbox.
120
+func (r *Relay) Drain() []Message {
121
+ r.mu.Lock()
122
+ defer r.mu.Unlock()
123
+ msgs := r.inbox
124
+ r.inbox = nil
125
+ return msgs
126
+}
127
+
128
+// WaitFor blocks until a message containing keyword arrives (case-insensitive),
129
+// or until timeout. Returns an error if the timeout elapses or a message
130
+// containing "no" or "stop" arrives first.
131
+func (r *Relay) WaitFor(keyword string, timeout time.Duration) error {
132
+ deadline := time.Now().Add(timeout)
133
+ for time.Now().Before(deadline) {
134
+ msg, ok := r.Poll()
135
+ if ok {
136
+ lower := strings.ToLower(msg.Text)
137
+ if strings.Contains(lower, strings.ToLower(keyword)) {
138
+ return nil
139
+ }
140
+ if strings.Contains(lower, "no") || strings.Contains(lower, "stop") || strings.Contains(lower, "abort") {
141
+ return fmt.Errorf("agentrelay: operator said %q", msg.Text)
142
+ }
143
+ }
144
+ time.Sleep(250 * time.Millisecond)
145
+ }
146
+ return fmt.Errorf("agentrelay: timed out waiting for %q", keyword)
147
+}
148
+
149
+// Close stops the background SSE listener.
150
+func (r *Relay) Close() {
151
+ if r.cancel != nil {
152
+ r.cancel()
153
+ }
154
+}
155
+
156
+// streamLoop maintains an SSE connection and feeds inbound messages into inbox.
157
+func (r *Relay) streamLoop(ctx context.Context) {
158
+ slug := strings.TrimPrefix(r.cfg.Channel, "#")
159
+ url := r.cfg.ServerURL + "/v1/channels/" + slug + "/stream?token=" + r.cfg.Token
160
+
161
+ for {
162
+ if ctx.Err() != nil {
163
+ return
164
+ }
165
+ if err := r.stream(ctx, url); err != nil && ctx.Err() == nil {
166
+ // Back off briefly before reconnecting.
167
+ select {
168
+ case <-ctx.Done():
169
+ return
170
+ case <-time.After(3 * time.Second):
171
+ }
172
+ }
173
+ }
174
+}
175
+
176
+func (r *Relay) stream(ctx context.Context, url string) error {
177
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
178
+ if err != nil {
179
+ return err
180
+ }
181
+ req.Header.Set("Accept", "text/event-stream")
182
+ resp, err := r.http.Do(req)
183
+ if err != nil {
184
+ return err
185
+ }
186
+ defer resp.Body.Close()
187
+
188
+ scanner := bufio.NewScanner(resp.Body)
189
+ for scanner.Scan() {
190
+
--- a/pkg/agentrelay/relay.go
+++ b/pkg/agentrelay/relay.go
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/agentrelay/relay.go
+++ b/pkg/agentrelay/relay.go
@@ -0,0 +1,190 @@
1 // Package agentrelay lets any agent post status to a scuttlebot IRC channel
2 // and receive human instructions mid-work.
3 //
4 // Typical usage:
5 //
6 // relay, err := agentrelay.New(agentrelay.Config{
7 // ServerURL: "http://localhost:8080",
8 // Token: os.Getenv("SCUTTLEBOT_TOKEN"),
9 // Nick: "my-agent",
10 // Channel: "#fleet",
11 // })
12 // relay.Post("starting task: rewrite auth module")
13 //
14 // // between steps, check for human instructions
15 // if msg, ok := relay.Poll(); ok {
16 // // msg.Text is what the human said — incorporate or surface to agent
17 // }
18 //
19 // // or block waiting for approval
20 // relay.Post("about to drop table users — approve?")
21 // if err := relay.WaitFor("yes", 2*time.Minute); err != nil {
22 // // timed out or got "no" — abort
23 // }
24 package agentrelay
25
26 import (
27 "bufio"
28 "bytes"
29 "context"
30 "encoding/json"
31 "fmt"
32 "io"
33 "net/http"
34 "strings"
35 "sync"
36 "time"
37 )
38
39 // Config configures a Relay.
40 type Config struct {
41 // ServerURL is the scuttlebot HTTP API base URL.
42 ServerURL string
43 // Token is the scuttlebot bearer token.
44 Token string
45 // Nick is this agent's IRC nick — used to filter out its own messages.
46 Nick string
47 // Channel is the IRC channel to post to and listen on.
48 Channel string
49 }
50
51 // Message is an inbound message from the channel.
52 type Message struct {
53 At time.Time `json:"at"`
54 Nick string `json:"nick"`
55 Text string `json:"text"`
56 Channel string `json:"channel"`
57 }
58
59 // Relay posts status messages to an IRC channel and surfaces inbound
60 // human messages to the running agent. It is safe for concurrent use.
61 type Relay struct {
62 cfg fig
63 http *http.Client
64
65 []Message // buffered inbound messages not yet consumed
66 cancel context.CancelFunc
67 }
68
69 // New creates a Relay and starts listening for inbound messages via SSE.
70 // Call Close when done.
71 func New(cfg Config) (*Relay, error) {
72 if cfg.ServerURL == "" || cfg.Token == "" || cfg.Nick == "" || cfg.Channel == "" {
73 return nil, fmt.Errorf("agentrelay: ServerURL, Token, Nick, and Channel are required")
74 }
75 r := &Relay{
76 cfg: cfg,
77 http: &http.Client{Timeout: 0}, // no timeout for SSE
78 }
79 ctx, cancel := context.WithCancel(context.Background())
80 r.cancel = cancel
81 go r.streamLoop(ctx)
82 return r, nil
83 }
84
85 // Post sends a status message to the channel. Non-blocking.
86 func (r *Relay) Post(text string) error {
87 body, _ := json.Marshal(map[string]string{
88 "text": text,
89 "nick": r.cfg.Nick,
90 })
91 slug := strings.TrimPrefix(r.cfg.Channel, "#")
92 req, err := http.NewRequest("POST", r.cfg.ServerURL+"/v1/channels/"+slug+"/messages", bytes.NewReader(body))
93 if err != nil {
94 return err
95 }
96 req.Header.Set("Content-Type", "application/json")
97 req.Header.Set("Authorization", "Bearer "+r.cfg.Token)
98 resp, err := r.http.Do(req)
99 if err != nil {
100 return fmt.Errorf("agentrelay post: %w", err)
101 }
102 resp.Body.Close()
103 return nil
104 }
105
106 // Poll returns the oldest unread inbound message, if any.
107 // Returns false if there are no pending messages.
108 func (r *Relay) Poll() (Message, bool) {
109 r.mu.Lock()
110 defer r.mu.Unlock()
111 if len(r.inbox) == 0 {
112 return Message{}, false
113 }
114 msg := r.inbox[0]
115 r.inbox = r.inbox[1:]
116 return msg, true
117 }
118
119 // Drain returns all buffered inbound messages and clears the inbox.
120 func (r *Relay) Drain() []Message {
121 r.mu.Lock()
122 defer r.mu.Unlock()
123 msgs := r.inbox
124 r.inbox = nil
125 return msgs
126 }
127
128 // WaitFor blocks until a message containing keyword arrives (case-insensitive),
129 // or until timeout. Returns an error if the timeout elapses or a message
130 // containing "no" or "stop" arrives first.
131 func (r *Relay) WaitFor(keyword string, timeout time.Duration) error {
132 deadline := time.Now().Add(timeout)
133 for time.Now().Before(deadline) {
134 msg, ok := r.Poll()
135 if ok {
136 lower := strings.ToLower(msg.Text)
137 if strings.Contains(lower, strings.ToLower(keyword)) {
138 return nil
139 }
140 if strings.Contains(lower, "no") || strings.Contains(lower, "stop") || strings.Contains(lower, "abort") {
141 return fmt.Errorf("agentrelay: operator said %q", msg.Text)
142 }
143 }
144 time.Sleep(250 * time.Millisecond)
145 }
146 return fmt.Errorf("agentrelay: timed out waiting for %q", keyword)
147 }
148
149 // Close stops the background SSE listener.
150 func (r *Relay) Close() {
151 if r.cancel != nil {
152 r.cancel()
153 }
154 }
155
156 // streamLoop maintains an SSE connection and feeds inbound messages into inbox.
157 func (r *Relay) streamLoop(ctx context.Context) {
158 slug := strings.TrimPrefix(r.cfg.Channel, "#")
159 url := r.cfg.ServerURL + "/v1/channels/" + slug + "/stream?token=" + r.cfg.Token
160
161 for {
162 if ctx.Err() != nil {
163 return
164 }
165 if err := r.stream(ctx, url); err != nil && ctx.Err() == nil {
166 // Back off briefly before reconnecting.
167 select {
168 case <-ctx.Done():
169 return
170 case <-time.After(3 * time.Second):
171 }
172 }
173 }
174 }
175
176 func (r *Relay) stream(ctx context.Context, url string) error {
177 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
178 if err != nil {
179 return err
180 }
181 req.Header.Set("Accept", "text/event-stream")
182 resp, err := r.http.Do(req)
183 if err != nil {
184 return err
185 }
186 defer resp.Body.Close()
187
188 scanner := bufio.NewScanner(resp.Body)
189 for scanner.Scan() {
190
A run.sh
+228
--- a/run.sh
+++ b/run.sh
@@ -0,0 +1,228 @@
1
+#!/usr/bin/env bash
2
+# run.sh — scuttlebot dev helper
3
+# Usage: ./run.sh [command]
4
+# (no args) build and start scuttlebot
5
+# stop kill running scuttlebot
6
+# restart stop + build + start
7
+# agent build and run a claude IRC agent with a fleet-style nick
8
+# token print the current API token
9
+# log tail the log (if logging to file is configured)
10
+# test run Go unit tests
11
+# e2e run Playwright e2e tests (requires scuttlebot running)
12
+# clean #
13
+# After start/restart, if ~/Library/LaunchAgents/io.conflict.claude-agent.plist
14
+# exists, the claude IRC agent credentials are rotated an
15
+
16
+set -euo pipefail
17
+
18
+BINARY=bin/scuttlebot
19
+CONFIG=${SCUTTLEBOT_CONFIG:-scuttlebot.yaml}
20
+TOKEN_FILE=data/ergo/api_token
21
+PID_FILE=.scuttlebot.pid
22
+LOG_FILE=.scuttlebot.log
23
+cmd=${1:-start}
24
+
25
+_pid() { cat "$PID_FILE" 2>/dev/null || echo ""; }
26
+
27
+_running() {
28
+ local pid; pid=$(_pid)
29
+ [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
30
+}
31
+
32
+_stop() {
33
+ if _running; then
34
+ local pid; pid=$(_pid)
35
+ kill "$pid" && rm -f "$PID_FILE"
36
+ echo "stopped (pid $pid)"
37
+ else
38
+ echo "not running"
39
+ fi
40
+ # Kill any stale ergo processes holding IRC/API ports.
41
+ pkill -f "data/ergo/ergo" 2>/dev/null && sleep 1 || true
42
+}
43
+
44
+_build() {
45
+ echo "building..."
46
+ go build -o "$BINARY" ./cmd/scuttlebot
47
+ echo "ok → $BINARY"
48
+}
49
+
50
+_start() {
51
+ if _running; then
52
+ echo "already running (pid $(_pid)) — use ./run.sh restart"
53
+ exit 0
54
+ fi
55
+
56
+ if [[ ! -f "$CONFIG" ]]; then
57
+ echo "no $CONFIG found — copying from example"
58
+ cp deploy/standalone/scuttlebot.yaml.example "$CONFIG"
59
+ echo "edit $CONFIG if needed, then re-run"
60
+ fi
61
+
62
+ mkdir -p bin data/ergo
63
+
64
+ "$BINARY" -config "$CONFIG" >"$LOG_FILE" 2>&1 &
65
+ echo $! >"$PID_FILE"
66
+ local pid; pid=$(_pid)
67
+ echo "started (pid $pid) — logs: $LOG_FILE"
68
+
69
+ # wait briefly and print token so it's handy
70
+ sleep 1
71
+ if [[ -f "$TOKEN_FILE" ]]; then
72
+ echo "token: $(cat "$TOKEN_FILE")"
73
+ fi
74
+
75
+ echo "ui: http://localhost:8080/ui/"
76
+}
77
+
78
+_token() {
79
+ if [[ -f "$TOKEN_FILE" ]]; then
80
+ cat "$TOKEN_FILE"
81
+ else
82
+ echo "no token file found (is scuttlebot running?)" >&2
83
+ exit 1
84
+ fi
85
+}
86
+
87
+
88
+case "$cmd" in
89
+ start)
90
+ _build
91
+ _start
92
+ ;;
93
+ stop)
94
+ _stop
95
+ ;;
96
+ restart)
97
+ _stop || true
98
+ _build
99
+ _start
100
+ ;;
101
+ build)
102
+ _build
103
+ ;;
104
+ token)
105
+ _token
106
+ ;;
107
+ log|logs)
108
+ tail -f "$LOG_FILE"
109
+ ;;
110
+ test)
111
+ go test ./...
112
+ ;;
113
+ e2e)
114
+ SB_TOKEN=$(cat "$TOKEN_FILE" 2>/dev/null) \
115
+ SB_USERNAME=${SB_USERNAME:-admin} \
116
+ SB_PASSWORD=${SB_PASSWORD:-} \
117
+ npx --prefix tests/e2e playwright test "${@:2}"
118
+ ;;
119
+ clean)
120
+ _stop || true
121
+ rm -f "$BINARY" bin/scuttlectl "$LOG_FILE" "$PID_FILE"
122
+ echo "clean"
123
+ ;;
124
+ *)
125
+ echo "usage: $0 {start|stop|restart|build|token|log|test|e2e|clean}"
126
+ exit 1
127
+ ;;
128
+esac
129
+CLAUDE_AGENT_ENV="${CLAUDE_AGENT_ENV:-$HOME/.config/scuttlebot-claude-agent.env}"
130
+CLAUDE_AGENT_PLIST="${CLAUDE_AGENT_PLIST:-$HOME/Library/LaunchAgents/io.conid
131
+LOG_FILE=.scuttlebot.log
132
+cmd=${1:-start}
133
+
134
+_pid() { cat "$PID_FILE" 2>/dev/null || echo ""; }
135
+
136
+_running() {
137
+ local pid; pid=$(_pid)
138
+ [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
139
+}
140
+
141
+_stop() {
142
+ if _running; then
143
+ local pid; pid=$(_pid)
144
+ kill "$pid" && rm -f "$PID_FILE"
145
+ echo "stopped (pid $pid)"
146
+ else
147
+ echo "not running"
148
+ fi
149
+ # Kill any stale ergo processes holding IRC/API ports.
150
+ pkill -f "data/ergo/ergo" 2>/dev/null && sleep 1 || true
151
+}
152
+
153
+_build() {
154
+ echo "building..."
155
+ go build -o "$BINARY" ./cmd/scuttlebot
156
+ echo "ok → $BINARY"
157
+}
158
+
159
+_start() {
160
+ if _running; then
161
+ echo "already running (pid $(_pid)) — use ./run.sh restart"
162
+ exit 0
163
+ fi
164
+
165
+ if [[ ! -f "$CONFIG" ]]; then
166
+ echo "no $CONFIG found — copying from example"
167
+ cp deploy/standalone/scuttlebot.yaml.example "$CONFIG"
168
+ echo "edit $CONFIG if needed, then re-run"
169
+ fi
170
+
171
+ mkdir -p bin data/ergo
172
+
173
+ "$BINARY" -config "$CONFIG" >"$LOG_FILE" 2>&1 &
174
+ echo $! >"$PID_FILE"
175
+ local pid; pid=$(_pid)
176
+ echo "started (pid $pid) — logs: $LOG_FILE"
177
+
178
+ # wait briefly and print token so it's handy
179
+ sleep 1
180
+ if [[ -f "$TOKEN_FILE" ]]; then
181
+ echo "token: $(cat "$TOKEN_FILE")"
182
+ fi
183
+
184
+ echo "ui: http://localhost:8080/ui/"
185
+}
186
+
187
+_token() {
188
+ if [[ -f "$TOKEN_FILE" ]]; then
189
+ cat "$TOKEN_FILE"
190
+ else
191
+ echo "no token file found (is scuttlebot running# _sync_claude_agent rotates the claude IRC agent's credentials, updates the
192
+# env file, and reloads the LaunchAgent. No-ops silently if the plist or env
193
+# file don't exist (agent not installed on this machine).
194
+_sync_claude_agent() {
195
+ [[ -f "$CLAUDE_AGENT_PLIST" ]] || return 0
196
+ [[ -f "$CLAUDE_AGENT_ENV" ]] || return 0
197
+
198
+ local token; token=$(_token 2>/dev/null) || return 0
199
+
200
+ # Wait up to 15s for the HTTP API, then give ergo another 5s to finish
201
+ # starting NickServ before we attempt a password rotation.
202
+ local ready=0
203
+ for i in $(seq 1 15); do
204
+ curl -sf -H "Authorization: Bearer $token" "http://localhost:8080/ scuttlebot API not ready, skipping claude-agent sync" >&2; return 0; }
205
+ sleep 5
206
+
207
+ echo "syncing claude-agent credentials..."
208
+ local resp; resp=$(curl -sf -X POST \
209
+ -H "Authorization: Bearer $token" \
210
+ "M@S0,9i:v1/agents/claude/rotate" 2>/dev/null) || {
211
+ echo "warning: could not rotate claude-agent credentials (API not ready?)" >&2
212
+ return 0
213
+ }
214
+
215
+ local pass; pass=$(echo "$resp" | grep -o '"passphrase":"[^"]*"' | cut -d'"' -f4)
216
+ [[ -n "$pass" ]] || { echo "warning: empty passphrase in rotate response" >&2; return 0; }
217
+
218
+ # Rewrite only the CLAUDE_AGENT_PASS line; preserve everything else.
219
+ sed -i '' "s|^CLAUDE_AGENT_PASS=.*|CLAUDE_AGENT_PASS=$pass|" "$CLAUDE_AGENT_ENV"
220
+
221
+ launchctl unload "$CLAUDE_AGENT_PLIST" 2>/dev/null || true
222
+ launchctl load "$CLAUDE_AGENT_PLIST"
223
+ echo "claude-agent reloaded"
224
+}
225
+
226
+_run_agen8@IB,82:local token; token=$(_token)
227
+ local base; base=$(basename "$(pwd)" | tr -cs '[:alnum:]_-' '-' | tr '[:upper:]' '[:lower:]')
228
+ local session; session=$(printf '%s' "$$|$PPID|$(date +%s)" | cksum | awk '{printf "%08x\n",
--- a/run.sh
+++ b/run.sh
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/run.sh
+++ b/run.sh
@@ -0,0 +1,228 @@
1 #!/usr/bin/env bash
2 # run.sh — scuttlebot dev helper
3 # Usage: ./run.sh [command]
4 # (no args) build and start scuttlebot
5 # stop kill running scuttlebot
6 # restart stop + build + start
7 # agent build and run a claude IRC agent with a fleet-style nick
8 # token print the current API token
9 # log tail the log (if logging to file is configured)
10 # test run Go unit tests
11 # e2e run Playwright e2e tests (requires scuttlebot running)
12 # clean #
13 # After start/restart, if ~/Library/LaunchAgents/io.conflict.claude-agent.plist
14 # exists, the claude IRC agent credentials are rotated an
15
16 set -euo pipefail
17
18 BINARY=bin/scuttlebot
19 CONFIG=${SCUTTLEBOT_CONFIG:-scuttlebot.yaml}
20 TOKEN_FILE=data/ergo/api_token
21 PID_FILE=.scuttlebot.pid
22 LOG_FILE=.scuttlebot.log
23 cmd=${1:-start}
24
25 _pid() { cat "$PID_FILE" 2>/dev/null || echo ""; }
26
27 _running() {
28 local pid; pid=$(_pid)
29 [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
30 }
31
32 _stop() {
33 if _running; then
34 local pid; pid=$(_pid)
35 kill "$pid" && rm -f "$PID_FILE"
36 echo "stopped (pid $pid)"
37 else
38 echo "not running"
39 fi
40 # Kill any stale ergo processes holding IRC/API ports.
41 pkill -f "data/ergo/ergo" 2>/dev/null && sleep 1 || true
42 }
43
44 _build() {
45 echo "building..."
46 go build -o "$BINARY" ./cmd/scuttlebot
47 echo "ok → $BINARY"
48 }
49
50 _start() {
51 if _running; then
52 echo "already running (pid $(_pid)) — use ./run.sh restart"
53 exit 0
54 fi
55
56 if [[ ! -f "$CONFIG" ]]; then
57 echo "no $CONFIG found — copying from example"
58 cp deploy/standalone/scuttlebot.yaml.example "$CONFIG"
59 echo "edit $CONFIG if needed, then re-run"
60 fi
61
62 mkdir -p bin data/ergo
63
64 "$BINARY" -config "$CONFIG" >"$LOG_FILE" 2>&1 &
65 echo $! >"$PID_FILE"
66 local pid; pid=$(_pid)
67 echo "started (pid $pid) — logs: $LOG_FILE"
68
69 # wait briefly and print token so it's handy
70 sleep 1
71 if [[ -f "$TOKEN_FILE" ]]; then
72 echo "token: $(cat "$TOKEN_FILE")"
73 fi
74
75 echo "ui: http://localhost:8080/ui/"
76 }
77
78 _token() {
79 if [[ -f "$TOKEN_FILE" ]]; then
80 cat "$TOKEN_FILE"
81 else
82 echo "no token file found (is scuttlebot running?)" >&2
83 exit 1
84 fi
85 }
86
87
88 case "$cmd" in
89 start)
90 _build
91 _start
92 ;;
93 stop)
94 _stop
95 ;;
96 restart)
97 _stop || true
98 _build
99 _start
100 ;;
101 build)
102 _build
103 ;;
104 token)
105 _token
106 ;;
107 log|logs)
108 tail -f "$LOG_FILE"
109 ;;
110 test)
111 go test ./...
112 ;;
113 e2e)
114 SB_TOKEN=$(cat "$TOKEN_FILE" 2>/dev/null) \
115 SB_USERNAME=${SB_USERNAME:-admin} \
116 SB_PASSWORD=${SB_PASSWORD:-} \
117 npx --prefix tests/e2e playwright test "${@:2}"
118 ;;
119 clean)
120 _stop || true
121 rm -f "$BINARY" bin/scuttlectl "$LOG_FILE" "$PID_FILE"
122 echo "clean"
123 ;;
124 *)
125 echo "usage: $0 {start|stop|restart|build|token|log|test|e2e|clean}"
126 exit 1
127 ;;
128 esac
129 CLAUDE_AGENT_ENV="${CLAUDE_AGENT_ENV:-$HOME/.config/scuttlebot-claude-agent.env}"
130 CLAUDE_AGENT_PLIST="${CLAUDE_AGENT_PLIST:-$HOME/Library/LaunchAgents/io.conid
131 LOG_FILE=.scuttlebot.log
132 cmd=${1:-start}
133
134 _pid() { cat "$PID_FILE" 2>/dev/null || echo ""; }
135
136 _running() {
137 local pid; pid=$(_pid)
138 [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
139 }
140
141 _stop() {
142 if _running; then
143 local pid; pid=$(_pid)
144 kill "$pid" && rm -f "$PID_FILE"
145 echo "stopped (pid $pid)"
146 else
147 echo "not running"
148 fi
149 # Kill any stale ergo processes holding IRC/API ports.
150 pkill -f "data/ergo/ergo" 2>/dev/null && sleep 1 || true
151 }
152
153 _build() {
154 echo "building..."
155 go build -o "$BINARY" ./cmd/scuttlebot
156 echo "ok → $BINARY"
157 }
158
159 _start() {
160 if _running; then
161 echo "already running (pid $(_pid)) — use ./run.sh restart"
162 exit 0
163 fi
164
165 if [[ ! -f "$CONFIG" ]]; then
166 echo "no $CONFIG found — copying from example"
167 cp deploy/standalone/scuttlebot.yaml.example "$CONFIG"
168 echo "edit $CONFIG if needed, then re-run"
169 fi
170
171 mkdir -p bin data/ergo
172
173 "$BINARY" -config "$CONFIG" >"$LOG_FILE" 2>&1 &
174 echo $! >"$PID_FILE"
175 local pid; pid=$(_pid)
176 echo "started (pid $pid) — logs: $LOG_FILE"
177
178 # wait briefly and print token so it's handy
179 sleep 1
180 if [[ -f "$TOKEN_FILE" ]]; then
181 echo "token: $(cat "$TOKEN_FILE")"
182 fi
183
184 echo "ui: http://localhost:8080/ui/"
185 }
186
187 _token() {
188 if [[ -f "$TOKEN_FILE" ]]; then
189 cat "$TOKEN_FILE"
190 else
191 echo "no token file found (is scuttlebot running# _sync_claude_agent rotates the claude IRC agent's credentials, updates the
192 # env file, and reloads the LaunchAgent. No-ops silently if the plist or env
193 # file don't exist (agent not installed on this machine).
194 _sync_claude_agent() {
195 [[ -f "$CLAUDE_AGENT_PLIST" ]] || return 0
196 [[ -f "$CLAUDE_AGENT_ENV" ]] || return 0
197
198 local token; token=$(_token 2>/dev/null) || return 0
199
200 # Wait up to 15s for the HTTP API, then give ergo another 5s to finish
201 # starting NickServ before we attempt a password rotation.
202 local ready=0
203 for i in $(seq 1 15); do
204 curl -sf -H "Authorization: Bearer $token" "http://localhost:8080/ scuttlebot API not ready, skipping claude-agent sync" >&2; return 0; }
205 sleep 5
206
207 echo "syncing claude-agent credentials..."
208 local resp; resp=$(curl -sf -X POST \
209 -H "Authorization: Bearer $token" \
210 "M@S0,9i:v1/agents/claude/rotate" 2>/dev/null) || {
211 echo "warning: could not rotate claude-agent credentials (API not ready?)" >&2
212 return 0
213 }
214
215 local pass; pass=$(echo "$resp" | grep -o '"passphrase":"[^"]*"' | cut -d'"' -f4)
216 [[ -n "$pass" ]] || { echo "warning: empty passphrase in rotate response" >&2; return 0; }
217
218 # Rewrite only the CLAUDE_AGENT_PASS line; preserve everything else.
219 sed -i '' "s|^CLAUDE_AGENT_PASS=.*|CLAUDE_AGENT_PASS=$pass|" "$CLAUDE_AGENT_ENV"
220
221 launchctl unload "$CLAUDE_AGENT_PLIST" 2>/dev/null || true
222 launchctl load "$CLAUDE_AGENT_PLIST"
223 echo "claude-agent reloaded"
224 }
225
226 _run_agen8@IB,82:local token; token=$(_token)
227 local base; base=$(basename "$(pwd)" | tr -cs '[:alnum:]_-' '-' | tr '[:upper:]' '[:lower:]')
228 local session; session=$(printf '%s' "$$|$PPID|$(date +%s)" | cksum | awk '{printf "%08x\n",

Binary file

Keyboard Shortcuts

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