Skip to main content

khive_runtime/
presentation.rs

1//! Verb response presentation modes and transformation.
2//!
3//! Transforms canonical handler output into caller-appropriate form after dispatch
4//! and before wire serialization. `Agent` mode abbreviates UUIDs/timestamps and drops
5//! empty fields; `Verbose` and `Human` pass through canonical JSON unchanged.
6
7use std::collections::HashSet;
8
9use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value};
11
12/// Convert a microsecond epoch `i64` to an RFC 3339 / ISO-8601 string.
13///
14/// Entity and Note storage uses `i64` microseconds internally; this is the
15/// single conversion point before any field reaches the MCP boundary.
16///
17/// Format: `YYYY-MM-DDTHH:MM:SS.ffffffZ` (SecondsFormat::Micros, UTC `Z`).
18pub fn micros_to_iso(micros: i64) -> String {
19    chrono::DateTime::<chrono::Utc>::from_timestamp_micros(micros)
20        .unwrap_or_else(chrono::Utc::now)
21        .to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
22}
23
24/// How the response envelope is presented to the caller.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
26#[serde(rename_all = "snake_case")]
27pub enum PresentationMode {
28    /// Token-efficient. Default for MCP callers (agents).
29    ///
30    /// Short UUIDs (8-char), compact timestamps (minute granularity or
31    /// relative), empty fields dropped, lifecycle nulls preserved, score
32    /// fields truncated to 3 significant figures.
33    #[default]
34    Agent,
35    /// Full canonical shape. Default for `kkernel call` and CI/scripted callers.
36    ///
37    /// No transformation — handler output passes through as-is.
38    Verbose,
39    /// Pretty-printed terminal output. Default for `khive` CLI.
40    ///
41    /// **At the MCP runtime level this is identical to `Verbose`** — the
42    /// canonical JSON is returned unchanged. Terminal formatting (relative
43    /// timestamps, glyph substitution, table layout) is applied by the CLI
44    /// layer (`khive-cli::format::pretty`), not the MCP response pipeline.
45    Human,
46}
47
48/// Lifecycle `null` fields that are PRESERVED in Agent mode even when null.
49///
50/// These fields carry lifecycle meaning (absent ≠ null) and must not be dropped.
51const LIFECYCLE_NULL_PRESERVE: &[&str] = &[
52    "completed_at",
53    "deleted_at",
54    "due_at",
55    "read_at",
56    "started_at",
57    "superseded_at",
58    "applied_at",
59    "withdrawn_at",
60    "reviewed_at",
61    "parent_id",
62    "superseded_by",
63    "replaced_by",
64];
65
66/// Score field names that are truncated to 3 significant figures in Agent mode.
67const SCORE_FIELDS: &[&str] = &[
68    "score",
69    "salience",
70    "decay_factor",
71    "rrf_score",
72    "similarity",
73    "cross_encoder_score",
74    "graph_proximity_score",
75];
76
77/// UUID v4 canonical string length (8-4-4-4-12 = 32 hex + 4 dashes = 36).
78const UUID_CANONICAL_LEN: usize = 36;
79
80/// Return true for fields whose whole-string UUID values may be shortened in
81/// Agent mode. Content-like fields are intentionally excluded even when their
82/// value happens to be UUID-shaped.
83///
84/// `full_id` is explicitly excluded (P-C1): its purpose is to give callers a
85/// stable chaining handle, so shortening it makes it identical to `id` and
86/// defeats the field entirely.
87fn should_shorten_uuid_field(key: &str) -> bool {
88    if key == "full_id" {
89        return false;
90    }
91    key == "id" || key.ends_with("_id") || matches!(key, "superseded_by" | "replaced_by")
92}
93
94/// Transform a successful verb result value according to the given
95/// [`PresentationMode`].
96///
97/// - `Verbose` / `Human`: returns `value` unchanged.
98/// - `Agent`: applies UUID shortening, timestamp compaction, empty-field
99///   dropping, lifecycle-null preservation, and score truncation.
100///
101/// `now_unix_seconds` is sampled once per response and passed through so all
102/// relative datetime renderings within a response use the same instant.
103pub fn present(value: Value, mode: PresentationMode, now_unix_seconds: i64) -> Value {
104    match mode {
105        PresentationMode::Verbose | PresentationMode::Human => value,
106        PresentationMode::Agent => {
107            let lifecycle_preserve: HashSet<&str> =
108                LIFECYCLE_NULL_PRESERVE.iter().copied().collect();
109            let score_fields: HashSet<&str> = SCORE_FIELDS.iter().copied().collect();
110            transform_agent(
111                value,
112                &lifecycle_preserve,
113                &score_fields,
114                now_unix_seconds,
115                false,
116            )
117        }
118    }
119}
120
121/// Apply the Agent-mode transform to an arbitrary JSON value.
122///
123/// `inside_properties` is `true` when recursing inside a `"properties"` object.
124/// Caller-supplied payload timestamps (e.g. `trigger_at`) must not be compacted
125/// because they encode domain semantics the agent may need to round-trip (#546).
126fn transform_agent(
127    value: Value,
128    lifecycle: &HashSet<&str>,
129    scores: &HashSet<&str>,
130    now: i64,
131    inside_properties: bool,
132) -> Value {
133    match value {
134        Value::Object(map) => {
135            let mut out = Map::new();
136            for (k, v) in map {
137                let child_inside_properties = inside_properties || k == "properties";
138                let transformed =
139                    transform_field_agent(&k, v, lifecycle, scores, now, child_inside_properties);
140                match transformed {
141                    None => {} // drop
142                    Some(tv) => {
143                        out.insert(k, tv);
144                    }
145                }
146            }
147            Value::Object(out)
148        }
149        Value::Array(arr) => {
150            let items: Vec<Value> = arr
151                .into_iter()
152                .map(|v| transform_agent(v, lifecycle, scores, now, inside_properties))
153                .collect();
154            Value::Array(items)
155        }
156        other => other,
157    }
158}
159
160/// Transform a single named field value under Agent mode.
161///
162/// Returns `None` if the field should be dropped.
163///
164/// `inside_properties` suppresses timestamp compaction for caller-submitted
165/// payload values (e.g. `trigger_at` stored under `"properties"`). Metadata
166/// timestamps at the top level (`created_at`, `updated_at`) are still compacted.
167fn transform_field_agent(
168    key: &str,
169    value: Value,
170    lifecycle: &HashSet<&str>,
171    scores: &HashSet<&str>,
172    now: i64,
173    inside_properties: bool,
174) -> Option<Value> {
175    match &value {
176        // Preserve lifecycle nulls; drop other nulls.
177        Value::Null => {
178            if lifecycle.contains(key) {
179                Some(value)
180            } else {
181                None
182            }
183        }
184        // Drop empty strings, arrays, objects.
185        Value::String(s) if s.is_empty() => None,
186        Value::Array(a) if a.is_empty() => None,
187        Value::Object(o) if o.is_empty() => None,
188        // Truncate score fields.
189        Value::Number(_) if scores.contains(key) => {
190            if let Some(f) = value.as_f64() {
191                Some(truncate_to_3_sig_figs(f))
192            } else {
193                Some(value)
194            }
195        }
196        // Shorten UUIDs only in fields whose names carry ID semantics.
197        Value::String(s) if is_canonical_uuid(s) && should_shorten_uuid_field(key) => {
198            Some(Value::String(s[..8].to_string()))
199        }
200        // Compact ISO-8601 timestamps only outside caller-supplied payload objects.
201        Value::String(s) if !inside_properties && looks_like_iso8601(s) => {
202            Some(Value::String(compact_timestamp(s, now)))
203        }
204        // Recurse into objects and arrays.
205        Value::Object(_) | Value::Array(_) => Some(transform_agent(
206            value,
207            lifecycle,
208            scores,
209            now,
210            inside_properties,
211        )),
212        // Everything else passes through.
213        _ => Some(value),
214    }
215}
216
217/// Returns `true` if `s` looks like a canonical UUID (36 chars, standard form).
218fn is_canonical_uuid(s: &str) -> bool {
219    if s.len() != UUID_CANONICAL_LEN {
220        return false;
221    }
222    let b = s.as_bytes();
223    // Pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
224    b[8] == b'-'
225        && b[13] == b'-'
226        && b[18] == b'-'
227        && b[23] == b'-'
228        && b[..8].iter().all(|c| c.is_ascii_hexdigit())
229        && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
230        && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
231        && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
232        && b[24..].iter().all(|c| c.is_ascii_hexdigit())
233}
234
235/// Returns `true` if `s` looks like an ISO-8601 datetime string.
236///
237/// Heuristic: starts with `YYYY-MM-DDTHH:` (16 chars, proper digit positions).
238fn looks_like_iso8601(s: &str) -> bool {
239    if s.len() < 16 {
240        return false;
241    }
242    let b = s.as_bytes();
243    b[4] == b'-'
244        && b[7] == b'-'
245        && b[10] == b'T'
246        && b[13] == b':'
247        && b[..4].iter().all(|c| c.is_ascii_digit())
248        && b[5..7].iter().all(|c| c.is_ascii_digit())
249        && b[8..10].iter().all(|c| c.is_ascii_digit())
250        && b[11..13].iter().all(|c| c.is_ascii_digit())
251}
252
253/// Compact an ISO-8601 timestamp for Agent mode.
254///
255/// - Within the last 24 hours: relative form (e.g. `"3m ago"`, `"2h ago"`).
256/// - Older: minute-granularity absolute form `"YYYY-MM-DDTHH:MM"`.
257fn compact_timestamp(s: &str, now: i64) -> String {
258    // Parse Unix seconds from the timestamp if possible; fall back to truncation.
259    if let Some(unix) = parse_iso8601_unix(s) {
260        let diff = now - unix;
261        if (0..86400).contains(&diff) {
262            return relative_time(diff);
263        }
264    }
265    // Minute granularity: take the first 16 chars.
266    s.chars().take(16).collect()
267}
268
269/// Attempt to parse an ISO-8601 datetime string to Unix seconds.
270///
271/// Only handles the subset produced by khive handlers:
272/// `YYYY-MM-DDTHH:MM:SS[.frac][Z]`. Returns `None` for anything we can't parse
273/// (graceful degradation — the timestamp is still compacted by truncation).
274fn parse_iso8601_unix(s: &str) -> Option<i64> {
275    // Minimum parseable: "YYYY-MM-DDTHH:MM:SS"
276    if s.len() < 19 {
277        return None;
278    }
279    let b = s.as_bytes();
280    let year: i64 = parse_digits(&b[0..4])?;
281    let month: i64 = parse_digits(&b[5..7])?;
282    let day: i64 = parse_digits(&b[8..10])?;
283    let hour: i64 = parse_digits(&b[11..13])?;
284    let minute: i64 = parse_digits(&b[14..16])?;
285    let second: i64 = parse_digits(&b[17..19])?;
286
287    // Simple Gregorian → Unix seconds (no timezone offsets other than 'Z').
288    // Close enough for relative-time comparisons; not for calendar correctness.
289    let days_since_epoch = days_from_civil(year, month, day);
290    Some(days_since_epoch * 86400 + hour * 3600 + minute * 60 + second)
291}
292
293fn parse_digits(b: &[u8]) -> Option<i64> {
294    let s = std::str::from_utf8(b).ok()?;
295    s.parse().ok()
296}
297
298/// Gregorian date → days since 1970-01-01. Algorithm: Howard Hinnant's civil.
299fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
300    let y = if m <= 2 { y - 1 } else { y };
301    let era = y.div_euclid(400);
302    let yoe = y - era * 400;
303    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
304    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
305    era * 146097 + doe - 719468
306}
307
308/// Format a duration in seconds as a relative time string (e.g. `"3m ago"`).
309fn relative_time(diff_secs: i64) -> String {
310    if diff_secs < 60 {
311        format!("{diff_secs}s ago")
312    } else if diff_secs < 3600 {
313        format!("{}m ago", diff_secs / 60)
314    } else {
315        format!("{}h ago", diff_secs / 3600)
316    }
317}
318
319/// Truncate a float to 3 significant figures, returning a `serde_json::Value`.
320fn truncate_to_3_sig_figs(f: f64) -> Value {
321    if f == 0.0 || !f.is_finite() {
322        return Value::from(f);
323    }
324    let magnitude = f.abs().log10().floor() as i32;
325    let factor = 10f64.powi(2 - magnitude);
326    let rounded = (f * factor).round() / factor;
327    // Re-serialize through serde_json to avoid floating-point noise.
328    serde_json::Number::from_f64(rounded)
329        .map(Value::Number)
330        .unwrap_or(Value::from(rounded))
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use serde_json::json;
337
338    /// A fixed "now" for deterministic tests: 2026-05-23T16:18:00Z ≈ 1748016480.
339    const NOW: i64 = 1_748_016_480;
340
341    fn agent(v: Value) -> Value {
342        present(v, PresentationMode::Agent, NOW)
343    }
344
345    #[test]
346    fn verbose_passthrough() {
347        let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "X"});
348        let out = present(v.clone(), PresentationMode::Verbose, NOW);
349        assert_eq!(out, v);
350    }
351
352    #[test]
353    fn agent_shortens_uuid() {
354        let v = json!({"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"});
355        let out = agent(v);
356        assert_eq!(out["id"], json!("a1b2c3d4"));
357    }
358
359    #[test]
360    fn agent_drops_empty_string() {
361        let v = json!({"title": "ok", "description": ""});
362        let out = agent(v);
363        assert!(out.get("description").is_none());
364        assert_eq!(out["title"], json!("ok"));
365    }
366
367    #[test]
368    fn agent_drops_empty_array() {
369        let v = json!({"tags": [], "title": "ok"});
370        let out = agent(v);
371        assert!(out.get("tags").is_none());
372    }
373
374    #[test]
375    fn agent_drops_empty_object() {
376        let v = json!({"properties": {}, "title": "ok"});
377        let out = agent(v);
378        assert!(out.get("properties").is_none());
379    }
380
381    #[test]
382    fn agent_drops_non_lifecycle_null() {
383        let v = json!({"result": null, "title": "ok"});
384        let out = agent(v);
385        assert!(out.get("result").is_none());
386    }
387
388    #[test]
389    fn agent_preserves_lifecycle_null() {
390        let v = json!({"completed_at": null, "due_at": null, "title": "ok"});
391        let out = agent(v);
392        assert_eq!(out["completed_at"], json!(null));
393        assert_eq!(out["due_at"], json!(null));
394    }
395
396    #[test]
397    fn agent_preserves_relationship_null() {
398        let v = json!({"parent_id": null, "superseded_by": null});
399        let out = agent(v);
400        assert_eq!(out["parent_id"], json!(null));
401        assert_eq!(out["superseded_by"], json!(null));
402    }
403
404    #[test]
405    fn agent_truncates_score_field() {
406        let v = json!({"score": 0.12345678});
407        let out = agent(v);
408        let s = out["score"].as_f64().unwrap();
409        assert!((s - 0.123).abs() < 1e-9, "expected ~0.123, got {s}");
410    }
411
412    #[test]
413    fn agent_compacts_old_timestamp_to_minutes() {
414        // Far past — not within 24h of NOW. Should be truncated to 16 chars.
415        let v = json!({"created_at": "2020-01-01T10:30:45.123456Z"});
416        let out = agent(v);
417        assert_eq!(out["created_at"], json!("2020-01-01T10:30"));
418    }
419
420    #[test]
421    fn agent_compacts_recent_timestamp_to_relative() {
422        // 3 minutes before NOW: diff = 180s.
423        let ts_unix = NOW - 180;
424        // Format as ISO-8601.
425        let ts = unix_to_iso8601(ts_unix);
426        let v = json!({"updated_at": ts});
427        let out = agent(v);
428        assert_eq!(out["updated_at"], json!("3m ago"));
429    }
430
431    #[test]
432    fn agent_recurses_into_nested_objects() {
433        let v = json!({
434            "items": [
435                {
436                    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
437                    "tags": [],
438                    "score": 0.9999
439                }
440            ]
441        });
442        let out = agent(v);
443        let item = &out["items"][0];
444        assert_eq!(item["id"], json!("a1b2c3d4"));
445        assert!(item.get("tags").is_none());
446        let s = item["score"].as_f64().unwrap();
447        assert!((s - 1.0).abs() < 1e-9);
448    }
449
450    // P-C1 regression: full_id must never be shortened in Agent mode.
451    #[test]
452    fn agent_preserves_full_id_as_36_chars() {
453        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
454        let v = json!({"id": uuid, "full_id": uuid, "title": "X"});
455        let out = agent(v);
456        // `id` is shortened to 8 chars
457        assert_eq!(
458            out["id"],
459            json!("a1b2c3d4"),
460            "id should be 8-char short form"
461        );
462        // `full_id` must remain the full 36-char UUID
463        assert_eq!(
464            out["full_id"].as_str().unwrap().len(),
465            36,
466            "full_id must be 36 chars in agent mode"
467        );
468        assert_eq!(
469            out["full_id"],
470            json!(uuid),
471            "full_id must equal the original UUID"
472        );
473        // Verify the invariant: full_id starts with the short id prefix
474        assert!(
475            out["full_id"]
476                .as_str()
477                .unwrap()
478                .starts_with(out["id"].as_str().unwrap()),
479            "full_id must start with the short id prefix"
480        );
481    }
482
483    #[test]
484    fn is_canonical_uuid_recognizes_valid() {
485        assert!(is_canonical_uuid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
486        assert!(!is_canonical_uuid("a1b2c3d4"));
487        assert!(!is_canonical_uuid("not-a-uuid-at-all-here---------"));
488    }
489
490    #[test]
491    fn looks_like_iso8601_recognizes_valid() {
492        assert!(looks_like_iso8601("2026-05-23T16:18:15.234567Z"));
493        assert!(!looks_like_iso8601("not a timestamp"));
494        assert!(!looks_like_iso8601("2026-05-23"));
495    }
496
497    /// Format Unix seconds as ISO-8601 for test construction.
498    fn unix_to_iso8601(unix: i64) -> String {
499        let (y, mo, d, h, mi, s) = unix_to_civil(unix);
500        format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
501    }
502
503    fn unix_to_civil(unix: i64) -> (i64, i64, i64, i64, i64, i64) {
504        let s = unix % 86400;
505        let days = unix / 86400;
506        let h = s / 3600;
507        let m = (s % 3600) / 60;
508        let sec = s % 60;
509        // Howard Hinnant civil_from_days
510        let z = days + 719468;
511        let era = z.div_euclid(146097);
512        let doe = z - era * 146097;
513        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
514        let y = yoe + era * 400;
515        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
516        let mp = (5 * doy + 2) / 153;
517        let d = doy - (153 * mp + 2) / 5 + 1;
518        let mo = if mp < 10 { mp + 3 } else { mp - 9 };
519        let y = if mo <= 2 { y + 1 } else { y };
520        (y, mo, d, h, m, sec)
521    }
522
523    #[test]
524    fn agent_does_not_shorten_uuid_shaped_content_fields() {
525        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
526        let out = agent(json!({
527            "id": uuid,
528            "full_id": uuid,
529            "content": uuid,
530            "description": uuid,
531            "title": uuid,
532            "query": uuid,
533        }));
534
535        assert_eq!(out["id"], json!("a1b2c3d4"));
536        assert_eq!(out["full_id"], json!(uuid));
537        assert_eq!(out["content"], json!(uuid));
538        assert_eq!(out["description"], json!(uuid));
539        assert_eq!(out["title"], json!(uuid));
540        assert_eq!(out["query"], json!(uuid));
541    }
542
543    #[test]
544    fn agent_shortens_suffix_id_fields() {
545        let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
546        let out = agent(json!({
547            "note_id": uuid,
548            "source_id": uuid,
549            "target_id": uuid,
550            "proposal_id": uuid,
551        }));
552
553        assert_eq!(out["note_id"], json!("a1b2c3d4"));
554        assert_eq!(out["source_id"], json!("a1b2c3d4"));
555        assert_eq!(out["target_id"], json!("a1b2c3d4"));
556        assert_eq!(out["proposal_id"], json!("a1b2c3d4"));
557    }
558}