Skip to main content

mur_common/config/
skills.rs

1use super::*;
2
3/// Configuration for the daemon-side sleep cycle (idle background learning).
4///
5/// Skill injection configuration (M2 — runtime injection).
6///
7/// Whether the `mur-dev` discipline hub appears in the session-start learning
8/// index on the AI-tool (CLI hook) surface. Runtime injection for MUR agents
9/// is never affected by this setting.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
11#[serde(rename_all = "lowercase")]
12pub enum DevDisciplineIndex {
13    /// Suppress the hub when a superpowers plugin install is detected (default).
14    #[default]
15    Auto,
16    /// Always list the hub, even when superpowers is installed.
17    Always,
18    /// Never list the hub on the CLI surface.
19    Never,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(default)]
24pub struct SkillsConfig {
25    pub max_skills_in_prompt: usize,
26    pub max_total_tokens: usize,
27    pub priority_order: Vec<String>,
28    pub adaptive: Option<AdaptiveSkillsConfig>,
29
30    /// When true (default), LLM-authored skills cannot auto-promote past
31    /// `Emerging` until a human curates them (amendment A1). Set false to
32    /// let LLM-extracted skills promote on run stats alone.
33    #[serde(default = "default_require_human_curation")]
34    pub require_human_curation_before_stable: bool,
35
36    /// Lifecycle scoring thresholds (W3b-P4). All fields default to the
37    /// compile-time constants in `mur_common::skill::lifecycle` so existing
38    /// deployments see no behaviour change without an explicit config entry.
39    #[serde(default)]
40    pub lifecycle: SkillLifecycleConfig,
41
42    /// Daily daemon auto-upgrade of origin-stamped (registry-installed)
43    /// skills (`mur-daemon` `skill_upgrade_tick`). Non-destructive: never
44    /// overwrites a locally-modified skill (origin hash drift blocks it).
45    /// Defaults to `true`.
46    #[serde(default = "default_auto_upgrade")]
47    pub auto_upgrade: bool,
48
49    /// See [`DevDisciplineIndex`]. Key: `skills.dev_discipline_index`.
50    #[serde(default)]
51    pub dev_discipline_index: DevDisciplineIndex,
52}
53
54fn default_require_human_curation() -> bool {
55    true
56}
57
58fn default_auto_upgrade() -> bool {
59    true
60}
61
62impl Default for SkillsConfig {
63    fn default() -> Self {
64        Self {
65            max_skills_in_prompt: 5,
66            max_total_tokens: 2000,
67            priority_order: vec!["agent".into(), "global".into()],
68            adaptive: Some(AdaptiveSkillsConfig::default()),
69            require_human_curation_before_stable: default_require_human_curation(),
70            lifecycle: SkillLifecycleConfig::default(),
71            auto_upgrade: default_auto_upgrade(),
72            dev_discipline_index: DevDisciplineIndex::default(),
73        }
74    }
75}
76
77/// Per-skill lifecycle scoring thresholds.
78///
79/// Stored under `skill.lifecycle.*` in `~/.mur/config.yaml`.
80/// All fields are optional on disk — missing keys fall back to the
81/// compile-time defaults so a partial config is always valid.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(default)]
84pub struct SkillLifecycleConfig {
85    // ── Per-kind decay curves (memory federation P1) ─────────────────────
86    /// Half-life multiplier for `kind=rule` notes — behavioral guidance
87    /// iterates fast, so it decays fast.
88    pub note_rule_half_life_factor: f64,
89    /// Half-life multiplier for `kind=fact` notes — environment truths
90    /// decay slowly.
91    pub note_fact_half_life_factor: f64,
92    // ── Promotion thresholds (must be exceeded) ──────────────────────────
93    pub promote_draft_uses: u64,
94    pub promote_emerging_uses: u64,
95    pub promote_emerging_success_rate: f64,
96    pub promote_emerging_age_days: i64,
97    pub promote_stable_uses: u64,
98    pub promote_stable_success_rate: f64,
99    pub promote_stable_age_days: i64,
100
101    // ── Demotion thresholds (must drop below) ────────────────────────────
102    pub demote_emerging_uses: u64,
103    pub demote_emerging_success_rate: f64,
104    pub demote_stable_uses: u64,
105    pub demote_stable_success_rate: f64,
106    pub deprecated_success_rate: f64,
107    pub deprecated_no_success_days: i64,
108
109    // ── Auto-archive thresholds ───────────────────────────────────────────
110    pub auto_archive_confidence: f64,
111    pub auto_archive_age_days: i64,
112
113    // ── P4: broken fast-path ─────────────────────────────────────────────
114    /// Number of consecutive `Execution` events with `env_class == "workflow"`
115    /// that immediately triggers a `Deprecated` transition, bypassing the
116    /// normal scoring path. Set to 0 to disable the fast-path.
117    pub broken_workflow_streak: u32,
118
119    // ── P4: archived hard-delete ─────────────────────────────────────────
120    /// Days a skill must remain in `Archived` state before `mur skill sweep`
121    /// transitions it to `Destroyed` and removes its directory from disk.
122    /// Set to 0 to disable hard-delete.
123    pub archive_destroy_grace_days: i64,
124}
125
126impl Default for SkillLifecycleConfig {
127    fn default() -> Self {
128        Self {
129            promote_draft_uses: 3,
130            promote_emerging_uses: 10,
131            promote_emerging_success_rate: 0.6,
132            promote_emerging_age_days: 7,
133            promote_stable_uses: 30,
134            promote_stable_success_rate: 0.8,
135            promote_stable_age_days: 30,
136            demote_emerging_uses: 8,
137            demote_emerging_success_rate: 0.55,
138            demote_stable_uses: 25,
139            demote_stable_success_rate: 0.75,
140            deprecated_success_rate: 0.3,
141            deprecated_no_success_days: 90,
142            auto_archive_confidence: 0.10,
143            auto_archive_age_days: 180,
144            broken_workflow_streak: 3,
145            archive_destroy_grace_days: 30,
146            note_rule_half_life_factor: crate::skill::lifecycle::NOTE_RULE_HALF_LIFE_FACTOR,
147            note_fact_half_life_factor: crate::skill::lifecycle::NOTE_FACT_HALF_LIFE_FACTOR,
148        }
149    }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(default)]
154pub struct AdaptiveSkillsConfig {
155    pub context_fill_decay: f64,
156    pub min_remaining_context_ratio: f64,
157    pub recent_fire_boost_turns: usize,
158    /// Model max context window in tokens. Used to compute
159    /// `context_fill_ratio = cumulative_input_tokens / model_max_context_tokens`.
160    /// Default 200_000 (Claude 3.5/4.x).
161    pub model_max_context_tokens: u64,
162}
163
164impl Default for AdaptiveSkillsConfig {
165    fn default() -> Self {
166        Self {
167            context_fill_decay: 1.5,
168            min_remaining_context_ratio: 0.20,
169            recent_fire_boost_turns: 5,
170            model_max_context_tokens: 200_000,
171        }
172    }
173}
174
175/// When enabled, the daemon fires a consolidation pipeline after the user has been
176/// idle for `idle_threshold_minutes` minutes (default 15). Opt-in only — off by default.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct SleepCycleConfig {
179    /// Master switch. False by default (opt-in).
180    #[serde(default)]
181    pub enabled: bool,
182
183    /// Minutes of idle (no events) before triggering the daemon sleep cycle.
184    #[serde(default = "default_idle_threshold_minutes")]
185    pub idle_threshold_minutes: u64,
186
187    /// Minutes of agent idle before the agent-side cycle fires (outbox flush + snapshot pull).
188    #[serde(default = "default_agent_idle_minutes")]
189    pub agent_idle_minutes: u64,
190}
191
192fn default_idle_threshold_minutes() -> u64 {
193    15
194}
195
196fn default_agent_idle_minutes() -> u64 {
197    5
198}
199
200impl Default for SleepCycleConfig {
201    fn default() -> Self {
202        Self {
203            enabled: false,
204            idle_threshold_minutes: default_idle_threshold_minutes(),
205            agent_idle_minutes: default_agent_idle_minutes(),
206        }
207    }
208}
209
210// ── Nudge config ───────────────────────────────────────────────────
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct NudgeConfig {
214    /// Master switch. Default on — Phase 2 companion surface is live.
215    #[serde(default = "default_nudge_enabled")]
216    pub enabled: bool,
217    #[serde(default = "default_nudge_daily_cap")]
218    pub daily_cap: u32,
219    #[serde(default = "default_nudge_snooze_days")]
220    pub snooze_days: u32,
221    #[serde(default = "default_nudge_threshold")]
222    pub threshold: usize,
223}
224
225fn default_nudge_enabled() -> bool {
226    true
227}
228fn default_nudge_daily_cap() -> u32 {
229    3
230}
231fn default_nudge_snooze_days() -> u32 {
232    7
233}
234fn default_nudge_threshold() -> usize {
235    3
236}
237
238impl Default for NudgeConfig {
239    fn default() -> Self {
240        Self {
241            enabled: true,
242            daily_cap: default_nudge_daily_cap(),
243            snooze_days: default_nudge_snooze_days(),
244            threshold: default_nudge_threshold(),
245        }
246    }
247}
248
249// ── Ambient capture & harvest (2026-06-11 spec) ────────────────────
250
251/// Ambient session capture (spec 2026-06-11-mur-ambient-capture-and-harvest §3.1).
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct SessionCfg {
254    /// "ambient" (hooks always record) | "manual" (legacy `mur session in` gate) | "off"
255    #[serde(default = "default_capture_mode")]
256    pub capture: String,
257    /// Recordings older than this many days are removed by `mur session gc`.
258    #[serde(default = "default_retention_days")]
259    pub retention_days: u32,
260}
261
262impl Default for SessionCfg {
263    fn default() -> Self {
264        Self {
265            capture: default_capture_mode(),
266            retention_days: default_retention_days(),
267        }
268    }
269}
270
271fn default_capture_mode() -> String {
272    "ambient".to_string()
273}
274fn default_retention_days() -> u32 {
275    14
276}
277
278/// Harvest gate + token-budget defenses (spec §3.2, §3.7).
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct HarvestCfg {
281    /// Run the heuristic gate automatically (from `mur session gc` / `mur out`).
282    #[serde(default = "default_harvest_enabled")]
283    pub auto_gate: bool,
284    /// "local-first" | "cloud" | "off" — W1/W2 only persist this; LLM wiring lands with v2 P5a.
285    #[serde(default = "default_harvest_llm")]
286    pub llm: String,
287    /// Gate thresholds — a session must clear at least one of these (see harvest::gate).
288    #[serde(default = "default_min_events")]
289    pub min_events: usize,
290    #[serde(default = "default_min_user_turns")]
291    pub min_user_turns: usize,
292    #[serde(default = "default_min_duration_secs")]
293    pub min_duration_secs: i64,
294    /// A session is considered ended when its last event is older than this.
295    #[serde(default = "default_idle_minutes")]
296    pub idle_minutes: i64,
297    /// Ceilings — past these a recording is a session, not a procedure (#781).
298    /// A session marked with `mur in` bypasses both.
299    #[serde(default = "default_max_steps")]
300    pub max_steps: usize,
301    #[serde(default = "default_max_duration_secs")]
302    pub max_duration_secs: i64,
303    /// §3.7 hard caps (persisted now; enforced when the LLM extract path lands in v2 P5a).
304    #[serde(default = "default_max_llm_calls_per_day")]
305    pub max_llm_calls_per_day: u32,
306    #[serde(default = "default_max_extract_input_tokens")]
307    pub max_extract_input_tokens: usize,
308    /// §3.8 tier-1: one-line pending-proposals hint at SessionStart.
309    #[serde(default = "default_harvest_enabled")]
310    pub session_start_hint: bool,
311    /// Step-skeleton Jaccard similarity at/above which a proposal becomes a merge suggestion.
312    /// Doubles as the "same procedure?" test for the recurrence index (#783).
313    #[serde(default = "default_similarity_merge_threshold")]
314    pub similarity_merge_threshold: f32,
315    /// A procedure is something done more than once (#783): a session's skeleton
316    /// must have been seen this many times before it becomes a proposal.
317    /// A session marked with `mur in` bypasses it.
318    #[serde(default = "default_min_occurrences")]
319    pub min_occurrences: usize,
320}
321
322impl Default for HarvestCfg {
323    fn default() -> Self {
324        serde_yaml::from_str("{}").expect("HarvestCfg defaults")
325    }
326}
327
328fn default_harvest_enabled() -> bool {
329    true
330}
331fn default_harvest_llm() -> String {
332    "local-first".to_string()
333}
334fn default_min_events() -> usize {
335    5
336}
337fn default_min_user_turns() -> usize {
338    2
339}
340fn default_min_duration_secs() -> i64 {
341    120
342}
343fn default_idle_minutes() -> i64 {
344    30
345}
346/// Above ~20 distinct commands a recording reads as a transcript, not a
347/// procedure a human would write down. Measured against a real 38-proposal
348/// inbox: everything plausible sat below it, nothing accepted sat above (#781).
349fn default_max_steps() -> usize {
350    20
351}
352/// 30 minutes. Long enough for a real deploy/release procedure including waits,
353/// short enough to exclude debugging sessions (#781).
354fn default_max_duration_secs() -> i64 {
355    1800
356}
357fn default_max_llm_calls_per_day() -> u32 {
358    10
359}
360fn default_max_extract_input_tokens() -> usize {
361    12000
362}
363fn default_similarity_merge_threshold() -> f32 {
364    0.6
365}
366/// Twice. The minimum that can distinguish "did it again" from "did it" — a
367/// higher bar would silently discard real routines while the index is young (#783).
368fn default_min_occurrences() -> usize {
369    2
370}
371
372// ── M7a: Cross-agent observability ─────────────────────────────────
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
375#[serde(default)]
376pub struct CrossAgentConfig {
377    #[serde(default = "default_half_life_days")]
378    pub fitness_half_life_days: u32,
379    #[serde(default = "default_fitness_floor")]
380    pub fitness_floor: f64,
381}
382
383fn default_half_life_days() -> u32 {
384    7
385}
386fn default_fitness_floor() -> f64 {
387    0.1
388}
389
390impl Default for CrossAgentConfig {
391    fn default() -> Self {
392        Self {
393            fitness_half_life_days: default_half_life_days(),
394            fitness_floor: default_fitness_floor(),
395        }
396    }
397}
398
399// ── M6c: LLM-augmented skill maintenance ─────────────────────────────
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
402#[serde(default)]
403pub struct SkillLlmConfig {
404    /// Per-call output token cap.
405    #[serde(default = "default_per_call_token_cap")]
406    pub per_call_token_cap: u32,
407
408    /// Per-day USD cap for all maintenance LLM calls.
409    #[serde(default = "default_per_day_usd_cap")]
410    pub per_day_usd_cap: f64,
411
412    /// Cache TTL in days.
413    #[serde(default = "default_cache_ttl_days")]
414    pub cache_ttl_days: u32,
415
416    /// Optional explicit model key override. When `None`, role resolution picks.
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub model_ref: Option<String>,
419}
420
421fn default_per_call_token_cap() -> u32 {
422    1500
423}
424fn default_per_day_usd_cap() -> f64 {
425    0.50
426}
427fn default_cache_ttl_days() -> u32 {
428    30
429}
430
431impl Default for SkillLlmConfig {
432    fn default() -> Self {
433        Self {
434            per_call_token_cap: default_per_call_token_cap(),
435            per_day_usd_cap: default_per_day_usd_cap(),
436            cache_ttl_days: default_cache_ttl_days(),
437            model_ref: None,
438        }
439    }
440}
441
442#[cfg(test)]
443mod skills_config_tests {
444    use super::*;
445
446    #[test]
447    fn empty_yaml_hydrates_defaults() {
448        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
449        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
450        assert_eq!(cfg.skills.max_total_tokens, 2000);
451        assert!(cfg.skills.adaptive.is_some());
452    }
453
454    #[test]
455    fn load_or_default_missing_file_returns_default() {
456        let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
457        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
458    }
459
460    #[test]
461    fn dev_discipline_index_defaults_auto_and_parses() {
462        use crate::config::DevDisciplineIndex;
463        let cfg: Config = serde_yaml_ng::from_str("").unwrap_or_default();
464        assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Auto);
465        let cfg: Config =
466            serde_yaml_ng::from_str("skills:\n  dev_discipline_index: never\n").unwrap();
467        assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Never);
468        let cfg: Config =
469            serde_yaml_ng::from_str("skills:\n  dev_discipline_index: always\n").unwrap();
470        assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Always);
471    }
472}