Skip to main content

hematite/agent/
shell_history.rs

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