Skip to main content

hematite/agent/
shell_history.rs

1use std::fmt::Write as _;
2
3/// Reads recent shell command history from the host and returns a compact
4/// context block for injection into the system prompt.
5///
6/// Sources (in priority order):
7///   Windows — PSReadLine history file (PowerShell 5+ / PowerShell Core)
8///   Linux/macOS — ~/.bash_history or ~/.zsh_history
9///
10/// The block is loaded once at session start and injected every turn so the
11/// model knows what the user was recently doing without them explaining it.
12const MAX_COMMANDS: usize = 20;
13const MIN_CMD_LEN: usize = 4;
14
15pub fn load_shell_history_block() -> Option<String> {
16    let commands = read_history()?;
17    if commands.is_empty() {
18        return None;
19    }
20    let mut block = String::from("## Recent Shell History (last session commands)\n");
21    for cmd in &commands {
22        let _ = writeln!(block, "  $ {}", cmd);
23    }
24    block.push_str("Use this context to understand what the user was recently working on.\n");
25    Some(block)
26}
27
28fn read_history() -> Option<Vec<String>> {
29    let path = history_path()?;
30    let raw = std::fs::read_to_string(&path).ok()?;
31
32    let mut seen = LinkedHashSet::new();
33    let mut cmds: Vec<String> = Vec::new();
34
35    for line in raw.lines().rev() {
36        let cmd = line.trim();
37        if cmd.len() < MIN_CMD_LEN {
38            continue;
39        }
40        // Skip trivials
41        if matches!(
42            cmd,
43            "ls" | "pwd" | "cd" | "clear" | "exit" | "cls" | "history"
44        ) {
45            continue;
46        }
47        // Skip lines that look like prompts or comments
48        if cmd.starts_with('#') || cmd.starts_with("PS ") {
49            continue;
50        }
51        if seen.contains(cmd) {
52            continue;
53        }
54        seen.insert(cmd.to_string());
55        cmds.push(cmd.to_string());
56        if cmds.len() >= MAX_COMMANDS {
57            break;
58        }
59    }
60
61    cmds.reverse(); // oldest-first for readability
62    if cmds.is_empty() {
63        None
64    } else {
65        Some(cmds)
66    }
67}
68
69fn history_path() -> Option<std::path::PathBuf> {
70    #[cfg(target_os = "windows")]
71    {
72        // PSReadLine: %APPDATA%\Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txt
73        let appdata = std::env::var("APPDATA").ok()?;
74        let p = std::path::PathBuf::from(appdata)
75            .join("Microsoft")
76            .join("Windows")
77            .join("PowerShell")
78            .join("PSReadLine")
79            .join("ConsoleHost_history.txt");
80        if p.exists() {
81            Some(p)
82        } else {
83            None
84        }
85    }
86    #[cfg(not(target_os = "windows"))]
87    {
88        let home = std::env::var("HOME").ok()?;
89        // Prefer zsh, fall back to bash
90        let zsh = std::path::PathBuf::from(&home).join(".zsh_history");
91        if zsh.exists() {
92            return Some(zsh);
93        }
94        let bash = std::path::PathBuf::from(&home).join(".bash_history");
95        if bash.exists() {
96            return Some(bash);
97        }
98        None
99    }
100}
101
102// Minimal insertion-ordered dedup set — avoids pulling in a crate.
103struct LinkedHashSet(Vec<String>);
104
105impl LinkedHashSet {
106    fn new() -> Self {
107        Self(Vec::new())
108    }
109    fn contains(&self, s: &str) -> bool {
110        self.0.iter().any(|x| x == s)
111    }
112    fn insert(&mut self, s: String) {
113        self.0.push(s);
114    }
115}