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