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