Skip to main content

sioul/mcp/
log.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What each agent asked, and what it was given (docs/ai.md, "A log"): one
5//! JSON line per call, one file per month, in the state folder
6//! (`$XDG_STATE_HOME/sioul/mcp/2026-10.jsonl`), readable by you alone. It
7//! stays on this computer. What was handed over is named by its addresses
8//! (mid:…, sioul:…) and its length, never by its words: no message's text, no
9//! code, no secret is kept a second time, in a file that outlives the mail.
10
11use 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
19/// Characters of an argument kept: a query, a title, an address.
20const KEPT: usize = 200;
21
22/// Appends one call to this month's file. A log that cannot be written is
23/// said on standard error and never stops the call.
24pub 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    // One write per line: two calls logged at once never mix their lines.
37    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
43/// An argument as it is kept: a body or notes by their length alone (a draft
44/// or a note may quote mail), any other text shortened, its numbers masked.
45fn 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
65/// A text shortened to what tells what it was, on one line, its account and card numbers masked.
66fn 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
72/// The addresses an answer gave, each once: what the agent was shown, by name.
73fn 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
84/// The log's folder, made for you alone where the system has owners (Unix).
85fn 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
93/// This month's file, to add to; readable and writable by you alone on Unix
94/// (on Windows, your profile's folders are yours already).
95fn 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    // A file made before, by a version that left it readable to others: closed now.
102    #[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}