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    /// Let the model edit its live conversation through a private JSON file.
222    pub experimental_context_file: bool,
223    pub reasoning_effort: ReasoningEffortSettings,
224    pub retry: RetrySettings,
225    pub cache_warming: CacheWarmingMode,
226    pub subagents: SubagentSettings,
227    pub workflows: WorkflowSettings,
228    pub steering_mode: QueueMode,
229    pub follow_up_mode: QueueMode,
230    pub shell_path: Option<String>,
231    pub shell_command_prefix: Option<String>,
232    pub session_dir: Option<String>,
233    pub enabled_models: Option<Vec<String>>,
234    pub default_tools: Option<Vec<String>>,
235    pub external_editor: Option<String>,
236    pub skills: Vec<String>,
237    pub prompts: Vec<String>,
238    pub themes: Vec<String>,
239    pub enable_skill_commands: Option<bool>,
240    /// `None` preserves the default-on behavior for existing settings files.
241    pub auto_recap: Option<bool>,
242    pub voice_language: Option<String>,
243    pub voice_backend: Option<String>,
244    pub markdown: MarkdownSettings,
245    pub webmcp: WebMcpSettings,
246    /// Unknown keys survive load/save.
247    #[serde(flatten)]
248    pub extra: serde_json::Map<String, Value>,
249}
250
251pub fn global_settings_path() -> Option<PathBuf> {
252    dirs::home_dir().map(|h| h.join(".kiss/agent/settings.json"))
253}
254
255pub fn project_settings_path(cwd: &Path) -> PathBuf {
256    cwd.join(".kiss/settings.json")
257}
258
259fn read_json(path: &Path) -> Option<Value> {
260    let text = std::fs::read_to_string(path).ok()?;
261    serde_json::from_str(&text).ok()
262}
263
264/// Deep-merge `overlay` onto `base` (objects merge, everything else replaces).
265fn deep_merge(base: &mut Value, overlay: Value) {
266    match (base, overlay) {
267        (Value::Object(base_map), Value::Object(overlay_map)) => {
268            for (k, v) in overlay_map {
269                match base_map.get_mut(&k) {
270                    Some(slot) => deep_merge(slot, v),
271                    None => {
272                        base_map.insert(k, v);
273                    }
274                }
275            }
276        }
277        (slot, v) => *slot = v,
278    }
279}
280
281fn merged_settings(mut global: Value, project: Option<Value>) -> Settings {
282    let experimental_context_file = global
283        .get("experimentalContextFile")
284        .and_then(Value::as_bool)
285        .unwrap_or(false);
286    let subagents_enabled = global
287        .pointer("/subagents/enabled")
288        .and_then(Value::as_bool)
289        .unwrap_or(false);
290    let workflows_enabled = global
291        .pointer("/workflows/enabled")
292        .and_then(Value::as_bool)
293        .unwrap_or(true);
294    // A project must not opt the user's microphone in to a cloud service.
295    let voice_backend = global
296        .get("voiceBackend")
297        .and_then(Value::as_str)
298        .map(str::to_owned);
299    let cache_warming = global
300        .get("cacheWarming")
301        .cloned()
302        .and_then(|value| serde_json::from_value(value).ok())
303        .unwrap_or_default();
304    if let Some(mut project) = project {
305        if let Some(object) = project.as_object_mut() {
306            object.remove("deviceId");
307        }
308        if let Some(entries) = project.get("defaultTools").and_then(Value::as_array)
309            && entries.iter().all(|entry| {
310                entry
311                    .as_str()
312                    .is_some_and(|name| name.starts_with(['+', '-']))
313            })
314            && let Some(base) = global.get("defaultTools").and_then(Value::as_array)
315        {
316            let mut combined = base.clone();
317            combined.extend(entries.iter().cloned());
318            project["defaultTools"] = Value::Array(combined);
319        }
320        deep_merge(&mut global, project);
321    }
322    let mut settings: Settings = serde_json::from_value(global).unwrap_or_default();
323    // Subagents are a user-level authority choice. A repository must not turn
324    // them on through a trusted project settings file.
325    settings.subagents.enabled = subagents_enabled;
326    settings.experimental_context_file = experimental_context_file;
327    // Workflows spend the user's tokens many agents at a time, so the same rule
328    // applies: only the user's own settings file decides whether they are on.
329    settings.workflows.enabled = workflows_enabled;
330    settings.cache_warming = cache_warming;
331    settings.voice_backend = voice_backend;
332    settings
333}
334
335impl Settings {
336    /// Read only user settings when creating the installation ID.
337    pub fn get_or_create_device_id() -> anyhow::Result<String> {
338        let path = global_settings_path().ok_or_else(|| anyhow::anyhow!(
339            "ChatGPT login has no user settings path. A home directory is required. Set HOME and run login again."
340        ))?;
341        let mut settings: Value = match std::fs::read_to_string(&path) {
342            Ok(text) => serde_json::from_str(&text).map_err(|_| anyhow::anyhow!(
343                "ChatGPT login cannot read {}. Settings must contain a JSON object. Correct the JSON and run login again.", path.display()
344            ))?,
345            Err(error) if error.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
346            Err(error) => return Err(error.into()),
347        };
348        if !settings.is_object() {
349            anyhow::bail!(
350                "ChatGPT login cannot update {}. Settings must contain a JSON object. Correct the JSON and run login again.",
351                path.display()
352            );
353        }
354        if let Some(id) = settings.get("deviceId").and_then(Value::as_str)
355            && uuid::Uuid::parse_str(id).is_ok()
356        {
357            return Ok(id.to_string());
358        }
359        let id = uuid::Uuid::new_v4().to_string();
360        settings["deviceId"] = Value::String(id.clone());
361        if let Some(parent) = path.parent() {
362            std::fs::create_dir_all(parent)?;
363        }
364        std::fs::write(path, serde_json::to_string_pretty(&settings)?)?;
365        Ok(id)
366    }
367
368    pub fn auto_recap_enabled(&self) -> bool {
369        self.auto_recap.unwrap_or(true)
370    }
371
372    /// Load global settings, overlaying project settings when trusted.
373    pub fn load(cwd: &Path, project_trusted: bool) -> Settings {
374        let global = global_settings_path()
375            .and_then(|p| read_json(&p))
376            .unwrap_or_else(|| Value::Object(Default::default()));
377        let project = project_trusted
378            .then(|| read_json(&project_settings_path(cwd)))
379            .flatten();
380        merged_settings(global, project)
381    }
382
383    pub fn save_global(&self) -> anyhow::Result<()> {
384        let path = global_settings_path().ok_or_else(|| anyhow::anyhow!("no home directory"))?;
385        if let Some(parent) = path.parent() {
386            std::fs::create_dir_all(parent)?;
387        }
388        let mut value = serde_json::to_value(self)?;
389        if let Some(id) = read_json(&path).and_then(|disk| disk.get("deviceId").cloned()) {
390            value["deviceId"] = id;
391        }
392        std::fs::write(&path, serde_json::to_string_pretty(&value)?)?;
393        Ok(())
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use serde_json::json;
401
402    #[test]
403    fn project_tool_modifiers_preserve_user_selection() {
404        let user = json!({"defaultTools":["read", "+grep"]});
405        let settings =
406            merged_settings(user.clone(), Some(json!({"defaultTools":["-read", "+ls"]})));
407        assert_eq!(
408            settings.default_tools.unwrap(),
409            ["read", "+grep", "-read", "+ls"]
410        );
411        let settings = merged_settings(user.clone(), Some(json!({"defaultTools":["bash"]})));
412        assert_eq!(settings.default_tools.unwrap(), ["bash"]);
413        let settings = merged_settings(user, Some(json!({"defaultTools":[]})));
414        assert_eq!(settings.default_tools.unwrap(), ["read", "+grep"]);
415        let settings = merged_settings(
416            json!({"deviceId":"user-id"}),
417            Some(json!({"deviceId":"project-id"})),
418        );
419        assert_eq!(settings.extra["deviceId"], "user-id");
420    }
421
422    #[test]
423    fn project_cannot_enable_cloud_voice() {
424        let settings = merged_settings(json!({}), Some(json!({"voiceBackend":"deepgram"})));
425        assert_eq!(settings.voice_backend, None);
426        let settings = merged_settings(
427            json!({"voiceBackend":"elevenlabs"}),
428            Some(json!({"voiceBackend":"local"})),
429        );
430        assert_eq!(settings.voice_backend.as_deref(), Some("elevenlabs"));
431    }
432
433    #[test]
434    fn deep_merge_nested() {
435        let mut base =
436            json!({"theme": "dark", "compaction": {"enabled": true, "reserveTokens": 16384}});
437        deep_merge(&mut base, json!({"compaction": {"reserveTokens": 8192}}));
438        assert_eq!(base["theme"], "dark");
439        assert_eq!(base["compaction"]["enabled"], true);
440        assert_eq!(base["compaction"]["reserveTokens"], 8192);
441    }
442
443    #[test]
444    fn defaults_match_pi() {
445        let s = Settings::default();
446        assert_eq!(s.compaction.mode, CompactionMode::Summary);
447        assert!(!s.experimental_context_file);
448        assert_eq!(s.compaction.reserve_tokens, 16_384);
449        assert_eq!(s.compaction.keep_recent_tokens, 20_000);
450        assert_eq!(s.reasoning_effort.mode, ReasoningEffortMode::Fixed);
451        assert_eq!(s.retry.max_retries, 3);
452        assert_eq!(s.retry.max_agent_delay_ms, 60_000);
453        assert_eq!(s.cache_warming, CacheWarmingMode::Streaming);
454        assert!(!s.subagents.enabled);
455        assert_eq!(s.steering_mode, QueueMode::OneAtATime);
456        assert!(s.auto_recap_enabled());
457        assert_eq!(s.markdown.mermaid, MermaidRendering::Streaming);
458    }
459
460    #[test]
461    fn compaction_model_overrides_use_exact_provider_model_keys() {
462        let settings: Settings = serde_json::from_value(json!({
463            "compaction": {
464                "modelOverrides": {
465                    "anthropic/claude-opus-4-8": {
466                        "reserveTokens": 1000,
467                        "keepRecentTokens": 2000
468                    }
469                }
470            },
471            "cacheWarming": "idle"
472        }))
473        .unwrap();
474        let override_settings = &settings.compaction.model_overrides["anthropic/claude-opus-4-8"];
475        assert_eq!(override_settings.reserve_tokens, Some(1000));
476        assert_eq!(override_settings.keep_recent_tokens, Some(2000));
477        assert_eq!(settings.cache_warming, CacheWarmingMode::Idle);
478    }
479
480    #[test]
481    fn old_compaction_settings_default_to_summary_mode() {
482        let settings: Settings = serde_json::from_value(json!({
483            "compaction": {"enabled": true, "reserveTokens": 1, "keepRecentTokens": 2}
484        }))
485        .unwrap();
486        assert_eq!(settings.compaction.mode, CompactionMode::Summary);
487        assert_eq!(
488            serde_json::to_value(settings).unwrap()["compaction"]["mode"],
489            "summary"
490        );
491    }
492
493    #[test]
494    fn old_settings_default_to_fixed_reasoning_effort() {
495        let settings: Settings = serde_json::from_value(json!({})).unwrap();
496        assert_eq!(settings.reasoning_effort.mode, ReasoningEffortMode::Fixed);
497        assert_eq!(
498            serde_json::to_value(settings).unwrap()["reasoningEffort"]["mode"],
499            "fixed"
500        );
501    }
502
503    #[test]
504    fn queue_mode_wire_format() {
505        assert_eq!(
506            serde_json::to_value(QueueMode::OneAtATime).unwrap(),
507            "one-at-a-time"
508        );
509    }
510
511    #[test]
512    fn markdown_settings_use_the_pi_wire_shape() {
513        let value = serde_json::to_value(Settings::default()).unwrap();
514        assert_eq!(value["markdown"]["mermaid"], "streaming");
515        assert!(value.get("mermaidRendering").is_none());
516    }
517
518    #[test]
519    fn webmcp_settings_use_the_reference_wire_shape() {
520        let loaded: Settings = serde_json::from_value(json!({
521            "webmcp": {
522                "allowedOrigins": ["example.com"],
523                "disallowedOrigins": ["blocked.example"],
524                "cdp": 9333
525            }
526        }))
527        .unwrap();
528        assert_eq!(
529            loaded.webmcp.allowed_origins,
530            Some(vec!["example.com".to_string()])
531        );
532        assert_eq!(
533            loaded.webmcp.disallowed_origins,
534            vec!["blocked.example".to_string()]
535        );
536        assert_eq!(loaded.webmcp.cdp, Some(json!(9333)));
537    }
538
539    #[test]
540    fn subagents_are_opt_in_on_the_wire() {
541        let value = serde_json::to_value(Settings::default()).unwrap();
542        assert_eq!(value["subagents"]["enabled"], false);
543
544        let loaded: Settings = serde_json::from_value(json!({})).unwrap();
545        assert!(!loaded.subagents.enabled);
546    }
547
548    #[test]
549    fn workflows_default_on_but_only_matter_with_subagents() {
550        let settings = Settings::default();
551        assert!(settings.workflows.enabled);
552        assert!(settings.workflows.confirm);
553        assert!(settings.workflows.keyword_trigger);
554        assert_eq!(settings.workflows.size, WorkflowSize::Medium);
555        // Subagents stay off, so workflows are unavailable until the user opts
556        // in to child agents.
557        assert!(!settings.subagents.enabled);
558    }
559
560    #[test]
561    fn project_settings_cannot_turn_workflows_on_or_off() {
562        let off = merged_settings(
563            json!({"workflows": {"enabled": false}}),
564            Some(json!({"workflows": {"enabled": true}})),
565        );
566        assert!(!off.workflows.enabled);
567
568        let on = merged_settings(json!({}), Some(json!({"workflows": {"enabled": false}})));
569        assert!(on.workflows.enabled);
570    }
571
572    #[test]
573    fn project_settings_cannot_enable_paid_cache_warming() {
574        let settings = merged_settings(
575            json!({"cacheWarming": "off"}),
576            Some(json!({"cacheWarming": "idle"})),
577        );
578        assert_eq!(settings.cache_warming, CacheWarmingMode::Off);
579    }
580
581    #[test]
582    fn experimental_context_file_is_a_user_setting() {
583        for enabled in [false, true] {
584            let settings = merged_settings(
585                json!({"experimentalContextFile": enabled}),
586                Some(json!({"experimentalContextFile": !enabled})),
587            );
588            assert_eq!(settings.experimental_context_file, enabled);
589            assert_eq!(
590                serde_json::to_value(settings).unwrap()["experimentalContextFile"],
591                enabled
592            );
593        }
594    }
595
596    #[test]
597    fn the_size_guideline_maps_to_an_agent_count() {
598        assert_eq!(WorkflowSize::Small.target_agents(), Some(5));
599        assert_eq!(WorkflowSize::Medium.target_agents(), Some(15));
600        assert_eq!(WorkflowSize::Large.target_agents(), Some(50));
601        assert_eq!(WorkflowSize::Unrestricted.target_agents(), None);
602    }
603
604    #[test]
605    fn project_settings_cannot_change_subagent_authority() {
606        let enabled = merged_settings(
607            json!({"subagents": {"enabled": true}}),
608            Some(json!({"subagents": {"enabled": false}})),
609        );
610        assert!(enabled.subagents.enabled);
611
612        let disabled = merged_settings(json!({}), Some(json!({"subagents": {"enabled": true}})));
613        assert!(!disabled.subagents.enabled);
614    }
615}