use chrono::{Local, TimeZone};
use kindling_service::{PreCompactContext, ResolvedPin, SessionStartContext};
const SESSION_PIN_PREVIEW: usize = 200;
const SESSION_OBS_PREVIEW: usize = 300;
const PRECOMPACT_PIN_PREVIEW: usize = 300;
const PRECOMPACT_SUMMARY_PREVIEW: usize = 500;
const SESSION_HEADER: &str =
"# Prior Context (from Kindling)\n\nThe following is prior session context for this project:\n";
pub fn format_session_start(ctx: &SessionStartContext, offset_seconds: i32) -> Option<String> {
let mut items: Vec<String> = Vec::new();
if !ctx.pins.is_empty() {
items.push("## Pinned Items".to_string());
for pin in &ctx.pins {
items.push(format_pin_line(pin, SESSION_PIN_PREVIEW));
}
}
if !ctx.recent.is_empty() {
items.push("## Recent Activity".to_string());
for obs in &ctx.recent {
let ts = if obs.ts != 0 {
format_local_datetime(obs.ts, offset_seconds)
} else {
String::new()
};
let preview = substring_utf16(&obs.content, SESSION_OBS_PREVIEW).replace('\n', " ");
items.push(format!("- [{ts}] {}: {preview}", obs_kind_str(obs.kind)));
}
}
if items.is_empty() {
return None;
}
Some(format!("{SESSION_HEADER}{}", items.join("\n")))
}
pub fn format_pre_compact(ctx: &PreCompactContext) -> Option<String> {
let mut items: Vec<String> = Vec::new();
if !ctx.pins.is_empty() {
items.push("## Pinned Items (preserve across compaction)".to_string());
for pin in &ctx.pins {
items.push(format_pin_line(pin, PRECOMPACT_PIN_PREVIEW));
}
}
if let Some(summary) = &ctx.latest_summary {
items.push("## Session Summary".to_string());
items.push(substring_utf16(
&summary.content,
PRECOMPACT_SUMMARY_PREVIEW,
));
}
if items.is_empty() {
return None;
}
Some(items.join("\n"))
}
fn format_pin_line(pin: &ResolvedPin, preview_units: usize) -> String {
let label = pin.note.as_deref().unwrap_or("Pin");
let preview = match &pin.content {
Some(content) => substring_utf16(content, preview_units),
None => "(no content)".to_string(),
};
format!("- **{label}**: {preview}")
}
fn obs_kind_str(kind: kindling_types::ObservationKind) -> &'static str {
use kindling_types::ObservationKind as K;
match kind {
K::ToolCall => "tool_call",
K::Command => "command",
K::FileDiff => "file_diff",
K::Error => "error",
K::Message => "message",
K::NodeStart => "node_start",
K::NodeEnd => "node_end",
K::NodeOutput => "node_output",
K::NodeError => "node_error",
}
}
fn substring_utf16(s: &str, max_units: usize) -> String {
let mut units = 0usize;
for (byte_idx, ch) in s.char_indices() {
let ch_units = ch.len_utf16();
if units + ch_units > max_units {
return s[..byte_idx].to_string();
}
units += ch_units;
}
s.to_string()
}
pub fn local_offset_seconds(epoch_ms: i64) -> i32 {
use chrono::Offset;
let secs = epoch_ms.div_euclid(1000);
let nanos = (epoch_ms.rem_euclid(1000) * 1_000_000) as u32;
match Local.timestamp_opt(secs, nanos) {
chrono::LocalResult::Single(dt) => dt.offset().fix().local_minus_utc(),
chrono::LocalResult::Ambiguous(dt, _) => dt.offset().fix().local_minus_utc(),
chrono::LocalResult::None => 0,
}
}
pub fn format_local_datetime(epoch_ms: i64, offset_seconds: i32) -> String {
let local_ms = epoch_ms + (offset_seconds as i64) * 1000;
let total_secs = local_ms.div_euclid(1000);
let days = total_secs.div_euclid(86_400);
let secs_of_day = total_secs.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
let hour24 = (secs_of_day / 3600) as u32;
let minute = ((secs_of_day % 3600) / 60) as u32;
let second = (secs_of_day % 60) as u32;
let (hour12, meridiem) = to_12_hour(hour24);
format!("{month}/{day}/{year}, {hour12}:{minute:02}:{second:02} {meridiem}")
}
fn to_12_hour(hour24: u32) -> (u32, &'static str) {
let meridiem = if hour24 < 12 { "AM" } else { "PM" };
let hour12 = match hour24 % 12 {
0 => 12,
h => h,
};
(hour12, meridiem)
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
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 };
(year, m as u32, d as u32)
}
#[cfg(test)]
mod tests {
use super::*;
use kindling_types::{Observation, ObservationKind, ScopeIds, Summary};
use serde_json::Map;
const NY_EST: i32 = -5 * 3600; const UTC: i32 = 0;
const IST: i32 = 5 * 3600 + 30 * 60;
#[test]
fn matches_node_en_us_known_instants() {
assert_eq!(
format_local_datetime(1_700_000_000_000, NY_EST),
"11/14/2023, 5:13:20 PM"
);
assert_eq!(format_local_datetime(0, UTC), "1/1/1970, 12:00:00 AM");
assert_eq!(
format_local_datetime(1_700_049_600_000, UTC),
"11/15/2023, 12:00:00 PM"
);
assert_eq!(
format_local_datetime(1_700_010_000_000, UTC),
"11/15/2023, 1:00:00 AM"
);
assert_eq!(
format_local_datetime(1_700_000_000_000, IST),
"11/15/2023, 3:43:20 AM"
);
}
#[test]
fn midnight_and_noon_use_twelve() {
assert_eq!(to_12_hour(0), (12, "AM"));
assert_eq!(to_12_hour(12), (12, "PM"));
assert_eq!(to_12_hour(11), (11, "AM"));
assert_eq!(to_12_hour(13), (1, "PM"));
assert_eq!(to_12_hour(23), (11, "PM"));
}
#[test]
fn single_and_double_digit_components() {
let ms = epoch_ms_utc(2023, 1, 5, 9, 7, 3);
assert_eq!(format_local_datetime(ms, UTC), "1/5/2023, 9:07:03 AM");
let ms = epoch_ms_utc(2023, 12, 25, 11, 59, 59);
assert_eq!(format_local_datetime(ms, UTC), "12/25/2023, 11:59:59 AM");
}
#[test]
fn pre_epoch_negative_instant() {
assert_eq!(format_local_datetime(0, NY_EST), "12/31/1969, 7:00:00 PM");
}
#[test]
fn civil_from_days_roundtrips_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(-1), (1969, 12, 31));
assert_eq!(civil_from_days(11_016), (2000, 2, 29));
}
fn epoch_ms_utc(y: i64, m: u32, d: u32, hh: u32, mm: u32, ss: u32) -> i64 {
let days = days_from_civil(y, m, d);
(days * 86_400 + (hh as i64) * 3600 + (mm as i64) * 60 + ss as i64) * 1000
}
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = if m > 2 { m - 3 } else { m + 9 } as i64;
let doy = (153 * mp + 2) / 5 + (d as i64) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
#[test]
fn substring_counts_utf16_units() {
assert_eq!(substring_utf16("hello", 200), "hello");
assert_eq!(substring_utf16("hello", 3), "hel");
assert_eq!(substring_utf16("🦀🦀", 2), "🦀");
assert_eq!(substring_utf16("🦀🦀", 3), "🦀");
assert_eq!(substring_utf16("🦀🦀", 1), "");
assert_eq!(substring_utf16("🦀🦀", 4), "🦀🦀");
}
fn obs(kind: ObservationKind, content: &str, ts: i64) -> Observation {
Observation {
id: "o".to_string(),
kind,
content: content.to_string(),
provenance: Map::new(),
ts,
scope_ids: ScopeIds::default(),
redacted: false,
}
}
fn pin(note: Option<&str>, content: Option<&str>) -> ResolvedPin {
ResolvedPin {
note: note.map(str::to_string),
content: content.map(str::to_string),
}
}
#[test]
fn session_start_full_markdown() {
let ctx = SessionStartContext {
pins: vec![
pin(Some("auth design"), Some("use argon2id")),
pin(None, None),
],
recent: vec![
obs(ObservationKind::Command, "git status", 1_700_000_000_000),
obs(
ObservationKind::Message,
"line one\nline two",
1_700_010_000_000,
),
],
};
let out = format_session_start(&ctx, NY_EST).expect("non-empty");
let expected = "# Prior Context (from Kindling)\n\n\
The following is prior session context for this project:\n\
## Pinned Items\n\
- **auth design**: use argon2id\n\
- **Pin**: (no content)\n\
## Recent Activity\n\
- [11/14/2023, 5:13:20 PM] command: git status\n\
- [11/14/2023, 8:00:00 PM] message: line one line two";
assert_eq!(out, expected);
}
#[test]
fn session_start_recent_only() {
let ctx = SessionStartContext {
pins: vec![],
recent: vec![obs(ObservationKind::Error, "boom", 1_700_049_600_000)],
};
let out = format_session_start(&ctx, UTC).expect("non-empty");
let expected = "# Prior Context (from Kindling)\n\n\
The following is prior session context for this project:\n\
## Recent Activity\n\
- [11/15/2023, 12:00:00 PM] error: boom";
assert_eq!(out, expected);
}
#[test]
fn session_start_zero_ts_renders_empty_bracket() {
let ctx = SessionStartContext {
pins: vec![],
recent: vec![obs(ObservationKind::Message, "hi", 0)],
};
let out = format_session_start(&ctx, UTC).expect("non-empty");
assert!(
out.ends_with("## Recent Activity\n- [] message: hi"),
"{out}"
);
}
#[test]
fn session_start_empty_is_none() {
let ctx = SessionStartContext {
pins: vec![],
recent: vec![],
};
assert!(format_session_start(&ctx, UTC).is_none());
}
#[test]
fn pre_compact_full_markdown() {
let ctx = PreCompactContext {
pins: vec![pin(Some("keep"), Some("important note"))],
latest_summary: Some(Summary {
id: "s".to_string(),
capsule_id: "c".to_string(),
content: "we fixed the bug".to_string(),
confidence: 0.9,
created_at: 1,
evidence_refs: vec![],
}),
};
let out = format_pre_compact(&ctx).expect("non-empty");
let expected = "## Pinned Items (preserve across compaction)\n\
- **keep**: important note\n\
## Session Summary\n\
we fixed the bug";
assert_eq!(out, expected);
}
#[test]
fn pre_compact_summary_only_no_header() {
let ctx = PreCompactContext {
pins: vec![],
latest_summary: Some(Summary {
id: "s".to_string(),
capsule_id: "c".to_string(),
content: "summary text".to_string(),
confidence: 1.0,
created_at: 1,
evidence_refs: vec![],
}),
};
let out = format_pre_compact(&ctx).expect("non-empty");
assert_eq!(out, "## Session Summary\nsummary text");
}
#[test]
fn pre_compact_empty_is_none() {
let ctx = PreCompactContext {
pins: vec![],
latest_summary: None,
};
assert!(format_pre_compact(&ctx).is_none());
}
#[test]
fn pin_preview_truncates_to_unit_limit() {
let long = "x".repeat(250);
let line = format_pin_line(&pin(Some("n"), Some(&long)), SESSION_PIN_PREVIEW);
assert_eq!(line, format!("- **n**: {}", "x".repeat(200)));
}
}