1use chrono::Utc;
2use serde::{Deserialize, Serialize};
3use std::io::Write;
4use std::path::PathBuf;
5use std::sync::{Mutex, OnceLock};
6
7static CMD_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
8
9fn cmd_lock() -> std::sync::MutexGuard<'static, ()> {
10 CMD_LOCK
11 .get_or_init(|| Mutex::new(()))
12 .lock()
13 .expect("cmd history lock poisoned")
14}
15
16#[derive(Debug, Serialize, Deserialize, Clone)]
17pub struct SkillEntry {
18 pub ts: String,
19 pub skill: String,
20 pub ok: bool,
21 pub output: String,
22}
23
24#[derive(Debug, Serialize, Deserialize, Clone)]
25pub struct CmdEntry {
26 pub ts: String,
27 pub session: Option<String>,
28 pub cmd: Vec<String>,
29 pub exit_code: i32,
30}
31
32pub fn history_dir() -> PathBuf {
33 dirs::home_dir()
34 .unwrap_or_else(|| PathBuf::from("/tmp"))
35 .join(".cache/virtuoso_bridge/history")
36}
37
38fn write_jsonl_line(path: &std::path::Path, line: &str) {
39 if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
40 let _ = writeln!(f, "{line}");
41 }
42}
43
44pub fn append_skill(session_id: &str, skill: &str, ok: bool, output: &str) {
45 let dir = history_dir();
46 let _ = std::fs::create_dir_all(&dir);
47 let entry = SkillEntry {
48 ts: Utc::now().to_rfc3339(),
49 skill: skill.to_string(),
50 ok,
51 output: output.chars().take(512).collect(),
52 };
53 if let Ok(line) = serde_json::to_string(&entry) {
54 write_jsonl_line(&dir.join(format!("{session_id}.jsonl")), &line);
55 }
56}
57
58pub fn append_cmd(args: &[String], session: Option<&str>, exit_code: i32) {
59 let _guard = cmd_lock();
60 let dir = history_dir();
61 let _ = std::fs::create_dir_all(&dir);
62 let entry = CmdEntry {
63 ts: Utc::now().to_rfc3339(),
64 session: session.map(String::from),
65 cmd: args.to_vec(),
66 exit_code,
67 };
68 if let Ok(line) = serde_json::to_string(&entry) {
69 write_jsonl_line(&dir.join("cmd.jsonl"), &line);
70 }
71}
72
73fn tail<T>(mut v: Vec<T>, limit: usize) -> Vec<T> {
74 if limit > 0 && v.len() > limit {
75 v.drain(..v.len() - limit);
76 }
77 v
78}
79
80pub fn load_skill(session_id: &str, limit: usize) -> Vec<SkillEntry> {
81 let path = history_dir().join(format!("{session_id}.jsonl"));
82 let entries: Vec<SkillEntry> = std::fs::read_to_string(path)
83 .unwrap_or_default()
84 .lines()
85 .filter_map(|line| serde_json::from_str(line).ok())
86 .collect();
87 tail(entries, limit)
88}
89
90pub fn load_cmd(session_filter: Option<&str>, limit: usize) -> Vec<CmdEntry> {
91 let _guard = cmd_lock();
92 let path = history_dir().join("cmd.jsonl");
93 let entries: Vec<CmdEntry> = std::fs::read_to_string(path)
94 .unwrap_or_default()
95 .lines()
96 .filter_map(|line| serde_json::from_str(line).ok())
97 .filter(|e: &CmdEntry| {
98 session_filter.map_or(true, |id| e.session.as_deref() == Some(id))
99 })
100 .collect();
101 tail(entries, limit)
102}