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