Skip to main content

khive_runtime/
presentation.rs

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