aonyx-agent 0.9.0

The agent with a real memory palace — Knowledge Graph + Hybrid Search + Time-machine. Agent loop + the `aonyx` CLI.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! Configuration loading / persisting for the `aonyx` binary.
//!
//! V1 layout:
//!
//! ```text
//! ~/.aonyx/
//! ├── config.toml      # provider, model, defaults
//! └── sessions.db      # (P2) cross-project session FTS5 store
//! ```
//!
//! Per-project palace lives at `<project_root>/.aonyx/{kg.db,diary.db}` — see
//! [`aonyx_memory::Palace::default_project_dir`].

use std::path::PathBuf;

use serde::{Deserialize, Serialize};

const DEFAULT_MODEL: &str = "claude-sonnet-4-5-20250929";
const DEFAULT_SYSTEM_PROMPT: &str = "You are Aonyx Agent — the agent with a real memory palace. Be concise. Cite sources when you recall facts. Confirm scope before destructive actions.";

/// User-level configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    /// Provider id — one of: `"anthropic"`, `"openai"`, `"openrouter"`,
    /// `"ollama"`, `"lm-studio"`.
    pub provider: String,
    /// Model identifier as understood by the provider.
    pub model: String,
    /// Anthropic API key. `null` falls back to `ANTHROPIC_API_KEY` env var.
    #[serde(default)]
    pub anthropic_api_key: Option<String>,
    /// OpenAI API key. `null` falls back to `OPENAI_API_KEY` env var.
    #[serde(default)]
    pub openai_api_key: Option<String>,
    /// OpenRouter API key. `null` falls back to `OPENROUTER_API_KEY` env var.
    #[serde(default)]
    pub openrouter_api_key: Option<String>,
    /// Override OpenAI base URL (defaults to `https://api.openai.com`).
    #[serde(default)]
    pub openai_base_url: Option<String>,
    /// LM Studio base URL (defaults to `http://localhost:1234`).
    #[serde(default)]
    pub lm_studio_base_url: Option<String>,
    /// Ollama base URL (defaults to `http://localhost:11434`).
    #[serde(default)]
    pub ollama_base_url: Option<String>,
    /// Path to the `claude` binary (defaults to `claude` on PATH).
    #[serde(default)]
    pub claude_code_binary: Option<String>,
    /// Extra arguments forwarded to every `claude` invocation
    /// (e.g. `["--max-turns", "5"]`).
    #[serde(default)]
    pub claude_code_extra_args: Vec<String>,
    /// OpenRouter `HTTP-Referer` attribution header.
    #[serde(default)]
    pub openrouter_referer: Option<String>,
    /// OpenRouter `X-Title` attribution header.
    #[serde(default)]
    pub openrouter_title: Option<String>,
    /// Default system prompt injected at session start.
    #[serde(default)]
    pub system_prompt: Option<String>,
    /// Maximum agent-loop iterations per user turn.
    #[serde(default = "default_max_iterations")]
    pub max_iterations: usize,
    /// TUI theme name (`default`, `catppuccin`, `dracula`, `gruvbox`).
    #[serde(default)]
    pub theme: Option<String>,
    /// Show reasoning blocks (when a provider emits them) under each turn.
    #[serde(default)]
    pub show_thinking: bool,
    /// Emit a desktop notification when a turn finishes or errors out.
    #[serde(default)]
    pub desktop_notifications: bool,
    /// Auto-compact the conversation once its estimated token count
    /// crosses [`Self::auto_compact_threshold`]. Off by default — when
    /// off, the TUI only nudges you to run `/compact` (Phase BB).
    #[serde(default)]
    pub auto_compact: bool,
    /// Estimated-token threshold that arms auto-compaction (and the
    /// manual-compaction nudge). Defaults to 24000.
    #[serde(default = "default_compact_threshold")]
    pub auto_compact_threshold: u64,
    /// External MCP servers to connect at startup; their tools join the
    /// registry (Phase GG).
    #[serde(default)]
    pub mcp_servers: Vec<McpServerConfig>,
    /// User-authored theme saved by the `/theme-edit` panel (Phase KK).
    /// Active when `theme = "custom"`.
    #[serde(default)]
    pub custom_theme: Option<CustomTheme>,
    /// Tool names the user chose to "always allow" from the approval
    /// overlay (Phase OO). Destructive calls to these skip the prompt.
    #[serde(default)]
    pub tool_approvals: Vec<String>,
    /// Telegram chat ids allowed to talk to `aonyx serve telegram`
    /// (Phase TT). Empty = open to all chats. The bot token lives in the
    /// OS keyring (`telegram_bot_token`), never in this file.
    #[serde(default)]
    pub telegram_allowed_chats: Vec<i64>,
    /// Discord channel ids allowed to talk to `aonyx serve discord`
    /// (Phase UU). Empty = open to all channels. The bot token lives in
    /// the OS keyring (`discord_bot_token`), never in this file.
    #[serde(default)]
    pub discord_allowed_channels: Vec<i64>,
    /// Auto-generate a `SKILL.md` when a request shape recurs
    /// `skill_autogen_threshold` times (Phase XX). On by default.
    #[serde(default = "default_skill_autogen")]
    pub skill_autogen: bool,
    /// Occurrences of a recurring shape before a skill is auto-generated.
    #[serde(default = "default_skill_autogen_threshold")]
    pub skill_autogen_threshold: usize,
    /// Sandbox backend for `sandbox_exec` (Phase CCC): `"docker"` or
    /// `"http"`. Unset = the tool isn't registered. The HTTP token lives in
    /// the OS keyring (`sandbox_token`).
    #[serde(default)]
    pub sandbox_backend: Option<String>,
    /// Docker image for the `docker` sandbox backend (default `alpine`).
    #[serde(default)]
    pub sandbox_image: Option<String>,
    /// Endpoint URL for the `http` sandbox backend (Modal / Daytona / shim).
    #[serde(default)]
    pub sandbox_url: Option<String>,
    /// Restrict the tool catalogue offered to the model. When non-empty,
    /// **only** tools whose name matches one of these patterns stay enabled;
    /// everything else is disabled. A trailing `*` is a prefix wildcard, so
    /// `"aonyx-rag__*"` keeps just that MCP server's tools (MCP tools are
    /// named `<server>__<tool>`). Applies to `aonyx serve …` and the TUI.
    #[serde(default)]
    pub tools_allow: Vec<String>,
    /// Tool names/patterns to disable (same `*` wildcard), applied after
    /// `tools_allow`. Use it to strip dangerous built-ins (`"bash"`,
    /// `"fs_write"`, `"fs_edit"`, …) from an exposed bot/API deployment.
    #[serde(default)]
    pub tools_deny: Vec<String>,
}

/// Ten RGB colour fields persisted from the `/theme-edit` panel
/// (Phase KK), in [`crate::theme::EDITABLE_FIELDS`] order. Each is an
/// `[r, g, b]` array.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomTheme {
    /// Header text.
    pub header_fg: [u8; 3],
    /// Composer border (idle).
    pub composer_border: [u8; 3],
    /// Suggestion / panel border.
    pub suggestion_border: [u8; 3],
    /// Status bar background (idle).
    pub status_bg: [u8; 3],
    /// Status bar foreground (idle).
    pub status_fg: [u8; 3],
    /// `you>` prefix.
    pub user_prefix: [u8; 3],
    /// `aonyx>` prefix.
    pub assistant_prefix: [u8; 3],
    /// Thinking placeholder.
    pub thinking: [u8; 3],
    /// Dim / secondary text.
    pub dim: [u8; 3],
    /// Status bar background (busy).
    pub status_busy_bg: [u8; 3],
}

impl CustomTheme {
    /// Pack into the `[(u8,u8,u8); 10]` order expected by
    /// [`crate::theme::from_rgb_fields`].
    pub fn to_rgb_fields(&self) -> [(u8, u8, u8); 10] {
        let t = |a: [u8; 3]| (a[0], a[1], a[2]);
        [
            t(self.header_fg),
            t(self.composer_border),
            t(self.suggestion_border),
            t(self.status_bg),
            t(self.status_fg),
            t(self.user_prefix),
            t(self.assistant_prefix),
            t(self.thinking),
            t(self.dim),
            t(self.status_busy_bg),
        ]
    }

    /// Build from the `[(u8,u8,u8); 10]` snapshot a `Theme` produces.
    pub fn from_rgb_fields(f: &[(u8, u8, u8); 10]) -> Self {
        let a = |t: (u8, u8, u8)| [t.0, t.1, t.2];
        Self {
            header_fg: a(f[0]),
            composer_border: a(f[1]),
            suggestion_border: a(f[2]),
            status_bg: a(f[3]),
            status_fg: a(f[4]),
            user_prefix: a(f[5]),
            assistant_prefix: a(f[6]),
            thinking: a(f[7]),
            dim: a(f[8]),
            status_busy_bg: a(f[9]),
        }
    }
}

/// An MCP server declaration. Either **stdio** (set `command`, Phase GG)
/// or **HTTP** (set `url`, Phase II) — `url` wins when both are present.
///
/// ```toml
/// # stdio
/// [[mcp_servers]]
/// name = "brave"
/// command = "npx"
/// args = ["-y", "@modelcontextprotocol/server-brave-search"]
///
/// # HTTP (Streamable HTTP)
/// [[mcp_servers]]
/// name = "remote"
/// url = "https://mcp.example.com/v1"
/// bearer_token = "sk-…"
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
    /// Friendly name — namespaces the server's tools (`<name>__<tool>`).
    pub name: String,
    /// Executable to spawn for the stdio transport. Ignored when `url`
    /// is set.
    #[serde(default)]
    pub command: Option<String>,
    /// Arguments passed to the stdio executable.
    #[serde(default)]
    pub args: Vec<String>,
    /// HTTP endpoint for the Streamable-HTTP transport (Phase II).
    #[serde(default)]
    pub url: Option<String>,
    /// Optional bearer token for the HTTP transport.
    #[serde(default)]
    pub bearer_token: Option<String>,
}

fn default_compact_threshold() -> u64 {
    24_000
}

fn default_max_iterations() -> usize {
    10
}

fn default_skill_autogen() -> bool {
    true
}

fn default_skill_autogen_threshold() -> usize {
    3
}

impl Default for Config {
    fn default() -> Self {
        Self {
            provider: "anthropic".to_string(),
            model: DEFAULT_MODEL.to_string(),
            // Keys are NOT read from the environment here — that merge
            // happens in `load_or_init` (in memory only) so an env-sourced
            // secret can never be persisted to disk by `save`.
            anthropic_api_key: None,
            openai_api_key: None,
            openrouter_api_key: None,
            openai_base_url: None,
            lm_studio_base_url: None,
            ollama_base_url: None,
            claude_code_binary: None,
            claude_code_extra_args: Vec::new(),
            openrouter_referer: None,
            openrouter_title: None,
            system_prompt: Some(DEFAULT_SYSTEM_PROMPT.to_string()),
            max_iterations: default_max_iterations(),
            theme: None,
            show_thinking: false,
            desktop_notifications: false,
            auto_compact: false,
            auto_compact_threshold: default_compact_threshold(),
            mcp_servers: Vec::new(),
            custom_theme: None,
            tool_approvals: Vec::new(),
            telegram_allowed_chats: Vec::new(),
            discord_allowed_channels: Vec::new(),
            skill_autogen: true,
            skill_autogen_threshold: 3,
            sandbox_backend: None,
            sandbox_image: None,
            sandbox_url: None,
            tools_allow: Vec::new(),
            tools_deny: Vec::new(),
        }
    }
}

impl Config {
    /// `~/.aonyx/`.
    pub fn config_dir() -> anyhow::Result<PathBuf> {
        let home =
            dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not resolve home directory"))?;
        Ok(home.join(".aonyx"))
    }

    /// `~/.aonyx/config.toml`.
    pub fn config_path() -> anyhow::Result<PathBuf> {
        Ok(Self::config_dir()?.join("config.toml"))
    }

    /// Back-fill any unset API key from its environment variable, in
    /// memory only. Kept separate from persistence so an env-sourced key
    /// is never written to `config.toml` by [`Self::save`].
    fn merge_env(&mut self) {
        if self.anthropic_api_key.is_none() {
            self.anthropic_api_key = std::env::var("ANTHROPIC_API_KEY").ok();
        }
        if self.openai_api_key.is_none() {
            self.openai_api_key = std::env::var("OPENAI_API_KEY").ok();
        }
        if self.openrouter_api_key.is_none() {
            self.openrouter_api_key = std::env::var("OPENROUTER_API_KEY").ok();
        }
    }

    /// Read the config, creating a default file when none exists. API
    /// keys missing from the file are back-filled from the environment
    /// (in memory only — see [`Self::merge_env`]).
    pub fn load_or_init() -> anyhow::Result<Self> {
        let path = Self::config_path()?;
        if !path.exists() {
            std::fs::create_dir_all(Self::config_dir()?)?;
            let default = Self::default();
            std::fs::write(&path, toml::to_string_pretty(&default)?)?;
            eprintln!("aonyx: created {}", path.display());
            let mut config = default;
            config.merge_env();
            return Ok(config);
        }
        let raw = std::fs::read_to_string(&path)?;
        let mut config: Config = toml::from_str(&raw)?;
        config.merge_env();
        Ok(config)
    }

    /// Load the config exactly as stored on disk — no environment merge;
    /// the default (with empty keys) when no file exists. Use this before
    /// [`Self::save`] so env-sourced secrets never leak into the file.
    pub fn load_raw() -> anyhow::Result<Self> {
        let path = Self::config_path()?;
        if !path.exists() {
            return Ok(Self::default());
        }
        let raw = std::fs::read_to_string(&path)?;
        Ok(toml::from_str(&raw)?)
    }

    /// Persist the config to `~/.aonyx/config.toml` as pretty TOML.
    pub fn save(&self) -> anyhow::Result<()> {
        std::fs::create_dir_all(Self::config_dir()?)?;
        std::fs::write(Self::config_path()?, toml::to_string_pretty(self)?)?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_provider_is_anthropic() {
        let c = Config::default();
        assert_eq!(c.provider, "anthropic");
        assert_eq!(c.max_iterations, 10);
    }

    #[test]
    fn default_has_no_persisted_api_keys() {
        // default() must not read the environment, so save() can never
        // leak an env-sourced key into config.toml (Phase SS).
        let c = Config::default();
        assert!(c.anthropic_api_key.is_none());
        assert!(c.openai_api_key.is_none());
        assert!(c.openrouter_api_key.is_none());
    }

    #[test]
    fn toml_round_trip_preserves_fields() {
        let original = Config {
            provider: "ollama".into(),
            model: "llama3.1:8b".into(),
            anthropic_api_key: Some("sk-test".into()),
            openai_api_key: None,
            openrouter_api_key: None,
            openai_base_url: None,
            lm_studio_base_url: None,
            ollama_base_url: Some("http://localhost:9999".into()),
            claude_code_binary: None,
            claude_code_extra_args: Vec::new(),
            openrouter_referer: None,
            openrouter_title: None,
            system_prompt: Some("be quiet".into()),
            max_iterations: 5,
            theme: Some("dracula".into()),
            show_thinking: true,
            desktop_notifications: false,
            auto_compact: true,
            auto_compact_threshold: 12_000,
            mcp_servers: vec![McpServerConfig {
                name: "demo".into(),
                command: Some("echo".into()),
                args: vec!["hi".into()],
                url: None,
                bearer_token: None,
            }],
            custom_theme: None,
            tool_approvals: Vec::new(),
            telegram_allowed_chats: Vec::new(),
            discord_allowed_channels: Vec::new(),
            skill_autogen: true,
            skill_autogen_threshold: 3,
            sandbox_backend: None,
            sandbox_image: None,
            sandbox_url: None,
            tools_allow: Vec::new(),
            tools_deny: Vec::new(),
        };
        let s = toml::to_string(&original).unwrap();
        let parsed: Config = toml::from_str(&s).unwrap();
        assert_eq!(parsed.provider, original.provider);
        assert_eq!(parsed.model, original.model);
        assert_eq!(parsed.max_iterations, original.max_iterations);
        assert_eq!(parsed.system_prompt.as_deref(), Some("be quiet"));
        assert_eq!(
            parsed.ollama_base_url.as_deref(),
            Some("http://localhost:9999")
        );
        assert!(parsed.auto_compact);
        assert_eq!(parsed.auto_compact_threshold, 12_000);
    }

    #[test]
    fn missing_compact_fields_use_defaults() {
        let raw = r#"
            provider = "anthropic"
            model = "claude-sonnet"
        "#;
        let parsed: Config = toml::from_str(raw).unwrap();
        assert!(!parsed.auto_compact);
        assert_eq!(parsed.auto_compact_threshold, 24_000);
    }

    #[test]
    fn missing_optional_fields_use_defaults() {
        let raw = r#"
            provider = "anthropic"
            model = "claude-sonnet"
        "#;
        let parsed: Config = toml::from_str(raw).unwrap();
        assert_eq!(parsed.max_iterations, 10);
        assert!(parsed.system_prompt.is_none());
    }
}