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 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 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 #[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
264fn 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 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 settings.subagents.enabled = subagents_enabled;
326 settings.experimental_context_file = experimental_context_file;
327 settings.workflows.enabled = workflows_enabled;
330 settings.cache_warming = cache_warming;
331 settings.voice_backend = voice_backend;
332 settings
333}
334
335impl Settings {
336 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 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 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}