use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Datelike, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const ENV_ENABLED: &str = "TRUSTY_MEMORY_PROMPT_LOG";
pub const ENV_DIR: &str = "TRUSTY_MEMORY_PROMPT_LOG_DIR";
pub const ENV_MAX_BYTES: &str = "TRUSTY_MEMORY_PROMPT_LOG_MAX_BYTES";
pub const ENV_RETENTION_DAYS: &str = "TRUSTY_MEMORY_PROMPT_LOG_RETENTION_DAYS";
pub const ENV_HASH_PROMPTS: &str = "TRUSTY_MEMORY_PROMPT_LOG_HASH_PROMPTS";
pub const DEFAULT_MAX_BYTES: u64 = 50 * 1024 * 1024;
pub const DEFAULT_RETENTION_DAYS: u32 = 30;
const FILE_PREFIX: &str = "enriched-prompts";
const FILE_EXT: &str = "jsonl";
#[derive(Clone, Debug)]
pub struct PromptLogConfig {
pub enabled: bool,
pub dir: PathBuf,
pub max_bytes: u64,
pub retention_days: u32,
pub hash_prompts: bool,
}
impl PromptLogConfig {
pub fn from_env_with_root(data_root: &Path) -> Self {
let enabled = match std::env::var(ENV_ENABLED) {
Ok(v) => !is_off(&v),
Err(_) => true,
};
let dir = match std::env::var(ENV_DIR) {
Ok(d) if !d.trim().is_empty() => PathBuf::from(d),
_ => data_root.join("logs"),
};
let max_bytes = std::env::var(ENV_MAX_BYTES)
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_MAX_BYTES);
let retention_days = std::env::var(ENV_RETENTION_DAYS)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_RETENTION_DAYS);
let hash_prompts = std::env::var(ENV_HASH_PROMPTS)
.map(|v| is_on(&v))
.unwrap_or(false);
Self {
enabled,
dir,
max_bytes,
retention_days,
hash_prompts,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PromptLogEntry {
pub timestamp: DateTime<Utc>,
pub hook_type: String,
pub injection_kind: String,
pub palace: String,
pub trigger_prompt: String,
pub injection: String,
pub injection_length: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub palace_facts_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unread_messages_count: Option<usize>,
pub duration_ms: u64,
}
impl PromptLogEntry {
pub fn new(
hook_type: impl Into<String>,
injection_kind: impl Into<String>,
palace: impl Into<String>,
trigger_prompt: impl Into<String>,
injection: impl Into<String>,
) -> Self {
let injection = injection.into();
let injection_length = injection.len();
Self {
timestamp: Utc::now(),
hook_type: hook_type.into(),
injection_kind: injection_kind.into(),
palace: palace.into(),
trigger_prompt: trigger_prompt.into(),
injection,
injection_length,
palace_facts_count: None,
unread_messages_count: None,
duration_ms: 0,
}
}
#[must_use]
pub fn with_duration_ms(mut self, ms: u64) -> Self {
self.duration_ms = ms;
self
}
#[must_use]
pub fn with_palace_facts_count(mut self, n: usize) -> Self {
self.palace_facts_count = Some(n);
self
}
#[must_use]
pub fn with_unread_messages_count(mut self, n: usize) -> Self {
self.unread_messages_count = Some(n);
self
}
}
#[derive(Clone, Debug)]
pub struct PromptLogger {
config: PromptLogConfig,
}
impl PromptLogger {
pub fn from_env() -> Self {
let data_root = trusty_common::resolve_data_dir("trusty-memory")
.unwrap_or_else(|_| std::env::temp_dir().join("trusty-memory"));
Self::from_config(PromptLogConfig::from_env_with_root(&data_root))
}
pub fn from_config(config: PromptLogConfig) -> Self {
Self { config }
}
pub fn config(&self) -> &PromptLogConfig {
&self.config
}
pub fn log(&self, entry: PromptLogEntry) {
if !self.config.enabled {
return;
}
let entry = self.apply_privacy(entry);
if let Err(e) = std::fs::create_dir_all(&self.config.dir) {
tracing::warn!(
"trusty-memory prompt log: could not create {}: {e}",
self.config.dir.display()
);
return;
}
self.prune_if_needed();
let path = match self.resolve_active_path(entry.timestamp) {
Ok(p) => p,
Err(e) => {
tracing::warn!("trusty-memory prompt log: resolve path: {e}");
return;
}
};
let line = match serde_json::to_string(&entry) {
Ok(s) => s,
Err(e) => {
tracing::warn!("trusty-memory prompt log: serialise entry: {e}");
return;
}
};
match OpenOptions::new().create(true).append(true).open(&path) {
Ok(mut f) => {
if let Err(e) = writeln!(f, "{line}") {
tracing::warn!("trusty-memory prompt log: write {}: {e}", path.display());
}
}
Err(e) => {
tracing::warn!("trusty-memory prompt log: open {}: {e}", path.display());
}
}
}
fn apply_privacy(&self, mut entry: PromptLogEntry) -> PromptLogEntry {
if self.config.hash_prompts {
entry.trigger_prompt = hash_prompt(&entry.trigger_prompt);
}
entry
}
fn resolve_active_path(&self, timestamp: DateTime<Utc>) -> std::io::Result<PathBuf> {
let date_str = format!(
"{:04}-{:02}-{:02}",
timestamp.year(),
timestamp.month(),
timestamp.day()
);
let base = self
.config
.dir
.join(format!("{FILE_PREFIX}.{date_str}.{FILE_EXT}"));
let path_for = |i: u32| -> PathBuf {
if i == 0 {
base.clone()
} else {
self.config
.dir
.join(format!("{FILE_PREFIX}.{date_str}.{i}.{FILE_EXT}"))
}
};
for i in 0u32..=u32::MAX {
let candidate = path_for(i);
let size = match std::fs::metadata(&candidate) {
Ok(m) => m.len(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0,
Err(e) => return Err(e),
};
if size < self.config.max_bytes {
return Ok(candidate);
}
}
Ok(path_for(u32::MAX))
}
fn prune_if_needed(&self) {
let today = Utc::now().date_naive();
let cutoff =
match today.checked_sub_days(chrono::Days::new(self.config.retention_days as u64)) {
Some(d) => d,
None => return,
};
let dir = match std::fs::read_dir(&self.config.dir) {
Ok(d) => d,
Err(_) => return,
};
for entry in dir.flatten() {
let name = entry.file_name();
let name = match name.to_str() {
Some(s) => s,
None => continue,
};
let date = match parse_log_filename_date(name) {
Some(d) => d,
None => continue,
};
if date < cutoff {
if let Err(e) = std::fs::remove_file(entry.path()) {
tracing::warn!(
"trusty-memory prompt log: prune {}: {e}",
entry.path().display()
);
}
}
}
}
}
fn parse_log_filename_date(name: &str) -> Option<NaiveDate> {
let prefix = format!("{FILE_PREFIX}.");
let suffix = format!(".{FILE_EXT}");
let inner = name.strip_prefix(&prefix)?.strip_suffix(&suffix)?;
let date_part = match inner.find('.') {
Some(i) => &inner[..i],
None => inner,
};
NaiveDate::parse_from_str(date_part, "%Y-%m-%d").ok()
}
fn hash_prompt(text: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
let digest = hasher.finalize();
format!("sha256:{digest:x}")
}
fn is_off(v: &str) -> bool {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"0" | "off" | "false" | "no" | "disabled"
)
}
fn is_on(v: &str) -> bool {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "on" | "true" | "yes" | "enabled"
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn logger_in(
dir: &Path,
hash_prompts: bool,
max_bytes: u64,
retention_days: u32,
) -> PromptLogger {
PromptLogger::from_config(PromptLogConfig {
enabled: true,
dir: dir.join("logs"),
max_bytes,
retention_days,
hash_prompts,
})
}
fn read_jsonl_lines(path: &Path) -> Vec<String> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(|l| l.to_string())
.collect()
}
fn list_log_files(dir: &Path) -> Vec<PathBuf> {
let logs_dir = dir.join("logs");
let mut out: Vec<PathBuf> = std::fs::read_dir(&logs_dir)
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(FILE_PREFIX))
})
.collect()
})
.unwrap_or_default();
out.sort();
out
}
#[test]
fn single_event_roundtrip() {
let tmp = tempfile::tempdir().expect("tempdir");
let logger = logger_in(tmp.path(), false, DEFAULT_MAX_BYTES, 30);
let entry = PromptLogEntry::new(
"UserPromptSubmit",
"prompt-context-facts",
"test-palace",
"what tools should I use?",
"## Context\n- alias: tm -> trusty-memory\n",
)
.with_duration_ms(12)
.with_palace_facts_count(7);
logger.log(entry.clone());
let files = list_log_files(tmp.path());
assert_eq!(
files.len(),
1,
"expected exactly one log file, got {files:?}"
);
let lines = read_jsonl_lines(&files[0]);
assert_eq!(lines.len(), 1, "expected one JSONL line, got {lines:?}");
let parsed: PromptLogEntry = serde_json::from_str(&lines[0]).expect("parse JSONL entry");
assert_eq!(parsed.hook_type, "UserPromptSubmit");
assert_eq!(parsed.injection_kind, "prompt-context-facts");
assert_eq!(parsed.palace, "test-palace");
assert_eq!(parsed.trigger_prompt, "what tools should I use?");
assert_eq!(parsed.injection, entry.injection);
assert_eq!(parsed.injection_length, entry.injection.len());
assert_eq!(parsed.palace_facts_count, Some(7));
assert_eq!(parsed.unread_messages_count, None);
assert_eq!(parsed.duration_ms, 12);
}
#[test]
fn rotation_at_size_cap() {
let tmp = tempfile::tempdir().expect("tempdir");
let logger = logger_in(tmp.path(), false, 200, 30);
for i in 0..5 {
let entry = PromptLogEntry::new(
"UserPromptSubmit",
"prompt-context-facts",
"test-palace",
format!("prompt #{i} with some padding to push us over the cap"),
format!("injection #{i} with some padding to push us over the cap"),
)
.with_duration_ms(i as u64);
logger.log(entry);
}
let files = list_log_files(tmp.path());
assert!(
files.len() >= 2,
"expected rotation to produce at least two files, got {files:?}"
);
}
#[test]
fn retention_prunes_old_files() {
let tmp = tempfile::tempdir().expect("tempdir");
let logs_dir = tmp.path().join("logs");
std::fs::create_dir_all(&logs_dir).unwrap();
let stale_date = Utc::now()
.date_naive()
.checked_sub_days(chrono::Days::new(90))
.expect("stale date");
let stale_name = format!(
"{FILE_PREFIX}.{:04}-{:02}-{:02}.{FILE_EXT}",
stale_date.year(),
stale_date.month(),
stale_date.day()
);
let stale_path = logs_dir.join(&stale_name);
std::fs::write(&stale_path, "{\"stale\": true}\n").unwrap();
let unrelated = logs_dir.join("not-our-log.txt");
std::fs::write(&unrelated, "ignore me").unwrap();
let logger = logger_in(tmp.path(), false, DEFAULT_MAX_BYTES, 2);
logger.log(PromptLogEntry::new(
"UserPromptSubmit",
"prompt-context-facts",
"test-palace",
"trigger",
"injection",
));
assert!(
!stale_path.exists(),
"stale log file at {} should have been pruned",
stale_path.display()
);
assert!(
unrelated.exists(),
"unrelated file at {} must not be touched",
unrelated.display()
);
let files = list_log_files(tmp.path());
let today = Utc::now().date_naive();
let expected_today = format!(
"{FILE_PREFIX}.{:04}-{:02}-{:02}.{FILE_EXT}",
today.year(),
today.month(),
today.day()
);
assert!(
files.iter().any(|p| p
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == expected_today)),
"expected today's log file `{expected_today}` to exist, got {files:?}"
);
}
#[test]
fn disabled_mode_writes_nothing() {
let tmp = tempfile::tempdir().expect("tempdir");
let logger = PromptLogger::from_config(PromptLogConfig {
enabled: false,
dir: tmp.path().join("logs"),
max_bytes: DEFAULT_MAX_BYTES,
retention_days: 30,
hash_prompts: false,
});
logger.log(PromptLogEntry::new(
"UserPromptSubmit",
"prompt-context-facts",
"test-palace",
"trigger",
"injection",
));
assert!(
!tmp.path().join("logs").exists(),
"disabled logger must not create the log directory"
);
}
#[test]
fn hash_mode_hashes_trigger_prompt() {
let tmp = tempfile::tempdir().expect("tempdir");
let logger = logger_in(tmp.path(), true, DEFAULT_MAX_BYTES, 30);
let raw_prompt = "secret user prompt that must not land on disk";
logger.log(PromptLogEntry::new(
"UserPromptSubmit",
"prompt-context-facts",
"test-palace",
raw_prompt,
"injection body",
));
let files = list_log_files(tmp.path());
assert_eq!(files.len(), 1);
let content = std::fs::read_to_string(&files[0]).unwrap();
assert!(
!content.contains(raw_prompt),
"raw prompt must not appear in the log file; got {content}"
);
let parsed: PromptLogEntry = serde_json::from_str(content.trim()).expect("parse JSONL");
assert!(
parsed.trigger_prompt.starts_with("sha256:"),
"trigger_prompt should be hashed, got {}",
parsed.trigger_prompt
);
assert_eq!(parsed.trigger_prompt, hash_prompt(raw_prompt));
}
#[tokio::test]
async fn config_from_env_defaults() {
let _guard = crate::commands::env_test_lock().lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
let prev_enabled = std::env::var(ENV_ENABLED).ok();
let prev_dir = std::env::var(ENV_DIR).ok();
let prev_max = std::env::var(ENV_MAX_BYTES).ok();
let prev_ret = std::env::var(ENV_RETENTION_DAYS).ok();
let prev_hash = std::env::var(ENV_HASH_PROMPTS).ok();
unsafe {
std::env::remove_var(ENV_ENABLED);
std::env::remove_var(ENV_DIR);
std::env::remove_var(ENV_MAX_BYTES);
std::env::remove_var(ENV_RETENTION_DAYS);
std::env::remove_var(ENV_HASH_PROMPTS);
}
let cfg = PromptLogConfig::from_env_with_root(tmp.path());
assert!(cfg.enabled);
assert_eq!(cfg.dir, tmp.path().join("logs"));
assert_eq!(cfg.max_bytes, DEFAULT_MAX_BYTES);
assert_eq!(cfg.retention_days, DEFAULT_RETENTION_DAYS);
assert!(!cfg.hash_prompts);
unsafe {
for (k, v) in [
(ENV_ENABLED, prev_enabled),
(ENV_DIR, prev_dir),
(ENV_MAX_BYTES, prev_max),
(ENV_RETENTION_DAYS, prev_ret),
(ENV_HASH_PROMPTS, prev_hash),
] {
if let Some(val) = v {
std::env::set_var(k, val);
} else {
std::env::remove_var(k);
}
}
}
}
#[test]
fn is_off_matches_documented_values() {
for v in ["0", "off", "OFF", "Off", "false", "False", "no", "disabled"] {
assert!(is_off(v), "{v} should be parsed as off");
}
for v in ["1", "on", "true", "yes", "yeah", ""] {
assert!(!is_off(v), "{v} should NOT be parsed as off");
}
}
#[test]
fn is_on_matches_documented_values() {
for v in ["1", "on", "ON", "true", "True", "yes", "enabled"] {
assert!(is_on(v), "{v} should be parsed as on");
}
for v in ["0", "off", "false", "no", ""] {
assert!(!is_on(v), "{v} should NOT be parsed as on");
}
}
#[test]
fn parse_filename_date_parses_canonical_and_rotated() {
let canonical = "enriched-prompts.2026-05-25.jsonl";
let rotated = "enriched-prompts.2026-05-25.3.jsonl";
let canonical_date = parse_log_filename_date(canonical).expect("canonical parses");
let rotated_date = parse_log_filename_date(rotated).expect("rotated parses");
assert_eq!(canonical_date, rotated_date);
assert_eq!(
canonical_date,
NaiveDate::from_ymd_opt(2026, 5, 25).unwrap()
);
for bad in [
"not-our-log.txt",
"enriched-prompts..jsonl",
"enriched-prompts.bogus.jsonl",
"enriched-prompts.2026-13-99.jsonl",
"enriched-prompts.2026-05-25.txt",
"other-prefix.2026-05-25.jsonl",
] {
assert!(
parse_log_filename_date(bad).is_none(),
"should not parse: {bad}"
);
}
}
}