Skip to main content

lean_ctx/
instructions.rs

1use crate::tools::CrpMode;
2
3/// Claude Code truncates MCP server instructions at 2048 characters.
4/// Full instructions are installed as `$CLAUDE_CONFIG_DIR/rules/lean-ctx.md`
5/// (defaulting to `~/.claude/rules/lean-ctx.md`) instead.
6/// Session state is dynamically appended to the MCP instructions for continuity.
7///
8/// Universal instruction cap for all MCP clients (in tokens, not bytes).
9/// Enforced via `count_tokens` so truncation is accurate regardless of
10/// character mix (ASCII, CJK, emoji).
11const INSTRUCTION_CAP_TOKENS: usize = 1200;
12
13pub fn build_instructions(crp_mode: CrpMode) -> String {
14    build_instructions_with_client(crp_mode, "")
15}
16
17pub fn build_instructions_with_client(crp_mode: CrpMode, client_name: &str) -> String {
18    if is_claude_code_client(client_name) {
19        return build_claude_code_instructions();
20    }
21    build_full_instructions(crp_mode, client_name)
22}
23
24pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
25    // Avoid loading dynamic on-disk session/knowledge/gotcha blocks in tests, which can
26    // vary across machines and between concurrent test runs.
27    build_full_instructions_for_test(crp_mode, "")
28}
29
30pub fn build_instructions_with_client_for_test(crp_mode: CrpMode, client_name: &str) -> String {
31    if is_claude_code_client(client_name) {
32        return build_claude_code_instructions();
33    }
34    build_full_instructions_for_test(crp_mode, client_name)
35}
36
37/// Deterministic instruction builder for the Instruction Compiler.
38///
39/// MUST NOT depend on process-global env toggles or on-disk mutable config, because the compiler
40/// output is intended to be stable and diffable across runs and in CI.
41pub fn build_instructions_with_client_for_compiler(
42    crp_mode: CrpMode,
43    client_name: &str,
44    unified_tool_mode: bool,
45) -> String {
46    if is_claude_code_client(client_name) {
47        return build_claude_code_instructions();
48    }
49    build_full_instructions_for_compiler(crp_mode, client_name, unified_tool_mode)
50}
51
52fn is_claude_code_client(client_name: &str) -> bool {
53    let lower = client_name.to_lowercase();
54    lower.contains("claude") && !lower.contains("cursor")
55}
56
57pub fn claude_config_dir_display() -> String {
58    match std::env::var("CLAUDE_CONFIG_DIR") {
59        Ok(dir) if !dir.trim().is_empty() => {
60            let dir = dir.trim().to_string();
61            if dir.starts_with('~') {
62                dir
63            } else if let Some(home) = dirs::home_dir() {
64                let home_str = home.to_string_lossy();
65                if let Some(rest) = dir.strip_prefix(home_str.as_ref()) {
66                    format!("~{rest}")
67                } else {
68                    dir
69                }
70            } else {
71                dir
72            }
73        }
74        _ => "~/.claude".to_string(),
75    }
76}
77
78fn build_claude_code_instructions() -> String {
79    let shell_hint = build_shell_hint();
80    let config_dir = claude_config_dir_display();
81
82    // Load session state for continuity (compact version for Claude Code's char limit)
83    let session_block = match crate::core::session::SessionState::load_latest() {
84        Some(session) => {
85            let mut parts = Vec::new();
86            if let Some(ref task) = session.task {
87                let pct = task
88                    .progress_pct
89                    .map_or(String::new(), |p| format!(" [{p}%]"));
90                parts.push(format!("Task: {}{pct}", task.description));
91            }
92            if !session.decisions.is_empty() {
93                let items: Vec<&str> = session
94                    .decisions
95                    .iter()
96                    .rev()
97                    .take(3)
98                    .map(|d| d.summary.as_str())
99                    .collect();
100                parts.push(format!("Decisions: {}", items.join("; ")));
101            }
102            if !session.files_touched.is_empty() {
103                let modified: Vec<&str> = session
104                    .files_touched
105                    .iter()
106                    .filter(|f| f.modified)
107                    .take(5)
108                    .map(|f| f.path.as_str())
109                    .collect();
110                if !modified.is_empty() {
111                    parts.push(format!("Modified: {}", modified.join(", ")));
112                }
113            }
114            if !session.findings.is_empty() {
115                let recent: Vec<&str> = session
116                    .findings
117                    .iter()
118                    .rev()
119                    .take(3)
120                    .map(|f| f.summary.as_str())
121                    .collect();
122                parts.push(format!("Recent: {}", recent.join("; ")));
123            }
124            if parts.is_empty() {
125                String::new()
126            } else {
127                format!("\n\n--- SESSION ---\n{}\n---", parts.join("\n"))
128            }
129        }
130        None => String::new(),
131    };
132
133    let instr = format!("\
134ALWAYS use lean-ctx MCP tools instead of native equivalents.
135
136Tool mapping (MANDATORY):
137• Read/cat/head/tail -> ctx_read(path, mode)
138• Shell/bash -> ctx_shell(command)
139• Grep/rg -> ctx_search(pattern, path)
140• ls/find -> ctx_tree(path, depth)
141• Edit/StrReplace -> native (lean-ctx=READ only). If Edit needs Read and Read is unavailable, use ctx_edit.
142• Write, Delete, Glob -> normal. NEVER loop on Edit failures — use ctx_edit.
143
144ctx_read modes: full|map|signatures|diff|task|reference|aggressive|entropy|lines:N-M
145Auto-selects mode. Re-reads ~13 tok. File refs F1,F2.. persist.
146Cache auto-validates via file mtime. Use fresh=true (or start_line / lines:N-M) to force a disk re-read.
147
148Auto: ctx_overview, ctx_preload, ctx_dedup, ctx_compress behind the scenes.
149Multi-agent: ctx_agent(action=handoff|sync|diary).
150ctx_semantic_search for meaning search. ctx_session for memory.
151ctx_knowledge: remember|recall|timeline|rooms|search|wakeup.
152ctx_shell raw=true for uncompressed.
153
154CEP: 1.ACT FIRST 2.DELTA ONLY 3.STRUCTURED(+/-/~) 4.ONE LINE 5.QUALITY
155{shell_hint}\
156Prefer: ctx_read>Read | ctx_shell>Shell | ctx_search>Grep | ctx_tree>ls
157Edit: native Edit/StrReplace preferred, ctx_edit if Edit unavailable.
158Never echo tool output. Never narrate. Show only changed code.
159Full instructions at {config_dir}/CLAUDE.md (imports rules/lean-ctx.md){session_block}");
160
161    instr
162}
163
164fn build_full_instructions(crp_mode: CrpMode, client_name: &str) -> String {
165    let cfg = crate::core::config::Config::load();
166    let minimal = cfg.minimal_overhead_effective_for_client(client_name);
167
168    let profile = crate::core::litm::LitmProfile::from_client_name(client_name);
169    let loaded_session = if minimal {
170        None
171    } else {
172        crate::core::session::SessionState::load_latest()
173    };
174
175    let (session_block, litm_end_block) = match loaded_session {
176        Some(ref session) => {
177            let positioned = crate::core::litm::position_optimize(session);
178            let begin = format!(
179                "\n\n--- ACTIVE SESSION (LITM P1: begin position, profile: {}) ---\n{}\n---\n",
180                profile.name, positioned.begin_block
181            );
182            let end = if positioned.end_block.is_empty() {
183                String::new()
184            } else {
185                format!(
186                    "\n--- SESSION RESUME (post-compaction) ---\n{}\n---\n",
187                    positioned.end_block
188                )
189            };
190            (begin, end)
191        }
192        None => (String::new(), String::new()),
193    };
194
195    let project_root_for_blocks = if minimal {
196        None
197    } else {
198        loaded_session
199            .as_ref()
200            .and_then(|s| s.project_root.clone())
201            .or_else(|| {
202                std::env::current_dir()
203                    .ok()
204                    .map(|p| p.to_string_lossy().to_string())
205            })
206    };
207
208    let knowledge_block = match &project_root_for_blocks {
209        Some(root) => {
210            let knowledge = crate::core::knowledge::ProjectKnowledge::load(root);
211            match knowledge {
212                Some(k) if !k.facts.is_empty() || !k.patterns.is_empty() => {
213                    let aaak = k.format_aaak();
214                    if aaak.is_empty() {
215                        String::new()
216                    } else {
217                        format!("\n--- PROJECT MEMORY (AAAK) ---\n{}\n---\n", aaak.trim())
218                    }
219                }
220                _ => String::new(),
221            }
222        }
223        None => String::new(),
224    };
225
226    let gotcha_block = match &project_root_for_blocks {
227        Some(root) => {
228            let store = crate::core::gotcha_tracker::GotchaStore::load(root);
229            let files: Vec<String> = loaded_session
230                .as_ref()
231                .map(|s| s.files_touched.iter().map(|ft| ft.path.clone()).collect())
232                .unwrap_or_default();
233            let block = store.format_injection_block(&files);
234            if block.is_empty() {
235                String::new()
236            } else {
237                format!("\n{block}\n")
238            }
239        }
240        None => String::new(),
241    };
242
243    let shell_hint = build_shell_hint();
244
245    use crate::core::rules_canonical as rc;
246    let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
247    let compat = rc::compatibility_block();
248    let read_modes = rc::ctx_read_modes_block();
249    let auto_block = rc::automation_block();
250    let cep = rc::cep_block();
251    let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
252
253    let mut base = format!(
254        "\
255CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
256\n\
257{tool_bullets}\n\
258\n\
259{compat}\n\
260{shell_hint}\
261\n\
262{read_modes}\n\
263\n\
264{auto_block}\n\
265\n\
266{cep}\n\
267\n\
268{decoder_block}\n\
269\n\
270{session_block}\
271{knowledge_block}\
272{gotcha_block}\
273\n\
274--- ORIGIN ---\n\
275{origin}\n\
276\n\
277{litm_pref}\
278{litm_end_block}",
279        decoder_block = crate::core::protocol::instruction_decoder_block(),
280        origin = crate::core::integrity::origin_line(),
281        litm_end_block = &litm_end_block
282    );
283
284    if should_use_unified(client_name) {
285        base.push_str("\n\n");
286        base.push_str(rc::unified_tool_mode_block());
287        base.push('\n');
288    }
289
290    let intelligence_block = build_intelligence_block();
291    let terse_block = build_terse_agent_block_for_client(&crp_mode, client_name);
292
293    let base = base;
294    let full = match crp_mode {
295        CrpMode::Off => format!("{base}\n\n{terse_block}{intelligence_block}"),
296        CrpMode::Compact => {
297            format!(
298                "{base}\n\n\
299CRP MODE: compact\n\
300Omit filler. Abbreviate: fn,cfg,impl,deps,req,res,ctx,err,ret,arg,val,ty,mod.\n\
301Diff lines (+/-) only. TARGET: <=200 tok. Trust tool outputs.\n\n\
302{terse_block}{intelligence_block}"
303            )
304        }
305        CrpMode::Tdd => {
306            format!(
307                "{base}\n\n\
308CRP MODE: tdd\n\
309Max density. Every token carries meaning. Fn refs only, diff lines (+/-) only.\n\
310Abbreviate: fn,cfg,impl,deps,req,res,ctx,err,ret,arg,val,ty,mod.\n\
311+F1:42 param(timeout:Duration) | -F1:10-15 | ~F1:42 old->new\n\
312BUDGET: <=150 tok. ZERO NARRATION. Trust tool outputs.\n\n\
313{terse_block}{intelligence_block}"
314            )
315        }
316    };
317
318    if crate::core::tokens::count_tokens(&full) > INSTRUCTION_CAP_TOKENS {
319        truncate_to_token_cap(&full, INSTRUCTION_CAP_TOKENS)
320    } else {
321        full
322    }
323}
324
325fn truncate_to_token_cap(s: &str, cap_tokens: usize) -> String {
326    if crate::core::tokens::count_tokens(s) <= cap_tokens {
327        return s.to_string();
328    }
329    let mut end = s.len();
330    loop {
331        match s[..end].rfind('\n') {
332            Some(pos) if pos > 0 => {
333                end = pos;
334                if crate::core::tokens::count_tokens(&s[..end]) <= cap_tokens {
335                    return s[..end].to_string();
336                }
337            }
338            _ => break,
339        }
340    }
341    let byte_approx = cap_tokens * 4;
342    let safe = s.floor_char_boundary(byte_approx.min(s.len()));
343    s[..safe].to_string()
344}
345
346fn build_full_instructions_for_test(crp_mode: CrpMode, client_name: &str) -> String {
347    use crate::core::rules_canonical as rc;
348    let shell_hint = build_shell_hint();
349    let session_block = String::new();
350    let knowledge_block = String::new();
351    let gotcha_block = String::new();
352    let litm_end_block = String::new();
353
354    let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
355    let compat = rc::compatibility_block();
356    let read_modes = rc::ctx_read_modes_block();
357    let auto_block = rc::automation_block();
358    let cep = rc::cep_block();
359    let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
360
361    let mut base = format!(
362        "\
363CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
364\n\
365{tool_bullets}\n\
366\n\
367{compat}\n\
368{shell_hint}\
369\n\
370{read_modes}\n\
371\n\
372{auto_block}\n\
373\n\
374{cep}\n\
375\n\
376{decoder_block}\n\
377\n\
378{session_block}\
379{knowledge_block}\
380{gotcha_block}\
381\n\
382--- ORIGIN ---\n\
383{origin}\n\
384\n\
385{litm_pref}\
386{litm_end_block}",
387        decoder_block = crate::core::protocol::instruction_decoder_block(),
388        origin = crate::core::integrity::origin_line(),
389        litm_end_block = &litm_end_block
390    );
391
392    if should_use_unified(client_name) {
393        base.push_str("\n\n");
394        base.push_str(rc::unified_tool_mode_block());
395        base.push('\n');
396    }
397
398    let intelligence_block = build_intelligence_block();
399    let terse_block = build_terse_agent_block_for_client(&crp_mode, client_name);
400
401    match crp_mode {
402        CrpMode::Off => format!("{base}\n\n{terse_block}{intelligence_block}"),
403        CrpMode::Compact => {
404            format!(
405                "{base}\n\n\
406CRP MODE: compact\n\
407Omit filler. Abbreviate: fn,cfg,impl,deps,req,res,ctx,err,ret,arg,val,ty,mod.\n\
408Diff lines (+/-) only. TARGET: <=200 tok. Trust tool outputs.\n\n\
409{terse_block}{intelligence_block}"
410            )
411        }
412        CrpMode::Tdd => {
413            format!(
414                "{base}\n\n\
415CRP MODE: tdd\n\
416Max density. Every token carries meaning. Fn refs only, diff lines (+/-) only.\n\
417Abbreviate: fn,cfg,impl,deps,req,res,ctx,err,ret,arg,val,ty,mod.\n\
418+F1:42 param(timeout:Duration) | -F1:10-15 | ~F1:42 old->new\n\
419BUDGET: <=150 tok. ZERO NARRATION. Trust tool outputs.\n\n\
420{terse_block}{intelligence_block}"
421            )
422        }
423    }
424}
425
426fn build_full_instructions_for_compiler(
427    crp_mode: CrpMode,
428    client_name: &str,
429    unified_tool_mode: bool,
430) -> String {
431    let shell_hint = build_shell_hint();
432    let session_block = String::new();
433    let knowledge_block = String::new();
434    let gotcha_block = String::new();
435    let litm_end_block = String::new();
436
437    use crate::core::rules_canonical as rc;
438    let tool_bullets = rc::tool_mapping_bullets(rc::Mode::Mcp);
439    let compat = rc::compatibility_block();
440    let read_modes = rc::ctx_read_modes_block();
441    let auto_blk = rc::automation_block();
442    let cep = rc::cep_block();
443    let litm_pref = rc::litm_end_block(rc::Mode::Mcp);
444
445    let mut base = format!(
446        "\
447CRITICAL: ALWAYS use lean-ctx MCP tools instead of native equivalents for token savings.\n\
448\n\
449{tool_bullets}\n\
450\n\
451{compat}\n\
452{shell_hint}\
453\n\
454{read_modes}\n\
455\n\
456{auto_blk}\n\
457\n\
458{cep}\n\
459\n\
460{decoder_block}\n\
461\n\
462{session_block}\
463{knowledge_block}\
464{gotcha_block}\
465\n\
466--- ORIGIN ---\n\
467{origin}\n\
468\n\
469{litm_pref}\
470{litm_end_block}",
471        decoder_block = crate::core::protocol::instruction_decoder_block(),
472        origin = crate::core::integrity::origin_line(),
473        litm_end_block = &litm_end_block
474    );
475
476    if unified_tool_mode {
477        base.push_str("\n\n");
478        base.push_str(rc::unified_tool_mode_block());
479        base.push('\n');
480    }
481
482    let _ = client_name; // keep signature aligned with other builders
483    let intelligence_block = build_intelligence_block();
484
485    match crp_mode {
486        CrpMode::Off => format!("{base}\n\n{intelligence_block}"),
487        CrpMode::Compact => {
488            format!(
489                "{base}\n\n\
490CRP MODE: compact\n\
491Omit filler. Abbreviate: fn,cfg,impl,deps,req,res,ctx,err,ret,arg,val,ty,mod.\n\
492Diff lines (+/-) only. TARGET: <=200 tok. Trust tool outputs.\n\n\
493{intelligence_block}"
494            )
495        }
496        CrpMode::Tdd => {
497            format!(
498                "{base}\n\n\
499CRP MODE: tdd\n\
500Max density. Every token carries meaning. Fn refs only, diff lines (+/-) only.\n\
501Abbreviate: fn,cfg,impl,deps,req,res,ctx,err,ret,arg,val,ty,mod.\n\
502+F1:42 param(timeout:Duration) | -F1:10-15 | ~F1:42 old->new\n\
503BUDGET: <=150 tok. ZERO NARRATION. Trust tool outputs.\n\n\
504{intelligence_block}"
505            )
506        }
507    }
508}
509
510pub fn claude_code_instructions() -> String {
511    build_claude_code_instructions()
512}
513
514fn build_terse_agent_block_for_client(_crp_mode: &CrpMode, client_name: &str) -> String {
515    use crate::core::config::{CompressionLevel, Config};
516    let cfg = Config::load();
517    let compression = CompressionLevel::effective(&cfg);
518    if compression.is_active() {
519        return crate::core::terse::agent_prompts::build_prompt_block_for_client(
520            &compression,
521            client_name,
522        );
523    }
524    String::new()
525}
526
527fn build_intelligence_block() -> String {
528    "\
529OUTPUT EFFICIENCY:\n\
530• Never echo tool output code. Never add narration comments. Show only changed code.\n\
531• [TASK:type] and SCOPE hints included. Architecture=thorough, generate=code."
532        .to_string()
533}
534
535fn build_shell_hint() -> String {
536    if !cfg!(windows) {
537        return String::new();
538    }
539    let name = crate::shell::shell_name();
540    let is_posix = matches!(name.as_str(), "bash" | "sh" | "zsh" | "fish");
541    if is_posix {
542        format!(
543            "\nSHELL: {name} (POSIX). Use POSIX commands (cat, head, grep, find, ls). \
544             Do NOT use PowerShell cmdlets (Get-Content, Select-Object, Get-ChildItem).\n"
545        )
546    } else if name.contains("powershell") || name.contains("pwsh") {
547        format!("\nSHELL: {name}. Use PowerShell cmdlets.\n")
548    } else {
549        format!("\nSHELL: {name}.\n")
550    }
551}
552
553fn should_use_unified(client_name: &str) -> bool {
554    if std::env::var("LEAN_CTX_FULL_TOOLS").is_ok() {
555        return false;
556    }
557    if std::env::var("LEAN_CTX_UNIFIED").is_ok() {
558        return true;
559    }
560    let _ = client_name;
561    false
562}