Skip to main content

kindling_server/
inject.rs

1//! Injection-context markdown formatting — the byte-for-byte parity surface.
2//!
3//! The daemon (not the hook) owns the markdown so the date logic lives in
4//! exactly one place. Two formatters reproduce the Node plugin hooks:
5//!
6//! - [`format_session_start`] ⇄ `plugins/kindling-claude-code/hooks/session-start.js`
7//! - [`format_pre_compact`] ⇄ `plugins/kindling-claude-code/hooks/pre-compact.js`
8//!
9//! # `toLocaleString` parity
10//!
11//! The Node hook renders observation timestamps with
12//! `new Date(ts).toLocaleString()`. Under the `en-US` locale (our parity
13//! target) that yields `M/D/YYYY, H:MM:SS AM/PM`:
14//!
15//! - month / day / hour: **no** leading zero
16//! - minute / second: zero-padded to two digits
17//! - 12-hour clock; midnight and noon render as `12`
18//! - `AM` / `PM` uppercase, a comma + space between date and time
19//!
20//! Verified against Node:
21//!
22//! ```text
23//! TZ=America/New_York node -e 'console.log(new Date(1700000000000).toLocaleString("en-US"))'
24//! // → 11/14/2023, 5:13:20 PM
25//! TZ=UTC          … 0           → 1/1/1970, 12:00:00 AM
26//! TZ=UTC          … 1700049600000 → 11/15/2023, 12:00:00 PM
27//! ```
28//!
29//! The timezone is the machine-local zone at the formatted instant (resolved
30//! via [`local_offset_seconds`], which honours `TZ` on Unix — the same zone the
31//! Node hook process used). [`format_local_datetime`] takes the offset
32//! explicitly so the civil-date arithmetic is deterministic and unit-tested with
33//! fixed offsets, independent of the host.
34//!
35//! # UTF-16 truncation
36//!
37//! Node `String.prototype.substring` counts UTF-16 code units. Previews use
38//! [`substring_utf16`], which reproduces that (rounding down at a surrogate-pair
39//! boundary, the only divergence — a lone surrogate cannot survive JSON anyway).
40
41use chrono::{Local, TimeZone};
42use kindling_service::{PreCompactContext, ResolvedPin, SessionStartContext};
43
44/// SessionStart preview length for pins (UTF-16 code units). Node:
45/// `pin.content.substring(0, 200)`.
46const SESSION_PIN_PREVIEW: usize = 200;
47/// SessionStart preview length for observations. Node:
48/// `obs.content.substring(0, 300)`.
49const SESSION_OBS_PREVIEW: usize = 300;
50/// PreCompact preview length for pins. Node: `pin.content.substring(0, 300)`.
51const PRECOMPACT_PIN_PREVIEW: usize = 300;
52/// PreCompact summary clamp. Node: `latestSummary.content.substring(0, 500)`.
53const PRECOMPACT_SUMMARY_PREVIEW: usize = 500;
54
55/// Header prefixed to the SessionStart injection. Mirrors the Node template
56/// literal exactly (note the trailing newline).
57const SESSION_HEADER: &str =
58    "# Prior Context (from Kindling)\n\nThe following is prior session context for this project:\n";
59
60/// Format the SessionStart `additionalContext`, or `None` when there is nothing
61/// to inject (matching the Node hook's "only if ≥1 item" gate).
62///
63/// `offset_seconds` is the UTC offset to render observation timestamps in (use
64/// [`local_offset_seconds`] for the live daemon; a fixed value in tests).
65pub 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            // `new Date(obs.ts).toLocaleString()`; Node guards `obs.ts ? … : ''`.
79            let ts = if obs.ts != 0 {
80                format_local_datetime(obs.ts, offset_seconds)
81            } else {
82                String::new()
83            };
84            // `(obs.content || '').substring(0,300).replace(/\n/g, ' ')` — replace
85            // ALL newlines (the JS regex is global) AFTER truncating.
86            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
97/// Format the PreCompact `additionalContext`, or `None` when there is nothing to
98/// inject. No top-level header (matches the Node hook).
99pub 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        // The service already dropped empty-content summaries, mirroring the
111        // Node `latestSummary.content` truthiness gate.
112        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
125/// `- **${note || 'Pin'}**: ${content ? content.substring(0, n) : '(no content)'}`
126fn 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
135/// The wire/string form of an observation kind, identical to the value stored in
136/// the `observations.kind` column and emitted by the Node hook's `obs.kind`.
137fn 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
152/// Longest prefix of `s` within `max_units` UTF-16 code units, reproducing JS
153/// `String.prototype.substring(0, max_units)`. Rounds down at a surrogate-pair
154/// boundary (the lone-surrogate case JS could emit cannot survive JSON anyway).
155fn 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
167/// The machine-local UTC offset (seconds) at the instant `epoch_ms`. Honours
168/// the `TZ` env var on Unix, so it matches the Node hook process's
169/// `toLocaleString()` zone. DST-correct because the offset is resolved *at that
170/// instant*, not "now".
171pub 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        // Ambiguous (fall-back) or skipped (spring-forward) wall-clock instants:
178        // pick the earliest candidate. `timestamp_opt` keys on the *UTC* instant
179        // which is never ambiguous in practice, so this is belt-and-braces.
180        chrono::LocalResult::Ambiguous(dt, _) => dt.offset().fix().local_minus_utc(),
181        chrono::LocalResult::None => 0,
182    }
183}
184
185/// Format `epoch_ms` at a fixed UTC `offset_seconds` as Node's `en-US`
186/// `toLocaleString()`: `M/D/YYYY, H:MM:SS AM/PM`.
187pub fn format_local_datetime(epoch_ms: i64, offset_seconds: i32) -> String {
188    // Shift to local wall-clock seconds, then split into civil date + time of
189    // day. All arithmetic is integer and floor-based so negative epochs (pre-1970)
190    // behave like JS.
191    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    // Month / day / hour: no leading zero. Minute / second: zero-padded.
205    format!("{month}/{day}/{year}, {hour12}:{minute:02}:{second:02} {meridiem}")
206}
207
208/// 24-hour → (12-hour, AM/PM). Midnight and noon render as 12.
209fn 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
218/// Civil date `(year, month, day)` from a day count relative to 1970-01-01.
219/// Howard Hinnant's `civil_from_days` algorithm (proleptic Gregorian, valid for
220/// the full Timestamp range). `month`/`day` are 1-based.
221fn 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; // [0, 146096]
225    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
226    let y = yoe + era * 400;
227    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
228    let mp = (5 * doy + 2) / 153; // [0, 11]
229    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
230    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
231    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    // ---- date formatter (the parity anchor) --------------------------------
242
243    const NY_EST: i32 = -5 * 3600; // America/New_York, standard time (Nov).
244    const UTC: i32 = 0;
245    const IST: i32 = 5 * 3600 + 30 * 60; // Asia/Kolkata, +05:30.
246
247    #[test]
248    fn matches_node_en_us_known_instants() {
249        // TZ=America/New_York: 11/14/2023, 5:13:20 PM
250        assert_eq!(
251            format_local_datetime(1_700_000_000_000, NY_EST),
252            "11/14/2023, 5:13:20 PM"
253        );
254        // TZ=UTC, epoch 0: 1/1/1970, 12:00:00 AM  (midnight → 12, no leading zeros)
255        assert_eq!(format_local_datetime(0, UTC), "1/1/1970, 12:00:00 AM");
256        // TZ=UTC noon: 11/15/2023, 12:00:00 PM
257        assert_eq!(
258            format_local_datetime(1_700_049_600_000, UTC),
259            "11/15/2023, 12:00:00 PM"
260        );
261        // TZ=UTC 1am: 11/15/2023, 1:00:00 AM
262        assert_eq!(
263            format_local_datetime(1_700_010_000_000, UTC),
264            "11/15/2023, 1:00:00 AM"
265        );
266        // TZ=Asia/Kolkata (+05:30): 11/15/2023, 3:43:20 AM
267        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        // 2023-01-05 09:07:03 UTC → single-digit month/day/hour, padded min/sec.
285        // Compute epoch: days from 1970-01-01 to 2023-01-05.
286        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        // Double-digit everything just before noon.
289        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        // TZ=America/New_York, epoch 0 → 12/31/1969, 7:00:00 PM
296        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        // 2000-02-29 (leap day) is day 11016.
304        assert_eq!(civil_from_days(11_016), (2000, 2, 29));
305    }
306
307    /// Build epoch ms for a UTC civil date/time (test helper; not parity code).
308    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    // ---- UTF-16 substring ---------------------------------------------------
324
325    #[test]
326    fn substring_counts_utf16_units() {
327        assert_eq!(substring_utf16("hello", 200), "hello");
328        assert_eq!(substring_utf16("hello", 3), "hel");
329        // '🦀' is two UTF-16 units. Limit 2 keeps one; limit 1 rounds down to "".
330        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    // ---- end-to-end markdown ------------------------------------------------
337
338    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        // No "# Prior Context" header on PreCompact.
457        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        // 200-unit cap on the preview.
474        assert_eq!(line, format!("- **n**: {}", "x".repeat(200)));
475    }
476}