Skip to main content

kiss_coding/
settings.rs

1//! Settings: global `~/.kiss/agent/settings.json` deep-merged with project
2//! `.kiss/settings.json` (project wins).
3
4use kiss_ai::Transport;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
11#[serde(rename_all = "lowercase")]
12pub enum CompactionMode {
13    #[default]
14    Summary,
15    Jev,
16}
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase", default)]
20pub struct CompactionSettings {
21    pub enabled: bool,
22    pub mode: CompactionMode,
23    pub reserve_tokens: u64,
24    pub keep_recent_tokens: u64,
25    pub model_overrides: BTreeMap<String, CompactionModelOverride>,
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
29#[serde(rename_all = "camelCase", default)]
30pub struct CompactionModelOverride {
31    pub reserve_tokens: Option<u64>,
32    pub keep_recent_tokens: Option<u64>,
33}
34
35impl Default for CompactionSettings {
36    fn default() -> Self {
37        CompactionSettings {
38            enabled: true,
39            mode: CompactionMode::Summary,
40            reserve_tokens: 16_384,
41            keep_recent_tokens: 20_000,
42            model_overrides: BTreeMap::new(),
43        }
44    }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
48#[serde(rename_all = "lowercase")]
49pub enum ReasoningEffortMode {
50    #[default]
51    Fixed,
52    Jev,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
56#[serde(rename_all = "camelCase", default)]
57pub struct ReasoningEffortSettings {
58    pub mode: ReasoningEffortMode,
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase", default)]
63pub struct RetrySettings {
64    pub enabled: bool,
65    pub max_retries: u32,
66    pub base_delay_ms: u64,
67    pub max_agent_delay_ms: u64,
68}
69
70impl Default for RetrySettings {
71    fn default() -> Self {
72        RetrySettings {
73            enabled: true,
74            max_retries: 3,
75            base_delay_ms: 2000,
76            max_agent_delay_ms: 60_000,
77        }
78    }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
82#[serde(rename_all = "camelCase", default)]
83pub struct SubagentSettings {
84    /// Subagent tools are hidden until the user enables them.
85    pub enabled: bool,
86}
87
88/// How many agents to advise the model to aim for when it writes a workflow.
89///
90/// This is advice sent to the model, not a cap: a prompt that clearly calls for
91/// a different scale still wins. The runtime limits are the real bound.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
93#[serde(rename_all = "lowercase")]
94pub enum WorkflowSize {
95    Small,
96    #[default]
97    Medium,
98    Large,
99    Unrestricted,
100}
101
102impl WorkflowSize {
103    pub fn as_str(self) -> &'static str {
104        match self {
105            Self::Small => "small",
106            Self::Medium => "medium",
107            Self::Large => "large",
108            Self::Unrestricted => "unrestricted",
109        }
110    }
111
112    /// The agent count the model is asked to stay under, if any.
113    pub fn target_agents(self) -> Option<u32> {
114        match self {
115            Self::Small => Some(5),
116            Self::Medium => Some(15),
117            Self::Large => Some(50),
118            Self::Unrestricted => None,
119        }
120    }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase", default)]
125pub struct WorkflowSettings {
126    /// Dynamic workflows are built on subagents, so they are available only
127    /// when `subagents.enabled` is also on.
128    pub enabled: bool,
129    /// Ask before a run starts. This is a cost gate, not a permission gate: one
130    /// run can start hundreds of child agents.
131    pub confirm: bool,
132    /// Let a typed prompt containing `ultracode`, or asking for a workflow in
133    /// plain words, start one.
134    pub keyword_trigger: bool,
135    pub size: WorkflowSize,
136}
137
138impl Default for WorkflowSettings {
139    fn default() -> Self {
140        WorkflowSettings {
141            enabled: true,
142            confirm: true,
143            keyword_trigger: true,
144            size: WorkflowSize::default(),
145        }
146    }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
150#[serde(rename_all = "kebab-case")]
151pub enum QueueMode {
152    All,
153    #[default]
154    OneAtATime,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
158#[serde(rename_all = "lowercase")]
159pub enum ProjectTrustDefault {
160    #[default]
161    Ask,
162    Always,
163    Never,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
167#[serde(rename_all = "lowercase")]
168pub enum CacheWarmingMode {
169    Off,
170    #[default]
171    Streaming,
172    Idle,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
176#[serde(rename_all = "lowercase")]
177pub enum MermaidRendering {
178    Off,
179    Final,
180    #[default]
181    Streaming,
182}
183
184impl MermaidRendering {
185    pub fn as_str(self) -> &'static str {
186        match self {
187            Self::Off => "off",
188            Self::Final => "final",
189            Self::Streaming => "streaming",
190        }
191    }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
195#[serde(rename_all = "camelCase", default)]
196pub struct MarkdownSettings {
197    pub code_block_indent: Option<String>,
198    pub mermaid: MermaidRendering,
199}
200
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
202#[serde(rename_all = "camelCase", default)]
203pub struct WebMcpSettings {
204    pub allowed_origins: Option<Vec<String>>,
205    pub disallowed_origins: Vec<String>,
206    pub cdp: Option<Value>,
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
210#[serde(rename_all = "camelCase", default)]
211pub struct Settings {
212    pub default_provider: Option<String>,
213    pub default_model: Option<String>,
214    pub default_thinking_level: Option<String>,
215    pub transport: Transport,
216    pub hide_thinking_block: bool,
217    pub theme: Option<String>,
218    pub quiet_startup: bool,
219    pub default_project_trust: ProjectTrustDefault,
220    pub compaction: CompactionSettings,
221    pub reasoning_effort: ReasoningEffortSettings,
222    pub retry: RetrySettings,
223    pub cache_warming: CacheWarmingMode,
224    pub subagents: SubagentSettings,
225    pub workflows: WorkflowSettings,
226    pub steering_mode: QueueMode,
227    pub follow_up_mode: QueueMode,
228    pub shell_path: Option<String>,
229    pub shell_command_prefix: Option<String>,
230    pub session_dir: Option<String>,
231    pub enabled_models: Option<Vec<String>>,
232    pub external_editor: Option<String>,
233    pub skills: Vec<String>,
234    pub prompts: Vec<String>,
235    pub themes: Vec<String>,
236    pub enable_skill_commands: Option<bool>,
237    /// `None` preserves the default-on behavior for existing settings files.
238    pub auto_recap: Option<bool>,
239    pub voice_language: Option<String>,
240    pub voice_backend: Option<String>,
241    pub markdown: MarkdownSettings,
242    pub webmcp: WebMcpSettings,
243    /// Unknown keys survive load/save.
244    #[serde(flatten)]
245    pub extra: serde_json::Map<String, Value>,
246}
247
248pub fn global_settings_path() -> Option<PathBuf> {
249    dirs::home_dir().map(|h| h.join(".kiss/agent/settings.json"))
250}
251
252pub fn project_settings_path(cwd: &Path) -> PathBuf {
253    cwd.join(".kiss/settings.json")
254}
255
256fn read_json(path: &Path) -> Option<Value> {
257    let text = std::fs::read_to_string(path).ok()?;
258    serde_json::from_str(&text).ok()
259}
260
261/// Deep-merge `overlay` onto `base` (objects merge, everything else replaces).
262fn deep_merge(base: &mut Value, overlay: Value) {
263    match (base, overlay) {
264        (Value::Object(base_map), Value::Object(overlay_map)) => {
265            for (k, v) in overlay_map {
266                match base_map.get_mut(&k) {
267                    Some(slot) => deep_merge(slot, v),
268                    None => {
269                        base_map.insert(k, v);
270                    }
271                }
272            }
273        }
274        (slot, v) => *slot = v,
275    }
276}
277
278fn merged_settings(mut global: Value, project: Option<Value>) -> Settings {
279    let subagents_enabled = global
280        .pointer("/subagents/enabled")
281        .and_then(Value::as_bool)
282        .unwrap_or(false);
283    let workflows_enabled = global
284        .pointer("/workflows/enabled")
285        .and_then(Value::as_bool)
286        .unwrap_or(true);
287    // A project must not opt the user's microphone in to a cloud service.
288    let voice_backend = global
289        .get("voiceBackend")
290        .and_then(Value::as_str)
291        .map(str::to_owned);
292    let cache_warming = global
293        .get("cacheWarming")
294        .cloned()
295        .and_then(|value| serde_json::from_value(value).ok())
296        .unwrap_or_default();
297    if let Some(project) = project {
298        deep_merge(&mut global, project);
299    }
300    let mut settings: Settings = serde_json::from_value(global).unwrap_or_default();
301    // Subagents are a user-level authority choice. A repository must not turn
302    // them on through a trusted project settings file.
303    settings.subagents.enabled = subagents_enabled;
304    // Workflows spend the user's tokens many agents at a time, so the same rule
305    // applies: only the user's own settings file decides whether they are on.
306    settings.workflows.enabled = workflows_enabled;
307    settings.cache_warming = cache_warming;
308    settings.voice_backend = voice_backend;
309    settings
310}
311
312impl Settings {
313    pub fn auto_recap_enabled(&self) -> bool {
314        self.auto_recap.unwrap_or(true)
315    }
316
317    /// Load global settings, overlaying project settings when trusted.
318    pub fn load(cwd: &Path, project_trusted: bool) -> Settings {
319        let global = global_settings_path()
320            .and_then(|p| read_json(&p))
321            .unwrap_or_else(|| Value::Object(Default::default()));
322        let project = project_trusted
323            .then(|| read_json(&project_settings_path(cwd)))
324            .flatten();
325        merged_settings(global, project)
326    }
327
328    pub fn save_global(&self) -> anyhow::Result<()> {
329        let path = global_settings_path().ok_or_else(|| anyhow::anyhow!("no home directory"))?;
330        if let Some(parent) = path.parent() {
331            std::fs::create_dir_all(parent)?;
332        }
333        std::fs::write(&path, serde_json::to_string_pretty(self)?)?;
334        Ok(())
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use serde_json::json;
342
343    #[test]
344    fn project_cannot_enable_cloud_voice() {
345        let settings = merged_settings(json!({}), Some(json!({"voiceBackend":"deepgram"})));
346        assert_eq!(settings.voice_backend, None);
347        let settings = merged_settings(
348            json!({"voiceBackend":"elevenlabs"}),
349            Some(json!({"voiceBackend":"local"})),
350        );
351        assert_eq!(settings.voice_backend.as_deref(), Some("elevenlabs"));
352    }
353
354    #[test]
355    fn deep_merge_nested() {
356        let mut base =
357            json!({"theme": "dark", "compaction": {"enabled": true, "reserveTokens": 16384}});
358        deep_merge(&mut base, json!({"compaction": {"reserveTokens": 8192}}));
359        assert_eq!(base["theme"], "dark");
360        assert_eq!(base["compaction"]["enabled"], true);
361        assert_eq!(base["compaction"]["reserveTokens"], 8192);
362    }
363
364    #[test]
365    fn defaults_match_pi() {
366        let s = Settings::default();
367        assert_eq!(s.compaction.mode, CompactionMode::Summary);
368        assert_eq!(s.compaction.reserve_tokens, 16_384);
369        assert_eq!(s.compaction.keep_recent_tokens, 20_000);
370        assert_eq!(s.reasoning_effort.mode, ReasoningEffortMode::Fixed);
371        assert_eq!(s.retry.max_retries, 3);
372        assert_eq!(s.retry.max_agent_delay_ms, 60_000);
373        assert_eq!(s.cache_warming, CacheWarmingMode::Streaming);
374        assert!(!s.subagents.enabled);
375        assert_eq!(s.steering_mode, QueueMode::OneAtATime);
376        assert!(s.auto_recap_enabled());
377        assert_eq!(s.markdown.mermaid, MermaidRendering::Streaming);
378    }
379
380    #[test]
381    fn compaction_model_overrides_use_exact_provider_model_keys() {
382        let settings: Settings = serde_json::from_value(json!({
383            "compaction": {
384                "modelOverrides": {
385                    "anthropic/claude-opus-4-8": {
386                        "reserveTokens": 1000,
387                        "keepRecentTokens": 2000
388                    }
389                }
390            },
391            "cacheWarming": "idle"
392        }))
393        .unwrap();
394        let override_settings = &settings.compaction.model_overrides["anthropic/claude-opus-4-8"];
395        assert_eq!(override_settings.reserve_tokens, Some(1000));
396        assert_eq!(override_settings.keep_recent_tokens, Some(2000));
397        assert_eq!(settings.cache_warming, CacheWarmingMode::Idle);
398    }
399
400    #[test]
401    fn old_compaction_settings_default_to_summary_mode() {
402        let settings: Settings = serde_json::from_value(json!({
403            "compaction": {"enabled": true, "reserveTokens": 1, "keepRecentTokens": 2}
404        }))
405        .unwrap();
406        assert_eq!(settings.compaction.mode, CompactionMode::Summary);
407        assert_eq!(
408            serde_json::to_value(settings).unwrap()["compaction"]["mode"],
409            "summary"
410        );
411    }
412
413    #[test]
414    fn old_settings_default_to_fixed_reasoning_effort() {
415        let settings: Settings = serde_json::from_value(json!({})).unwrap();
416        assert_eq!(settings.reasoning_effort.mode, ReasoningEffortMode::Fixed);
417        assert_eq!(
418            serde_json::to_value(settings).unwrap()["reasoningEffort"]["mode"],
419            "fixed"
420        );
421    }
422
423    #[test]
424    fn queue_mode_wire_format() {
425        assert_eq!(
426            serde_json::to_value(QueueMode::OneAtATime).unwrap(),
427            "one-at-a-time"
428        );
429    }
430
431    #[test]
432    fn markdown_settings_use_the_pi_wire_shape() {
433        let value = serde_json::to_value(Settings::default()).unwrap();
434        assert_eq!(value["markdown"]["mermaid"], "streaming");
435        assert!(value.get("mermaidRendering").is_none());
436    }
437
438    #[test]
439    fn webmcp_settings_use_the_reference_wire_shape() {
440        let loaded: Settings = serde_json::from_value(json!({
441            "webmcp": {
442                "allowedOrigins": ["example.com"],
443                "disallowedOrigins": ["blocked.example"],
444                "cdp": 9333
445            }
446        }))
447        .unwrap();
448        assert_eq!(
449            loaded.webmcp.allowed_origins,
450            Some(vec!["example.com".to_string()])
451        );
452        assert_eq!(
453            loaded.webmcp.disallowed_origins,
454            vec!["blocked.example".to_string()]
455        );
456        assert_eq!(loaded.webmcp.cdp, Some(json!(9333)));
457    }
458
459    #[test]
460    fn subagents_are_opt_in_on_the_wire() {
461        let value = serde_json::to_value(Settings::default()).unwrap();
462        assert_eq!(value["subagents"]["enabled"], false);
463
464        let loaded: Settings = serde_json::from_value(json!({})).unwrap();
465        assert!(!loaded.subagents.enabled);
466    }
467
468    #[test]
469    fn workflows_default_on_but_only_matter_with_subagents() {
470        let settings = Settings::default();
471        assert!(settings.workflows.enabled);
472        assert!(settings.workflows.confirm);
473        assert!(settings.workflows.keyword_trigger);
474        assert_eq!(settings.workflows.size, WorkflowSize::Medium);
475        // Subagents stay off, so workflows are unavailable until the user opts
476        // in to child agents.
477        assert!(!settings.subagents.enabled);
478    }
479
480    #[test]
481    fn project_settings_cannot_turn_workflows_on_or_off() {
482        let off = merged_settings(
483            json!({"workflows": {"enabled": false}}),
484            Some(json!({"workflows": {"enabled": true}})),
485        );
486        assert!(!off.workflows.enabled);
487
488        let on = merged_settings(json!({}), Some(json!({"workflows": {"enabled": false}})));
489        assert!(on.workflows.enabled);
490    }
491
492    #[test]
493    fn project_settings_cannot_enable_paid_cache_warming() {
494        let settings = merged_settings(
495            json!({"cacheWarming": "off"}),
496            Some(json!({"cacheWarming": "idle"})),
497        );
498        assert_eq!(settings.cache_warming, CacheWarmingMode::Off);
499    }
500
501    #[test]
502    fn the_size_guideline_maps_to_an_agent_count() {
503        assert_eq!(WorkflowSize::Small.target_agents(), Some(5));
504        assert_eq!(WorkflowSize::Medium.target_agents(), Some(15));
505        assert_eq!(WorkflowSize::Large.target_agents(), Some(50));
506        assert_eq!(WorkflowSize::Unrestricted.target_agents(), None);
507    }
508
509    #[test]
510    fn project_settings_cannot_change_subagent_authority() {
511        let enabled = merged_settings(
512            json!({"subagents": {"enabled": true}}),
513            Some(json!({"subagents": {"enabled": false}})),
514        );
515        assert!(enabled.subagents.enabled);
516
517        let disabled = merged_settings(json!({}), Some(json!({"subagents": {"enabled": true}})));
518        assert!(!disabled.subagents.enabled);
519    }
520}