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