1use chrono::{Local, TimeZone};
42use kindling_service::{PreCompactContext, ResolvedPin, SessionStartContext};
43
44const SESSION_PIN_PREVIEW: usize = 200;
47const SESSION_OBS_PREVIEW: usize = 300;
50const PRECOMPACT_PIN_PREVIEW: usize = 300;
52const PRECOMPACT_SUMMARY_PREVIEW: usize = 500;
54
55const SESSION_HEADER: &str =
58 "# Prior Context (from Kindling)\n\nThe following is prior session context for this project:\n";
59
60pub fn format_session_start(ctx: &SessionStartContext, offset_seconds: i32) -> Option<String> {
66 let mut items: Vec<String> = Vec::new();
67
68 if !ctx.pins.is_empty() {
69 items.push("## Pinned Items".to_string());
70 for pin in &ctx.pins {
71 items.push(format_pin_line(pin, SESSION_PIN_PREVIEW));
72 }
73 }
74
75 if !ctx.recent.is_empty() {
76 items.push("## Recent Activity".to_string());
77 for obs in &ctx.recent {
78 let ts = if obs.ts != 0 {
80 format_local_datetime(obs.ts, offset_seconds)
81 } else {
82 String::new()
83 };
84 let preview = substring_utf16(&obs.content, SESSION_OBS_PREVIEW).replace('\n', " ");
87 items.push(format!("- [{ts}] {}: {preview}", obs_kind_str(obs.kind)));
88 }
89 }
90
91 if items.is_empty() {
92 return None;
93 }
94 Some(format!("{SESSION_HEADER}{}", items.join("\n")))
95}
96
97pub fn format_pre_compact(ctx: &PreCompactContext) -> Option<String> {
100 let mut items: Vec<String> = Vec::new();
101
102 if !ctx.pins.is_empty() {
103 items.push("## Pinned Items (preserve across compaction)".to_string());
104 for pin in &ctx.pins {
105 items.push(format_pin_line(pin, PRECOMPACT_PIN_PREVIEW));
106 }
107 }
108
109 if let Some(summary) = &ctx.latest_summary {
110 items.push("## Session Summary".to_string());
113 items.push(substring_utf16(
114 &summary.content,
115 PRECOMPACT_SUMMARY_PREVIEW,
116 ));
117 }
118
119 if items.is_empty() {
120 return None;
121 }
122 Some(items.join("\n"))
123}
124
125fn format_pin_line(pin: &ResolvedPin, preview_units: usize) -> String {
127 let label = pin.note.as_deref().unwrap_or("Pin");
128 let preview = match &pin.content {
129 Some(content) => substring_utf16(content, preview_units),
130 None => "(no content)".to_string(),
131 };
132 format!("- **{label}**: {preview}")
133}
134
135fn obs_kind_str(kind: kindling_types::ObservationKind) -> &'static str {
138 use kindling_types::ObservationKind as K;
139 match kind {
140 K::ToolCall => "tool_call",
141 K::Command => "command",
142 K::FileDiff => "file_diff",
143 K::Error => "error",
144 K::Message => "message",
145 K::NodeStart => "node_start",
146 K::NodeEnd => "node_end",
147 K::NodeOutput => "node_output",
148 K::NodeError => "node_error",
149 }
150}
151
152fn substring_utf16(s: &str, max_units: usize) -> String {
156 let mut units = 0usize;
157 for (byte_idx, ch) in s.char_indices() {
158 let ch_units = ch.len_utf16();
159 if units + ch_units > max_units {
160 return s[..byte_idx].to_string();
161 }
162 units += ch_units;
163 }
164 s.to_string()
165}
166
167pub fn local_offset_seconds(epoch_ms: i64) -> i32 {
172 use chrono::Offset;
173 let secs = epoch_ms.div_euclid(1000);
174 let nanos = (epoch_ms.rem_euclid(1000) * 1_000_000) as u32;
175 match Local.timestamp_opt(secs, nanos) {
176 chrono::LocalResult::Single(dt) => dt.offset().fix().local_minus_utc(),
177 chrono::LocalResult::Ambiguous(dt, _) => dt.offset().fix().local_minus_utc(),
181 chrono::LocalResult::None => 0,
182 }
183}
184
185pub fn format_local_datetime(epoch_ms: i64, offset_seconds: i32) -> String {
188 let local_ms = epoch_ms + (offset_seconds as i64) * 1000;
192 let total_secs = local_ms.div_euclid(1000);
193 let days = total_secs.div_euclid(86_400);
194 let secs_of_day = total_secs.rem_euclid(86_400);
195
196 let (year, month, day) = civil_from_days(days);
197
198 let hour24 = (secs_of_day / 3600) as u32;
199 let minute = ((secs_of_day % 3600) / 60) as u32;
200 let second = (secs_of_day % 60) as u32;
201
202 let (hour12, meridiem) = to_12_hour(hour24);
203
204 format!("{month}/{day}/{year}, {hour12}:{minute:02}:{second:02} {meridiem}")
206}
207
208fn to_12_hour(hour24: u32) -> (u32, &'static str) {
210 let meridiem = if hour24 < 12 { "AM" } else { "PM" };
211 let hour12 = match hour24 % 12 {
212 0 => 12,
213 h => h,
214 };
215 (hour12, meridiem)
216}
217
218fn civil_from_days(z: i64) -> (i64, u32, u32) {
222 let z = z + 719_468;
223 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
224 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
227 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let year = if m <= 2 { y + 1 } else { y };
232 (year, m as u32, d as u32)
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use kindling_types::{Observation, ObservationKind, ScopeIds, Summary};
239 use serde_json::Map;
240
241 const NY_EST: i32 = -5 * 3600; const UTC: i32 = 0;
245 const IST: i32 = 5 * 3600 + 30 * 60; #[test]
248 fn matches_node_en_us_known_instants() {
249 assert_eq!(
251 format_local_datetime(1_700_000_000_000, NY_EST),
252 "11/14/2023, 5:13:20 PM"
253 );
254 assert_eq!(format_local_datetime(0, UTC), "1/1/1970, 12:00:00 AM");
256 assert_eq!(
258 format_local_datetime(1_700_049_600_000, UTC),
259 "11/15/2023, 12:00:00 PM"
260 );
261 assert_eq!(
263 format_local_datetime(1_700_010_000_000, UTC),
264 "11/15/2023, 1:00:00 AM"
265 );
266 assert_eq!(
268 format_local_datetime(1_700_000_000_000, IST),
269 "11/15/2023, 3:43:20 AM"
270 );
271 }
272
273 #[test]
274 fn midnight_and_noon_use_twelve() {
275 assert_eq!(to_12_hour(0), (12, "AM"));
276 assert_eq!(to_12_hour(12), (12, "PM"));
277 assert_eq!(to_12_hour(11), (11, "AM"));
278 assert_eq!(to_12_hour(13), (1, "PM"));
279 assert_eq!(to_12_hour(23), (11, "PM"));
280 }
281
282 #[test]
283 fn single_and_double_digit_components() {
284 let ms = epoch_ms_utc(2023, 1, 5, 9, 7, 3);
287 assert_eq!(format_local_datetime(ms, UTC), "1/5/2023, 9:07:03 AM");
288 let ms = epoch_ms_utc(2023, 12, 25, 11, 59, 59);
290 assert_eq!(format_local_datetime(ms, UTC), "12/25/2023, 11:59:59 AM");
291 }
292
293 #[test]
294 fn pre_epoch_negative_instant() {
295 assert_eq!(format_local_datetime(0, NY_EST), "12/31/1969, 7:00:00 PM");
297 }
298
299 #[test]
300 fn civil_from_days_roundtrips_known_dates() {
301 assert_eq!(civil_from_days(0), (1970, 1, 1));
302 assert_eq!(civil_from_days(-1), (1969, 12, 31));
303 assert_eq!(civil_from_days(11_016), (2000, 2, 29));
305 }
306
307 fn epoch_ms_utc(y: i64, m: u32, d: u32, hh: u32, mm: u32, ss: u32) -> i64 {
309 let days = days_from_civil(y, m, d);
310 (days * 86_400 + (hh as i64) * 3600 + (mm as i64) * 60 + ss as i64) * 1000
311 }
312
313 fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
314 let y = if m <= 2 { y - 1 } else { y };
315 let era = if y >= 0 { y } else { y - 399 } / 400;
316 let yoe = y - era * 400;
317 let mp = if m > 2 { m - 3 } else { m + 9 } as i64;
318 let doy = (153 * mp + 2) / 5 + (d as i64) - 1;
319 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
320 era * 146_097 + doe - 719_468
321 }
322
323 #[test]
326 fn substring_counts_utf16_units() {
327 assert_eq!(substring_utf16("hello", 200), "hello");
328 assert_eq!(substring_utf16("hello", 3), "hel");
329 assert_eq!(substring_utf16("🦀🦀", 2), "🦀");
331 assert_eq!(substring_utf16("🦀🦀", 3), "🦀");
332 assert_eq!(substring_utf16("🦀🦀", 1), "");
333 assert_eq!(substring_utf16("🦀🦀", 4), "🦀🦀");
334 }
335
336 fn obs(kind: ObservationKind, content: &str, ts: i64) -> Observation {
339 Observation {
340 id: "o".to_string(),
341 kind,
342 content: content.to_string(),
343 provenance: Map::new(),
344 ts,
345 scope_ids: ScopeIds::default(),
346 redacted: false,
347 }
348 }
349
350 fn pin(note: Option<&str>, content: Option<&str>) -> ResolvedPin {
351 ResolvedPin {
352 note: note.map(str::to_string),
353 content: content.map(str::to_string),
354 }
355 }
356
357 #[test]
358 fn session_start_full_markdown() {
359 let ctx = SessionStartContext {
360 pins: vec![
361 pin(Some("auth design"), Some("use argon2id")),
362 pin(None, None),
363 ],
364 recent: vec![
365 obs(ObservationKind::Command, "git status", 1_700_000_000_000),
366 obs(
367 ObservationKind::Message,
368 "line one\nline two",
369 1_700_010_000_000,
370 ),
371 ],
372 };
373 let out = format_session_start(&ctx, NY_EST).expect("non-empty");
374 let expected = "# Prior Context (from Kindling)\n\n\
375The following is prior session context for this project:\n\
376## Pinned Items\n\
377- **auth design**: use argon2id\n\
378- **Pin**: (no content)\n\
379## Recent Activity\n\
380- [11/14/2023, 5:13:20 PM] command: git status\n\
381- [11/14/2023, 8:00:00 PM] message: line one line two";
382 assert_eq!(out, expected);
383 }
384
385 #[test]
386 fn session_start_recent_only() {
387 let ctx = SessionStartContext {
388 pins: vec![],
389 recent: vec![obs(ObservationKind::Error, "boom", 1_700_049_600_000)],
390 };
391 let out = format_session_start(&ctx, UTC).expect("non-empty");
392 let expected = "# Prior Context (from Kindling)\n\n\
393The following is prior session context for this project:\n\
394## Recent Activity\n\
395- [11/15/2023, 12:00:00 PM] error: boom";
396 assert_eq!(out, expected);
397 }
398
399 #[test]
400 fn session_start_zero_ts_renders_empty_bracket() {
401 let ctx = SessionStartContext {
402 pins: vec![],
403 recent: vec![obs(ObservationKind::Message, "hi", 0)],
404 };
405 let out = format_session_start(&ctx, UTC).expect("non-empty");
406 assert!(
407 out.ends_with("## Recent Activity\n- [] message: hi"),
408 "{out}"
409 );
410 }
411
412 #[test]
413 fn session_start_empty_is_none() {
414 let ctx = SessionStartContext {
415 pins: vec![],
416 recent: vec![],
417 };
418 assert!(format_session_start(&ctx, UTC).is_none());
419 }
420
421 #[test]
422 fn pre_compact_full_markdown() {
423 let ctx = PreCompactContext {
424 pins: vec![pin(Some("keep"), Some("important note"))],
425 latest_summary: Some(Summary {
426 id: "s".to_string(),
427 capsule_id: "c".to_string(),
428 content: "we fixed the bug".to_string(),
429 confidence: 0.9,
430 created_at: 1,
431 evidence_refs: vec![],
432 }),
433 };
434 let out = format_pre_compact(&ctx).expect("non-empty");
435 let expected = "## Pinned Items (preserve across compaction)\n\
436- **keep**: important note\n\
437## Session Summary\n\
438we fixed the bug";
439 assert_eq!(out, expected);
440 }
441
442 #[test]
443 fn pre_compact_summary_only_no_header() {
444 let ctx = PreCompactContext {
445 pins: vec![],
446 latest_summary: Some(Summary {
447 id: "s".to_string(),
448 capsule_id: "c".to_string(),
449 content: "summary text".to_string(),
450 confidence: 1.0,
451 created_at: 1,
452 evidence_refs: vec![],
453 }),
454 };
455 let out = format_pre_compact(&ctx).expect("non-empty");
456 assert_eq!(out, "## Session Summary\nsummary text");
458 }
459
460 #[test]
461 fn pre_compact_empty_is_none() {
462 let ctx = PreCompactContext {
463 pins: vec![],
464 latest_summary: None,
465 };
466 assert!(format_pre_compact(&ctx).is_none());
467 }
468
469 #[test]
470 fn pin_preview_truncates_to_unit_limit() {
471 let long = "x".repeat(250);
472 let line = format_pin_line(&pin(Some("n"), Some(&long)), SESSION_PIN_PREVIEW);
473 assert_eq!(line, format!("- **n**: {}", "x".repeat(200)));
475 }
476}