1use crate::model::{Message, Role, Session};
10use serde_json::{json, Value};
11
12pub const WINDOW_SCHEMA: &str = "sessionwiki.window/1";
16pub const TURN_SCHEMA: &str = "sessionwiki.turn/1";
18pub const TURN_TEXT_CAP: usize = 23_000;
21
22pub struct WindowOpts {
23 pub tool_head: usize,
25 pub tool_tail: usize,
27 pub per_msg_chars: usize,
29 pub budget_chars: Option<usize>,
32 pub tool_byte_cap: usize,
36}
37
38impl Default for WindowOpts {
39 fn default() -> Self {
40 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
51fn 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
64fn 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
91fn 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
119pub 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 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
177fn 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
194fn 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
221pub 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 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
275pub 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 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 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}