Skip to main content

lean_ctx_server/tools/
ctx_discover.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::collections::HashMap;
3
4use lean_ctx::core::stats::StatsStore;
5use lean_ctx::core::tokens::count_tokens;
6use lean_ctx::shell::output_policy::{OutputPolicy, classify};
7
8/// Command families with a dedicated lean-ctx compressor, used to *recognise*
9/// compressible commands in shell history (the savings numbers themselves come
10/// from real measured `core::stats`, never from this table). `base` is the
11/// `normalize_command` base name; keep roughly in sync with the dispatch in
12/// `core::patterns::try_specific_pattern`.
13const COMPRESSIBLE_FAMILIES: &[(&str, &str)] = &[
14    ("git", "git status/diff/log/commit/push"),
15    ("gh", "GitHub CLI"),
16    ("glab", "GitLab CLI"),
17    ("cargo", "cargo build/test/clippy"),
18    ("npm", "npm install/run/test"),
19    ("pnpm", "pnpm install/run/test"),
20    ("yarn", "yarn install/run/test"),
21    ("bun", "Bun runtime"),
22    ("deno", "Deno runtime"),
23    ("docker", "docker ps/images/logs/build"),
24    ("kubectl", "kubectl get/describe/logs"),
25    ("helm", "Kubernetes Helm"),
26    ("pip", "pip install/list/freeze"),
27    ("poetry", "Poetry"),
28    ("uv", "uv add/lock/sync"),
29    ("conda", "conda/mamba env"),
30    ("pipx", "pipx install"),
31    ("go", "go test/build/vet"),
32    ("mypy", "mypy type check"),
33    ("pyright", "pyright type check"),
34    ("ruff", "ruff check/format"),
35    ("eslint", "eslint/biome lint"),
36    ("prettier", "prettier --check"),
37    ("tsc", "TypeScript compiler"),
38    ("pytest", "Python tests"),
39    ("jest", "Jest tests"),
40    ("vitest", "Vitest tests"),
41    ("mocha", "Mocha tests"),
42    ("playwright", "Playwright tests"),
43    ("rspec", "Ruby tests"),
44    ("rubocop", "RuboCop lint"),
45    ("bundle", "Bundler"),
46    ("rake", "Rake tasks"),
47    ("curl", "HTTP requests"),
48    ("wget", "HTTP downloads"),
49    ("grep", "grep/rg search"),
50    ("rg", "ripgrep search"),
51    ("find", "find files"),
52    ("fd", "fd file search"),
53    ("ls", "directory listing"),
54    ("jq", "JSON processing"),
55    ("aws", "AWS CLI"),
56    ("terraform", "Terraform"),
57    ("tofu", "OpenTofu"),
58    ("pulumi", "Pulumi IaC"),
59    ("ansible", "Ansible"),
60    ("prisma", "Prisma ORM"),
61    ("psql", "PostgreSQL"),
62    ("mysql", "MySQL/MariaDB"),
63    ("cmake", "CMake build"),
64    ("ninja", "Ninja build"),
65    ("bazel", "Bazel build"),
66    ("make", "Make targets"),
67    ("just", "just recipes"),
68    ("mvn", "Maven build"),
69    ("gradle", "Gradle build"),
70    ("dotnet", "dotnet build/test"),
71    ("flutter", "Flutter build"),
72    ("swift", "Swift build/test"),
73    ("zig", "Zig build/test"),
74    ("composer", "PHP Composer"),
75    ("mix", "Elixir Mix"),
76    ("next", "Next.js build"),
77    ("vite", "Vite build"),
78    ("turbo", "Turborepo"),
79    ("nx", "Nx monorepo"),
80    ("systemctl", "systemd units"),
81    ("journalctl", "systemd logs"),
82    ("dbt", "dbt models/tests"),
83    ("alembic", "Alembic migrations"),
84    ("flyway", "Flyway migrations"),
85    ("ollama", "Ollama models"),
86    ("mlflow", "MLflow runs"),
87    ("semgrep", "Semgrep scan"),
88    ("trivy", "Trivy scan"),
89    ("grype", "Grype scan"),
90    ("syft", "Syft SBOM"),
91    ("cosign", "Cosign verify"),
92    ("swiftlint", "SwiftLint"),
93    ("jj", "Jujutsu VCS"),
94    ("mise", "mise toolchain"),
95    ("buf", "Protobuf buf"),
96    ("gem", "RubyGems"),
97    ("linkerd", "Linkerd check"),
98    ("argocd", "Argo CD"),
99    ("vercel", "Vercel deploy"),
100    ("fly", "Fly.io deploy"),
101    ("wrangler", "Cloudflare deploy"),
102    ("skaffold", "Skaffold"),
103    ("supabase", "Supabase"),
104];
105
106pub struct DiscoverResult {
107    pub total_commands: u32,
108    pub already_optimized: u32,
109    pub missed_commands: Vec<MissedCommand>,
110    pub potential_tokens: usize,
111    pub potential_usd: f64,
112    /// True when at least one missed command had real measured savings backing
113    /// its token estimate. When false, `potential_tokens` is 0 and callers
114    /// should fall back to a frequency-based framing instead of a $ figure.
115    pub has_measured_data: bool,
116}
117
118pub struct MissedCommand {
119    pub prefix: String,
120    pub description: String,
121    /// Real measured savings for this family (e.g. "84%"), or "—" when the
122    /// user has not yet run this family through lean-ctx.
123    pub savings_range: String,
124    pub count: u32,
125    pub estimated_tokens: usize,
126    pub measured: bool,
127}
128
129/// First whitespace token's file name — the `normalize_command` base.
130fn base_of(command: &str) -> &str {
131    let first = command.split_whitespace().next().unwrap_or(command);
132    std::path::Path::new(first)
133        .file_name()
134        .and_then(|n| n.to_str())
135        .unwrap_or(first)
136}
137
138fn describe(base: &str) -> Option<&'static str> {
139    COMPRESSIBLE_FAMILIES
140        .iter()
141        .find(|(b, _)| *b == base)
142        .map(|(_, d)| *d)
143}
144
145/// Aggregated real measurements per command family from `core::stats`:
146/// base → (input_tokens, output_tokens, count).
147fn measured_by_family(store: &StatsStore) -> HashMap<String, (u64, u64, u64)> {
148    let mut by_base: HashMap<String, (u64, u64, u64)> = HashMap::new();
149    for (key, s) in &store.commands {
150        let entry = by_base.entry(base_of(key).to_string()).or_default();
151        entry.0 = entry.0.saturating_add(s.input_tokens);
152        entry.1 = entry.1.saturating_add(s.output_tokens);
153        entry.2 = entry.2.saturating_add(s.count);
154    }
155    by_base
156}
157
158pub fn analyze_history(history: &[String], limit: usize) -> DiscoverResult {
159    let store = lean_ctx::core::stats::load_for_display();
160    analyze_history_with_stats(history, limit, &store)
161}
162
163/// Core analysis with the measured-stats source injected (testable, pure aside
164/// from the policy classifier). `analyze_history` is the disk-backed wrapper.
165fn analyze_history_with_stats(
166    history: &[String],
167    limit: usize,
168    store: &StatsStore,
169) -> DiscoverResult {
170    let mut missed: HashMap<String, u32> = HashMap::new();
171    let mut already_optimized = 0u32;
172    let mut total_commands = 0u32;
173
174    let measured = measured_by_family(store);
175
176    for cmd in history {
177        let trimmed = cmd.trim();
178        if trimmed.is_empty() {
179            continue;
180        }
181        total_commands += 1;
182
183        if trimmed.starts_with("lean-ctx ") || trimmed.starts_with("lean-ctx\t") {
184            already_optimized += 1;
185            continue;
186        }
187
188        let base = base_of(trimmed);
189        // A command is a missed save only if lean-ctx has a compressor for it
190        // (known family or already measured) AND the policy engine would
191        // actually compress it (not a protected verbatim/passthrough command).
192        let known = describe(base).is_some() || measured.contains_key(base);
193        if known && classify(trimmed, &[]) == OutputPolicy::Compressible {
194            *missed.entry(base.to_string()).or_insert(0) += 1;
195        }
196    }
197
198    let mut sorted: Vec<_> = missed.into_iter().collect();
199    sorted.sort_by_key(|x| std::cmp::Reverse(x.1));
200
201    let price_per_tok = lean_ctx::core::stats::DEFAULT_INPUT_PRICE_PER_M / 1_000_000.0;
202    let mut potential_tokens = 0usize;
203    let mut has_measured_data = false;
204
205    let missed_commands: Vec<MissedCommand> = sorted
206        .into_iter()
207        .take(limit)
208        .map(|(base, count)| {
209            let description = describe(&base).unwrap_or("compressible output").to_string();
210            // Project the family's *real* measured savings onto its plain-shell
211            // frequency. Families without measurements contribute nothing — we
212            // never invent a token figure.
213            let (savings_range, estimated_tokens, is_measured) = match measured.get(&base) {
214                Some(&(input, output, cnt)) if input > 0 && cnt > 0 => {
215                    let rate = 1.0 - (output as f64 / input as f64);
216                    let avg_input = input as f64 / cnt as f64;
217                    let est = (count as f64 * avg_input * rate).max(0.0) as usize;
218                    has_measured_data = true;
219                    potential_tokens += est;
220                    (format!("{:.0}%", (rate * 100.0).max(0.0)), est, true)
221                }
222                _ => ("—".to_string(), 0, false),
223            };
224            MissedCommand {
225                prefix: base,
226                description,
227                savings_range,
228                count,
229                estimated_tokens,
230                measured: is_measured,
231            }
232        })
233        .collect();
234
235    let potential_usd = potential_tokens as f64 * price_per_tok;
236
237    DiscoverResult {
238        total_commands,
239        already_optimized,
240        missed_commands,
241        potential_tokens,
242        potential_usd,
243        has_measured_data,
244    }
245}
246
247pub fn discover_from_history(history: &[String], limit: usize) -> String {
248    let result = analyze_history(history, limit);
249
250    if result.missed_commands.is_empty() {
251        return format!(
252            "No missed savings found in last {} commands. \
253            {} already optimized.",
254            result.total_commands, result.already_optimized
255        );
256    }
257
258    let mut lines = Vec::new();
259    lines.push(format!(
260        "Analyzed {} commands ({} already optimized):",
261        result.total_commands, result.already_optimized
262    ));
263    lines.push(String::new());
264
265    let total_missed: u32 = result.missed_commands.iter().map(|m| m.count).sum();
266    lines.push(format!(
267        "{total_missed} commands could benefit from lean-ctx:"
268    ));
269    lines.push(String::new());
270
271    for m in &result.missed_commands {
272        lines.push(format!(
273            "  {:>4}x  {:<12} {} ({})",
274            m.count, m.prefix, m.description, m.savings_range
275        ));
276    }
277
278    lines.push(String::new());
279    if result.has_measured_data {
280        lines.push(format!(
281            "Estimated potential: ~{} tokens saved (~${:.2}), projected from your measured savings",
282            result.potential_tokens, result.potential_usd
283        ));
284    } else {
285        lines.push(
286            "No measured savings yet — run these via lean-ctx, then re-run discover for real numbers."
287                .to_string(),
288        );
289    }
290    lines.push(String::new());
291    lines.push("Fix: run 'lean-ctx init --global' to auto-compress all commands.".to_string());
292    lines.push("Or:  run 'lean-ctx init --agent <tool>' for AI tool hooks.".to_string());
293
294    let output = lines.join("\n");
295    let tokens = count_tokens(&output);
296    format!("{output}\n\n[{tokens} tok]")
297}
298
299pub fn format_cli_output(result: &DiscoverResult) -> String {
300    if result.missed_commands.is_empty() {
301        return format!(
302            "All compressible commands are already using lean-ctx!\n\
303             ({} commands analyzed, {} via lean-ctx)",
304            result.total_commands, result.already_optimized
305        );
306    }
307
308    let mut lines = Vec::new();
309    let total_missed: u32 = result.missed_commands.iter().map(|m| m.count).sum();
310
311    lines.push(format!(
312        "Found {total_missed} compressible commands not using lean-ctx:\n"
313    ));
314    lines.push(format!(
315        "  {:<14} {:>5}  {:>10}  {:<30} {}",
316        "COMMAND", "COUNT", "SAVINGS", "DESCRIPTION", "EST. TOKENS"
317    ));
318    lines.push(format!("  {}", "-".repeat(80)));
319
320    for m in &result.missed_commands {
321        let est = if m.measured {
322            format!("~{}", m.estimated_tokens)
323        } else {
324            "—".to_string()
325        };
326        lines.push(format!(
327            "  {:<14} {:>5}x {:>10}  {:<30} {}",
328            m.prefix, m.count, m.savings_range, m.description, est
329        ));
330    }
331
332    lines.push(String::new());
333    if result.has_measured_data {
334        lines.push(format!(
335            "Estimated missed savings: ~{} tokens (~${:.2}/month), projected from your measured rate",
336            result.potential_tokens,
337            result.potential_usd * 30.0
338        ));
339    } else {
340        lines.push(
341            "No measured savings yet — route these through lean-ctx, then re-run discover."
342                .to_string(),
343        );
344    }
345    lines.push(format!(
346        "Already using lean-ctx: {} commands",
347        result.already_optimized
348    ));
349    lines.push(String::new());
350    lines.push("Run 'lean-ctx init --global' to enable compression for all commands.".to_string());
351
352    lines.join("\n")
353}
354
355/// Renders a shareable "before lean-ctx" SVG card from a discover analysis — the
356/// "ghost tokens you're leaving on the table" framing that drives the first-run share
357/// loop. Same 1200x630 social-card dimensions and visual language as the Wrapped card,
358/// but in an amber/red "leak" palette. Pure string building; all data-derived text is
359/// XML-escaped. Aggregate estimates only — never command contents or arguments.
360pub fn render_before_card(result: &DiscoverResult) -> String {
361    let saved = lean_ctx::core::wrapped::format_tokens(result.potential_tokens as u64);
362    let monthly_usd = result.potential_usd * 30.0;
363    let total_missed: u32 = result.missed_commands.iter().map(|m| m.count).sum();
364    let top = before_card_top_commands(result);
365    format!(
366        r##"<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" font-family="Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
367  <defs>
368    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
369      <stop offset="0" stop-color="#0b1020"/>
370      <stop offset="1" stop-color="#131a2e"/>
371    </linearGradient>
372    <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
373      <stop offset="0" stop-color="#f59e0b"/>
374      <stop offset="1" stop-color="#ef4444"/>
375    </linearGradient>
376  </defs>
377  <rect width="1200" height="630" fill="url(#bg)"/>
378  <rect x="0" y="0" width="1200" height="8" fill="url(#accent)"/>
379  <text x="70" y="92" fill="#e5e7eb" font-size="34" font-weight="700">lean-ctx <tspan fill="#f59e0b">Ghost Tokens</tspan></text>
380  <text x="70" y="130" fill="#94a3b8" font-size="24">before lean-ctx — estimated from my shell history</text>
381  <text x="70" y="300" fill="#f59e0b" font-size="120" font-weight="800" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">{saved}</text>
382  <text x="76" y="346" fill="#94a3b8" font-size="26">tokens/month left on the table</text>
383  <text x="70" y="430" fill="#e5e7eb" font-size="60" font-weight="800" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">${monthly_usd:.0}</text>
384  <text x="74" y="462" fill="#94a3b8" font-size="22">potential monthly savings</text>
385  <text x="70" y="512" fill="#cbd5e1" font-size="22">{total_missed} uncompressed commands · {already} already via lean-ctx</text>
386{top}
387  <text x="70" y="600" fill="#475569" font-size="17">Estimate from local shell history · run `lean-ctx onboard` to stop the leak</text>
388  <text x="1130" y="600" text-anchor="end" fill="#f59e0b" font-size="26" font-weight="700">leanctx.com</text>
389</svg>"##,
390        already = result.already_optimized,
391    )
392}
393
394/// The top three missed commands as a single muted line. Empty when none.
395fn before_card_top_commands(result: &DiscoverResult) -> String {
396    if result.missed_commands.is_empty() {
397        return String::new();
398    }
399    let joined = result
400        .missed_commands
401        .iter()
402        .take(3)
403        .map(|m| format!("{} {}x", m.prefix, m.count))
404        .collect::<Vec<_>>()
405        .join("    ·    ");
406    format!(
407        "  <text x=\"70\" y=\"556\" fill=\"#cbd5e1\" font-size=\"22\">top missed  {}</text>",
408        xml_escape(&joined)
409    )
410}
411
412/// Minimal XML text escaping for data-derived strings in the SVG card.
413fn xml_escape(s: &str) -> String {
414    s.replace('&', "&amp;")
415        .replace('<', "&lt;")
416        .replace('>', "&gt;")
417        .replace('"', "&quot;")
418        .replace('\'', "&apos;")
419}
420
421#[cfg(test)]
422mod tests {
423    use super::{analyze_history, analyze_history_with_stats, render_before_card};
424    use lean_ctx::core::stats::{CommandStats, StatsStore};
425
426    fn history() -> Vec<String> {
427        vec![
428            "git status".into(),
429            "git diff".into(),
430            "cargo build".into(),
431            "cargo test".into(),
432            "lean-ctx gain".into(),
433            "vim notes.txt".into(),
434        ]
435    }
436
437    fn stats_with(entries: &[(&str, u64, u64, u64)]) -> StatsStore {
438        let mut s = StatsStore::default();
439        for (key, count, input, output) in entries {
440            s.commands.insert(
441                (*key).to_string(),
442                CommandStats {
443                    count: *count,
444                    input_tokens: *input,
445                    output_tokens: *output,
446                },
447            );
448            s.total_commands += count;
449            s.total_input_tokens += input;
450            s.total_output_tokens += output;
451        }
452        s
453    }
454
455    #[test]
456    fn no_measured_data_yields_zero_potential_and_no_fabrication() {
457        // Empty stats: detection still works, but NO token figure is invented.
458        let r = analyze_history_with_stats(&history(), 20, &StatsStore::default());
459        assert!(!r.has_measured_data, "no stats => no measured data");
460        assert_eq!(r.potential_tokens, 0, "must not fabricate tokens");
461        assert_eq!(r.potential_usd, 0.0, "must not fabricate dollars");
462        assert!(
463            r.missed_commands
464                .iter()
465                .all(|m| !m.measured && m.savings_range == "—"),
466            "unmeasured families show em dash, not a fake range"
467        );
468        // git (2) + cargo (2) recognised; vim ignored; lean-ctx already-optimized.
469        assert_eq!(r.already_optimized, 1);
470        assert!(
471            r.missed_commands
472                .iter()
473                .any(|m| m.prefix == "git" && m.count == 2)
474        );
475    }
476
477    #[test]
478    fn measured_family_projects_real_savings() {
479        // git measured at 90% savings (1000 in -> 100 out over 2 runs => 500 avg in).
480        let store = stats_with(&[("git status", 2, 1000, 100)]);
481        let r = analyze_history_with_stats(&history(), 20, &store);
482        assert!(r.has_measured_data);
483        let git = r
484            .missed_commands
485            .iter()
486            .find(|m| m.prefix == "git")
487            .expect("git present");
488        assert!(git.measured);
489        assert_eq!(git.savings_range, "90%", "real measured rate, not a guess");
490        // 2 plain-shell git cmds * 500 avg_input * 0.9 = 900 projected.
491        assert_eq!(git.estimated_tokens, 900);
492        assert!(git.savings_range.ends_with('%'));
493        // cargo has no measurement => contributes nothing.
494        let cargo = r
495            .missed_commands
496            .iter()
497            .find(|m| m.prefix == "cargo")
498            .unwrap();
499        assert_eq!(cargo.estimated_tokens, 0);
500        assert_eq!(r.potential_tokens, 900);
501    }
502
503    #[test]
504    fn newly_shipped_families_are_recognised() {
505        // Regression guard: families added in #657-#661 must be discoverable.
506        let hist: Vec<String> = ["dbt run", "trivy image nginx", "pulumi up", "jj log"]
507            .into_iter()
508            .map(String::from)
509            .collect();
510        let r = analyze_history_with_stats(&hist, 20, &StatsStore::default());
511        for fam in ["dbt", "trivy", "pulumi", "jj"] {
512            assert!(
513                r.missed_commands.iter().any(|m| m.prefix == fam),
514                "{fam} should be recognised as compressible"
515            );
516        }
517    }
518
519    #[test]
520    fn before_card_is_well_formed_and_branded() {
521        let result = analyze_history(&history(), 20);
522        let svg = render_before_card(&result);
523        assert!(svg.starts_with("<svg"), "must be an SVG document");
524        assert!(svg.trim_end().ends_with("</svg>"), "must close the svg tag");
525        assert!(svg.contains("leanctx.com"), "must carry the brand footer");
526        assert!(svg.contains("Ghost Tokens"), "must frame the leak");
527        assert!(
528            svg.contains("tokens/month left on the table"),
529            "headline label present"
530        );
531    }
532
533    #[test]
534    fn xml_escape_neutralizes_markup() {
535        assert_eq!(super::xml_escape("a<b>&\"'"), "a&lt;b&gt;&amp;&quot;&apos;");
536    }
537}