Skip to main content

sessionwiki/
window.rs

1//! A bounded, agent-consumable render of a session: the actual conversation
2//! (not a lossy summary), but with tool-output bulk folded to head+tail and an
3//! optional total budget that keeps the recent tail. This is what a caller (an
4//! agent via MCP, or `show --window`) reads to know what another session is
5//! doing without flooding its own context. The four things that make it
6//! parseable: an orientation header, role labels, tool CALLS kept but tool
7//! RESULTS folded, and a drill-down hint to `show <id> --full`.
8
9use crate::model::{Message, Role, Session};
10use serde_json::{json, Value};
11
12/// Schema id for the JSON window returned over MCP. Bump the version ONLY on a
13/// breaking change to field names or semantics, so a consuming agent can rely on
14/// the contract.
15pub const WINDOW_SCHEMA: &str = "sessionwiki.window/1";
16/// Schema id for a single drilled-down turn (`session_window` with `turn`).
17pub const TURN_SCHEMA: &str = "sessionwiki.turn/1";
18/// Char cap for a single drilled-down turn's full text, sized to stay inside the
19/// MCP text-content window; `clipped` reports when the original was longer.
20pub const TURN_TEXT_CAP: usize = 23_000;
21
22pub struct WindowOpts {
23    /// Lines kept at the head of a folded tool result.
24    pub tool_head: usize,
25    /// Lines kept at the tail of a folded tool result.
26    pub tool_tail: usize,
27    /// Char cap for a single user/assistant message (head+tail beyond it).
28    pub per_msg_chars: usize,
29    /// Total char budget across the rendered turns; `None` folds the whole
30    /// session. When set, the most RECENT turns are kept (walk from the end).
31    pub budget_chars: Option<usize>,
32    /// Byte cap for a single folded tool result. Line folding alone can't bound a
33    /// result that is one enormous line, so the folded text is additionally
34    /// clipped to this many bytes (on a char boundary).
35    pub tool_byte_cap: usize,
36}
37
38impl Default for WindowOpts {
39    fn default() -> Self {
40        // ~4 chars/token: per-msg ~800 tok, budget (when set) ~1.5k tok.
41        WindowOpts {
42            tool_head: 3,
43            tool_tail: 3,
44            per_msg_chars: 3200,
45            budget_chars: None,
46            tool_byte_cap: 2000,
47        }
48    }
49}
50
51/// Largest index `<= max` that lands on a UTF-8 char boundary (stable-Rust
52/// substitute for the unstable `str::floor_char_boundary`).
53fn floor_char_boundary(s: &str, max: usize) -> usize {
54    if max >= s.len() {
55        return s.len();
56    }
57    let mut i = max;
58    while i > 0 && !s.is_char_boundary(i) {
59        i -= 1;
60    }
61    i
62}
63
64/// Fold a multi-line tool result to its head and tail lines, marking the gap,
65/// then byte-bound the result so a single huge line can't blow the budget.
66/// Returns the folded text and whether anything was shortened. Short results
67/// pass through unchanged with `false`.
68fn fold_tool(text: &str, head: usize, tail: usize, byte_cap: usize) -> (String, bool) {
69    let trimmed = text.trim_end();
70    let lines: Vec<&str> = trimmed.lines().collect();
71    let mut folded = lines.len() > head + tail + 1;
72    let mut out = if folded {
73        let elided = lines.len() - head - tail;
74        let mut v: Vec<String> = lines[..head].iter().map(|s| s.to_string()).collect();
75        v.push(format!("  [… {elided} lines …]"));
76        v.extend(lines[lines.len() - tail..].iter().map(|s| s.to_string()));
77        v.join("\n")
78    } else {
79        trimmed.to_string()
80    };
81    if out.len() > byte_cap {
82        let cut = floor_char_boundary(&out, byte_cap);
83        let dropped = out.len() - cut;
84        out.truncate(cut);
85        out.push_str(&format!("\n  [… {dropped} bytes …]"));
86        folded = true;
87    }
88    (out, folded)
89}
90
91/// Cap a user/assistant message to `max` chars, keeping head and tail so the
92/// intent and the conclusion both survive. Cuts on a char boundary.
93fn cap_msg(text: &str, max: usize) -> String {
94    let t = text.trim();
95    if t.chars().count() <= max {
96        return t.to_string();
97    }
98    let half = max / 2;
99    let head: String = t.chars().take(half).collect();
100    let tail: String = {
101        let all: Vec<char> = t.chars().collect();
102        all[all.len() - half..].iter().collect()
103    };
104    let elided = t.chars().count() - 2 * half;
105    format!("{head}\n  [… {elided} chars …]\n{tail}")
106}
107
108fn render_msg(m: &crate::model::Message, opts: &WindowOpts) -> String {
109    match m.role {
110        Role::User => format!("[user]\n{}", cap_msg(&m.text, opts.per_msg_chars)),
111        Role::Assistant => format!("[assistant]\n{}", cap_msg(&m.text, opts.per_msg_chars)),
112        Role::Tool => format!(
113            "[tool]\n{}",
114            fold_tool(&m.text, opts.tool_head, opts.tool_tail, opts.tool_byte_cap).0
115        ),
116    }
117}
118
119/// Render the bounded window for `session`.
120pub fn render_window(session: &Session, opts: &WindowOpts) -> String {
121    let started = session
122        .started
123        .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
124        .unwrap_or_else(|| "?".into());
125    let header = format!(
126        "## {} [{}] · {} · {} · {} messages",
127        session.title,
128        session.tool,
129        if session.project.is_empty() {
130            "(no project)"
131        } else {
132            &session.project
133        },
134        started,
135        session.messages.len()
136    );
137
138    let blocks: Vec<String> = session
139        .messages
140        .iter()
141        .map(|m| render_msg(m, opts))
142        .collect();
143
144    let (kept, omitted) = match opts.budget_chars {
145        None => (blocks, 0usize),
146        Some(budget) => {
147            // Keep the most recent turns within the budget (walk backward).
148            let mut acc = 0usize;
149            let mut taken: Vec<String> = Vec::new();
150            for b in blocks.iter().rev() {
151                if acc + b.len() > budget && !taken.is_empty() {
152                    break;
153                }
154                acc += b.len();
155                taken.push(b.clone());
156            }
157            let omitted = blocks.len() - taken.len();
158            taken.reverse();
159            (taken, omitted)
160        }
161    };
162
163    let mut out = String::new();
164    out.push_str(&header);
165    out.push_str("\n\n");
166    if omitted > 0 {
167        out.push_str(&format!("[… {omitted} earlier turn(s) omitted …]\n\n"));
168    }
169    out.push_str(&kept.join("\n\n"));
170    out.push_str(&format!(
171        "\n\n→ full: sessionwiki show {} --full",
172        session.id
173    ));
174    out
175}
176
177/// Split a leading `[large] ` flag (set by adapters when a session was indexed
178/// head+tail because it was over the size cap) off a title.
179fn split_large(title: &str) -> (bool, &str) {
180    match title.strip_prefix("[large] ") {
181        Some(rest) => (true, rest),
182        None => (false, title),
183    }
184}
185
186fn role_str(r: Role) -> &'static str {
187    match r {
188        Role::User => "user",
189        Role::Assistant => "assistant",
190        Role::Tool => "tool",
191    }
192}
193
194/// One turn as JSON, plus the char length used for budget accounting. User and
195/// assistant turns are head+tail capped (`truncated`); tool turns are folded
196/// head+tail and byte-bounded (`folded`), carrying the original `bytes`. `i` is
197/// the turn's index in the full session - the stable per-turn drill-down anchor.
198fn turn_json(i: usize, m: &Message, opts: &WindowOpts) -> (Value, usize) {
199    match m.role {
200        Role::User | Role::Assistant => {
201            let truncated = m.text.trim().chars().count() > opts.per_msg_chars;
202            let text = cap_msg(&m.text, opts.per_msg_chars);
203            let len = text.len();
204            (
205                json!({"i": i, "role": role_str(m.role), "text": text, "truncated": truncated}),
206                len,
207            )
208        }
209        Role::Tool => {
210            let (text, folded) =
211                fold_tool(&m.text, opts.tool_head, opts.tool_tail, opts.tool_byte_cap);
212            let len = text.len();
213            (
214                json!({"i": i, "role": "tool", "text": text, "folded": folded, "bytes": m.text.len()}),
215                len,
216            )
217        }
218    }
219}
220
221/// Render `session` as the versioned, agent-parseable JSON window (schema
222/// [`WINDOW_SCHEMA`]): orientation header, role-labelled turns with tool output
223/// folded, the recent tail kept within `budget_chars`. Pure and deterministic
224/// for a fixed (session, opts) - the MCP layer handles neutralization and the
225/// final size guard.
226pub fn render_window_json(session: &Session, opts: &WindowOpts) -> Value {
227    let (large, title) = split_large(&session.title);
228
229    let rendered: Vec<(Value, usize)> = session
230        .messages
231        .iter()
232        .enumerate()
233        .map(|(i, m)| turn_json(i, m, opts))
234        .collect();
235
236    let (kept, omitted) = match opts.budget_chars {
237        None => (
238            rendered.into_iter().map(|(v, _)| v).collect::<Vec<_>>(),
239            0usize,
240        ),
241        Some(budget) => {
242            // Keep the most recent turns within the budget (walk backward).
243            let mut acc = 0usize;
244            let mut taken: Vec<Value> = Vec::new();
245            for (v, len) in rendered.iter().rev() {
246                if acc + len > budget && !taken.is_empty() {
247                    break;
248                }
249                acc += len;
250                taken.push(v.clone());
251            }
252            let omitted = rendered.len() - taken.len();
253            taken.reverse();
254            (taken, omitted)
255        }
256    };
257
258    json!({
259        "schema": WINDOW_SCHEMA,
260        "id": session.id,
261        "tool": session.tool,
262        "project": session.project,
263        "title": title,
264        "started": session.started.map(|d| d.format("%Y-%m-%d %H:%M").to_string()),
265        "ended": session.ended.map(|d| d.format("%Y-%m-%d %H:%M").to_string()),
266        "messages": session.messages.len(),
267        "large": large,
268        "budget_tokens": opts.budget_chars.map(|c| c / 4),
269        "omitted_leading": omitted,
270        "turns": kept,
271        "drilldown": format!("sessionwiki show {} --full", session.id),
272    })
273}
274
275/// Render ONE turn's full RETAINED text as JSON (schema [`TURN_SCHEMA`]) - the
276/// drill-down for `session_window(id, turn=i)`. "Full" means untruncated by the
277/// window's folding/cap; tool outputs are already capped at parse time, so this
278/// recovers folded-out lines, not adapter-dropped bulk. Bounded to [`TURN_TEXT_CAP`]
279/// chars for MCP transport; `clipped` and `bytes` report the true size. `None`
280/// if `i` is out of range.
281pub fn render_turn_json(session: &Session, i: usize) -> Option<Value> {
282    let m = session.messages.get(i)?;
283    let full = m.text.trim_end();
284    let clipped = full.chars().count() > TURN_TEXT_CAP;
285    let text = if clipped {
286        full.chars().take(TURN_TEXT_CAP).collect::<String>()
287    } else {
288        full.to_string()
289    };
290    Some(json!({
291        "schema": TURN_SCHEMA,
292        "id": session.id,
293        "i": i,
294        "role": role_str(m.role),
295        "text": text,
296        "bytes": m.text.len(),
297        "clipped": clipped,
298    }))
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::model::{Message, Role, Session};
305
306    fn msg(role: Role, text: &str) -> Message {
307        Message {
308            role,
309            text: text.to_string(),
310            ts: None,
311        }
312    }
313
314    fn session(messages: Vec<Message>) -> Session {
315        Session {
316            id: "abc123".into(),
317            tool: "codex",
318            path: std::path::PathBuf::from("/x.jsonl"),
319            project: "proj".into(),
320            started: None,
321            ended: None,
322            title: "refactor token guard".into(),
323            subagent: false,
324            messages,
325            touched: vec![],
326            edits: Vec::new(),
327        }
328    }
329
330    #[test]
331    fn folds_tool_output_to_head_and_tail() {
332        let big = (1..=50)
333            .map(|i| format!("line{i}"))
334            .collect::<Vec<_>>()
335            .join("\n");
336        let (folded, was_folded) = fold_tool(&big, 3, 3, 2000);
337        assert!(was_folded, "reported folded");
338        assert!(
339            folded.contains("line1") && folded.contains("line3"),
340            "head kept"
341        );
342        assert!(
343            folded.contains("line48") && folded.contains("line50"),
344            "tail kept"
345        );
346        assert!(
347            folded.contains("[… 44 lines …]"),
348            "middle elided with count"
349        );
350        assert!(!folded.contains("line25"), "bulk dropped");
351        // A short result is untouched.
352        assert_eq!(fold_tool("a\nb", 3, 3, 2000), ("a\nb".to_string(), false));
353    }
354
355    #[test]
356    fn fold_tool_byte_bounds_a_single_huge_line() {
357        // One enormous line has too few lines to line-fold, but must still be
358        // byte-bounded so it can't blow the budget.
359        let huge = "x".repeat(50_000);
360        let (out, folded) = fold_tool(&huge, 3, 3, 2000);
361        assert!(folded, "byte-clip counts as folded");
362        assert!(out.len() < 2_200, "clipped near the byte cap");
363        assert!(out.contains("bytes …]"), "byte elision marked");
364    }
365
366    #[test]
367    fn window_json_has_versioned_schema_roles_and_drilldown() {
368        let big_tool = (1..=40)
369            .map(|i| format!("l{i}"))
370            .collect::<Vec<_>>()
371            .join("\n");
372        let s = session(vec![
373            msg(
374                Role::User,
375                "refuse switch when a session runs on the same slot",
376            ),
377            msg(Role::Assistant, "adding a fail-closed check"),
378            msg(Role::Tool, &big_tool),
379        ]);
380        let v = render_window_json(&s, &WindowOpts::default());
381        assert_eq!(v["schema"], "sessionwiki.window/1");
382        assert_eq!(v["id"], "abc123");
383        assert_eq!(v["tool"], "codex");
384        assert_eq!(v["messages"], 3);
385        assert_eq!(v["large"], false);
386        let turns = v["turns"].as_array().unwrap();
387        assert_eq!(turns.len(), 3);
388        assert_eq!(turns[0]["role"], "user");
389        assert_eq!(turns[0]["i"], 0);
390        assert_eq!(turns[2]["role"], "tool");
391        assert_eq!(turns[2]["folded"], true);
392        assert!(turns[2]["bytes"].as_u64().unwrap() > 0);
393        assert_eq!(v["drilldown"], "sessionwiki show abc123 --full");
394    }
395
396    #[test]
397    fn window_json_strips_large_flag_into_a_boolean() {
398        let mut s = session(vec![msg(Role::User, "hi there friend")]);
399        s.title = "[large] refactor token guard".into();
400        let v = render_window_json(&s, &WindowOpts::default());
401        assert_eq!(v["large"], true);
402        assert_eq!(
403            v["title"], "refactor token guard",
404            "flag stripped from title"
405        );
406    }
407
408    #[test]
409    fn window_json_budget_keeps_recent_tail_and_is_deterministic() {
410        let msgs: Vec<Message> = (0..20)
411            .map(|i| msg(Role::User, &format!("turn number {i} with some words")))
412            .collect();
413        let s = session(msgs);
414        let opts = WindowOpts {
415            budget_chars: Some(120),
416            ..Default::default()
417        };
418        let a = render_window_json(&s, &opts);
419        let b = render_window_json(&s, &opts);
420        assert_eq!(a, b, "deterministic for a fixed (session, budget)");
421        assert!(
422            a["omitted_leading"].as_u64().unwrap() > 0,
423            "older turns omitted"
424        );
425        let turns = a["turns"].as_array().unwrap();
426        let last = turns.last().unwrap();
427        assert!(
428            last["text"].as_str().unwrap().contains("turn number 19"),
429            "most recent kept"
430        );
431    }
432
433    #[test]
434    fn turn_json_returns_full_untruncated_turn() {
435        let s = session(vec![
436            msg(Role::User, "short question"),
437            msg(Role::Tool, &"y".repeat(500)),
438        ]);
439        let v = render_turn_json(&s, 1).unwrap();
440        assert_eq!(v["schema"], "sessionwiki.turn/1");
441        assert_eq!(v["i"], 1);
442        assert_eq!(v["role"], "tool");
443        assert_eq!(v["clipped"], false);
444        assert_eq!(
445            v["text"].as_str().unwrap().len(),
446            500,
447            "full text, not folded"
448        );
449        assert!(render_turn_json(&s, 9).is_none(), "out of range is None");
450    }
451
452    #[test]
453    fn window_has_header_roles_tool_fold_and_drilldown() {
454        let big_tool = (1..=40)
455            .map(|i| format!("l{i}"))
456            .collect::<Vec<_>>()
457            .join("\n");
458        let s = session(vec![
459            msg(
460                Role::User,
461                "refuse switch when a session runs on the same slot",
462            ),
463            msg(Role::Assistant, "adding a fail-closed check; running tests"),
464            msg(Role::Tool, &big_tool),
465        ]);
466        let w = render_window(&s, &WindowOpts::default());
467        assert!(
468            w.starts_with("## refactor token guard [codex]"),
469            "orientation header"
470        );
471        assert!(
472            w.contains("[user]") && w.contains("[assistant]") && w.contains("[tool]"),
473            "role labels"
474        );
475        assert!(w.contains("[… 34 lines …]"), "tool result folded");
476        assert!(
477            w.contains("adding a fail-closed check"),
478            "assistant text kept verbatim"
479        );
480        assert!(
481            w.contains("→ full: sessionwiki show abc123 --full"),
482            "drill-down hint"
483        );
484    }
485
486    #[test]
487    fn budget_keeps_the_recent_tail_and_marks_omissions() {
488        let msgs: Vec<Message> = (0..20)
489            .map(|i| msg(Role::User, &format!("turn number {i} with some words")))
490            .collect();
491        let s = session(msgs);
492        let opts = WindowOpts {
493            budget_chars: Some(120),
494            ..Default::default()
495        };
496        let w = render_window(&s, &opts);
497        assert!(w.contains("turn number 19"), "most recent kept");
498        assert!(!w.contains("turn number 0"), "oldest dropped by budget");
499        assert!(w.contains("earlier turn(s) omitted"), "omission is marked");
500    }
501}