1use 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 pub enabled: bool,
86}
87
88#[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 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 pub enabled: bool,
129 pub confirm: bool,
132 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 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 #[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
261fn 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 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 settings.subagents.enabled = subagents_enabled;
304 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 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 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}