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