Skip to main content

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/// What shaping did to the recall query before it was embedded (#4972).
102///
103/// Why: the query used to go to the embedder whole and come back cut at the
104/// 512-token window with "no warning, no metric, and no signal to the caller"
105/// — the defect as filed. This struct is the metric. It rides the enriched-
106/// prompt log line, which is the same corpus the 52%-over-window rate was
107/// measured from, so the rate after the fix is two `jq` filters away:
108/// `select(.recall_query.units_dropped > 0)` for queries this module reduced,
109/// and `select(.recall_query.sent_tokens_max > .recall_query.budget_tokens)` for
110/// sends whose fit inside the window could not be proven.
111/// What: token estimates before and after shaping, the ceiling on what was
112/// sent, the budget in force, and what was removed. Absent from the JSON when
113/// no palace was resolved and no recall was attempted.
114/// Test: `single_event_roundtrip` covers serialisation;
115/// `prompt_context::tests::over_window_query_is_reduced_to_whole_units` covers
116/// the values.
117#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
118pub struct RecallQueryShape {
119    /// Estimated tokens in the raw prompt, before any shaping.
120    pub original_tokens: usize,
121    /// Estimated tokens actually sent to `/recall`.
122    pub sent_tokens: usize,
123    /// Upper bound on the true tokens sent (#4972).
124    ///
125    /// Why: `sent_tokens` charges ASCII-letter runs by a calibrated divisor,
126    /// which no divisor above 1 token per character can make a bound. Without
127    /// this field a shape reporting `sent_tokens: 490, units_dropped: 0` asserts
128    /// a clean pass on a query the embedder may have cut — the metric reading
129    /// healthy while the loss happens.
130    /// What: `prompt_context::query::max_tokens` of the sent text.
131    /// `#[serde(default)]` so log lines written before this field parse back.
132    #[serde(default)]
133    pub sent_tokens_max: usize,
134    /// Token budget in force for this firing.
135    pub budget_tokens: usize,
136    /// Whether a task-notification envelope was reduced to its payload.
137    pub envelope_stripped: bool,
138    /// Whole units (lines, or words) dropped to fit the budget. On the
139    /// last-resort character path the unit is the character.
140    pub units_dropped: usize,
141}
142
143impl RecallQueryShape {
144    /// True when shaping changed the query the embedder saw.
145    ///
146    /// Why: the pass-through case is the common one and must stay quiet — a
147    /// warn on every firing is a warn nobody reads.
148    /// What: `envelope_stripped || units_dropped > 0`.
149    /// Test: `prompt_context::tests::short_query_passes_through_untouched`.
150    pub fn reshaped(&self) -> bool {
151        self.envelope_stripped || self.units_dropped > 0
152    }
153
154    /// True when the sent query is *not* provably inside the embedder window.
155    ///
156    /// Why (#4972, round-3 review): `sent_tokens <= budget_tokens` is an
157    /// estimate clearing a budget, not a proof, and treating it as one is what
158    /// let a reshaped query still overrun the window while the log reported the
159    /// reduction as a success. This is the honest complement — false means the
160    /// send fits, full stop; true means it may have been cut and the shape
161    /// declines to claim otherwise.
162    /// What: `sent_tokens_max > budget_tokens`.
163    /// Test: `prompt_context::tests::shape_flags_a_send_it_cannot_prove_fits`.
164    pub fn may_exceed_window(&self) -> bool {
165        self.sent_tokens_max > self.budget_tokens
166    }
167}
168
169/// One enriched-prompt log entry — written as a single JSONL line.
170///
171/// Why: the consumer is a human running `jq` over a day's worth of injections
172/// to grade signal-vs-noise. Stable field names, RFC-3339 timestamps, and
173/// numeric byte/duration counts keep the analysis script trivial.
174/// What: tagged by `injection_kind`. `palace_facts_count` is filled for
175/// `prompt-context-facts`; `unread_messages_count` for `inbox-check-messages`.
176/// Both default to `None` so the JSON shape stays compact for entries that
177/// only have one of the two.
178/// Test: `single_event_roundtrip` writes one entry and parses it back.
179#[derive(Clone, Debug, Serialize, Deserialize)]
180pub struct PromptLogEntry {
181    /// RFC-3339 UTC timestamp set at the moment the entry is built.
182    pub timestamp: DateTime<Utc>,
183    /// `"UserPromptSubmit"` or `"SessionStart"`.
184    pub hook_type: String,
185    /// `"prompt-context-facts"` or `"inbox-check-messages"`.
186    pub injection_kind: String,
187    /// Palace id the injection was scoped to.
188    pub palace: String,
189    /// Hook stdin verbatim; replaced with `"sha256:<hex>"` when
190    /// `hash_prompts = true` in the active config.
191    pub trigger_prompt: String,
192    /// Hook stdout (the actual injection sent to Claude Code) verbatim.
193    pub injection: String,
194    /// Byte length of `injection`.
195    pub injection_length: usize,
196    /// Number of facts in the prompt-context injection, when applicable.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub palace_facts_count: Option<usize>,
199    /// Number of unread messages in the inbox-check injection, when applicable.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub unread_messages_count: Option<usize>,
202    /// How the recall query was shaped before embedding (#4972). `None` when no
203    /// palace resolved, so no recall was attempted.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub recall_query: Option<RecallQueryShape>,
206    /// Wall-clock duration of the invocation, in milliseconds.
207    pub duration_ms: u64,
208}
209
210impl PromptLogEntry {
211    /// Construct a new entry stamped with the current UTC time.
212    ///
213    /// Why: the hook caller has the raw fields handy but should not carry
214    /// chrono in its imports. This helper builds an entry with `timestamp`
215    /// auto-populated and zero-initialised optional counts.
216    /// What: sets `timestamp = Utc::now()` and copies the supplied fields.
217    /// Test: `single_event_roundtrip`.
218    pub fn new(
219        hook_type: impl Into<String>,
220        injection_kind: impl Into<String>,
221        palace: impl Into<String>,
222        trigger_prompt: impl Into<String>,
223        injection: impl Into<String>,
224    ) -> Self {
225        let injection = injection.into();
226        let injection_length = injection.len();
227        Self {
228            timestamp: Utc::now(),
229            hook_type: hook_type.into(),
230            injection_kind: injection_kind.into(),
231            palace: palace.into(),
232            trigger_prompt: trigger_prompt.into(),
233            injection,
234            injection_length,
235            palace_facts_count: None,
236            unread_messages_count: None,
237            recall_query: None,
238            duration_ms: 0,
239        }
240    }
241
242    /// Builder: attach how the recall query was shaped (prompt-context only).
243    ///
244    /// Why (#4972): the shaping is only observable if it reaches the log.
245    /// What: sets `recall_query`; `None` leaves the field off the JSON line.
246    /// Test: `prompt_context::tests::over_window_query_is_reduced_to_whole_units`.
247    #[must_use]
248    pub fn with_recall_query(mut self, shape: Option<RecallQueryShape>) -> Self {
249        self.recall_query = shape;
250        self
251    }
252
253    /// Builder: set the duration this hook invocation took.
254    #[must_use]
255    pub fn with_duration_ms(mut self, ms: u64) -> Self {
256        self.duration_ms = ms;
257        self
258    }
259
260    /// Builder: attach the palace-facts count (prompt-context only).
261    #[must_use]
262    pub fn with_palace_facts_count(mut self, n: usize) -> Self {
263        self.palace_facts_count = Some(n);
264        self
265    }
266
267    /// Builder: attach the unread-messages count (inbox-check only).
268    #[must_use]
269    pub fn with_unread_messages_count(mut self, n: usize) -> Self {
270        self.unread_messages_count = Some(n);
271        self
272    }
273}
274
275/// True when the value looks like an explicit off switch.
276pub(super) fn is_off(v: &str) -> bool {
277    matches!(
278        v.trim().to_ascii_lowercase().as_str(),
279        "0" | "off" | "false" | "no" | "disabled"
280    )
281}
282
283/// True when the value looks like an explicit on switch.
284pub(super) fn is_on(v: &str) -> bool {
285    matches!(
286        v.trim().to_ascii_lowercase().as_str(),
287        "1" | "on" | "true" | "yes" | "enabled"
288    )
289}