1use super::mask;
12use super::tools::Answer;
13use jiff::Zoned;
14use serde_json::{Map, Value, json};
15use sioul_core::config;
16use std::io::Write;
17use std::path::Path;
18
19const KEPT: usize = 200;
21
22pub fn record(client: &str, tool: &str, arguments: &Value, outcome: &Result<Answer, String>) {
25 let now = Zoned::now();
26 let (error, given, characters) = match outcome {
27 Ok(answer) => (Value::Null, addresses(&answer.text), answer.text.chars().count()),
28 Err(message) => (Value::String(short(message)), Vec::new(), 0),
29 };
30 let line = json!({
31 "at": now.strftime("%Y-%m-%dT%H:%M:%S%:z").to_string(), "client": client, "tool": tool,
32 "arguments": kept(arguments, 0), "error": error, "given": given, "characters": characters,
33 });
34 let folder = config::state_dir().join("mcp");
35 let path = folder.join(format!("{}.jsonl", now.strftime("%Y-%m")));
36 let written = private_folder(&folder).and_then(|()| open(&path)).and_then(|mut file| file.write_all(format!("{line}\n").as_bytes()));
38 if let Err(e) = written {
39 eprintln!("{}: {e}", path.display());
40 }
41}
42
43fn kept(value: &Value, depth: usize) -> Value {
46 match value {
47 Value::String(text) => Value::String(short(text)),
48 Value::Array(items) => Value::Array(items.iter().take(50).map(|item| kept(item, depth + 1)).collect()),
49 Value::Object(map) if depth == 0 => Value::Object(
50 map.iter()
51 .map(|(key, value)| {
52 let value = match (key.as_str(), value) {
53 ("body" | "notes", Value::String(text)) => json!(format!("({} characters)", text.chars().count())),
54 _ => kept(value, depth + 1),
55 };
56 (crate::one_line(key).chars().take(KEPT).collect(), value)
57 })
58 .collect::<Map<String, Value>>(),
59 ),
60 Value::Object(_) => json!("(an object)"),
61 other => other.clone(),
62 }
63}
64
65fn short(text: &str) -> String {
67 let text = mask::text(&crate::one_line(text));
68 let count = text.chars().count();
69 if count <= KEPT { text } else { format!("{}… ({count} characters)", text.chars().take(KEPT).collect::<String>()) }
70}
71
72fn addresses(text: &str) -> Vec<String> {
74 let mut out: Vec<String> = Vec::new();
75 let words = text.split(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '[' | ']' | '(' | ')' | '"' | ','));
76 for word in words.filter(|w| (w.starts_with("mid:") || w.starts_with("sioul:")) && w.len() > 6) {
77 if out.len() < 500 && !out.iter().any(|seen| seen == word) {
78 out.push(word.chars().take(KEPT).collect());
79 }
80 }
81 out
82}
83
84fn private_folder(folder: &Path) -> std::io::Result<()> {
86 let mut builder = std::fs::DirBuilder::new();
87 builder.recursive(true);
88 #[cfg(unix)]
89 std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
90 builder.create(folder)
91}
92
93fn open(path: &Path) -> std::io::Result<std::fs::File> {
96 let mut options = std::fs::OpenOptions::new();
97 options.create(true).append(true);
98 #[cfg(unix)]
99 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
100 let file = options.open(path)?;
101 #[cfg(unix)]
103 {
104 use std::os::unix::fs::PermissionsExt;
105 if file.metadata()?.permissions().mode() & 0o077 != 0 {
106 file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
107 }
108 }
109 Ok(file)
110}