use std::path::PathBuf;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum MemoryMode {
None,
History, Docs, Full, }
impl MemoryMode {
pub fn from_env() -> Self {
match std::env::var("MARS_MEMORY").as_deref().map(str::trim) {
Ok("history") | Ok("commands") => MemoryMode::History,
Ok("docs") | Ok("system") => MemoryMode::Docs,
Ok("full") | Ok("both") | Ok("all") => MemoryMode::Full,
_ => MemoryMode::None,
}
}
pub fn as_str(self) -> &'static str {
match self {
MemoryMode::None => "none",
MemoryMode::History => "history",
MemoryMode::Docs => "docs",
MemoryMode::Full => "full",
}
}
pub fn includes_history(self) -> bool {
matches!(self, MemoryMode::History | MemoryMode::Full)
}
pub fn includes_docs(self) -> bool {
matches!(self, MemoryMode::Docs | MemoryMode::Full)
}
}
pub fn command_memory_path() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("MARS_CMD_MEMORY") {
return Some(PathBuf::from(p));
}
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".mars").join("cmd_memory.jsonl"))
}
#[derive(Clone)]
#[allow(dead_code)] pub struct CommandMemory {
pub request: String,
pub command: String,
pub ts: u64, pub session: String, pub cwd: String, }
pub fn remember_command(request: &str, command: &str) {
let req = request.trim();
let cmd = command.trim();
if req.is_empty() || cmd.is_empty() {
return;
}
let Some(path) = command_memory_path() else { return };
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let line = serde_json::json!({
"request": req,
"command": cmd,
"ts": ts,
"session": crate::llm_log::session_id(),
"cwd": cwd,
});
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(f, "{line}");
}
}
pub fn load_command_records() -> Vec<CommandMemory> {
let Some(path) = command_memory_path() else { return Vec::new() };
let Ok(content) = std::fs::read_to_string(&path) else { return Vec::new() };
content
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter_map(|j| {
Some(CommandMemory {
request: j["request"].as_str()?.to_string(),
command: j["command"].as_str()?.to_string(),
ts: j["ts"].as_u64().unwrap_or(0),
session: j["session"].as_str().unwrap_or("").to_string(),
cwd: j["cwd"].as_str().unwrap_or("").to_string(),
})
})
.collect()
}
fn shell_history(limit: usize) -> Vec<String> {
let home = std::env::var_os("HOME").map(PathBuf::from);
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(hf) = std::env::var_os("HISTFILE") {
candidates.push(PathBuf::from(hf));
}
if let Some(h) = &home {
candidates.push(h.join(".zsh_history"));
candidates.push(h.join(".bash_history"));
}
let Some(path) = candidates.into_iter().find(|p| p.exists()) else { return Vec::new() };
let Ok(bytes) = std::fs::read(&path) else { return Vec::new() };
let content = String::from_utf8_lossy(&bytes);
let mut seen = std::collections::HashSet::new();
let mut out: Vec<String> = Vec::new();
for raw in content.lines().rev() {
let cmd = match raw.strip_prefix(':') {
Some(rest) => rest.splitn(2, ';').nth(1).unwrap_or(rest),
None => raw,
}
.trim();
if cmd.is_empty() || !seen.insert(cmd.to_string()) {
continue;
}
out.push(cmd.to_string());
if out.len() >= limit {
break;
}
}
out
}
pub fn fewshot_for(request: &str) -> String {
if !MemoryMode::from_env().includes_history() {
return String::new();
}
let mem = load_command_records();
let hist = shell_history(500); let t = crate::tuning::load();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let mut lines: Vec<String> = rank_memories(
&mem, request, 3, &cwd, now,
t.memory_cwd_boost, t.memory_recency_boost, t.memory_recency_halflife_days,
)
.into_iter()
.map(|i| format!("- {} → {}", mem[i].request, mem[i].command))
.collect();
for i in rank(request, &hist, 5) {
let cmd = &hist[i];
if !lines.iter().any(|l| l.contains(cmd.as_str())) {
lines.push(format!("- {cmd}"));
}
}
lines.truncate(6);
lines.iter().map(|l| redact(l)).collect::<Vec<_>>().join("\n")
}
pub fn docs_context_for(question: &str) -> Option<String> {
if !MemoryMode::from_env().includes_docs() {
return None;
}
let mut corpus = doc_chunks();
corpus.extend(crate::tuning::knob_descriptions());
corpus.extend(env_var_reference());
corpus.extend(memory_reference());
corpus.extend(crate::tiers::tier_descriptions());
let hits = rank(question, &corpus, 5);
if hits.is_empty() {
return None;
}
let body = hits.iter().map(|&i| format!("- {}", corpus[i])).collect::<Vec<_>>().join("\n");
Some(crate::prompts::DOCS_CONTEXT_PREAMBLE.trim_end().replace("{body}", &body))
}
const REDACTED: &str = "[REDACTED]";
pub fn denylist_path() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("MARS_DENYLIST") {
return Some(PathBuf::from(p));
}
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".mars").join("denylist"))
}
fn denylist() -> Vec<String> {
let Some(path) = denylist_path() else { return Vec::new() };
let Ok(s) = std::fs::read_to_string(path) else { return Vec::new() };
s.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(str::to_string)
.collect()
}
const TOKEN_PREFIXES: [&str; 11] =
["sk-", "gsk_", "ghp_", "gho_", "github_pat_", "xoxb-", "xoxp-", "xoxs-", "AKIA", "AIza", "eyJ"];
const SECRET_NAMES: [&str; 9] =
["password", "passwd", "pwd", "token", "secret", "api_key", "apikey", "access_key", "authorization"];
fn token_char(c: char, jwt: bool) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-' || (jwt && c == '.')
}
fn redact_token_prefixes(mut s: String) -> String {
for pre in TOKEN_PREFIXES {
let mut from = 0;
while let Some(pos) = s[from..].find(pre).map(|p| p + from) {
let boundary_ok = s[..pos]
.chars()
.next_back()
.map(|c| !c.is_ascii_alphanumeric() && c != '_')
.unwrap_or(true);
let jwt = pre == "eyJ";
let end = s[pos..]
.char_indices()
.find(|(_, c)| !token_char(*c, jwt))
.map(|(o, _)| pos + o)
.unwrap_or(s.len());
if boundary_ok && end - pos >= 20 {
s.replace_range(pos..end, REDACTED);
from = pos + REDACTED.len();
} else {
from = pos + pre.len();
}
}
}
s
}
fn redact_assignments(mut s: String) -> String {
for name in SECRET_NAMES {
let mut from = 0;
loop {
let lower = s.to_ascii_lowercase();
let Some(pos) = lower[from..].find(name).map(|p| p + from) else { break };
let after = pos + name.len();
let Some(sep) = s[after..].chars().next() else { break };
if sep != '=' && sep != ':' {
from = after;
continue;
}
let mut val_start = after + 1;
while s[val_start..].starts_with(' ') {
val_start += 1;
}
if s[val_start..].to_ascii_lowercase().starts_with("bearer ") {
val_start += "bearer ".len();
}
let val_end = s[val_start..]
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(o, _)| val_start + o)
.unwrap_or(s.len());
if val_end > val_start && &s[val_start..val_end] != REDACTED {
s.replace_range(val_start..val_end, REDACTED);
from = val_start + REDACTED.len();
} else {
from = val_end.max(after);
}
}
}
s
}
fn redact_url_credentials(mut s: String) -> String {
let mut from = 0;
while let Some(pos) = s[from..].find("://").map(|p| p + from) {
let auth_start = pos + 3;
let end = s[auth_start..]
.char_indices()
.find(|(_, c)| c.is_whitespace() || *c == '/')
.map(|(o, _)| auth_start + o)
.unwrap_or(s.len());
let authority = &s[auth_start..end];
if let Some(at) = authority.rfind('@') {
if let Some(colon) = authority[..at].find(':') {
let pw_start = auth_start + colon + 1;
let pw_end = auth_start + at;
if &s[pw_start..pw_end] != REDACTED {
s.replace_range(pw_start..pw_end, REDACTED);
from = pw_start + REDACTED.len();
continue;
}
}
}
from = auth_start;
}
s
}
pub fn redact(line: &str) -> String {
let mut s = line.to_string();
for entry in denylist() {
s = s.replace(&entry, REDACTED);
}
redact_url_credentials(redact_assignments(redact_token_prefixes(s)))
}
fn doc_chunks() -> Vec<String> {
let mut chunks = Vec::new();
for name in ["README.md", "DESIGN.md", "key_design.md"] {
let Ok(text) = std::fs::read_to_string(name) else { continue };
for para in text.split("\n\n") {
let p = para.trim();
if p.len() >= 40 {
chunks.push(p.to_string());
}
}
}
chunks
}
fn env_var_reference() -> Vec<String> {
[
"To use a different LLM model: set the MARS_LLM_MODEL environment variable (e.g. export MARS_LLM_MODEL=gpt-4o-mini).",
"To point the agent at a custom or local OpenAI-compatible endpoint (e.g. Ollama): set MARS_LLM_KEY and MARS_LLM_URL.",
"Provider keys (paid-first detection): ANTHROPIC_API_KEY, OPENAI_API_KEY, GROQ_API_KEY, GEMINI_API_KEY. Export one to select that provider.",
"To turn on memory retrieval: set MARS_MEMORY to history (your commands), docs (system knowledge), or full (both).",
"To log every LLM call (tokens, latency) for debugging: run mars --llm-debug or export MARS_LLM_DEBUG=1; the log lands in ~/.mars/logs/ and mars llm-stats profiles it.",
]
.iter()
.map(|s| s.to_string())
.collect()
}
fn tokenize(s: &str) -> Vec<String> {
s.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect()
}
pub fn rank(query: &str, docs: &[String], k: usize) -> Vec<usize> {
rank_scored(query, docs, k).into_iter().map(|(i, _)| i).collect()
}
pub fn rank_scored(query: &str, docs: &[String], k: usize) -> Vec<(usize, f64)> {
let q = tokenize(query);
if q.is_empty() || docs.is_empty() {
return Vec::new();
}
let toks: Vec<Vec<String>> = docs.iter().map(|d| tokenize(d)).collect();
let n = docs.len() as f64;
let avgdl = toks.iter().map(|t| t.len()).sum::<usize>() as f64 / n.max(1.0);
let mut df: std::collections::HashMap<&str, f64> = std::collections::HashMap::new();
for qt in q.iter().collect::<std::collections::HashSet<_>>() {
let c = toks.iter().filter(|t| t.iter().any(|w| w == qt)).count() as f64;
df.insert(qt.as_str(), c);
}
let (k1, b) = (1.5f64, 0.75f64);
let mut scored: Vec<(usize, f64)> = toks
.iter()
.enumerate()
.map(|(i, doc)| {
let dl = doc.len() as f64;
let mut score = 0.0;
for qt in &q {
let f = doc.iter().filter(|w| *w == qt).count() as f64;
if f == 0.0 {
continue;
}
let dfi = *df.get(qt.as_str()).unwrap_or(&0.0);
let idf = (((n - dfi + 0.5) / (dfi + 0.5)) + 1.0).ln();
score += idf * (f * (k1 + 1.0)) / (f + k1 * (1.0 - b + b * dl / avgdl.max(1.0)));
}
(i, score)
})
.filter(|(_, s)| *s > 0.0)
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(k);
scored
}
pub fn rank_memories(
records: &[CommandMemory],
query: &str,
k: usize,
cwd: &str,
now: u64,
cwd_boost: f64,
recency_boost: f64,
halflife_days: f64,
) -> Vec<usize> {
let docs: Vec<String> = records.iter().map(|r| r.request.clone()).collect();
let mut scored = rank_scored(query, &docs, docs.len());
for (i, score) in scored.iter_mut() {
let r = &records[*i];
let mut boost = 1.0;
if !cwd.is_empty() && r.cwd == cwd {
boost += cwd_boost;
}
if r.ts > 0 && now >= r.ts && halflife_days > 0.0 {
let age_days = (now - r.ts) as f64 / 86_400.0;
boost += recency_boost * 0.5f64.powf(age_days / halflife_days);
}
*score *= boost;
}
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.into_iter().take(k).map(|(i, _)| i).collect()
}
fn memory_reference() -> Vec<String> {
[
"To see or edit everything the agent remembers: run the 'open command memory' \
action from the command bar — it opens ~/.mars/cmd_memory.jsonl in the editor; \
delete lines to forget individual commands.",
"To make the agent forget all remembered commands at once: run the 'forget all \
commands' action (asks for confirmation), or delete ~/.mars/cmd_memory.jsonl.",
"Memory and shell-history lines are redacted before they enter any LLM prompt \
(API-key shapes, password=/token= values, URL credentials). To force-redact \
additional strings, add them to ~/.mars/denylist (one per line) — the 'open \
redaction denylist' action edits it.",
]
.iter()
.map(|s| s.to_string())
.collect()
}