use std::path::PathBuf;
pub struct WorkEntry {
pub ts: u64,
pub session: String,
pub tab: String,
pub verdict: String,
pub failed: bool,
pub dur_secs: Option<u64>,
pub cwd: String,
pub command: Option<String>,
pub exit: Option<i32>,
pub error_excerpt: Option<String>,
}
pub fn worklog_path() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("MARS_WORKLOG") {
return Some(PathBuf::from(p));
}
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".mars").join("worklog.jsonl"))
}
fn mission_path() -> Option<PathBuf> {
worklog_path().map(|p| p.with_file_name("mission.json"))
}
pub fn record(e: &WorkEntry) {
let Some(path) = worklog_path() else { return };
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let mut line = serde_json::json!({
"ts": e.ts,
"session": e.session,
"tab": e.tab,
"verdict": e.verdict,
"failed": e.failed,
"dur_secs": e.dur_secs,
});
if !e.cwd.is_empty() {
line["cwd"] = serde_json::json!(e.cwd);
}
if let Some(c) = &e.command {
line["command"] = serde_json::json!(c);
}
if let Some(x) = e.exit {
line["exit"] = serde_json::json!(x);
}
if let Some(x) = &e.error_excerpt {
line["error_excerpt"] = serde_json::json!(x);
}
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 recent(session: &str, limit: usize) -> Vec<WorkEntry> {
let Some(path) = worklog_path() else { return Vec::new() };
let Ok(content) = std::fs::read_to_string(&path) else { return Vec::new() };
let mut out: Vec<WorkEntry> = content
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter(|j| j["session"].as_str() == Some(session))
.map(|j| WorkEntry {
ts: j["ts"].as_u64().unwrap_or(0),
session: session.to_string(),
tab: j["tab"].as_str().unwrap_or("").to_string(),
verdict: j["verdict"].as_str().unwrap_or("").to_string(),
failed: j["failed"].as_bool().unwrap_or(false),
dur_secs: j["dur_secs"].as_u64(),
cwd: j["cwd"].as_str().unwrap_or("").to_string(),
command: j["command"].as_str().map(str::to_string),
exit: j["exit"].as_i64().map(|x| x as i32),
error_excerpt: j["error_excerpt"].as_str().map(str::to_string),
})
.collect();
let skip = out.len().saturating_sub(limit);
out.drain(..skip);
out
}
pub fn compact(max_lines: usize) {
if max_lines == 0 {
return;
}
let Some(path) = worklog_path() else { return };
let Ok(content) = std::fs::read_to_string(&path) else { return };
let lines: Vec<&str> = content.lines().collect();
if lines.len() <= max_lines * 2 {
return;
}
let keep = &lines[lines.len() - max_lines..];
let tmp = path.with_extension("jsonl.tmp");
if std::fs::write(&tmp, keep.join("\n") + "\n").is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
pub fn save_mission(session: &str, mission: &str, as_of: u64) {
let Some(path) = mission_path() else { return };
let mut map: serde_json::Map<String, serde_json::Value> = path
.exists()
.then(|| std::fs::read_to_string(&path).ok())
.flatten()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
map.insert(
session.to_string(),
serde_json::json!({ "mission": mission, "as_of": as_of }),
);
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Ok(s) = serde_json::to_string_pretty(&map) {
let _ = std::fs::write(&path, s);
}
}
pub fn load_mission(session: &str) -> Option<(String, u64)> {
let path = mission_path()?;
let s = std::fs::read_to_string(path).ok()?;
let j: serde_json::Value = serde_json::from_str(&s).ok()?;
let m = &j[session];
Some((m["mission"].as_str()?.to_string(), m["as_of"].as_u64().unwrap_or(0)))
}
fn goals_path() -> Option<PathBuf> {
worklog_path().map(|p| p.with_file_name("goals.json"))
}
pub fn save_goals(session: &str, goals: &[String], as_of: u64) {
let Some(path) = goals_path() else { return };
let mut map: serde_json::Map<String, serde_json::Value> = path
.exists()
.then(|| std::fs::read_to_string(&path).ok())
.flatten()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
map.insert(session.to_string(), serde_json::json!({ "goals": goals, "as_of": as_of }));
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Ok(s) = serde_json::to_string_pretty(&map) {
let _ = std::fs::write(&path, s);
}
}
pub fn load_goals(session: &str) -> Vec<String> {
let Some(path) = goals_path() else { return Vec::new() };
let Ok(s) = std::fs::read_to_string(path) else { return Vec::new() };
let Ok(j) = serde_json::from_str::<serde_json::Value>(&s) else { return Vec::new() };
j[session]["goals"]
.as_array()
.map(|a| a.iter().filter_map(|g| g.as_str().map(str::to_string)).collect())
.unwrap_or_default()
}
pub fn goals_as_of(session: &str) -> Option<u64> {
let path = goals_path()?;
let s = std::fs::read_to_string(path).ok()?;
let j: serde_json::Value = serde_json::from_str(&s).ok()?;
j[session]["as_of"].as_u64()
}
fn summarizing_path() -> Option<PathBuf> {
worklog_path().map(|p| p.with_file_name("summarizing.json"))
}
pub fn mark_summarizing(session: &str, ts: u64) {
let Some(path) = summarizing_path() else { return };
let mut map: serde_json::Map<String, serde_json::Value> = path
.exists()
.then(|| std::fs::read_to_string(&path).ok())
.flatten()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
map.insert(session.to_string(), serde_json::json!(ts));
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Ok(s) = serde_json::to_string(&map) {
let _ = std::fs::write(&path, s);
}
}
pub fn summarizing_since(session: &str) -> Option<u64> {
let path = summarizing_path()?;
let s = std::fs::read_to_string(path).ok()?;
let j: serde_json::Value = serde_json::from_str(&s).ok()?;
j[session].as_u64()
}
fn briefings_path() -> Option<PathBuf> {
worklog_path().map(|p| p.with_file_name("briefings.jsonl"))
}
pub struct PrevBriefing {
pub facts: String,
pub ts: u64,
}
pub fn log_briefing(session: &str, narrative: &str, facts: &str, away_secs: u64, ts: u64) {
let Some(path) = briefings_path() else { return };
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let line = serde_json::json!({
"ts": ts, "session": session, "narrative": narrative,
"facts": facts, "away_secs": away_secs,
});
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(f, "{line}");
}
if let Ok(content) = std::fs::read_to_string(&path) {
let lines: Vec<&str> = content.lines().collect();
if lines.len() > 1000 {
let keep = &lines[lines.len() - 500..];
let tmp = path.with_extension("jsonl.tmp");
if std::fs::write(&tmp, keep.join("\n") + "\n").is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
}
pub fn load_last_briefing(session: &str) -> Option<PrevBriefing> {
let path = briefings_path()?;
let content = std::fs::read_to_string(path).ok()?;
content
.lines()
.rev()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.find(|j| j["session"].as_str() == Some(session))
.map(|j| PrevBriefing {
facts: j["facts"].as_str().unwrap_or("").to_string(),
ts: j["ts"].as_u64().unwrap_or(0),
})
}