1#![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#[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
101pub 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}