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