Skip to main content

zero_session/
wrap.rs

1//! Daily wrap generator (Addendum A §9.1).
2//!
3//! When a session ends after more than 2 hours of live use, the
4//! CLI renders a *wrap*: a short, honest summary of what the
5//! operator did this session. The wrap is saved under
6//! `~/.zero/state/wraps/<session_ulid>.json` and printed as a
7//! single advisory line on stderr. The next session does not see
8//! the wrap — it is a closing statement, not a reminder.
9//!
10//! # Honesty contract
11//!
12//! - The wrap is *computed*, never curated. Every number traces
13//!   back to rows in the session store; no rankings, no
14//!   gamification, no "streak" counters (§15's "no cute"
15//!   locks those out).
16//! - The wrap never editorialises. It reports: duration,
17//!   command counts grouped by risk direction, a few top-N
18//!   tallies, and — if present — the number of `warn` /
19//!   `alert` lines the dispatcher emitted. That is it.
20//! - `/wrap-off` suppresses the current session's wrap only.
21//!   The operator cannot permanently disable it (§15).
22//!
23//! # Separation of concerns
24//!
25//! [`generate`] is pure: it takes a [`SessionRow`] + its stored
26//! events and returns a [`WrapReport`]. No disk I/O, no clock.
27//! This lets tests run fast and deterministically.
28//!
29//! [`write_wrap`] is the I/O half: it takes a report + a target
30//! dir and writes `<ulid>.json`, returning the final path. Tests
31//! use `tempfile::TempDir` or a throwaway path under
32//! `std::env::temp_dir()` so production `~/.zero` is never
33//! touched.
34
35use std::path::{Path, PathBuf};
36
37use chrono::{DateTime, Utc};
38use serde::{Deserialize, Serialize};
39
40use crate::event::{EventKind, SessionRow, StoredEvent};
41
42/// Minimum session length before a wrap is generated. The spec
43/// (Addendum A §9.1) says "session exit >2h"; re-declaring the
44/// threshold here lets callers avoid importing a magic number.
45pub const MIN_WRAP_DURATION: chrono::Duration = chrono::Duration::hours(2);
46
47/// The wrap artifact as persisted to disk and printed as a line
48/// in the log.
49///
50/// `#[serde(deny_unknown_fields)]` is intentional on the
51/// **reader** side (not here) — a future wrap schema that drops
52/// a field would be a silent honesty regression if old tooling
53/// kept deserialising the old shape. For now the writer is the
54/// only producer, so no deny-unknown on this struct.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct WrapReport {
57    /// Schema version of this wrap. Bump on shape changes so a
58    /// future reader can refuse an incompatible blob up front.
59    pub schema: u32,
60
61    /// The session's ULID. Matches the artifact filename so
62    /// reading a wraps directory without loading JSON is
63    /// possible.
64    pub session_ulid: String,
65
66    /// Session start — ISO-8601 with zone.
67    pub started_at: DateTime<Utc>,
68
69    /// Session end — the timestamp the wrap is being generated
70    /// at (the event-loop's `drive()` returns and we snapshot
71    /// `Utc::now()`). Not the last-event timestamp because the
72    /// operator may have idled at the prompt for a while after
73    /// the last command and that idle is still session time.
74    pub ended_at: DateTime<Utc>,
75
76    /// Duration in seconds. Redundant with `ended_at -
77    /// started_at` but materialised so a reader does not have
78    /// to do the subtraction and does not have to agree on
79    /// leap-second handling.
80    pub duration_secs: u64,
81
82    /// Total events the store captured this session. Includes
83    /// every line that hit `SessionSink::push`.
84    pub total_events: u64,
85
86    /// Per-kind event counts. Stable insertion order: prompt,
87    /// command, system, warn, alert, mode_change. A future
88    /// kind added to [`EventKind`] surfaces as a zero here
89    /// until the generator is updated (caught by the
90    /// exhaustive-match test).
91    pub event_counts: EventCounts,
92
93    /// The top-N most-invoked slash commands this session.
94    /// Computed from `prompt` events whose text starts with
95    /// `/`. Ordered by descending count, then alphabetically
96    /// on ties for determinism. `N = 10` is the hard cap so
97    /// a session that hammered `/status` does not bury the
98    /// rest.
99    pub top_commands: Vec<CommandCount>,
100}
101
102/// Per-kind event counts. Named fields rather than a HashMap
103/// so the JSON shape is self-documenting and readers do not
104/// have to probe for keys.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
106pub struct EventCounts {
107    pub prompt: u64,
108    pub command: u64,
109    pub system: u64,
110    pub warn: u64,
111    pub alert: u64,
112    pub mode_change: u64,
113}
114
115/// A single entry in [`WrapReport::top_commands`].
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct CommandCount {
118    /// The slash-command, leading slash included. We store
119    /// `/status` not `status` so the JSON reads the same as
120    /// the operator's input; no post-hoc normalisation on the
121    /// reader side.
122    pub name: String,
123
124    /// How many times the operator invoked it this session.
125    pub count: u64,
126}
127
128/// Current schema version. Bump on any shape change — new
129/// optional field is fine, renamed or removed field requires
130/// a bump + a reader-side compat shim.
131const SCHEMA: u32 = 1;
132
133/// Maximum top-N commands surfaced in a wrap.
134const TOP_COMMANDS_CAP: usize = 10;
135
136/// Pure wrap computation. No clock, no disk.
137///
138/// `ended_at` is the caller's snapshot of "when did the
139/// session end?" — in production it is `Utc::now()` at the
140/// moment `app.run()` returns. Keeping it an argument makes
141/// this function deterministic: tests pin a specific end
142/// timestamp and get a reproducible report.
143///
144/// Events outside the session's `[started_at, ended_at]`
145/// window are included as-is — the session sink only writes
146/// events belonging to the current session row, so a stray
147/// out-of-window event would already indicate a store bug,
148/// and silently filtering it here would mask the bug.
149#[must_use]
150pub fn generate(
151    session: &SessionRow,
152    events: &[StoredEvent],
153    ended_at: DateTime<Utc>,
154) -> WrapReport {
155    // `num_seconds()` returns i64; `.max(0)` clamps a
156    // clock-went-backwards edge to zero. `cast_unsigned` is
157    // the clippy-blessed u64 reinterpretation of a known-
158    // non-negative i64 — equivalent to `as u64` but the
159    // intent is documented by the call name.
160    let duration_secs = (ended_at - session.started_at)
161        .num_seconds()
162        .max(0)
163        .cast_unsigned();
164
165    let mut counts = EventCounts::default();
166    for e in events {
167        match e.kind {
168            EventKind::Prompt => counts.prompt += 1,
169            EventKind::Command => counts.command += 1,
170            EventKind::System => counts.system += 1,
171            EventKind::Warn => counts.warn += 1,
172            EventKind::Alert => counts.alert += 1,
173            EventKind::ModeChange => counts.mode_change += 1,
174        }
175    }
176
177    WrapReport {
178        schema: SCHEMA,
179        session_ulid: session.ulid.clone(),
180        started_at: session.started_at,
181        ended_at,
182        duration_secs,
183        // `usize → u64` is a widening on 64-bit platforms and
184        // saturating on 32-bit; either way we would never
185        // overflow a realistic session. `u64::try_from` is
186        // exactly-correct and Clippy-clean.
187        total_events: u64::try_from(events.len()).unwrap_or(u64::MAX),
188        event_counts: counts,
189        top_commands: compute_top_commands(events),
190    }
191}
192
193/// Compute the top-N slash-command tally from prompt events.
194///
195/// We only count events whose text starts with `/` to avoid
196/// pulling free-form conversation into the histogram. The
197/// first whitespace-separated token is the command name; args
198/// are stripped so `/status`, `/status BTC`, and `/status ETH`
199/// collapse into one `/status` row.
200///
201/// Ordering is descending count, then alphabetical on ties,
202/// so an operator running the same set of commands at the
203/// same cadence day after day gets a stable wrap.
204fn compute_top_commands(events: &[StoredEvent]) -> Vec<CommandCount> {
205    use std::collections::HashMap;
206
207    let mut tally: HashMap<String, u64> = HashMap::new();
208    for e in events {
209        if e.kind != EventKind::Prompt {
210            continue;
211        }
212        let trimmed = e.text.trim();
213        if !trimmed.starts_with('/') {
214            continue;
215        }
216        let first_token = trimmed.split_whitespace().next().unwrap_or(trimmed);
217        *tally.entry(first_token.to_string()).or_insert(0) += 1;
218    }
219
220    let mut rows: Vec<CommandCount> = tally
221        .into_iter()
222        .map(|(name, count)| CommandCount { name, count })
223        .collect();
224    rows.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
225    rows.truncate(TOP_COMMANDS_CAP);
226    rows
227}
228
229/// Determine whether this session qualifies for a wrap.
230///
231/// Two conditions, both must hold:
232/// 1. Duration ≥ [`MIN_WRAP_DURATION`] (§9.1 "session exit >2h").
233/// 2. At least one prompt event was captured. A 2-hour idle
234///    session with zero input is not worth a wrap — the
235///    operator never actually operated.
236#[must_use]
237pub fn should_wrap(session: &SessionRow, events: &[StoredEvent], ended_at: DateTime<Utc>) -> bool {
238    let duration = ended_at - session.started_at;
239    if duration < MIN_WRAP_DURATION {
240        return false;
241    }
242    events.iter().any(|e| e.kind == EventKind::Prompt)
243}
244
245/// Persist a wrap report as `<dir>/<session_ulid>.json`,
246/// creating `dir` if needed. Returns the final path.
247///
248/// Atomicity: write-to-temp-then-rename so a crash mid-write
249/// never leaves a half-written wrap. The rename target name
250/// is stable, so two concurrent writes (e.g. a double-wrap
251/// race) are last-writer-wins without corruption.
252///
253/// # Errors
254///
255/// Returns [`crate::SessionError::Io`] for any filesystem
256/// issue (directory create, temp-write, rename) and
257/// [`crate::SessionError::Serde`] if serialisation fails.
258pub fn write_wrap(dir: &Path, report: &WrapReport) -> Result<PathBuf, crate::SessionError> {
259    std::fs::create_dir_all(dir)?;
260    let final_path = dir.join(format!("{}.json", report.session_ulid));
261    let tmp_path = dir.join(format!("{}.json.tmp", report.session_ulid));
262    let json = serde_json::to_vec_pretty(report)?;
263    std::fs::write(&tmp_path, &json)?;
264    std::fs::rename(&tmp_path, &final_path)?;
265    Ok(final_path)
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use chrono::{Duration, TimeZone};
272
273    fn row(started_at: DateTime<Utc>) -> SessionRow {
274        SessionRow {
275            id: 1,
276            ulid: "01HTEST".into(),
277            started_at,
278            ended_at: None,
279            engine_base_url: Some("https://example".into()),
280            cli_version: "0.3.0-test".into(),
281            parent_ulid: None,
282        }
283    }
284
285    fn ev(
286        session_id: i64,
287        seq: i64,
288        at: DateTime<Utc>,
289        kind: EventKind,
290        text: &str,
291    ) -> StoredEvent {
292        StoredEvent {
293            id: seq,
294            session_id,
295            seq,
296            at,
297            kind,
298            text: text.into(),
299        }
300    }
301
302    #[test]
303    fn generate_counts_every_kind_and_computes_duration() {
304        let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
305        let end = start + Duration::hours(3);
306        let r = row(start);
307        let evs = vec![
308            ev(1, 1, start, EventKind::Prompt, "/status"),
309            ev(1, 2, start, EventKind::Command, "engine: OK"),
310            ev(1, 3, start, EventKind::Prompt, "/status BTC"),
311            ev(1, 4, start, EventKind::System, "poller started"),
312            ev(1, 5, start, EventKind::Warn, "slow response"),
313            ev(1, 6, start, EventKind::Alert, "engine unreachable"),
314            ev(1, 7, start, EventKind::ModeChange, "positions"),
315            ev(1, 8, start, EventKind::Prompt, "/risk"),
316        ];
317        let w = generate(&r, &evs, end);
318        assert_eq!(w.schema, SCHEMA);
319        assert_eq!(w.session_ulid, "01HTEST");
320        assert_eq!(w.started_at, start);
321        assert_eq!(w.ended_at, end);
322        assert_eq!(w.duration_secs, 3 * 3600);
323        assert_eq!(w.total_events, 8);
324        assert_eq!(w.event_counts.prompt, 3);
325        assert_eq!(w.event_counts.command, 1);
326        assert_eq!(w.event_counts.system, 1);
327        assert_eq!(w.event_counts.warn, 1);
328        assert_eq!(w.event_counts.alert, 1);
329        assert_eq!(w.event_counts.mode_change, 1);
330    }
331
332    #[test]
333    fn top_commands_strips_args_and_sorts_stably() {
334        let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
335        let r = row(start);
336        let evs = vec![
337            ev(1, 1, start, EventKind::Prompt, "/status"),
338            ev(1, 2, start, EventKind::Prompt, "/status BTC"),
339            ev(1, 3, start, EventKind::Prompt, "/status ETH"),
340            ev(1, 4, start, EventKind::Prompt, "/risk"),
341            ev(1, 5, start, EventKind::Prompt, "/regime"),
342            ev(1, 6, start, EventKind::Prompt, "/regime BTC"),
343            // Non-slash prompt: free-form chat, should be ignored.
344            ev(1, 7, start, EventKind::Prompt, "what is going on"),
345            // Non-prompt row that happens to start with /: should
346            // also be ignored (only `prompt` rows count).
347            ev(1, 8, start, EventKind::System, "/auto-line"),
348        ];
349        let w = generate(&r, &evs, start + Duration::hours(3));
350        let top: Vec<(&str, u64)> = w
351            .top_commands
352            .iter()
353            .map(|c| (c.name.as_str(), c.count))
354            .collect();
355        // /status=3 beats /regime=2 beats /risk=1; tie-break
356        // is alphabetical (no tie among these three).
357        assert_eq!(top, vec![("/status", 3), ("/regime", 2), ("/risk", 1)]);
358    }
359
360    #[test]
361    fn top_commands_tie_breaks_alphabetically() {
362        let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
363        let r = row(start);
364        let evs = vec![
365            ev(1, 1, start, EventKind::Prompt, "/zebra"),
366            ev(1, 2, start, EventKind::Prompt, "/alpha"),
367            ev(1, 3, start, EventKind::Prompt, "/mango"),
368        ];
369        let w = generate(&r, &evs, start + Duration::hours(3));
370        let names: Vec<&str> = w.top_commands.iter().map(|c| c.name.as_str()).collect();
371        assert_eq!(names, vec!["/alpha", "/mango", "/zebra"]);
372    }
373
374    #[test]
375    fn top_commands_caps_at_n() {
376        let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
377        let r = row(start);
378        let mut evs = Vec::new();
379        // TOP_COMMANDS_CAP is a usize constant; widen via
380        // `i64::try_from` to keep Clippy happy and avoid any
381        // platform-sensitive `as` casts.
382        let cap = i64::try_from(TOP_COMMANDS_CAP).expect("cap fits in i64");
383        for i in 0..(cap + 5) {
384            evs.push(ev(
385                1,
386                i + 1,
387                start,
388                EventKind::Prompt,
389                &format!("/cmd{i:02}"),
390            ));
391        }
392        let w = generate(&r, &evs, start + Duration::hours(3));
393        assert_eq!(w.top_commands.len(), TOP_COMMANDS_CAP);
394    }
395
396    #[test]
397    fn should_wrap_respects_duration_floor() {
398        let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
399        let r = row(start);
400        let evs = vec![ev(1, 1, start, EventKind::Prompt, "/status")];
401        assert!(!should_wrap(&r, &evs, start + Duration::minutes(119)));
402        assert!(should_wrap(&r, &evs, start + Duration::hours(2)));
403        assert!(should_wrap(&r, &evs, start + Duration::hours(5)));
404    }
405
406    #[test]
407    fn should_wrap_requires_at_least_one_prompt() {
408        let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
409        let r = row(start);
410        // 3 hours of polling with zero operator input. No wrap.
411        let evs: Vec<_> = (0..10)
412            .map(|i| ev(1, i + 1, start, EventKind::System, "poll"))
413            .collect();
414        assert!(!should_wrap(&r, &evs, start + Duration::hours(3)));
415    }
416
417    #[test]
418    fn should_wrap_handles_clock_going_backwards() {
419        // Defensive: if `ended_at` is before `started_at`
420        // (wall-clock NTP adjustment mid-session), the
421        // duration is negative; `should_wrap` must return
422        // false rather than panicking on a Duration arithmetic
423        // edge case.
424        let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
425        let r = row(start);
426        let evs = vec![ev(1, 1, start, EventKind::Prompt, "/status")];
427        assert!(!should_wrap(&r, &evs, start - Duration::minutes(5)));
428    }
429
430    #[test]
431    fn write_wrap_round_trips_through_disk() {
432        use std::fs;
433        let start = Utc.with_ymd_and_hms(2026, 4, 21, 10, 0, 0).unwrap();
434        let r = row(start);
435        let evs = vec![ev(1, 1, start, EventKind::Prompt, "/status")];
436        let report = generate(&r, &evs, start + Duration::hours(3));
437
438        let dir = std::env::temp_dir().join(format!("zero-wrap-test-{}", report.session_ulid));
439        let _ = fs::remove_dir_all(&dir);
440        let path = write_wrap(&dir, &report).expect("write");
441        assert!(path.ends_with("01HTEST.json"));
442        let bytes = fs::read(&path).expect("read");
443        let back: WrapReport = serde_json::from_slice(&bytes).expect("parse");
444        assert_eq!(back, report);
445        let _ = fs::remove_dir_all(&dir);
446    }
447}