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}