trusty_memory/prompt_log/config.rs
1//! Configuration and types for the enriched-prompt logger.
2//!
3//! Why: Separating the configuration + data types from the writer logic keeps
4//! each file under the 500-SLOC cap and allows tests to construct configs
5//! directly without importing the writer.
6//! What: `PromptLogConfig`, `PromptLogEntry`, environment variable constants,
7//! and the default-value constants.
8//! Test: `config_from_env_defaults`, `config_from_env_disabled`, and the
9//! round-trip / format tests in `writer`.
10
11use std::path::{Path, PathBuf};
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15
16/// Env var: master switch (`off`/`0`/`false`/`no` → disabled).
17pub const ENV_ENABLED: &str = "TRUSTY_MEMORY_PROMPT_LOG";
18/// Env var: directory override (defaults to `<data_root>/logs`).
19pub const ENV_DIR: &str = "TRUSTY_MEMORY_PROMPT_LOG_DIR";
20/// Env var: per-file size cap in bytes (default `DEFAULT_MAX_BYTES`).
21pub const ENV_MAX_BYTES: &str = "TRUSTY_MEMORY_PROMPT_LOG_MAX_BYTES";
22/// Env var: retention window in days (default `DEFAULT_RETENTION_DAYS`).
23pub const ENV_RETENTION_DAYS: &str = "TRUSTY_MEMORY_PROMPT_LOG_RETENTION_DAYS";
24/// Env var: SHA-256-hash `trigger_prompt` when truthy.
25pub const ENV_HASH_PROMPTS: &str = "TRUSTY_MEMORY_PROMPT_LOG_HASH_PROMPTS";
26
27/// Default per-file size cap (50 MiB).
28pub const DEFAULT_MAX_BYTES: u64 = 50 * 1024 * 1024;
29/// Default retention window in days.
30pub const DEFAULT_RETENTION_DAYS: u32 = 30;
31
32/// Configuration for [`crate::prompt_log::PromptLogger`].
33///
34/// Why: keeps env-parsing out of the hot path and allows tests to construct
35/// loggers directly without mutating process-wide env state. The struct is
36/// `Clone` so a logger can be cheaply re-derived per invocation.
37/// What: holds the resolved log directory, size cap, retention window, and
38/// privacy toggles. `enabled = false` short-circuits every write.
39/// Test: covered by `config_from_env_disabled` and the integration tests.
40#[derive(Clone, Debug)]
41pub struct PromptLogConfig {
42 /// Master enable switch. `false` → every method is a no-op.
43 pub enabled: bool,
44 /// Directory holding the rolling log files (created lazily on first write).
45 pub dir: PathBuf,
46 /// Per-file size cap; the writer rolls to a new numeric suffix when the
47 /// active file would exceed this size.
48 pub max_bytes: u64,
49 /// Retention window in days. Files older than this are pruned on the
50 /// first write of each day.
51 pub retention_days: u32,
52 /// Replace `trigger_prompt` field bodies with `sha256:<hex>` when true.
53 pub hash_prompts: bool,
54}
55
56impl PromptLogConfig {
57 /// Build a config rooted at the supplied `data_root` and overlayed with
58 /// env vars.
59 ///
60 /// Why: `prompt-context` and `inbox-check` both resolve their data root
61 /// via [`trusty_common::resolve_data_dir`] but only that caller knows the
62 /// app name. Accepting an explicit root lets the logger reuse the same
63 /// resolution without parsing dirs::data_dir twice.
64 /// What: defaults `dir = data_root/logs`; overrides via `TRUSTY_MEMORY_*`
65 /// envs. `enabled` defaults to `true`; flips to `false` when
66 /// `TRUSTY_MEMORY_PROMPT_LOG` is set to an off-value.
67 /// Test: `config_from_env_defaults`, `config_from_env_disabled`,
68 /// `config_from_env_overrides_dir`.
69 pub fn from_env_with_root(data_root: &Path) -> Self {
70 let enabled = match std::env::var(ENV_ENABLED) {
71 Ok(v) => !is_off(&v),
72 Err(_) => true,
73 };
74 let dir = match std::env::var(ENV_DIR) {
75 Ok(d) if !d.trim().is_empty() => PathBuf::from(d),
76 _ => data_root.join("logs"),
77 };
78 let max_bytes = std::env::var(ENV_MAX_BYTES)
79 .ok()
80 .and_then(|s| s.trim().parse::<u64>().ok())
81 .filter(|n| *n > 0)
82 .unwrap_or(DEFAULT_MAX_BYTES);
83 let retention_days = std::env::var(ENV_RETENTION_DAYS)
84 .ok()
85 .and_then(|s| s.trim().parse::<u32>().ok())
86 .filter(|n| *n > 0)
87 .unwrap_or(DEFAULT_RETENTION_DAYS);
88 let hash_prompts = std::env::var(ENV_HASH_PROMPTS)
89 .map(|v| is_on(&v))
90 .unwrap_or(false);
91 Self {
92 enabled,
93 dir,
94 max_bytes,
95 retention_days,
96 hash_prompts,
97 }
98 }
99}
100
101/// One enriched-prompt log entry — written as a single JSONL line.
102///
103/// Why: the consumer is a human running `jq` over a day's worth of injections
104/// to grade signal-vs-noise. Stable field names, RFC-3339 timestamps, and
105/// numeric byte/duration counts keep the analysis script trivial.
106/// What: tagged by `injection_kind`. `palace_facts_count` is filled for
107/// `prompt-context-facts`; `unread_messages_count` for `inbox-check-messages`.
108/// Both default to `None` so the JSON shape stays compact for entries that
109/// only have one of the two.
110/// Test: `single_event_roundtrip` writes one entry and parses it back.
111#[derive(Clone, Debug, Serialize, Deserialize)]
112pub struct PromptLogEntry {
113 /// RFC-3339 UTC timestamp set at the moment the entry is built.
114 pub timestamp: DateTime<Utc>,
115 /// `"UserPromptSubmit"` or `"SessionStart"`.
116 pub hook_type: String,
117 /// `"prompt-context-facts"` or `"inbox-check-messages"`.
118 pub injection_kind: String,
119 /// Palace id the injection was scoped to.
120 pub palace: String,
121 /// Hook stdin verbatim; replaced with `"sha256:<hex>"` when
122 /// `hash_prompts = true` in the active config.
123 pub trigger_prompt: String,
124 /// Hook stdout (the actual injection sent to Claude Code) verbatim.
125 pub injection: String,
126 /// Byte length of `injection`.
127 pub injection_length: usize,
128 /// Number of facts in the prompt-context injection, when applicable.
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub palace_facts_count: Option<usize>,
131 /// Number of unread messages in the inbox-check injection, when applicable.
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub unread_messages_count: Option<usize>,
134 /// Wall-clock duration of the invocation, in milliseconds.
135 pub duration_ms: u64,
136}
137
138impl PromptLogEntry {
139 /// Construct a new entry stamped with the current UTC time.
140 ///
141 /// Why: the hook caller has the raw fields handy but should not carry
142 /// chrono in its imports. This helper builds an entry with `timestamp`
143 /// auto-populated and zero-initialised optional counts.
144 /// What: sets `timestamp = Utc::now()` and copies the supplied fields.
145 /// Test: `single_event_roundtrip`.
146 pub fn new(
147 hook_type: impl Into<String>,
148 injection_kind: impl Into<String>,
149 palace: impl Into<String>,
150 trigger_prompt: impl Into<String>,
151 injection: impl Into<String>,
152 ) -> Self {
153 let injection = injection.into();
154 let injection_length = injection.len();
155 Self {
156 timestamp: Utc::now(),
157 hook_type: hook_type.into(),
158 injection_kind: injection_kind.into(),
159 palace: palace.into(),
160 trigger_prompt: trigger_prompt.into(),
161 injection,
162 injection_length,
163 palace_facts_count: None,
164 unread_messages_count: None,
165 duration_ms: 0,
166 }
167 }
168
169 /// Builder: set the duration this hook invocation took.
170 #[must_use]
171 pub fn with_duration_ms(mut self, ms: u64) -> Self {
172 self.duration_ms = ms;
173 self
174 }
175
176 /// Builder: attach the palace-facts count (prompt-context only).
177 #[must_use]
178 pub fn with_palace_facts_count(mut self, n: usize) -> Self {
179 self.palace_facts_count = Some(n);
180 self
181 }
182
183 /// Builder: attach the unread-messages count (inbox-check only).
184 #[must_use]
185 pub fn with_unread_messages_count(mut self, n: usize) -> Self {
186 self.unread_messages_count = Some(n);
187 self
188 }
189}
190
191/// True when the value looks like an explicit off switch.
192pub(super) fn is_off(v: &str) -> bool {
193 matches!(
194 v.trim().to_ascii_lowercase().as_str(),
195 "0" | "off" | "false" | "no" | "disabled"
196 )
197}
198
199/// True when the value looks like an explicit on switch.
200pub(super) fn is_on(v: &str) -> bool {
201 matches!(
202 v.trim().to_ascii_lowercase().as_str(),
203 "1" | "on" | "true" | "yes" | "enabled"
204 )
205}