Skip to main content

kcode_kennedy_session_presentation/
lib.rs

1//! Deterministic, effect-free rendering for Kennedy sessions.
2
3#![forbid(unsafe_code)]
4
5use std::collections::BTreeMap;
6use std::time::Duration;
7
8use anyhow::Context as _;
9use chrono::{DateTime, Datelike, Timelike, Utc};
10use serde_json::Value;
11
12/// One deterministic rendering request.
13#[derive(Clone, Debug)]
14pub enum RenderRequest<'a> {
15    LoadNodes {
16        changed_box_ids: &'a [String],
17        projected_boxes: &'a [(String, String)],
18    },
19    ProviderFooter {
20        result: &'a str,
21        footer: &'a str,
22    },
23    SlowTool {
24        text: &'a str,
25        elapsed: Duration,
26    },
27    WebSearch {
28        answer: &'a str,
29        sources: &'a [(String, String)],
30    },
31    WebFetch {
32        url: &'a str,
33        title: Option<&'a str>,
34        content_type: &'a str,
35        truncated: bool,
36        content: &'a str,
37    },
38    MediaAnnotation {
39        object_id: &'a str,
40        file_name: &'a str,
41        content_type: &'a str,
42        model: &'a str,
43        complete: bool,
44        incomplete_reason: Option<&'a str>,
45        text: &'a str,
46    },
47    AudioTranscription {
48        object_id: &'a str,
49        file_name: &'a str,
50        content_type: &'a str,
51        model: &'a str,
52        text: &'a str,
53    },
54    DocumentExtraction {
55        object_id: &'a str,
56        file_name: &'a str,
57        format: &'a str,
58        characters: usize,
59        truncated: bool,
60        text: &'a str,
61    },
62    UserFileMetadata {
63        ordinal: usize,
64        object_id: &'a str,
65        file_name: &'a str,
66        media_type: &'a str,
67        size_bytes: u64,
68    },
69    StagedTelegramMedia {
70        pending_id: &'a str,
71        kind: &'a str,
72        file_name: &'a str,
73        media_type: &'a str,
74        size_bytes: u64,
75        message_id: i64,
76        reused: bool,
77    },
78    CostSummary {
79        label: &'a str,
80        estimated_cost_usd_nanos: u64,
81        unpriced_calls: u64,
82    },
83    RuntimeDescription {
84        model: &'a str,
85        reasoning_effort: &'a str,
86        current_time: DateTime<Utc>,
87    },
88    FreeTimeOpening {
89        free_time: &'a Value,
90    },
91    WakeupOpening {
92        marker: DateTime<Utc>,
93    },
94    ControllerMessage {
95        mode: &'a str,
96        free_time: &'a Value,
97    },
98    CallKtoolDescription,
99}
100
101/// Renders a request without reading state or performing an effect.
102pub fn render(request: RenderRequest<'_>) -> anyhow::Result<String> {
103    match request {
104        RenderRequest::LoadNodes {
105            changed_box_ids,
106            projected_boxes,
107        } => render_load_nodes(changed_box_ids, projected_boxes),
108        RenderRequest::ProviderFooter { result, footer } => {
109            if result.is_empty() {
110                Ok(footer.into())
111            } else {
112                Ok(format!("{result}\n\n{footer}"))
113            }
114        }
115        RenderRequest::SlowTool { text, elapsed } => {
116            let mut text = text.to_owned();
117            if elapsed > Duration::from_secs(3) {
118                if !text.is_empty() && !text.ends_with('\n') {
119                    text.push('\n');
120                }
121                text.push_str(&format!("[tool duration: {:.3}s]", elapsed.as_secs_f64()));
122            }
123            Ok(text)
124        }
125        RenderRequest::WebSearch { answer, sources } => {
126            let mut text = answer.to_owned();
127            if !sources.is_empty() {
128                text.push_str("\n\nSources:");
129                for (title, url) in sources {
130                    let title = if title.trim().is_empty() { url } else { title };
131                    text.push_str("\n- ");
132                    text.push_str(title);
133                    if title != url {
134                        text.push_str(": ");
135                        text.push_str(url);
136                    }
137                }
138            }
139            Ok(text)
140        }
141        RenderRequest::WebFetch {
142            url,
143            title,
144            content_type,
145            truncated,
146            content,
147        } => {
148            let mut text = format!("Source URL: {url}");
149            if let Some(title) = title.filter(|title| !title.trim().is_empty()) {
150                text.push_str("\nTitle: ");
151                text.push_str(title);
152            }
153            text.push_str("\nContent type: ");
154            text.push_str(content_type);
155            if truncated {
156                text.push_str("\nThe returned page text was truncated.");
157            }
158            text.push_str("\n\n");
159            text.push_str(content);
160            Ok(text)
161        }
162        RenderRequest::MediaAnnotation {
163            object_id,
164            file_name,
165            content_type,
166            model,
167            complete,
168            incomplete_reason,
169            text,
170        } => {
171            anyhow::ensure!(
172                !text.trim().is_empty(),
173                "media annotation response has no text"
174            );
175            let status = if complete { "complete" } else { "incomplete" };
176            let mut rendered = format!(
177                "Annotation for {object_id}\nFile: {file_name}\nContent type: {content_type}\nModel: {model}\nStatus: {status}"
178            );
179            if let Some(reason) = incomplete_reason.filter(|reason| !reason.trim().is_empty()) {
180                rendered.push_str("\nIncomplete reason: ");
181                rendered.push_str(reason);
182            }
183            rendered.push_str("\n\n");
184            rendered.push_str(text);
185            Ok(rendered)
186        }
187        RenderRequest::AudioTranscription {
188            object_id,
189            file_name,
190            content_type,
191            model,
192            text,
193        } => {
194            anyhow::ensure!(
195                !text.trim().is_empty(),
196                "audio transcription response has no text"
197            );
198            Ok(format!(
199                "Transcription for {object_id}\nFile: {file_name}\nContent type: {content_type}\nModel: {model}\nStatus: complete\n\n{text}"
200            ))
201        }
202        RenderRequest::DocumentExtraction {
203            object_id,
204            file_name,
205            format,
206            characters,
207            truncated,
208            text,
209        } => Ok(format!(
210            "Extracted text for {object_id}\nFile: {file_name}\nFormat: {format}\nCharacters: {characters}\nTruncated: {truncated}\n\n{text}"
211        )),
212        RenderRequest::UserFileMetadata {
213            ordinal,
214            object_id,
215            file_name,
216            media_type,
217            size_bytes,
218        } => Ok(format!(
219            "User-provided file {ordinal}\nObject reference: {object_id}\nOriginal filename: {file_name}\nExtension: {}\nMIME type: {}\nSize: {size_bytes} bytes",
220            file_name_extension(file_name),
221            normalize_media_type(media_type),
222        )),
223        RenderRequest::StagedTelegramMedia {
224            pending_id,
225            kind,
226            file_name,
227            media_type,
228            size_bytes,
229            message_id,
230            reused,
231        } => Ok(format!(
232            "{} Telegram group media\nMessage ID: {message_id}\nObject: {pending_id}\nKind: {kind}\nOriginal filename: {file_name}\nExtension: {}\nMIME type: {}\nSize: {size_bytes} bytes\n\nUse Object {pending_id} with AnnotateMedia, GenerateImage (for images), TranscribeAudio, or ExtractDocumentText as appropriate.",
233            if reused {
234                "Reused already-staged"
235            } else {
236                "Staged"
237            },
238            file_name_extension(file_name),
239            normalize_media_type(media_type),
240        )),
241        RenderRequest::CostSummary {
242            label,
243            estimated_cost_usd_nanos,
244            unpriced_calls,
245        } => Ok(cost_summary(
246            label,
247            estimated_cost_usd_nanos,
248            unpriced_calls,
249        )),
250        RenderRequest::RuntimeDescription {
251            model,
252            reasoning_effort,
253            current_time,
254        } => Ok(format!(
255            "You are currently running on {model} with {reasoning_effort} thinking mode. The current date and time is {}.",
256            human_utc_datetime(current_time)
257        )),
258        RenderRequest::FreeTimeOpening { free_time } => {
259            let custom = free_time
260                .get("customPrompt")
261                .and_then(Value::as_str)
262                .unwrap_or_default();
263            if custom.trim().is_empty() {
264                Ok("Begin this self-time session.".into())
265            } else {
266                Ok(format!(
267                    "Begin this self-time session.\n\nRequested focus:\n{custom}"
268                ))
269            }
270        }
271        RenderRequest::WakeupOpening { marker } => Ok(format!(
272            "The time is {} UTC on {}. Determine whether you have any messages you would like to send the user",
273            marker.format("%H:%M"),
274            marker.format("%Y-%m-%d"),
275        )),
276        RenderRequest::ControllerMessage { mode, free_time } => {
277            render_controller_message(mode, free_time)
278        }
279        RenderRequest::CallKtoolDescription => Ok(
280            "Call one Kennedy Ktool. The provider function remains registered even if its explaining system-prompt box is dehydrated. Kennedy may display an object with an optional recipient-visible filename using {\"name\":\"EmitObject\",\"arguments\":{\"objectId\":\"AAECAwQF\",\"fileName\":\"report.pdf\"}}. She may make an out-of-band cold delivery to an authorized user's private Telegram chat, optionally with Kweb object attachments and per-attachment delivery filenames, from any session with {\"name\":\"SendTelegramDM\",\"arguments\":{\"user\":{\"telegramUserId\":42},\"message\":\"Exact message text.\",\"attachments\":[\"pending:1\",{\"objectId\":\"AAECAwQF\",\"fileName\":\"report.pdf\"}]}}. Kennedy may likewise send text and attachments to any known Telegram group, addressed by its canonical Kweb root, with {\"name\":\"SendTelegramGroupMessage\",\"arguments\":{\"group\":{\"rootNodeId\":\"AAAAAAAE\"},\"message\":\"Exact message text.\",\"attachments\":[\"pending:1\"]}}.".into(),
281        ),
282    }
283}
284
285fn render_load_nodes(
286    changed_box_ids: &[String],
287    projected_boxes: &[(String, String)],
288) -> anyhow::Result<String> {
289    if changed_box_ids.is_empty() {
290        return Ok("LoadNodes completed. The shared Kweb boxes were already current.".into());
291    }
292    let rendered = projected_boxes.iter().cloned().collect::<BTreeMap<_, _>>();
293    changed_box_ids
294        .iter()
295        .map(|box_id| {
296            rendered
297                .get(box_id)
298                .cloned()
299                .with_context(|| format!("updated Kweb box {box_id} is absent from the projection"))
300        })
301        .collect::<anyhow::Result<Vec<_>>>()
302        .map(|boxes| boxes.join("\n\n"))
303}
304
305fn normalize_media_type(value: &str) -> String {
306    value
307        .split(';')
308        .next()
309        .unwrap_or(value)
310        .trim()
311        .to_ascii_lowercase()
312}
313
314fn file_name_extension(file_name: &str) -> String {
315    file_name
316        .rsplit_once('.')
317        .and_then(|(stem, extension)| {
318            (!stem.is_empty() && !extension.is_empty()).then_some(extension)
319        })
320        .map(|extension| format!(".{extension}"))
321        .unwrap_or_else(|| "(none)".into())
322}
323
324fn cost_summary(label: &str, estimated_cost_usd_nanos: u64, unpriced_calls: u64) -> String {
325    let rounded_milli_pennies = estimated_cost_usd_nanos.saturating_add(5_000) / 10_000;
326    let pennies = format!(
327        "{}.{:03}",
328        rounded_milli_pennies / 1_000,
329        rounded_milli_pennies % 1_000
330    );
331    if unpriced_calls == 0 {
332        format!("Estimated {label}: {pennies} pennies at standard API rates.")
333    } else {
334        format!(
335            "Estimated {label}: {pennies} pennies at standard API rates; {unpriced_calls} provider {} could not be priced.",
336            if unpriced_calls == 1 { "call" } else { "calls" }
337        )
338    }
339}
340
341fn human_utc_datetime(value: DateTime<Utc>) -> String {
342    let day = value.day();
343    let suffix = match day % 100 {
344        11..=13 => "th",
345        _ => match day % 10 {
346            1 => "st",
347            2 => "nd",
348            3 => "rd",
349            _ => "th",
350        },
351    };
352    let hour = match value.hour() % 12 {
353        0 => 12,
354        hour => hour,
355    };
356    let period = if value.hour() < 12 { "am" } else { "pm" };
357    format!(
358        "{} {day}{suffix}, {}, {hour}:{:02}{period} UTC",
359        value.format("%B"),
360        value.year(),
361        value.minute()
362    )
363}
364
365fn deadline(value: &Value) -> Option<DateTime<Utc>> {
366    value
367        .get("deadlineAt")
368        .and_then(Value::as_str)
369        .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
370        .map(|value| value.with_timezone(&Utc))
371}
372
373fn free_time_schedule(value: &Value) -> String {
374    deadline(value)
375        .map(|deadline| {
376            format!(
377                "The self-time deadline is {}.",
378                human_utc_datetime(deadline)
379            )
380        })
381        .unwrap_or_else(|| "The self-time deadline was not supplied.".into())
382}
383
384fn render_controller_message(mode: &str, free_time: &Value) -> anyhow::Result<String> {
385    match mode {
386        "conversation" => {
387            Ok("Continue the turn. Use tools if needed, then answer the user.".into())
388        }
389        "free-time" => Ok(format!(
390            "Continue self time. {}",
391            free_time_schedule(free_time)
392        )),
393        "wakeup" => Ok(
394            "Continue this autonomous wakeup session. Sending no message is a valid outcome; call EndSession when you have finished.".into(),
395        ),
396        "ingress" => Ok(
397            "You are in a solo history-ingress session; there is no user to receive a conversational response. If you have completed all useful memory work, call EndSession now through the native call_ktool function with no arguments. A normal response does not end this session. If work remains, continue it with tools, then call EndSession when finished.".into(),
398        ),
399        _ => anyhow::bail!("unknown controller mode {mode}"),
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use chrono::TimeZone as _;
407    use serde_json::json;
408
409    #[test]
410    fn web_search_and_footer_are_exact() {
411        let sources = vec![
412            ("Title".into(), "https://one.example".into()),
413            (" ".into(), "https://two.example".into()),
414        ];
415        assert_eq!(
416            render(RenderRequest::WebSearch {
417                answer: "Answer",
418                sources: &sources,
419            })
420            .unwrap(),
421            "Answer\n\nSources:\n- Title: https://one.example\n- https://two.example"
422        );
423        assert_eq!(
424            render(RenderRequest::ProviderFooter {
425                result: "done",
426                footer: "status",
427            })
428            .unwrap(),
429            "done\n\nstatus"
430        );
431    }
432
433    #[test]
434    fn runtime_and_controller_text_are_exact() {
435        let current_time = Utc.with_ymd_and_hms(2026, 8, 3, 13, 7, 0).unwrap();
436        assert_eq!(
437            render(RenderRequest::RuntimeDescription {
438                model: "model",
439                reasoning_effort: "high",
440                current_time,
441            })
442            .unwrap(),
443            "You are currently running on model with high thinking mode. The current date and time is August 3rd, 2026, 1:07pm UTC."
444        );
445        assert_eq!(
446            render(RenderRequest::ControllerMessage {
447                mode: "conversation",
448                free_time: &json!(null),
449            })
450            .unwrap(),
451            "Continue the turn. Use tools if needed, then answer the user."
452        );
453    }
454
455    #[test]
456    fn slow_duration_and_empty_annotation_match_boundaries() {
457        assert_eq!(
458            render(RenderRequest::SlowTool {
459                text: "ok",
460                elapsed: Duration::from_secs(3),
461            })
462            .unwrap(),
463            "ok"
464        );
465        assert_eq!(
466            render(RenderRequest::SlowTool {
467                text: "ok",
468                elapsed: Duration::from_millis(3001),
469            })
470            .unwrap(),
471            "ok\n[tool duration: 3.001s]"
472        );
473        assert!(
474            render(RenderRequest::MediaAnnotation {
475                object_id: "pending:1",
476                file_name: "image.png",
477                content_type: "image/png",
478                model: "model",
479                complete: true,
480                incomplete_reason: None,
481                text: " ",
482            })
483            .is_err()
484        );
485    }
486}