Skip to main content

lean_ctx/
instructions.rs

1use crate::core::config::CompressionLevel;
2use crate::core::rules_canonical::{self as rc, Wrapper};
3use crate::tools::CrpMode;
4
5/// Universal instruction cap for all MCP clients (in tokens, not bytes).
6const INSTRUCTION_CAP_TOKENS: usize = 800;
7
8/// Token budget for the static instruction skeleton (no session/knowledge
9/// state).  Asserted in CI so instruction creep cannot silently tax every
10/// session.
11#[cfg(test)]
12const STATIC_INSTRUCTION_BUDGET_TOKENS: usize = 520;
13#[cfg(test)]
14const STATIC_INSTRUCTION_BUDGET_TDD_TOKENS: usize = 600;
15/// Windows carries a one-line SHELL hint inside the skeleton.
16#[cfg(all(test, windows))]
17const STATIC_INSTRUCTION_SHELL_HINT_TOKENS: usize = 25;
18#[cfg(all(test, not(windows)))]
19const STATIC_INSTRUCTION_SHELL_HINT_TOKENS: usize = 0;
20
21#[must_use]
22pub fn build_instructions(crp_mode: CrpMode) -> String {
23    build_instructions_with_client(crp_mode, "")
24}
25
26#[must_use]
27pub fn build_instructions_with_client(crp_mode: CrpMode, client_name: &str) -> String {
28    build_full_instructions(crp_mode, client_name)
29}
30
31/// Deterministic variant for tests (no session/knowledge state).
32#[must_use]
33pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
34    let shadow = false;
35    // Resolve the effective compression level from config/env (matches the live
36    // build_full_instructions path) so terse/compression env vars are honoured.
37    let level = CompressionLevel::effective(&crate::core::config::Config::load());
38    let skeleton = rc::render(shadow, Wrapper::Bare, level);
39    let shell_hint = build_shell_hint();
40
41    let base = format!(
42        "{skeleton}\n\
43        {shell_hint}\n\
44        {decoder_block}\n\
45        {origin}",
46        decoder_block =
47            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
48        origin = crate::core::integrity::origin_line(),
49    );
50
51    match crp_mode_suffix(crp_mode) {
52        "" => format!("{base}\n\n{}", rc::INTELLIGENCE),
53        crp => format!("{base}\n\n{crp}\n\n{}", rc::INTELLIGENCE),
54    }
55}
56
57/// Deterministic instruction builder for the Instruction Compiler.
58/// Uses shadow mode (COMPACT_SHADOW profile) to avoid duplicating
59/// BULLETS/NEVER/CRITICAL that the CLAUDE.md / dedicated rule file carries.
60#[must_use]
61pub fn build_instructions_with_client_for_compiler(
62    crp_mode: CrpMode,
63    client_name: &str,
64    _unified_tool_mode: bool,
65) -> String {
66    let skeleton = rc::render(true, Wrapper::Bare, CompressionLevel::Off);
67    let shell_hint = build_shell_hint();
68
69    let base = format!(
70        "{skeleton}\n\
71        {shell_hint}\n\
72        {decoder_block}\n\
73        {origin}",
74        decoder_block =
75            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
76        origin = crate::core::integrity::origin_line(),
77    );
78
79    let _ = client_name;
80
81    match crp_mode_suffix(crp_mode) {
82        "" => format!("{base}\n\n{}", rc::INTELLIGENCE),
83        crp => format!("{base}\n\n{crp}\n\n{}", rc::INTELLIGENCE),
84    }
85}
86
87/// LITM calibration manifest rotation (#539).
88fn rotate_wakeup_manifest(session: &crate::core::session::SessionState, profile_name: &str) {
89    use crate::core::litm_calibration::{Position, record_outcome};
90    use crate::core::session::ManifestEntry;
91
92    let mut updated = session.clone();
93
94    for entry in &updated.wakeup_manifest {
95        if !entry.missed
96            && let Some(pos) = Position::parse(&entry.position)
97        {
98            record_outcome(&entry.profile, pos, true);
99        }
100    }
101
102    let mut manifest: Vec<ManifestEntry> = Vec::new();
103    let mut push = |key: &str, position: &str| {
104        let key = key.trim();
105        if !key.is_empty() {
106            manifest.push(ManifestEntry {
107                key: key.chars().take(80).collect(),
108                position: position.to_string(),
109                profile: profile_name.to_string(),
110                missed: false,
111            });
112        }
113    };
114
115    if let Some(ref task) = updated.task {
116        push(&task.description, "begin");
117    }
118    for d in updated.decisions.iter().rev().take(5) {
119        push(&d.summary, "begin");
120    }
121    for f in updated.findings.iter().rev().take(8) {
122        push(&f.summary, "end");
123    }
124    for n in updated.next_steps.iter().take(3) {
125        push(n, "end");
126    }
127
128    updated.wakeup_manifest = manifest;
129    let _ = updated.save();
130}
131
132/// Display path for the Claude config directory (respected by CLAUDE_CONFIG_DIR).
133#[must_use]
134pub fn claude_config_dir_display() -> String {
135    match std::env::var("CLAUDE_CONFIG_DIR") {
136        Ok(dir) if !dir.trim().is_empty() => {
137            let dir = dir.trim().to_string();
138            if dir.starts_with('~') {
139                dir
140            } else if let Some(home) = dirs::home_dir() {
141                let home_str = home.to_string_lossy();
142                if let Some(rest) = dir.strip_prefix(home_str.as_ref()) {
143                    format!("~{rest}")
144                } else {
145                    dir
146                }
147            } else {
148                dir
149            }
150        }
151        _ => "~/.claude".to_string(),
152    }
153}
154
155// ── MCP per-session instructions builder ──────────────────────
156
157fn build_full_instructions(crp_mode: CrpMode, client_name: &str) -> String {
158    let cfg = crate::core::config::Config::load();
159    let minimal = cfg.minimal_overhead_effective_for_client(client_name);
160    let shadow = cfg.shadow_mode;
161
162    // Cross-channel dedup: if the client auto-loads compression from its own
163    // rule file, skip it here to avoid duplicate billing.
164    let level = if client_loads_compression_from_file(client_name) {
165        CompressionLevel::Off
166    } else {
167        CompressionLevel::effective(&cfg)
168    };
169
170    let profile = crate::core::litm::LitmProfile::from_client_name(client_name);
171    let loaded_session = if minimal {
172        None
173    } else {
174        crate::core::session::SessionState::load_latest()
175    };
176
177    let (session_block, litm_end_block) = match loaded_session {
178        Some(ref session) => {
179            rotate_wakeup_manifest(session, profile.name);
180            let share = crate::core::litm_calibration::begin_share(profile.name);
181            let positioned = crate::core::litm::position_optimize_with_share(session, share);
182            let begin = format!(
183                "\n\n--- ACTIVE SESSION (LITM P1: begin position, profile: {}) ---\n{}\n---\n",
184                profile.name, positioned.begin_block
185            );
186            let end = if positioned.end_block.is_empty() {
187                String::new()
188            } else {
189                format!(
190                    "\n--- SESSION RESUME (post-compaction) ---\n{}\n---\n",
191                    positioned.end_block
192                )
193            };
194            (begin, end)
195        }
196        None => (String::new(), String::new()),
197    };
198
199    let project_root_for_blocks = if minimal {
200        None
201    } else {
202        loaded_session
203            .as_ref()
204            .and_then(|s| s.project_root.clone())
205            .or_else(|| {
206                std::env::current_dir()
207                    .ok()
208                    .map(|p| p.to_string_lossy().to_string())
209            })
210    };
211
212    let knowledge_block = match &project_root_for_blocks {
213        Some(root) => {
214            let knowledge = crate::core::knowledge::ProjectKnowledge::load(root);
215            match knowledge {
216                Some(k) if !k.facts.is_empty() || !k.patterns.is_empty() => {
217                    let aaak = k.format_aaak();
218                    if aaak.is_empty() {
219                        String::new()
220                    } else {
221                        format!("\n--- PROJECT MEMORY (AAAK) ---\n{}\n---\n", aaak.trim())
222                    }
223                }
224                _ => String::new(),
225            }
226        }
227        None => String::new(),
228    };
229
230    let gotcha_block = match &project_root_for_blocks {
231        Some(root) => {
232            let store = crate::core::gotcha_tracker::GotchaStore::load(root);
233            let files: Vec<String> = loaded_session
234                .as_ref()
235                .map(|s| s.files_touched.iter().map(|ft| ft.path.clone()).collect())
236                .unwrap_or_default();
237            let block = store.format_injection_block(&files);
238            if block.is_empty() {
239                String::new()
240            } else {
241                format!("\n{block}\n")
242            }
243        }
244        None => String::new(),
245    };
246
247    let shell_hint = build_shell_hint();
248
249    // Skeleton includes tool-mapping rules + compression prompt (if level active).
250    // Shadow mode omits BULLETS/NEVER/CRITICAL automatically.
251    let skeleton = rc::render(shadow, Wrapper::Bare, level);
252
253    // Pointer to the full rule file (honours CLAUDE_CONFIG_DIR): agents load the
254    // detailed instructions on demand from there instead of inlining them.
255    let config_dir = claude_config_dir_display();
256
257    let base = format!(
258        "{skeleton}\n\
259        {shell_hint}\n\
260        {decoder_block}\n\
261        Full instructions at {config_dir}/CLAUDE.md\n\
262        {session_block}\n\
263        {knowledge_block}\n\
264        {gotcha_block}\n\
265        {origin}\n\
266        {litm_end_block}",
267        decoder_block =
268            crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
269        origin = crate::core::integrity::origin_line(),
270        litm_end_block = litm_end_block
271    );
272
273    // Guidance suffix: CRP mode + general output rule.
274    // This is the operational contract — protected from truncation.
275    let guidance_suffix = match crp_mode_suffix(crp_mode) {
276        "" => rc::INTELLIGENCE.to_string(),
277        crp => format!("{crp}\n\n{}", rc::INTELLIGENCE),
278    };
279
280    assemble_within_cap(&base, &guidance_suffix, INSTRUCTION_CAP_TOKENS)
281}
282
283fn crp_mode_suffix(crp_mode: CrpMode) -> &'static str {
284    match crp_mode {
285        CrpMode::Off => "",
286        CrpMode::Compact => {
287            "CRP MODE: compact — omit filler; abbreviate fn,cfg,impl,deps,req,res; \
288             diff lines (+/-) only; <=200 tok; trust tool outputs."
289        }
290        CrpMode::Tdd => {
291            "CRP MODE: tdd — max density; Fn refs + diff lines only \
292             (+F1:42 | -F1:10-15 | ~F1:42 old->new); <=150 tok; zero narration."
293        }
294    }
295}
296
297fn assemble_within_cap(base: &str, suffix: &str, cap_tokens: usize) -> String {
298    use crate::core::tokens::count_tokens;
299    let suffix = suffix.trim_end_matches('\n');
300    if suffix.is_empty() {
301        let full = base.to_string();
302        return if count_tokens(&full) > cap_tokens {
303            truncate_to_token_cap(&full, cap_tokens)
304        } else {
305            full
306        };
307    }
308
309    let full = format!("{base}\n\n{suffix}");
310    if count_tokens(&full) <= cap_tokens {
311        return full;
312    }
313
314    let suffix_tokens = count_tokens(suffix);
315    let Some(base_budget) = cap_tokens.checked_sub(suffix_tokens + 1) else {
316        return truncate_to_token_cap(&full, cap_tokens);
317    };
318    let trimmed_base = truncate_to_token_cap(base, base_budget);
319    format!("{trimmed_base}\n\n{suffix}")
320}
321
322fn truncate_to_token_cap(s: &str, cap_tokens: usize) -> String {
323    use crate::core::tokens::count_tokens;
324    if count_tokens(s) <= cap_tokens {
325        return s.to_string();
326    }
327    let cuts: Vec<usize> = s.match_indices('\n').map(|(i, _)| i).collect();
328    let (mut lo, mut hi) = (0usize, cuts.len());
329    let mut best: Option<usize> = None;
330    while lo < hi {
331        let mid = lo + (hi - lo) / 2;
332        let end = cuts[mid];
333        if end > 0 && count_tokens(&s[..end]) <= cap_tokens {
334            best = Some(end);
335            lo = mid + 1;
336        } else {
337            hi = mid;
338        }
339    }
340    if let Some(end) = best {
341        return s[..end].to_string();
342    }
343    let byte_approx = cap_tokens * 4;
344    let safe = s.floor_char_boundary(byte_approx.min(s.len()));
345    s[..safe].to_string()
346}
347
348/// Backward-compat alias kept for external callers.
349#[must_use]
350pub fn claude_code_instructions() -> String {
351    build_instructions(CrpMode::Off)
352}
353
354fn client_loads_compression_from_file(client_name: &str) -> bool {
355    crate::core::home::resolve_home_dir().is_some_and(|home| {
356        crate::core::rules_channel::client_autoloads_compression(client_name, &home)
357    })
358}
359
360fn build_shell_hint() -> String {
361    if !cfg!(windows) {
362        return String::new();
363    }
364    let name = crate::shell::shell_name();
365    let is_posix = matches!(name.as_str(), "bash" | "sh" | "zsh" | "fish");
366    if is_posix {
367        format!("\nSHELL: {name} (POSIX) — no PowerShell cmdlets.\n")
368    } else if name.contains("powershell") || name.contains("pwsh") {
369        format!("\nSHELL: {name}. Use PowerShell cmdlets.\n")
370    } else {
371        format!("\nSHELL: {name}.\n")
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::core::tokens::count_tokens;
379
380    #[test]
381    fn guidance_suffix_survives_oversized_base() {
382        let base = "SESSION LINE\n".repeat(4000);
383        let suffix = "OUTPUT STYLE: expert-terse\nFn refs only, diff lines only.";
384        let out = assemble_within_cap(&base, suffix, INSTRUCTION_CAP_TOKENS);
385        assert!(out.contains("OUTPUT STYLE: expert-terse"));
386        assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
387        assert!(out.len() < base.len());
388    }
389
390    #[test]
391    fn empty_client_never_dedups_compression() {
392        assert!(!client_loads_compression_from_file(""));
393        assert!(!client_loads_compression_from_file("totally-unknown-agent"));
394    }
395
396    #[test]
397    fn under_cap_keeps_everything() {
398        let base = "tool mapping block";
399        let suffix = "OUTPUT STYLE: dense";
400        let out = assemble_within_cap(base, suffix, INSTRUCTION_CAP_TOKENS);
401        assert!(out.contains(base));
402        assert!(out.contains(suffix));
403    }
404
405    #[test]
406    fn empty_suffix_caps_base_only() {
407        let base = "x\n".repeat(4000);
408        let out = assemble_within_cap(&base, "", INSTRUCTION_CAP_TOKENS);
409        assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
410    }
411
412    #[cfg(windows)]
413    #[test]
414    fn shell_hint_stays_within_its_budget() {
415        let hint = build_shell_hint();
416        let tokens = count_tokens(&hint);
417        assert!(
418            tokens <= STATIC_INSTRUCTION_SHELL_HINT_TOKENS,
419            "shell hint = {tokens} tok, budget {STATIC_INSTRUCTION_SHELL_HINT_TOKENS}: {hint}"
420        );
421    }
422
423    #[test]
424    fn minimal_overhead_instructions_stay_within_budget() {
425        const MINIMAL_INSTRUCTION_BUDGET_TOKENS: usize =
426            STATIC_INSTRUCTION_BUDGET_TDD_TOKENS + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
427        let _iso = crate::core::data_dir::isolated_data_dir();
428        crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
429        let out = build_instructions(CrpMode::Compact);
430        crate::test_env::remove_var("LEAN_CTX_MINIMAL");
431        let tokens = count_tokens(&out);
432        assert!(
433            tokens <= MINIMAL_INSTRUCTION_BUDGET_TOKENS,
434            "minimal-overhead instructions = {tokens} tok, budget {MINIMAL_INSTRUCTION_BUDGET_TOKENS}\n---\n{out}\n---"
435        );
436    }
437
438    #[test]
439    fn static_skeleton_stays_within_budget() {
440        let _iso = crate::core::data_dir::isolated_data_dir();
441        for (mode, base_budget) in [
442            (CrpMode::Off, STATIC_INSTRUCTION_BUDGET_TOKENS),
443            (CrpMode::Compact, STATIC_INSTRUCTION_BUDGET_TOKENS),
444            (CrpMode::Tdd, STATIC_INSTRUCTION_BUDGET_TDD_TOKENS),
445        ] {
446            let budget = base_budget + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
447            let out = build_instructions_for_test(mode);
448            let tokens = count_tokens(&out);
449            assert!(
450                tokens <= budget,
451                "static instructions for {mode:?} = {tokens} tok, budget {budget}\n---\n{out}\n---"
452            );
453        }
454    }
455}