1	//! Live activity buffer (PTY digest) parser primitive (M3a A5, ADR-0008).
2	//!
3	//! A rolling, *parsed*, source-tagged view of recent PTY I/O — the last ~N user
4	//! turns plus the agent output between them, with tool-usage sprints collapsed
5	//! to a single entry. Distinct from raw scrollback (bytes) and transcript
6	//! history (the full conversation); its job is *"what is this agent doing right
7	//! now, at a glance."*
8	//!
9	//! ## The parsing-boundary rule (ADR-0008 — NOT a built-in per-harness parser)
10	//!
11	//! **The adapter supplies the patterns; this primitive only runs them.** Exactly
12	//! Path A's "adapter owns the parser" rule and the "spt-core owns the mechanism,
13	//! adapter owns the pattern" principle (identical to opaque command templates).
14	//! The seam is three adapter-supplied pattern kinds:
15	//! - **`input`** (user-input boundary) and **`agent`** (agent-output boundary) —
16	//!   each *opens a span capturing everything, newlines included, until the next
17	//!   pattern match of any kind*.
18	//! - **`tools`** — a list (one regex per known tool) **plus an optional
19	//!   `catchall`** for unknowable user-installed tools — each extracting a tool
20	//!   `name` and an `arg` fragment via named capture groups.
21	//!
22	//! Window depth `N` and the tool-arg truncation width are spt-core-controlled
23	//! *presentation*; the template controls *extraction*.
24	//!
25	//! ## Scope (M3a is the primitive ONLY)
26	//!
27	//! This is the parser: patterns in, structured [`Digest`] out, fed from the A4
28	//! byte stream. The manifest-sourcing of the patterns, the `spt digest` pull,
29	//! the broker-pushed delta-stream, and Path-B persistence are **M3b** — they need
30	//! the broker + manifest + history store this crate deliberately does not build.
31	//!
32	//! ## OS-neutrality (Spike #4 / #5)
33	//!
34	//! ConPTY repaints on resize — it reorders and *duplicates* stream content. The
35	//! parser therefore re-derives the digest from a bounded rolling text buffer and
36	//! reports the **tail** (last `N` turns): a repaint that resends a turn just
37	//! makes the latest copy win. ANSI control sequences (cursor moves, clears,
38	//! repaint machinery) are stripped before matching so the adapter patterns see
39	//! clean text on both the forkpty raw-pipe and ConPTY screen-buffer backends.
40
41	use regex::Regex;
42	use serde::{Deserialize, Serialize};
43
44	/// A failure compiling an adapter-supplied pattern.
45	#[derive(Debug)]
46	pub enum DigestError {
47	    /// A pattern did not compile as a regex. Names which one.
48	    BadPattern { which: String, error: String },
49	}
50
51	impl std::fmt::Display for DigestError {
52	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53	        match self {
54	            DigestError::BadPattern { which, error } => {
55	                write!(f, "digest pattern {which} did not compile: {error}")
56	            }
57	        }
58	    }
59	}
60
61	impl std::error::Error for DigestError {}
62
63	/// The adapter-supplied patterns (regex source strings). `input` / `agent` are
64	/// boundary markers; `tools` + `catchall` extract `name` + `arg` capture groups.
65	#[derive(Debug, Clone)]
66	pub struct DigestPatterns {
67	    /// User-input boundary marker.
68	    pub input: String,
69	    /// Agent-output boundary marker.
70	    pub agent: String,
71	    /// One regex per known tool; each should name an `arg` group (and may name a
72	    /// `name` group — else the tool's matched text leads).
73	    pub tools: Vec<String>,
74	    /// Optional final catchall for user-installed tools.
75	    pub catchall: Option<String>,
76	}
77
78	/// Presentation knobs owned by spt-core (not the template).
79	#[derive(Debug, Clone, Copy)]
80	pub struct DigestConfig {
81	    /// Window depth — keep the last `window_turns` user turns (~3).
82	    pub window_turns: usize,
83	    /// Tool-arg truncation width in characters (~25).
84	    pub arg_truncation: usize,
85	    /// Strip ANSI control sequences before matching (default on — see module docs).
86	    pub strip_ansi: bool,
87	    /// Rolling raw-text buffer cap in bytes; older bytes are dropped from the
88	    /// front so memory stays bounded under an endless stream.
89	    pub max_scan_bytes: usize,
90	}
91
92	impl Default for DigestConfig {
93	    fn default() -> Self {
94	        Self {
95	            window_turns: 3,
96	            arg_truncation: 25,
97	            strip_ansi: true,
98	            max_scan_bytes: 64 * 1024,
99	        }
100	    }
101	}
102
103	/// A single collapsed tool invocation.
104	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105	pub struct ToolUse {
106	    /// The tool name (e.g. `Write`).
107	    pub name: String,
108	    /// The (truncated) argument fragment.
109	    pub arg: String,
110	}
111
112	/// One source-tagged entry within a turn.
113	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114	pub enum DigestEntry {
115	    /// Agent output text (between boundaries).
116	    Agent(String),
117	    /// A collapsed run of consecutive tool invocations (one "sprint").
118	    ToolSprint(Vec<ToolUse>),
119	}
120
121	/// One user turn: the user input that opened it (None for any pre-input
122	/// preamble) plus the agent output / tool sprints that followed, in order.
123	#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
124	pub struct Turn {
125	    /// The user input text that opened this turn.
126	    pub input: Option<String>,
127	    /// Agent + tool-sprint entries that followed, in stream order.
128	    pub entries: Vec<DigestEntry>,
129	}
130
131	/// The structured rolling digest — the last `N` turns.
132	#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
133	pub struct Digest {
134	    /// Turns oldest-to-newest, at most `window_turns` of them.
135	    pub turns: Vec<Turn>,
136	}
137
138	/// One located pattern hit while scanning the buffer.
139	enum Marker {
140	    Input { content: String },
141	    Agent { content: String },
142	    Tool(ToolUse),
143	}
144
145	/// The digest parser: feed bytes from the A4 stream, read a structured [`Digest`].
146	#[derive(Debug)]
147	pub struct DigestParser {
148	    input_re: Regex,
149	    agent_re: Regex,
150	    tool_res: Vec<Regex>,
151	    catchall_re: Option<Regex>,
152	    ansi_re: Option<Regex>,
153	    config: DigestConfig,
154	    /// Rolling, ANSI-stripped text buffer.
155	    buf: String,
156	}
157
158	impl DigestParser {
159	    /// Compile the adapter-supplied patterns. Returns [`DigestError::BadPattern`]
160	    /// naming the first pattern that fails to compile.
161	    //
162	    // [impl->REQ-TERM-4]
163	    pub fn new(patterns: DigestPatterns, config: DigestConfig) -> Result<Self, DigestError> {
164	        let compile = |src: &str, which: &str| {
165	            Regex::new(src).map_err(|e| DigestError::BadPattern {
166	                which: which.to_string(),
167	                error: e.to_string(),
168	            })
169	        };
170	        let input_re = compile(&patterns.input, "input")?;
171	        let agent_re = compile(&patterns.agent, "agent")?;
172	        let mut tool_res = Vec::with_capacity(patterns.tools.len());
173	        for (i, t) in patterns.tools.iter().enumerate() {
174	            tool_res.push(compile(t, &format!("tools[{i}]"))?);
175	        }
176	        let catchall_re = match &patterns.catchall {
177	            Some(c) => Some(compile(c, "catchall")?),
178	            None => None,
179	        };
180	        // CSI / OSC / lone-ESC stripper. Compiled by us, never fails.
181	        let ansi_re = if config.strip_ansi {
182	            Some(
183	                Regex::new(
184	                    r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]",
185	                )
186	                .expect("static ANSI regex compiles"),
187	            )
188	        } else {
189	            None
190	        };
191	        Ok(Self {
192	            input_re,
193	            agent_re,
194	            tool_res,
195	            catchall_re,
196	            ansi_re,
197	            config,
198	            buf: String::new(),
199	        })
200	    }
201
202	    /// Feed a chunk of the byte stream. Decoded lossily to UTF-8, ANSI-stripped
203	    /// (if configured), normalized (`\r\n`/`\r` → `\n`), appended, and trimmed to
204	    /// the rolling cap.
205	    //
206	    // [impl->REQ-TERM-4]
207	    pub fn feed(&mut self, bytes: &[u8]) {
208	        let text = String::from_utf8_lossy(bytes);
209	        let cleaned = match &self.ansi_re {
210	            Some(re) => re.replace_all(&text, "").into_owned(),
211	            None => text.into_owned(),
212	        };
213	        // Normalize carriage returns left by ConPTY repaint so boundary patterns
214	        // and span content match on line-oriented text.
215	        let cleaned = cleaned.replace("\r\n", "\n").replace('\r', "");
216	        self.buf.push_str(&cleaned);
217
218	        if self.buf.len() > self.config.max_scan_bytes {
219	            // Drop from the front, on a char boundary.
220	            let cut = self.buf.len() - self.config.max_scan_bytes;
221	            let mut idx = cut;
222	            while idx < self.buf.len() && !self.buf.is_char_boundary(idx) {
223	                idx += 1;
224	            }
225	            self.buf.drain(..idx);
226	        }
227	    }
228
229	    /// Re-derive the structured digest from the current buffer: scan all pattern
230	    /// hits in order, fold boundary spans + collapsed tool sprints into turns,
231	    /// and return the last `window_turns`.
232	    //
233	    // [impl->REQ-TERM-4]
234	    pub fn digest(&self) -> Digest {
235	        let markers = self.scan_markers();
236	        let mut turns: Vec<Turn> = Vec::new();
237	        let mut current = Turn::default();
238	        let mut current_started = false;
239
240	        for m in markers {
241	            match m {
242	                Marker::Input { content } => {
243	                    if current_started || !current.entries.is_empty() || current.input.is_some() {
244	                        turns.push(std::mem::take(&mut current));
245	                    }
246	                    current = Turn {
247	                        input: Some(content),
248	                        entries: Vec::new(),
249	                    };
250	                    current_started = true;
251	                }
252	                Marker::Agent { content } => {
253	                    if !content.is_empty() {
254	                        current.entries.push(DigestEntry::Agent(content));
255	                        current_started = true;
256	                    }
257	                }
258	                Marker::Tool(tu) => {
259	                    // Collapse into the trailing sprint if the last entry is one.
260	                    if let Some(DigestEntry::ToolSprint(sprint)) = current.entries.last_mut() {
261	                        sprint.push(tu);
262	                    } else {
263	                        current.entries.push(DigestEntry::ToolSprint(vec![tu]));
264	                    }
265	                    current_started = true;
266	                }
267	            }
268	        }
269	        if current_started || !current.entries.is_empty() || current.input.is_some() {
270	            turns.push(current);
271	        }
272
273	        // Keep only the last N turns.
274	        let n = self.config.window_turns;
275	        if turns.len() > n {
276	            turns.drain(..turns.len() - n);
277	        }
278	        Digest { turns }
279	    }
280
281	    /// Locate every pattern hit in the buffer, sorted by position, and resolve
282	    /// boundary span content (text from a boundary to the next hit of any kind).
283	    fn scan_markers(&self) -> Vec<Marker> {
284	        // (start, end, kind) where kind carries the extracted payload.
285	        enum Kind {
286	            Input,
287	            Agent,
288	            Tool(ToolUse),
289	        }
290	        let mut hits: Vec<(usize, usize, Kind)> = Vec::new();
291
292	        for m in self.input_re.find_iter(&self.buf) {
293	            hits.push((m.start(), m.end(), Kind::Input));
294	        }
295	        for m in self.agent_re.find_iter(&self.buf) {
296	            hits.push((m.start(), m.end(), Kind::Agent));
297	        }
298	        let mut push_tool = |re: &Regex| {
299	            for caps in re.captures_iter(&self.buf) {
300	                let whole = caps.get(0).unwrap();
301	                let name = caps
302	                    .name("name")
303	                    .map(|m| m.as_str().to_string())
304	                    .unwrap_or_else(|| {
305	                        // No name group: lead with the matched text's first token.
306	                        whole
307	                            .as_str()
308	                            .split_whitespace()
309	                            .next()
310	                            .unwrap_or("")
311	                            .to_string()
312	                    });
313	                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
314	                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
315	                hits.push((
316	                    whole.start(),
317	                    whole.end(),
318	                    Kind::Tool(ToolUse { name, arg }),
319	                ));
320	            }
321	        };
322	        for re in &self.tool_res {
323	            push_tool(re);
324	        }
325	        if let Some(re) = &self.catchall_re {
326	            // Only count a catchall hit where no specific tool already matched.
327	            for caps in re.captures_iter(&self.buf) {
328	                let whole = caps.get(0).unwrap();
329	                if hits.iter().any(|(s, e, k)| {
330	                    matches!(k, Kind::Tool(_)) && *s <= whole.start() && whole.end() <= *e
331	                }) {
332	                    continue;
333	                }
334	                let name = caps
335	                    .name("name")
336	                    .map(|m| m.as_str().to_string())
337	                    .unwrap_or_else(|| {
338	                        whole
339	                            .as_str()
340	                            .split_whitespace()
341	                            .next()
342	                            .unwrap_or("")
343	                            .to_string()
344	                    });
345	                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
346	                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
347	                hits.push((
348	                    whole.start(),
349	                    whole.end(),
350	                    Kind::Tool(ToolUse { name, arg }),
351	                ));
352	            }
353	        }
354
355	        // Order by position; on a tie, boundaries before tools (a boundary opens
356	        // a span, tools sit inside it).
357	        let rank = |k: &Kind| -> u8 { matches!(k, Kind::Tool(_)) as u8 };
358	        hits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| rank(&a.2).cmp(&rank(&b.2))));
359
360	        // Resolve boundary span content = text from this hit's end to the next
361	        // hit's start.
362	        let starts: Vec<usize> = hits.iter().map(|(s, _, _)| *s).collect();
363	        let mut out = Vec::with_capacity(hits.len());
364	        for (i, (_, end, kind)) in hits.iter().enumerate() {
365	            let next_start = starts.get(i + 1).copied().unwrap_or(self.buf.len());
366	            let span = self
367	                .buf
368	                .get(*end..next_start)
369	                .unwrap_or("")
370	                .trim()
371	                .to_string();
372	            match kind {
373	                Kind::Input => out.push(Marker::Input { content: span }),
374	                Kind::Agent => out.push(Marker::Agent { content: span }),
375	                Kind::Tool(tu) => out.push(Marker::Tool(tu.clone())),
376	            }
377	        }
378	        out
379	    }
380	}
381
382	/// Truncate `arg` to `width` characters, appending `…` when shortened.
383	fn truncate_arg(arg: &str, width: usize) -> String {
384	    if arg.chars().count() <= width {
385	        return arg.to_string();
386	    }
387	    let mut s: String = arg.chars().take(width).collect();
388	    s.push('…');
389	    s
390	}
391
392	#[cfg(test)]
393	mod tests {
394	    use super::*;
395
396	    fn patterns() -> DigestPatterns {
397	        DigestPatterns {
398	            // Boundary markers (the prompt sigils an adapter would supply).
399	            input: r"\nUSER> ".to_string(),
400	            agent: r"\nAGENT> ".to_string(),
401	            tools: vec![
402	                r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string(),
403	                r"(?P<name>Bash)\((?P<arg>[^)]*)\)".to_string(),
404	            ],
405	            catchall: Some(r"(?P<name>[A-Z][a-zA-Z]+)\((?P<arg>[^)]*)\)".to_string()),
406	        }
407	    }
408
409	    fn parser() -> DigestParser {
410	        DigestParser::new(
411	            patterns(),
412	            DigestConfig {
413	                window_turns: 3,
414	                ..Default::default()
415	            },
416	        )
417	        .expect("compile patterns")
418	    }
419
420	    // [unit->REQ-TERM-4]
421	    #[test]
422	    fn tags_input_agent_and_collapses_tool_sprints() {
423	        let mut p = parser();
424	        p.feed(
425	            b"\nUSER> add a file\nAGENT> sure, doing it\nWrite(src/a.rs) Bash(cargo build) done\n",
426	        );
427	        let d = p.digest();
428	        assert_eq!(d.turns.len(), 1);
429	        let t = &d.turns[0];
430	        assert_eq!(t.input.as_deref(), Some("add a file"));
431	        // First entry: agent text; second: a single collapsed sprint of 2 tools.
432	        assert_eq!(
433	            t.entries[0],
434	            DigestEntry::Agent("sure, doing it".to_string())
435	        );
436	        match &t.entries[1] {
437	            DigestEntry::ToolSprint(s) => {
438	                assert_eq!(s.len(), 2, "consecutive tools collapse into one sprint");
439	                assert_eq!(
440	                    s[0],
441	                    ToolUse {
442	                        name: "Write".into(),
443	                        arg: "src/a.rs".into()
444	                    }
445	                );
446	                assert_eq!(
447	                    s[1],
448	                    ToolUse {
449	                        name: "Bash".into(),
450	                        arg: "cargo build".into()
451	                    }
452	                );
453	            }
454	            other => panic!("expected a tool sprint, got {other:?}"),
455	        }
456	    }
457
458	    // [unit->REQ-TERM-4]
459	    #[test]
460	    fn windows_to_last_n_turns() {
461	        let mut p = parser();
462	        for i in 0..6 {
463	            p.feed(format!("\nUSER> turn {i}\nAGENT> reply {i}\n").as_bytes());
464	        }
465	        let d = p.digest();
466	        assert_eq!(d.turns.len(), 3, "window keeps only the last N=3 turns");
467	        assert_eq!(d.turns[0].input.as_deref(), Some("turn 3"));
468	        assert_eq!(d.turns[2].input.as_deref(), Some("turn 5"));
469	    }
470
471	    // [unit->REQ-TERM-4]
472	    #[test]
473	    fn truncates_tool_args_to_width() {
474	        let cfg = DigestConfig {
475	            window_turns: 3,
476	            arg_truncation: 10,
477	            ..Default::default()
478	        };
479	        let mut p = DigestParser::new(patterns(), cfg).unwrap();
480	        p.feed(b"\nUSER> x\nAGENT> y\nBash(this-is-a-very-long-command-line) z\n");
481	        let d = p.digest();
482	        let sprint = d.turns[0]
483	            .entries
484	            .iter()
485	            .find_map(|e| match e {
486	                DigestEntry::ToolSprint(s) => Some(s),
487	                _ => None,
488	            })
489	            .expect("a sprint");
490	        assert_eq!(sprint[0].name, "Bash");
491	        assert_eq!(
492	            sprint[0].arg, "this-is-a-…",
493	            "arg truncated to width with ellipsis"
494	        );
495	    }
496
497	    // [unit->REQ-TERM-4]
498	    #[test]
499	    fn strips_ansi_and_tolerates_repaint_duplication() {
500	        let mut p = parser();
501	        // A ConPTY-style repaint: clear + home + a duplicated earlier turn, then
502	        // the real latest turn. ANSI is stripped; the tail (last N) wins.
503	        p.feed(b"\nUSER> first\nAGENT> \x1b[2J\x1b[Hreply one\n");
504	        p.feed(b"\x1b[2J\x1b[H\nUSER> first\nAGENT> reply one\n\nUSER> second\nAGENT> reply two\x1b[0m\n");
505	        let d = p.digest();
506	        // No ANSI leaked into content.
507	        for t in &d.turns {
508	            if let Some(i) = &t.input {
509	                assert!(!i.contains('\x1b'), "ANSI stripped from input");
510	            }
511	            for e in &t.entries {
512	                if let DigestEntry::Agent(a) = e {
513	                    assert!(!a.contains('\x1b'), "ANSI stripped from agent text");
514	                    assert!(!a.contains("[2J"), "no CSI residue");
515	                }
516	            }
517	        }
518	        // The most recent turn is present and correct despite the repaint.
519	        let last = d.turns.last().unwrap();
520	        assert_eq!(last.input.as_deref(), Some("second"));
521	        assert_eq!(last.entries[0], DigestEntry::Agent("reply two".to_string()));
522	    }
523
524	    // [unit->REQ-TERM-4]
525	    #[test]
526	    fn bad_pattern_is_reported_by_name() {
527	        let mut pats = patterns();
528	        pats.agent = r"(unclosed".to_string();
529	        let err = DigestParser::new(pats, DigestConfig::default()).unwrap_err();
530	        match err {
531	            DigestError::BadPattern { which, .. } => assert_eq!(which, "agent"),
532	        }
533	    }
534	}
535

//! Live activity buffer (PTY digest) parser primitive (M3a A5, ADR-0008).
//!
//! A rolling, *parsed*, source-tagged view of recent PTY I/O — the last ~N user
//! turns plus the agent output between them, with tool-usage sprints collapsed
//! to a single entry. Distinct from raw scrollback (bytes) and transcript
//! history (the full conversation); its job is *"what is this agent doing right
//! now, at a glance."*
//!
//! ## The parsing-boundary rule (ADR-0008 — NOT a built-in per-harness parser)
//!
//! **The adapter supplies the patterns; this primitive only runs them.** Exactly
//! Path A's "adapter owns the parser" rule and the "spt-core owns the mechanism,
//! adapter owns the pattern" principle (identical to opaque command templates).
//! The seam is three adapter-supplied pattern kinds:
//! - **`input`** (user-input boundary) and **`agent`** (agent-output boundary) —
//!   each *opens a span capturing everything, newlines included, until the next
//!   pattern match of any kind*.
//! - **`tools`** — a list (one regex per known tool) **plus an optional
//!   `catchall`** for unknowable user-installed tools — each extracting a tool
//!   `name` and an `arg` fragment via named capture groups.
//!
//! Window depth `N` and the tool-arg truncation width are spt-core-controlled
//! *presentation*; the template controls *extraction*.
//!
//! ## Scope (M3a is the primitive ONLY)
//!
//! This is the parser: patterns in, structured [`Digest`] out, fed from the A4
//! byte stream. The manifest-sourcing of the patterns, the `spt digest` pull,
//! the broker-pushed delta-stream, and Path-B persistence are **M3b** — they need
//! the broker + manifest + history store this crate deliberately does not build.
//!
//! ## OS-neutrality (Spike #4 / #5)
//!
//! ConPTY repaints on resize — it reorders and *duplicates* stream content. The
//! parser therefore re-derives the digest from a bounded rolling text buffer and
//! reports the **tail** (last `N` turns): a repaint that resends a turn just
//! makes the latest copy win. ANSI control sequences (cursor moves, clears,
//! repaint machinery) are stripped before matching so the adapter patterns see
//! clean text on both the forkpty raw-pipe and ConPTY screen-buffer backends.

use regex::Regex;
use serde::{Deserialize, Serialize};

/// A failure compiling an adapter-supplied pattern.
#[derive(Debug)]
pub enum DigestError {
    /// A pattern did not compile as a regex. Names which one.
    BadPattern { which: String, error: String },
}

impl std::fmt::Display for DigestError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DigestError::BadPattern { which, error } => {
                write!(f, "digest pattern {which} did not compile: {error}")
            }
        }
    }
}

impl std::error::Error for DigestError {}

/// The adapter-supplied patterns (regex source strings). `input` / `agent` are
/// boundary markers; `tools` + `catchall` extract `name` + `arg` capture groups.
#[derive(Debug, Clone)]
pub struct DigestPatterns {
    /// User-input boundary marker.
    pub input: String,
    /// Agent-output boundary marker.
    pub agent: String,
    /// One regex per known tool; each should name an `arg` group (and may name a
    /// `name` group — else the tool's matched text leads).
    pub tools: Vec<String>,
    /// Optional final catchall for user-installed tools.
    pub catchall: Option<String>,
}

/// Presentation knobs owned by spt-core (not the template).
#[derive(Debug, Clone, Copy)]
pub struct DigestConfig {
    /// Window depth — keep the last `window_turns` user turns (~3).
    pub window_turns: usize,
    /// Tool-arg truncation width in characters (~25).
    pub arg_truncation: usize,
    /// Strip ANSI control sequences before matching (default on — see module docs).
    pub strip_ansi: bool,
    /// Rolling raw-text buffer cap in bytes; older bytes are dropped from the
    /// front so memory stays bounded under an endless stream.
    pub max_scan_bytes: usize,
}

impl Default for DigestConfig {
    fn default() -> Self {
        Self {
            window_turns: 3,
            arg_truncation: 25,
            strip_ansi: true,
            max_scan_bytes: 64 * 1024,
        }
    }
}

/// A single collapsed tool invocation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolUse {
    /// The tool name (e.g. `Write`).
    pub name: String,
    /// The (truncated) argument fragment.
    pub arg: String,
}

/// One source-tagged entry within a turn.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DigestEntry {
    /// Agent output text (between boundaries).
    Agent(String),
    /// A collapsed run of consecutive tool invocations (one "sprint").
    ToolSprint(Vec<ToolUse>),
}

/// One user turn: the user input that opened it (None for any pre-input
/// preamble) plus the agent output / tool sprints that followed, in order.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Turn {
    /// The user input text that opened this turn.
    pub input: Option<String>,
    /// Agent + tool-sprint entries that followed, in stream order.
    pub entries: Vec<DigestEntry>,
}

/// The structured rolling digest — the last `N` turns.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Digest {
    /// Turns oldest-to-newest, at most `window_turns` of them.
    pub turns: Vec<Turn>,
}

/// One located pattern hit while scanning the buffer.
enum Marker {
    Input { content: String },
    Agent { content: String },
    Tool(ToolUse),
}

/// The digest parser: feed bytes from the A4 stream, read a structured [`Digest`].
#[derive(Debug)]
pub struct DigestParser {
    input_re: Regex,
    agent_re: Regex,
    tool_res: Vec<Regex>,
    catchall_re: Option<Regex>,
    ansi_re: Option<Regex>,
    config: DigestConfig,
    /// Rolling, ANSI-stripped text buffer.
    buf: String,
}

impl DigestParser {
    /// Compile the adapter-supplied patterns. Returns [`DigestError::BadPattern`]
    /// naming the first pattern that fails to compile.
    //
    // [impl->REQ-TERM-4]
    pub fn new(patterns: DigestPatterns, config: DigestConfig) -> Result<Self, DigestError> {
        let compile = |src: &str, which: &str| {
            Regex::new(src).map_err(|e| DigestError::BadPattern {
                which: which.to_string(),
                error: e.to_string(),
            })
        };
        let input_re = compile(&patterns.input, "input")?;
        let agent_re = compile(&patterns.agent, "agent")?;
        let mut tool_res = Vec::with_capacity(patterns.tools.len());
        for (i, t) in patterns.tools.iter().enumerate() {
            tool_res.push(compile(t, &format!("tools[{i}]"))?);
        }
        let catchall_re = match &patterns.catchall {
            Some(c) => Some(compile(c, "catchall")?),
            None => None,
        };
        // CSI / OSC / lone-ESC stripper. Compiled by us, never fails.
        let ansi_re = if config.strip_ansi {
            Some(
                Regex::new(
                    r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]",
                )
                .expect("static ANSI regex compiles"),
            )
        } else {
            None
        };
        Ok(Self {
            input_re,
            agent_re,
            tool_res,
            catchall_re,
            ansi_re,
            config,
            buf: String::new(),
        })
    }

    /// Feed a chunk of the byte stream. Decoded lossily to UTF-8, ANSI-stripped
    /// (if configured), normalized (`\r\n`/`\r` → `\n`), appended, and trimmed to
    /// the rolling cap.
    //
    // [impl->REQ-TERM-4]
    pub fn feed(&mut self, bytes: &[u8]) {
        let text = String::from_utf8_lossy(bytes);
        let cleaned = match &self.ansi_re {
            Some(re) => re.replace_all(&text, "").into_owned(),
            None => text.into_owned(),
        };
        // Normalize carriage returns left by ConPTY repaint so boundary patterns
        // and span content match on line-oriented text.
        let cleaned = cleaned.replace("\r\n", "\n").replace('\r', "");
        self.buf.push_str(&cleaned);

        if self.buf.len() > self.config.max_scan_bytes {
            // Drop from the front, on a char boundary.
            let cut = self.buf.len() - self.config.max_scan_bytes;
            let mut idx = cut;
            while idx < self.buf.len() && !self.buf.is_char_boundary(idx) {
                idx += 1;
            }
            self.buf.drain(..idx);
        }
    }

    /// Re-derive the structured digest from the current buffer: scan all pattern
    /// hits in order, fold boundary spans + collapsed tool sprints into turns,
    /// and return the last `window_turns`.
    //
    // [impl->REQ-TERM-4]
    pub fn digest(&self) -> Digest {
        let markers = self.scan_markers();
        let mut turns: Vec<Turn> = Vec::new();
        let mut current = Turn::default();
        let mut current_started = false;

        for m in markers {
            match m {
                Marker::Input { content } => {
                    if current_started || !current.entries.is_empty() || current.input.is_some() {
                        turns.push(std::mem::take(&mut current));
                    }
                    current = Turn {
                        input: Some(content),
                        entries: Vec::new(),
                    };
                    current_started = true;
                }
                Marker::Agent { content } => {
                    if !content.is_empty() {
                        current.entries.push(DigestEntry::Agent(content));
                        current_started = true;
                    }
                }
                Marker::Tool(tu) => {
                    // Collapse into the trailing sprint if the last entry is one.
                    if let Some(DigestEntry::ToolSprint(sprint)) = current.entries.last_mut() {
                        sprint.push(tu);
                    } else {
                        current.entries.push(DigestEntry::ToolSprint(vec![tu]));
                    }
                    current_started = true;
                }
            }
        }
        if current_started || !current.entries.is_empty() || current.input.is_some() {
            turns.push(current);
        }

        // Keep only the last N turns.
        let n = self.config.window_turns;
        if turns.len() > n {
            turns.drain(..turns.len() - n);
        }
        Digest { turns }
    }

    /// Locate every pattern hit in the buffer, sorted by position, and resolve
    /// boundary span content (text from a boundary to the next hit of any kind).
    fn scan_markers(&self) -> Vec<Marker> {
        // (start, end, kind) where kind carries the extracted payload.
        enum Kind {
            Input,
            Agent,
            Tool(ToolUse),
        }
        let mut hits: Vec<(usize, usize, Kind)> = Vec::new();

        for m in self.input_re.find_iter(&self.buf) {
            hits.push((m.start(), m.end(), Kind::Input));
        }
        for m in self.agent_re.find_iter(&self.buf) {
            hits.push((m.start(), m.end(), Kind::Agent));
        }
        let mut push_tool = |re: &Regex| {
            for caps in re.captures_iter(&self.buf) {
                let whole = caps.get(0).unwrap();
                let name = caps
                    .name("name")
                    .map(|m| m.as_str().to_string())
                    .unwrap_or_else(|| {
                        // No name group: lead with the matched text's first token.
                        whole
                            .as_str()
                            .split_whitespace()
                            .next()
                            .unwrap_or("")
                            .to_string()
                    });
                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
                hits.push((
                    whole.start(),
                    whole.end(),
                    Kind::Tool(ToolUse { name, arg }),
                ));
            }
        };
        for re in &self.tool_res {
            push_tool(re);
        }
        if let Some(re) = &self.catchall_re {
            // Only count a catchall hit where no specific tool already matched.
            for caps in re.captures_iter(&self.buf) {
                let whole = caps.get(0).unwrap();
                if hits.iter().any(|(s, e, k)| {
                    matches!(k, Kind::Tool(_)) && *s <= whole.start() && whole.end() <= *e
                }) {
                    continue;
                }
                let name = caps
                    .name("name")
                    .map(|m| m.as_str().to_string())
                    .unwrap_or_else(|| {
                        whole
                            .as_str()
                            .split_whitespace()
                            .next()
                            .unwrap_or("")
                            .to_string()
                    });
                let arg_raw = caps.name("arg").map(|m| m.as_str()).unwrap_or("");
                let arg = truncate_arg(arg_raw.trim(), self.config.arg_truncation);
                hits.push((
                    whole.start(),
                    whole.end(),
                    Kind::Tool(ToolUse { name, arg }),
                ));
            }
        }

        // Order by position; on a tie, boundaries before tools (a boundary opens
        // a span, tools sit inside it).
        let rank = |k: &Kind| -> u8 { matches!(k, Kind::Tool(_)) as u8 };
        hits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| rank(&a.2).cmp(&rank(&b.2))));

        // Resolve boundary span content = text from this hit's end to the next
        // hit's start.
        let starts: Vec<usize> = hits.iter().map(|(s, _, _)| *s).collect();
        let mut out = Vec::with_capacity(hits.len());
        for (i, (_, end, kind)) in hits.iter().enumerate() {
            let next_start = starts.get(i + 1).copied().unwrap_or(self.buf.len());
            let span = self
                .buf
                .get(*end..next_start)
                .unwrap_or("")
                .trim()
                .to_string();
            match kind {
                Kind::Input => out.push(Marker::Input { content: span }),
                Kind::Agent => out.push(Marker::Agent { content: span }),
                Kind::Tool(tu) => out.push(Marker::Tool(tu.clone())),
            }
        }
        out
    }
}

/// Truncate `arg` to `width` characters, appending `…` when shortened.
fn truncate_arg(arg: &str, width: usize) -> String {
    if arg.chars().count() <= width {
        return arg.to_string();
    }
    let mut s: String = arg.chars().take(width).collect();
    s.push('…');
    s
}

#[cfg(test)]
mod tests {
    use super::*;

    fn patterns() -> DigestPatterns {
        DigestPatterns {
            // Boundary markers (the prompt sigils an adapter would supply).
            input: r"\nUSER> ".to_string(),
            agent: r"\nAGENT> ".to_string(),
            tools: vec![
                r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string(),
                r"(?P<name>Bash)\((?P<arg>[^)]*)\)".to_string(),
            ],
            catchall: Some(r"(?P<name>[A-Z][a-zA-Z]+)\((?P<arg>[^)]*)\)".to_string()),
        }
    }

    fn parser() -> DigestParser {
        DigestParser::new(
            patterns(),
            DigestConfig {
                window_turns: 3,
                ..Default::default()
            },
        )
        .expect("compile patterns")
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn tags_input_agent_and_collapses_tool_sprints() {
        let mut p = parser();
        p.feed(
            b"\nUSER> add a file\nAGENT> sure, doing it\nWrite(src/a.rs) Bash(cargo build) done\n",
        );
        let d = p.digest();
        assert_eq!(d.turns.len(), 1);
        let t = &d.turns[0];
        assert_eq!(t.input.as_deref(), Some("add a file"));
        // First entry: agent text; second: a single collapsed sprint of 2 tools.
        assert_eq!(
            t.entries[0],
            DigestEntry::Agent("sure, doing it".to_string())
        );
        match &t.entries[1] {
            DigestEntry::ToolSprint(s) => {
                assert_eq!(s.len(), 2, "consecutive tools collapse into one sprint");
                assert_eq!(
                    s[0],
                    ToolUse {
                        name: "Write".into(),
                        arg: "src/a.rs".into()
                    }
                );
                assert_eq!(
                    s[1],
                    ToolUse {
                        name: "Bash".into(),
                        arg: "cargo build".into()
                    }
                );
            }
            other => panic!("expected a tool sprint, got {other:?}"),
        }
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn windows_to_last_n_turns() {
        let mut p = parser();
        for i in 0..6 {
            p.feed(format!("\nUSER> turn {i}\nAGENT> reply {i}\n").as_bytes());
        }
        let d = p.digest();
        assert_eq!(d.turns.len(), 3, "window keeps only the last N=3 turns");
        assert_eq!(d.turns[0].input.as_deref(), Some("turn 3"));
        assert_eq!(d.turns[2].input.as_deref(), Some("turn 5"));
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn truncates_tool_args_to_width() {
        let cfg = DigestConfig {
            window_turns: 3,
            arg_truncation: 10,
            ..Default::default()
        };
        let mut p = DigestParser::new(patterns(), cfg).unwrap();
        p.feed(b"\nUSER> x\nAGENT> y\nBash(this-is-a-very-long-command-line) z\n");
        let d = p.digest();
        let sprint = d.turns[0]
            .entries
            .iter()
            .find_map(|e| match e {
                DigestEntry::ToolSprint(s) => Some(s),
                _ => None,
            })
            .expect("a sprint");
        assert_eq!(sprint[0].name, "Bash");
        assert_eq!(
            sprint[0].arg, "this-is-a-…",
            "arg truncated to width with ellipsis"
        );
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn strips_ansi_and_tolerates_repaint_duplication() {
        let mut p = parser();
        // A ConPTY-style repaint: clear + home + a duplicated earlier turn, then
        // the real latest turn. ANSI is stripped; the tail (last N) wins.
        p.feed(b"\nUSER> first\nAGENT> \x1b[2J\x1b[Hreply one\n");
        p.feed(b"\x1b[2J\x1b[H\nUSER> first\nAGENT> reply one\n\nUSER> second\nAGENT> reply two\x1b[0m\n");
        let d = p.digest();
        // No ANSI leaked into content.
        for t in &d.turns {
            if let Some(i) = &t.input {
                assert!(!i.contains('\x1b'), "ANSI stripped from input");
            }
            for e in &t.entries {
                if let DigestEntry::Agent(a) = e {
                    assert!(!a.contains('\x1b'), "ANSI stripped from agent text");
                    assert!(!a.contains("[2J"), "no CSI residue");
                }
            }
        }
        // The most recent turn is present and correct despite the repaint.
        let last = d.turns.last().unwrap();
        assert_eq!(last.input.as_deref(), Some("second"));
        assert_eq!(last.entries[0], DigestEntry::Agent("reply two".to_string()));
    }

    // [unit->REQ-TERM-4]
    #[test]
    fn bad_pattern_is_reported_by_name() {
        let mut pats = patterns();
        pats.agent = r"(unclosed".to_string();
        let err = DigestParser::new(pats, DigestConfig::default()).unwrap_err();
        match err {
            DigestError::BadPattern { which, .. } => assert_eq!(which, "agent"),
        }
    }
}


doyle ACK received (high-priority) — acknowledging before continuing.

doyle: §2 migrate-fixture locked, part 9 confirmed complete (T5 twohost = separate, but **verify it's logged on REQ-MSG-6/ROADMAP while in the tree**). "Go. Ping at G3 CI-green."

Quick ack + read spt-term structure to start T7a.


Bash

$OWL send doyle todlando <<'EOF'
[ACK — executing] Locked. Starting T7a commit 1 (spt-term projection primitive + published digest-record contract + units) now. Will fold in the T5-twohost-logged-on-REQ-MSG-6/ROADMAP verify as a side-check while in-tree (separate from G3 scope, as you flagged). Ping at G3 CI-green.
EOF

Ack doyle go-ahead


➜ SENT:doyle


1	[package]
2	name = "spt-term"
3	version.workspace = true
4	edition = "2021"
5	publish = false
6	description = "spt-core session-surface mechanism (M3a): the multiplatform PTY layer every hosted agent session runs inside. ConPTY (Windows) / forkpty (Unix) behind one SessionSurface trait, byte-stream output with bounded backpressure, and the live activity buffer (PTY digest) parser primitive. Internal broker machinery (NOT public SDK, R-ARCH-2) — the M3b daemon hosts many of these."
7
8	[dependencies]
9	spt-proto = { path = "../spt-proto" }
10	spt-store = { path = "../spt-store" }
11	spt-msg = { path = "../spt-msg" }
12	portable-pty = "0.8"
13	regex = "1"
14	# The digest data types (Digest/Turn/DigestEntry/ToolUse) cross the broker↔brain
15	# IPC + the `spt digest` control channel (M3b B8), so they are serde-serializable.
16	serde = { version = "1", features = ["derive"] }
17
18	[dev-dependencies]
19	tempfile = "3"
20

[package]
name = "spt-term"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core session-surface mechanism (M3a): the multiplatform PTY layer every hosted agent session runs inside. ConPTY (Windows) / forkpty (Unix) behind one SessionSurface trait, byte-stream output with bounded backpressure, and the live activity buffer (PTY digest) parser primitive. Internal broker machinery (NOT public SDK, R-ARCH-2) — the M3b daemon hosts many of these."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
portable-pty = "0.8"
regex = "1"
# The digest data types (Digest/Turn/DigestEntry/ToolUse) cross the broker↔brain
# IPC + the `spt digest` control channel (M3b B8), so they are serde-serializable.
serde = { version = "1", features = ["derive"] }

[dev-dependencies]
tempfile = "3"


1	//! spt-core session-surface mechanism — the broker's PTY half (M3a).
2	//!
3	//! `spt-term` is the **multiplatform session-surface mechanism** that every hosted
4	//! agent session runs inside: a real child under a native PTY — ConPTY on Windows
5	//! 10+, `forkpty(3)` on Unix — behind one [`SessionSurface`] trait, with input
6	//! injection, byte-stream output, and the live activity buffer (PTY digest) parser
7	//! primitive layered on top. It is the sixth crate in the acyclic stack
8	//! (`spt-proto -> spt-store -> spt-msg -> spt-term`), a sibling of `spt-runtime`.
9	//!
10	//! M3a delivers the *library mechanism*; the **M3b** `spt-daemon` broker delivers
11	//! the *process* that hosts many of these behind versioned IPC. `spt-term` is
12	//! deliberately **single-surface-per-handle** — it owns one child's PTY, not the
13	//! multiplexed name-addressable fleet (that supervisor is M3b). See `M3a-PLAN.md`.
14	//!
15	//! ## OS stream-contract neutrality (Spike #4 / #5, binding)
16	//!
17	//! The single biggest design risk this crate carries: `forkpty` is a **raw ordered
18	//! pipe**; ConPTY is a **screen buffer** (repaint-on-resize reorders + duplicates
19	//! the stream; a mid-session attach sees the viewport, not scrollback). The
20	//! [`SessionSurface`] trait and the byte-stream / digest contracts therefore must
21	//! **not** bake in either OS's semantics — the surface is modeled as a byte stream
22	//! with explicit, OS-independent attach/replay semantics.
23	//
24	// spt-term is internal broker machinery, NOT part of the public semver SDK
25	// surface (R-ARCH-2 stays spt-proto/spt-store/spt-msg/spt-runtime). The M3b
26	// daemon programs against it; external harness authors do not.
27
28	// Surfaces land across M3a (see M3a-PLAN.md task table):
29	//   A1  SessionSurface trait + ConPTY/forkpty backends     (REQ-TERM-1)       ← landed
30	//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR) ← landed
31	//   A3  send-keys / send-line injection                     (REQ-TERM-2)
32	//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)       ← landed
33	//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)       ← landed
34
35	pub mod digest;
36	pub mod pty;
37	pub mod reader;
38	pub mod stream;
39	pub mod surface;
40
41	pub use digest::{
42	    Digest, DigestConfig, DigestEntry, DigestError, DigestParser, DigestPatterns, ToolUse, Turn,
43	};
44	pub use portable_pty::CommandBuilder;
45	pub use pty::PtySession;
46	pub use reader::Drain;
47	pub use stream::OutputStream;
48	pub use surface::{SessionSurface, SurfaceError, SurfaceSize};
49

//! spt-core session-surface mechanism — the broker's PTY half (M3a).
//!
//! `spt-term` is the **multiplatform session-surface mechanism** that every hosted
//! agent session runs inside: a real child under a native PTY — ConPTY on Windows
//! 10+, `forkpty(3)` on Unix — behind one [`SessionSurface`] trait, with input
//! injection, byte-stream output, and the live activity buffer (PTY digest) parser
//! primitive layered on top. It is the sixth crate in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> spt-term`), a sibling of `spt-runtime`.
//!
//! M3a delivers the *library mechanism*; the **M3b** `spt-daemon` broker delivers
//! the *process* that hosts many of these behind versioned IPC. `spt-term` is
//! deliberately **single-surface-per-handle** — it owns one child's PTY, not the
//! multiplexed name-addressable fleet (that supervisor is M3b). See `M3a-PLAN.md`.
//!
//! ## OS stream-contract neutrality (Spike #4 / #5, binding)
//!
//! The single biggest design risk this crate carries: `forkpty` is a **raw ordered
//! pipe**; ConPTY is a **screen buffer** (repaint-on-resize reorders + duplicates
//! the stream; a mid-session attach sees the viewport, not scrollback). The
//! [`SessionSurface`] trait and the byte-stream / digest contracts therefore must
//! **not** bake in either OS's semantics — the surface is modeled as a byte stream
//! with explicit, OS-independent attach/replay semantics.
//
// spt-term is internal broker machinery, NOT part of the public semver SDK
// surface (R-ARCH-2 stays spt-proto/spt-store/spt-msg/spt-runtime). The M3b
// daemon programs against it; external harness authors do not.

// Surfaces land across M3a (see M3a-PLAN.md task table):
//   A1  SessionSurface trait + ConPTY/forkpty backends     (REQ-TERM-1)       ← landed
//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR) ← landed
//   A3  send-keys / send-line injection                     (REQ-TERM-2)
//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)       ← landed
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)       ← landed

pub mod digest;
pub mod pty;
pub mod reader;
pub mod stream;
pub mod surface;

pub use digest::{
    Digest, DigestConfig, DigestEntry, DigestError, DigestParser, DigestPatterns, ToolUse, Turn,
};
pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use reader::Drain;
pub use stream::OutputStream;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};


spt-term has `serde` but not `serde_json`; projection needs to parse the contract JSON line. Add `serde_json`, make `truncate_arg` crate-visible, add the `projection` module. T7a commit 1 is additive (parser stays green until T7b).


Edit

# The digest data types (Digest/Turn/DigestEntry/ToolUse) cross the broker↔brain
# IPC + the `spt digest` control channel (M3b B8), so they are serde-serializable.
serde = { version = "1", features = ["derive"] }

# The digest data types (Digest/Turn/DigestEntry/ToolUse) cross the broker↔brain
# IPC + the `spt digest` control channel (M3b B8), so they are serde-serializable.
serde = { version = "1", features = ["derive"] }
# The session-digest log projection parses the published digest-record contract
# (one normalized JSON line per record) — ADR-0008 amendment / REQ-TERM-4.
serde_json = "1"


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-term\Cargo.toml has been updated successfully. (file state is current in your context — no need to Read it back)

# The digest data types (Digest/Turn/DigestEntry/ToolUse) cross the broker↔brain
# IPC + the `spt digest` control channel (M3b B8), so they are serde-serializable.
serde = { version = "1", features = ["derive"] }
# The session-digest log projection parses the published digest-record contract
# (one normalized JSON line per record) — ADR-0008 amendment / REQ-TERM-4.
serde_json = "1"

[package]
name = "spt-term"
version.workspace = true
edition = "2021"
publish = false
description = "spt-core session-surface mechanism (M3a): the multiplatform PTY layer every hosted agent session runs inside. ConPTY (Windows) / forkpty (Unix) behind one SessionSurface trait, byte-stream output with bounded backpressure, and the live activity buffer (PTY digest) parser primitive. Internal broker machinery (NOT public SDK, R-ARCH-2) — the M3b daemon hosts many of these."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
portable-pty = "0.8"
regex = "1"
# The digest data types (Digest/Turn/DigestEntry/ToolUse) cross the broker↔brain
# IPC + the `spt digest` control channel (M3b B8), so they are serde-serializable.
serde = { version = "1", features = ["derive"] }

[dev-dependencies]
tempfile = "3"

+# The session-digest log projection parses the published digest-record contract

+# (one normalized JSON line per record) — ADR-0008 amendment / REQ-TERM-4.


/// Truncate `arg` to `width` characters, appending `…` when shortened.
fn truncate_arg(arg: &str, width: usize) -> String {

/// Truncate `arg` to `width` characters, appending `…` when shortened.
pub(crate) fn truncate_arg(arg: &str, width: usize) -> String {


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-term\src\digest.rs has been updated successfully. (file state is current in your context — no need to Read it back)

/// Truncate `arg` to `width` characters, appending `…` when shortened.
fn truncate_arg(arg: &str, width: usize) -> String {

/// Truncate `arg` to `width` characters, appending `…` when shortened.
pub(crate) fn truncate_arg(arg: &str, width: usize) -> String {

-fn truncate_arg(arg: &str, width: usize) -> String {

+pub(crate) fn truncate_arg(arg: &str, width: usize) -> String {


Write

//! Session-digest **log projection** — the live activity buffer built from an
//! endpoint's normalized session logs, never from PTY bytes (ADR-0008 amendment,
//! 2026-06-12; REQ-TERM-4 log-source re-foundation).
//!
//! ## Why logs, not the byte stream
//!
//! A TUI's PTY stream is a *rendering protocol*, not a content log: scroll/resize
//! trigger full repaints that re-enter the stream (duplicate content) and drown
//! the semantic turn boundaries in repaint traffic. The split (CONTEXT.md
//! "session digest"): **render surfaces read the PTY** (the raw byte ring,
//! unchanged); **content surfaces read the logs.** The digest is a rolling
//! tail-projection of the **same normalized history the echo-commune consumes**
//! (history subsystem Path A locate+normalize / Path B native store) — one
//! pipeline, two consumers.
//!
//! ## The published digest-record contract (one schema, no mapping layer)
//!
//! The history subsystem stays **opaque** — it carries each record's `raw` line
//! verbatim, adapter-defined, and never parses it (echo pipes those lines raw to
//! an adapter summarizer). This projection does NOT make history non-opaque:
//! [`DigestRecord`] is a **spt-core-published contract** — a small, fixed overlay
//! on the *content* of `raw` that a projectable adapter targets (or pushes
//! pre-formed via `api digest-entry`). It is the digest's own surface, not the
//! history subsystem's, and it is the **only** digest record schema (no
//! second-schema mapping). A normalized line is a JSON object:
//!
//! ```json
//! {"role": "input", "text": "fix the bug"}
//! {"role": "agent", "text": "on it"}
//! {"role": "tool",  "tool": {"name": "Write", "arg": "src/a.rs"}}
//! ```
//!
//! - `role` is the closed set `input` | `agent` | `tool`.
//! - `text` carries the input / agent span (omitted/empty for `tool`).
//! - `tool` is `{name, arg}`, present iff `role == "tool"`.
//!
//! **Forward-compat:** unknown object fields are ignored (a future contract field
//! does not break an older reader); a line that is not a valid record — malformed
//! JSON, missing/unknown `role`, or a `tool` record without its `tool` object —
//! is **skipped**, never a panic and never an aborted projection.
//!
//! ## Presentation (unchanged from the original conception)
//!
//! The formula is spt-core-owned: the last [`DigestConfig::window_turns`] user
//! turns (~3), consecutive tool records collapsed into one `ToolSprint`, and
//! tool args truncated to [`DigestConfig::arg_truncation`] (~25) chars. Only the
//! *source* changed (records, not regex-over-bytes); the projected [`Digest`]
//! shape and its rendering are identical to the superseded parser's output.
// [impl->REQ-TERM-4]

use serde::Deserialize;

use crate::digest::{truncate_arg, Digest, DigestConfig, DigestEntry, ToolUse, Turn};

/// The role of a [`DigestRecord`] — the closed source-tag set. Deserialized from
/// the lowercase wire token; an unrecognized token fails the record (→ skipped),
/// which is exactly the forward-compat "unknown role → skip" behavior.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DigestRole {
    /// A user-input span opens a new turn.
    Input,
    /// Agent output within the current turn.
    Agent,
    /// One tool invocation (collapsed into the trailing sprint).
    Tool,
}

/// The `{name, arg}` of a `role: "tool"` record. `arg` is the *untruncated*
/// fragment as the adapter emitted it; presentation truncation is applied during
/// projection (extraction is the adapter's, presentation is spt-core's).
#[derive(Debug, Clone, Deserialize)]
pub struct ToolRef {
    /// The tool name (e.g. `Write`).
    pub name: String,
    /// The argument fragment (truncated at projection time).
    #[serde(default)]
    pub arg: String,
}

/// One record of the **published digest-record contract** — the spt-core-owned
/// overlay a projectable adapter targets in its normalized history line. Unknown
/// fields are ignored (forward-compat); the history subsystem still carries the
/// underlying line opaquely.
#[derive(Debug, Clone, Deserialize)]
pub struct DigestRecord {
    /// The source tag (input / agent / tool).
    pub role: DigestRole,
    /// The span text (input or agent). Empty/absent for a tool record.
    #[serde(default)]
    pub text: String,
    /// The tool reference — present iff `role == Tool`.
    #[serde(default)]
    pub tool: Option<ToolRef>,
}

/// Parse one normalized line into a [`DigestRecord`] per the published contract.
/// Returns `None` for a line that is not a valid record — malformed JSON or an
/// unknown/missing `role` — so the caller drops it (forward-compat skip). Unknown
/// *fields* on an otherwise-valid record are tolerated (ignored).
// [impl->REQ-TERM-4]
pub fn record_to_tagged(raw: &str) -> Option<DigestRecord> {
    serde_json::from_str(raw).ok()
}

/// Project a sequence of contract records into the rolling [`Digest`]: fold them
/// into turns (an `input` opens a turn; `agent` appends text; consecutive `tool`
/// records collapse into one `ToolSprint`), then keep the last
/// `config.window_turns`. A `tool` record missing its `tool` object is skipped
/// (defensive — the contract requires it present for the tool role).
// [impl->REQ-TERM-4]
pub fn project(records: &[DigestRecord], config: &DigestConfig) -> Digest {
    let mut turns: Vec<Turn> = Vec::new();
    let mut current = Turn::default();
    let mut started = false;

    for r in records {
        match r.role {
            DigestRole::Input => {
                if started {
                    turns.push(std::mem::take(&mut current));
                }
                current = Turn {
                    input: Some(r.text.clone()),
                    entries: Vec::new(),
                };
                started = true;
            }
            DigestRole::Agent => {
                if !r.text.is_empty() {
                    current.entries.push(DigestEntry::Agent(r.text.clone()));
                    started = true;
                }
            }
            DigestRole::Tool => {
                let Some(t) = &r.tool else {
                    continue; // tool role without a tool object → malformed, skip
                };
                let tu = ToolUse {
                    name: t.name.clone(),
                    arg: truncate_arg(t.arg.trim(), config.arg_truncation),
                };
                // Collapse into the trailing sprint when the last entry is one.
                if let Some(DigestEntry::ToolSprint(sprint)) = current.entries.last_mut() {
                    sprint.push(tu);
                } else {
                    current.entries.push(DigestEntry::ToolSprint(vec![tu]));
                }
                started = true;
            }
        }
    }
    if started {
        turns.push(current);
    }

    // Keep only the last N turns (the rolling window).
    let n = config.window_turns;
    if turns.len() > n {
        turns.drain(..turns.len() - n);
    }
    Digest { turns }
}

/// Tail-projection convenience: parse a sequence of normalized lines (skipping
/// any that are not valid contract records) and project them. The single entry a
/// log-sourced digest engine calls with the endpoint's current normalized
/// history records.
// [impl->REQ-TERM-4]
pub fn project_lines<'a, I>(lines: I, config: &DigestConfig) -> Digest
where
    I: IntoIterator<Item = &'a str>,
{
    let records: Vec<DigestRecord> = lines.into_iter().filter_map(record_to_tagged).collect();
    project(&records, config)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cfg(window: usize) -> DigestConfig {
        DigestConfig {
            window_turns: window,
            ..Default::default()
        }
    }

    // [unit->REQ-TERM-4] an input opens a turn, agent text appends, and consecutive
    // tool records collapse into one sprint — the structured, source-tagged shape.
    #[test]
    fn projects_input_agent_and_collapses_tool_sprints() {
        let lines = [
            r#"{"role":"input","text":"add a file"}"#,
            r#"{"role":"agent","text":"sure, doing it"}"#,
            r#"{"role":"tool","tool":{"name":"Write","arg":"src/a.rs"}}"#,
            r#"{"role":"tool","tool":{"name":"Bash","arg":"cargo build"}}"#,
        ];
        let d = project_lines(lines, &cfg(3));
        assert_eq!(d.turns.len(), 1);
        let t = &d.turns[0];
        assert_eq!(t.input.as_deref(), Some("add a file"));
        assert_eq!(t.entries[0], DigestEntry::Agent("sure, doing it".to_string()));
        match &t.entries[1] {
            DigestEntry::ToolSprint(s) => {
                assert_eq!(s.len(), 2, "consecutive tools collapse into one sprint");
                assert_eq!(s[0].name, "Write");
                assert_eq!(s[0].arg, "src/a.rs");
                assert_eq!(s[1].name, "Bash");
                assert_eq!(s[1].arg, "cargo build");
            }
            other => panic!("expected a tool sprint, got {other:?}"),
        }
    }

    // [unit->REQ-TERM-4] non-adjacent tool records form separate sprints (an agent
    // entry between them breaks the collapse).
    #[test]
    fn agent_between_tools_breaks_the_sprint() {
        let lines = [
            r#"{"role":"input","text":"go"}"#,
            r#"{"role":"tool","tool":{"name":"Read","arg":"a"}}"#,
            r#"{"role":"agent","text":"thinking"}"#,
            r#"{"role":"tool","tool":{"name":"Write","arg":"b"}}"#,
        ];
        let d = project_lines(lines, &cfg(3));
        let sprints = d.turns[0]
            .entries
            .iter()
            .filter(|e| matches!(e, DigestEntry::ToolSprint(_)))
            .count();
        assert_eq!(sprints, 2, "an agent entry splits the two tool sprints");
    }

    // [unit->REQ-TERM-4] the window keeps only the last N user turns.
    #[test]
    fn windows_to_last_n_turns() {
        let mut lines: Vec<String> = Vec::new();
        for i in 0..6 {
            lines.push(format!(r#"{{"role":"input","text":"turn {i}"}}"#));
            lines.push(format!(r#"{{"role":"agent","text":"reply {i}"}}"#));
        }
        let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
        let d = project_lines(refs, &cfg(3));
        assert_eq!(d.turns.len(), 3, "window keeps only the last N=3 turns");
        assert_eq!(d.turns[0].input.as_deref(), Some("turn 3"));
        assert_eq!(d.turns[2].input.as_deref(), Some("turn 5"));
    }

    // [unit->REQ-TERM-4] tool args truncate to the configured width with an ellipsis
    // (presentation is spt-core's; extraction was the adapter's).
    #[test]
    fn truncates_tool_args_to_width() {
        let c = DigestConfig {
            window_turns: 3,
            arg_truncation: 10,
            ..Default::default()
        };
        let lines = [
            r#"{"role":"input","text":"x"}"#,
            r#"{"role":"tool","tool":{"name":"Bash","arg":"this-is-a-very-long-command-line"}}"#,
        ];
        let d = project_lines(lines, &c);
        let sprint = d.turns[0]
            .entries
            .iter()
            .find_map(|e| match e {
                DigestEntry::ToolSprint(s) => Some(s),
                _ => None,
            })
            .expect("a sprint");
        assert_eq!(sprint[0].name, "Bash");
        assert_eq!(sprint[0].arg, "this-is-a-…", "arg truncated to width with ellipsis");
    }

    // [unit->REQ-TERM-4] forward-compat: a malformed line, an unknown role, and a
    // tool record missing its tool object are each skipped — valid records around
    // them still project, never a panic.
    #[test]
    fn malformed_unknown_role_and_toolless_tool_are_skipped() {
        let lines = [
            r#"not json at all"#,
            r#"{"role":"input","text":"real"}"#,
            r#"{"role":"whisper","text":"unknown role dropped"}"#,
            r#"{"role":"tool"}"#, // tool role, no tool object → skipped
            r#"{"role":"agent","text":"kept"}"#,
        ];
        let d = project_lines(lines, &cfg(3));
        assert_eq!(d.turns.len(), 1, "only the one real turn survives");
        let t = &d.turns[0];
        assert_eq!(t.input.as_deref(), Some("real"));
        assert_eq!(t.entries.len(), 1, "the toolless tool record was skipped");
        assert_eq!(t.entries[0], DigestEntry::Agent("kept".to_string()));
    }

    // [unit->REQ-TERM-4] forward-compat: an unknown *field* on an otherwise-valid
    // record is tolerated (ignored), not a reason to drop the record.
    #[test]
    fn unknown_fields_are_tolerated() {
        let lines = [r#"{"role":"input","text":"hi","future_field":42}"#];
        let d = project_lines(lines, &cfg(3));
        assert_eq!(d.turns.len(), 1);
        assert_eq!(d.turns[0].input.as_deref(), Some("hi"));
    }

    // [unit->REQ-TERM-4] agent/tool records before any input form a preamble turn
    // (input: None) — the same shape the parser produced for pre-input output.
    #[test]
    fn leading_output_without_input_is_a_preamble_turn() {
        let lines = [
            r#"{"role":"agent","text":"booting"}"#,
            r#"{"role":"input","text":"first"}"#,
        ];
        let d = project_lines(lines, &cfg(3));
        assert_eq!(d.turns.len(), 2);
        assert_eq!(d.turns[0].input, None, "preamble turn has no input");
        assert_eq!(d.turns[0].entries[0], DigestEntry::Agent("booting".to_string()));
        assert_eq!(d.turns[1].input.as_deref(), Some("first"));
    }

    // [unit->REQ-TERM-4] an empty record stream projects an empty digest (no panic,
    // no phantom turn).
    #[test]
    fn empty_input_projects_empty_digest() {
        let d = project_lines(Vec::<&str>::new(), &cfg(3));
        assert!(d.turns.is_empty());
    }
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-term\src\projection.rs (file state is current in your context — no need to Read it back)

//! Session-digest **log projection** — the live activity buffer built from an
//! endpoint's normalized session logs, never from PTY bytes (ADR-0008 amendment,
//! 2026-06-12; REQ-TERM-4 log-source re-foundation).
//!
//! ## Why logs, not the byte stream
//!
//! A TUI's PTY stream is a *rendering protocol*, not a content log: scroll/resize
//! trigger full repaints that re-enter the stream (duplicate content) and drown
//! the semantic turn boundaries in repaint traffic. The split (CONTEXT.md
//! "session digest"): **render surfaces read the PTY** (the raw byte ring,
//! unchanged); **content surfaces read the logs.** The digest is a rolling
//! tail-projection of the **same normalized history the echo-commune consumes**
//! (history subsystem Path A locate+normalize / Path B native store) — one
//! pipeline, two consumers.
//!
//! ## The published digest-record contract (one schema, no mapping layer)
//!
//! The history subsystem stays **opaque** — it carries each record's `raw` line
//! verbatim, adapter-defined, and never parses it (echo pipes those lines raw to
//! an adapter summarizer). This projection does NOT make history non-opaque:
//! [`DigestRecord`] is a **spt-core-published contract** — a small, fixed overlay
//! on the *content* of `raw` that a projectable adapter targets (or pushes
//! pre-formed via `api digest-entry`). It is the digest's own surface, not the
//! history subsystem's, and it is the **only** digest record schema (no
//! second-schema mapping). A normalized line is a JSON object:
//!
//! ```json
//! {"role": "input", "text": "fix the bug"}
//! {"role": "agent", "text": "on it"}
//! {"role": "tool",  "tool": {"name": "Write", "arg": "src/a.rs"}}
//! ```
//!
//! - `role` is the closed set `input` | `agent` | `tool`.
//! - `text` carries the input / agent span (omitted/empty for `tool`).
//! - `tool` is `{name, arg}`, present iff `role == "tool"`.
//!
//! **Forward-compat:** unknown object fields are ignored (a future contract field
//! does not break an older reader); a line that is not a valid record — malformed
//! JSON, missing/unknown `role`, or a `tool` record without its `tool` object —
//! is **skipped**, never a panic and never an aborted projection.
//!
//! ## Presentation (unchanged from the original conception)
//!
//! The formula is spt-core-owned: the last [`DigestConfig::window_turns`] user
//! turns (~3), consecutive tool records collapsed into one `ToolSprint`, and
//! tool args truncated to [`DigestConfig::arg_truncation`] (~25) chars. Only the
//! *source* changed (records, not regex-over-bytes); the projected [`Digest`]
//! shape and its rendering are identical to the superseded parser's output.
// [impl->REQ-TERM-4]

use serde::Deserialize;

use crate::digest::{truncate_arg, Digest, DigestConfig, DigestEntry, ToolUse, Turn};

/// The role of a [`DigestRecord`] — the closed source-tag set. Deserialized from
/// the lowercase wire token; an unrecognized token fails the record (→ skipped),
/// which is exactly the forward-compat "unknown role → skip" behavior.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DigestRole {
    /// A user-input span opens a new turn.
    Input,
    /// Agent output within the current turn.
    Agent,
    /// One tool invocation (collapsed into the trailing sprint).
    Tool,
}

/// The `{name, arg}` of a `role: "tool"` record. `arg` is the *untruncated*
/// fragment as the adapter emitted it; presentation truncation is applied during
/// projection (extraction is the adapter's, presentation is spt-core's).
#[derive(Debug, Clone, Deserialize)]
pub struct ToolRef {
    /// The tool name (e.g. `Write`).
    pub name: String,
    /// The argument fragment (truncated at projection time).
    #[serde(default)]
    pub arg: String,
}

/// One record of the **published digest-record contract** — the spt-core-owned
/// overlay a projectable adapter targets in its normalized history line. Unknown
/// fields are ignored (forward-compat); the history subsystem still carries the
/// underlying line opaquely.
#[derive(Debug, Clone, Deserialize)]
pub struct DigestRecord {
    /// The source tag (input / agent / tool).
    pub role: DigestRole,
    /// The span text (input or agent). Empty/absent for a tool record.
    #[serde(default)]
    pub text: String,
    /// The tool reference — present iff `role == Tool`.
    #[serde(default)]
    pub tool: Option<ToolRef>,
}

/// Parse one normalized line into a [`DigestRecord`] per the published contract.
/// Returns `None` for a line that is not a valid record — malformed JSON or an
/// unknown/missing `role` — so the caller drops it (forward-compat skip). Unknown
/// *fields* on an otherwise-valid record are tolerated (ignored).
// [impl->REQ-TERM-4]
pub fn record_to_tagged(raw: &str) -> Option<DigestRecord> {
    serde_json::from_str(raw).ok()
}

/// Project a sequence of contract records into the rolling [`Digest`]: fold them
/// into turns (an `input` opens a turn; `agent` appends text; consecutive `tool`
/// records collapse into one `ToolSprint`), then keep the last
/// `config.window_turns`. A `tool` record missing its `tool` object is skipped
/// (defensive — the contract requires it present for the tool role).
// [impl->REQ-TERM-4]
pub fn project(records: &[DigestRecord], config: &DigestConfig) -> Digest {
    let mut turns: Vec<Turn> = Vec::new();
    let mut current = Turn::default();
    let mut started = false;

    for r in records {
        match r.role {
            DigestRole::Input => {
                if started {
                    turns.push(std::mem::take(&mut current));
                }
                current = Turn {
                    input: Some(r.text.clone()),
                    entries: Vec::new(),
                };
                started = true;
            }
            DigestRole::Agent => {
                if !r.text.is_empty() {
                    current.entries.push(DigestEntry::Agent(r.text.clone()));
                    started = true;
                }
            }
            DigestRole::Tool => {
                let Some(t) = &r.tool else {
                    continue; // tool role without a tool object → malformed, skip
                };
                let tu = ToolUse {
                    name: t.name.clone(),
                    arg: truncate_arg(t.arg.trim(), config.arg_truncation),
                };
                // Collapse into the trailing sprint when the last entry is one.
                if let Some(DigestEntry::ToolSprint(sprint)) = current.entries.last_mut() {
                    sprint.push(tu);
                } else {
                    current.entries.push(DigestEntry::ToolSprint(vec![tu]));
                }
                started = true;
            }
        }
    }
    if started {
        turns.push(current);
    }

    // Keep only the last N turns (the rolling window).
    let n = config.window_turns;
    if turns.len() > n {
        turns.drain(..turns.len() - n);
    }
    Digest { turns }
}

/// Tail-projection convenience: parse a sequence of normalized lines (skipping
/// any that are not valid contract records) and project them. The single entry a
/// log-sourced digest engine calls with the endpoint's current normalized
/// history records.
// [impl->REQ-TERM-4]
pub fn project_lines<'a, I>(lines: I, config: &DigestConfig) -> Digest
where
    I: IntoIterator<Item = &'a str>,
{
    let records: Vec<DigestRecord> = lines.into_iter().filter_map(record_to_tagged).collect();
    project(&records, config)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cfg(window: usize) -> DigestConfig {
        DigestConfig {
            window_turns: window,
            ..Default::default()
        }
    }

    // [unit->REQ-TERM-4] an input opens a turn, agent text appends, and consecutive
    // tool records collapse into one sprint — the structured, source-tagged shape.
    #[test]
    fn projects_input_agent_and_collapses_tool_sprints() {
        let lines = [
            r#"{"role":"input","text":"add a file"}"#,
            r#"{"role":"agent","text":"sure, doing it"}"#,
            r#"{"role":"tool","tool":{"name":"Write","arg":"src/a.rs"}}"#,
            r#"{"role":"tool","tool":{"name":"Bash","arg":"cargo build"}}"#,
        ];
        let d = project_lines(lines, &cfg(3));
        assert_eq!(d.turns.len(), 1);
        let t = &d.turns[0];
        assert_eq!(t.input.as_deref(), Some("add a file"));
        assert_eq!(t.entries[0], DigestEntry::Agent("sure, doing it".to_string()));
        match &t.entries[1] {
            DigestEntry::ToolSprint(s) => {
                assert_eq!(s.len(), 2, "consecutive tools collapse into one sprint");
                assert_eq!(s[0].name, "Write");
                assert_eq!(s[0].arg, "src/a.rs");
                assert_eq!(s[1].name, "Bash");
                assert_eq!(s[1].arg, "cargo build");
            }
            other => panic!("expected a tool sprint, got {other:?}"),
        }
    }

    // [unit->REQ-TERM-4] non-adjacent tool records form separate sprints (an agent
    // entry between them breaks the collapse).
    #[test]
    fn agent_between_tools_breaks_the_sprint() {
        let lines = [
            r#"{"role":"input","text":"go"}"#,
            r#"{"role":"tool","tool":{"name":"Read","arg":"a"}}"#,
            r#"{"role":"agent","text":"thinking"}"#,
            r#"{"role":"tool","tool":{"name":"Write","arg":"b"}}"#,
        ];
        let d = project_lines(lines, &cfg(3));
        let sprints = d.turns[0]
            .entries
            .iter()
            .filter(|e| matches!(e, DigestEntry::ToolSprint(_)))
            .count();
        assert_eq!(sprints, 2, "an agent entry splits the two tool sprints");
    }

    // [unit->REQ-TERM-4] the window keeps only the last N user turns.
    #[test]
    fn windows_to_last_n_turns() {
        let mut lines: Vec<String> = Vec::new();
        for i in 0..6 {
            lines.push(format!(r#"{{"role":"input","text":"turn {i}"}}"#));
            lines.push(format!(r#"{{"role":"agent","text":"reply {i}"}}"#));
        }
        let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
        let d = project_lines(refs, &cfg(3));
        assert_eq!(d.turns.len(), 3, "window keeps only the last N=3 turns");
        assert_eq!(d.turns[0].input.as_deref(), Some("turn 3"));
        assert_eq!(d.turns[2].input.as_deref(), Some("turn 5"));
    }

    // [unit->REQ-TERM-4] tool args truncate to the configured width with an ellipsis
    // (presentation is spt-core's; extraction was the adapter's).
    #[test]
    fn truncates_tool_args_to_width() {
        let c = DigestConfig {
            window_turns: 3,
            arg_truncation: 10,
            ..Default::default()
        };
        let lines = [
            r#"{"role":"input","text":"x"}"#,
            r#"{"role":"tool","tool":{"name":"Bash","arg":"this-is-a-very-long-command-line"}}"#,
        ];
        let d = project_lines(lines, &c);
        let sprint = d.turns[0]
            .entries
            .iter()
            .find_map(|e| match e {
                DigestEntry::ToolSprint(s) => Some(s),
                _ => None,
            })
            .expect("a sprint");
        assert_eq!(sprint[0].name, "Bash");
        assert_eq!(sprint[0].arg, "this-is-a-…", "arg truncated to width with ellipsis");
    }

    // [unit->REQ-TERM-4] forward-compat: a malformed line, an unknown role, and a
    // tool record missing its tool object are each skipped — valid records around
    // them still project, never a panic.
    #[test]
    fn malformed_unknown_role_and_toolless_tool_are_skipped() {
        let lines = [
            r#"not json at all"#,
            r#"{"role":"input","text":"real"}"#,
            r#"{"role":"whisper","text":"unknown role dropped"}"#,
            r#"{"role":"tool"}"#, // tool role, no tool object → skipped
            r#"{"role":"agent","text":"kept"}"#,
        ];
        let d = project_lines(lines, &cfg(3));
        assert_eq!(d.turns.len(), 1, "only the one real turn survives");
        let t = &d.turns[0];
        assert_eq!(t.input.as_deref(), Some("real"));
        assert_eq!(t.entries.len(), 1, "the toolless tool record was skipped");
        assert_eq!(t.entries[0], DigestEntry::Agent("kept".to_string()));
    }

    // [unit->REQ-TERM-4] forward-compat: an unknown *field* on an otherwise-valid
    // record is tolerated (ignored), not a reason to drop the record.
    #[test]
    fn unknown_fields_are_tolerated() {
        let lines = [r#"{"role":"input","text":"hi","future_field":42}"#];
        let d = project_lines(lines, &cfg(3));
        assert_eq!(d.turns.len(), 1);
        assert_eq!(d.turns[0].input.as_deref(), Some("hi"));
    }

    // [unit->REQ-TERM-4] agent/tool records before any input form a preamble turn
    // (input: None) — the same shape the parser produced for pre-input output.
    #[test]
    fn leading_output_without_input_is_a_preamble_turn() {
        let lines = [
            r#"{"role":"agent","text":"booting"}"#,
            r#"{"role":"input","text":"first"}"#,
        ];
        let d = project_lines(lines, &cfg(3));
        assert_eq!(d.turns.len(), 2);
        assert_eq!(d.turns[0].input, None, "preamble turn has no input");
        assert_eq!(d.turns[0].entries[0], DigestEntry::Agent("booting".to_string()));
        assert_eq!(d.turns[1].input.as_deref(), Some("first"));
    }

    // [unit->REQ-TERM-4] an empty record stream projects an empty digest (no panic,
    // no phantom turn).
    #[test]
    fn empty_input_projects_empty_digest() {
        let d = project_lines(Vec::<&str>::new(), &cfg(3));
        assert!(d.turns.is_empty());
    }
}


Now wire the module into `lib.rs`:


Edit

pub mod digest;
pub mod pty;
pub mod reader;
pub mod stream;
pub mod surface;

pub use digest::{
    Digest, DigestConfig, DigestEntry, DigestError, DigestParser, DigestPatterns, ToolUse, Turn,
};

pub mod digest;
pub mod projection;
pub mod pty;
pub mod reader;
pub mod stream;
pub mod surface;

pub use digest::{
    Digest, DigestConfig, DigestEntry, DigestError, DigestParser, DigestPatterns, ToolUse, Turn,
};
pub use projection::{project, project_lines, record_to_tagged, DigestRecord, DigestRole, ToolRef};


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-term\src\lib.rs has been updated successfully. (file state is current in your context — no need to Read it back)

pub mod digest;
pub mod projection;
pub mod pty;
pub mod reader;
pub mod stream;
pub mod surface;

pub use digest::{
    Digest, DigestConfig, DigestEntry, DigestError, DigestParser, DigestPatterns, ToolUse, Turn,
};
pub use projection::{project, project_lines, record_to_tagged, DigestRecord, DigestRole, ToolRef};

//! spt-core session-surface mechanism — the broker's PTY half (M3a).
//!
//! `spt-term` is the **multiplatform session-surface mechanism** that every hosted
//! agent session runs inside: a real child under a native PTY — ConPTY on Windows
//! 10+, `forkpty(3)` on Unix — behind one [`SessionSurface`] trait, with input
//! injection, byte-stream output, and the live activity buffer (PTY digest) parser
//! primitive layered on top. It is the sixth crate in the acyclic stack
//! (`spt-proto -> spt-store -> spt-msg -> spt-term`), a sibling of `spt-runtime`.
//!
//! M3a delivers the *library mechanism*; the **M3b** `spt-daemon` broker delivers
//! the *process* that hosts many of these behind versioned IPC. `spt-term` is
//! deliberately **single-surface-per-handle** — it owns one child's PTY, not the
//! multiplexed name-addressable fleet (that supervisor is M3b). See `M3a-PLAN.md`.
//!
//! ## OS stream-contract neutrality (Spike #4 / #5, binding)
//!
//! The single biggest design risk this crate carries: `forkpty` is a **raw ordered
//! pipe**; ConPTY is a **screen buffer** (repaint-on-resize reorders + duplicates
//! the stream; a mid-session attach sees the viewport, not scrollback). The
//! [`SessionSurface`] trait and the byte-stream / digest contracts therefore must
//! **not** bake in either OS's semantics — the surface is modeled as a byte stream
//! with explicit, OS-independent attach/replay semantics.
//
// spt-term is internal broker machinery, NOT part of the public semver SDK
// surface (R-ARCH-2 stays spt-proto/spt-store/spt-msg/spt-runtime). The M3b
// daemon programs against it; external harness authors do not.

// Surfaces land across M3a (see M3a-PLAN.md task table):
//   A1  SessionSurface trait + ConPTY/forkpty backends     (REQ-TERM-1)       ← landed
//   A2  ConPTY-DSR auto-answer drain thread                 (REQ-HAZARD-CONPTY-DSR) ← landed
//   A3  send-keys / send-line injection                     (REQ-TERM-2)
//   A4  byte-stream local streaming (bounded backpressure)  (REQ-TERM-3)       ← landed
//   A5  live activity buffer (PTY digest) parser primitive  (REQ-TERM-4)       ← landed

pub mod digest;
pub mod pty;
pub mod reader;
pub mod stream;
pub mod surface;

pub use digest::{
    Digest, DigestConfig, DigestEntry, DigestError, DigestParser, DigestPatterns, ToolUse, Turn,
};
pub use portable_pty::CommandBuilder;
pub use pty::PtySession;
pub use reader::Drain;
pub use stream::OutputStream;
pub use surface::{SessionSurface, SurfaceError, SurfaceSize};

+pub use projection::{project, project_lines, record_to_tagged, DigestRecord, DigestRole, ToolRef};


Bash

cd C:/Users/decid/Documents/projects/spt-core && cargo test -p spt-term projection 2>&1 | tail -30

Run spt-term projection tests


     Running tests\digest.rs (target\debug\deps\digest-32d4830ba670a520.exe)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

     Running tests\dsr.rs (target\debug\deps\dsr-075a415d03e97a6f.exe)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

     Running tests\inject.rs (target\debug\deps\inject-8f3a7df3a5c1a37f.exe)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

     Running tests\stream.rs (target\debug\deps\stream-4be7c8f2d1dc1bdb.exe)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

     Running tests\surface.rs (target\debug\deps\surface-29ab089ee32350c5.exe)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s


Tail cut the lib unit results. Run the lib unit tests directly:


cd C:/Users/decid/Documents/projects/spt-core && cargo test -p spt-term --lib projection 2>&1 | tail -25

Run projection lib unit tests


    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.24s
     Running unittests src\lib.rs (target\debug\deps\spt_term-4429b6905fdd920d.exe)

running 8 tests
test projection::tests::empty_input_projects_empty_digest ... ok
test projection::tests::unknown_fields_are_tolerated ... ok
test projection::tests::agent_between_tools_breaks_the_sprint ... ok
test projection::tests::malformed_unknown_role_and_toolless_tool_are_skipped ... ok
test projection::tests::projects_input_agent_and_collapses_tool_sprints ... ok
test projection::tests::leading_output_without_input_is_a_preamble_turn ... ok
test projection::tests::truncates_tool_args_to_width ... ok
test projection::tests::windows_to_last_n_turns ... ok

test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 8 filtered out; finished in 0.00s


8/8 green. Verify the whole spt-term suite still passes (parser untouched), clippy clean, and traceability intact before committing.


cd C:/Users/decid/Documents/projects/spt-core && cargo test -p spt-term --lib 2>&1 | tail -4 && echo "===CLIPPY===" && cargo clippy -p spt-term --all-targets -- -D warnings 2>&1 | tail -6

Full spt-term lib tests + clippy


cd C:/Users/decid/Documents/projects/spt-core && (traceable-reqs check 2>&1 || cargo run -q -p xtask -- traceable-reqs check 2>&1 || echo "TRACEABLE_CMD_NOT_FOUND") | tail -20

Run traceable-reqs check


test digest::tests::tags_input_agent_and_collapses_tool_sprints ... ok

test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

===CLIPPY===
    Checking spt-proto v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-proto)
    Checking regex v1.12.3
    Checking spt-store v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
    Checking spt-msg v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
    Checking spt-term v0.4.2 (C:\Users\decid\Documents\projects\spt-core\crates\spt-term)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.74s


  [OK] REQ-TERM-3  required: [impl, unit]  stages: -doc +impl +unit -int
       Byte-stream remote terminal streaming for v1
  [OK] REQ-TERM-4  required: [impl, unit, int]  stages: +doc +impl +unit +int
       Live activity buffer (PTY digest): adapter-supplied patterns over broker PTY, spt digest pull + delta-stream, opt-in Path-B log
  [OK] REQ-UPD-1  required: [impl, unit, int]  stages: -doc +impl +unit +int
       Peer-propagated update over P2P
  [OK] REQ-UPD-2  required: [impl, unit]  stages: -doc +impl +unit -int
       All binaries signature-verified before handoff
  [OK] REQ-UPD-3  required: [impl, unit, int]  stages: -doc +impl +unit +int
       No endpoint process terminates/suspends during self-update
  [OK] REQ-UPD-4  required: [impl, unit]  stages: -doc +impl +unit -int
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: [impl, unit]  stages: -doc +impl +unit -int
       spt-core ripple-updates registered adapters
  [OK] REQ-UPD-6  required: [doc, impl, unit, int]  stages: +doc +impl +unit +int
       Platform-targeted update sets and debug rollout: signed multi-platform update metadata, recipient platform selection, channel-scoped monotonic counters, debug-channel opt-in via release-key overlay, local staging plus pull-based peer propagation, and maintainer-only convergence tooling (ADR-0016)
  [OK] REQ-UPD-7  required: [impl, unit]  stages: -doc +impl +unit -int
       Origin-source update bootstrap (`spt update fetch`): pull the latest signed release directly from the GitHub release origin (`SaberMage/spt-releases`) — the per-platform artifact + its `<asset>.release.json` SignedRelease metadata — and stage it through the EXISTING verify→stage pipeline (the same `plan_verified` gate: two-key signature + channel + monotonic rollback floor + SHA-256), after which the normal consent-notif / `spt update apply` flow is unchanged. Closes the peer-only-discovery gap (REQ-UPD-1): a first-in-fleet / isolated node can update with no peer to pull from. The signed-release anchor keeps the GitHub transport untrusted-but-verified.
  [OK] REQ-UPD-8  required: [impl, unit]  stages: -doc +impl +unit -int
       Platform-safe `spt update fetch` + apply platform-guard (v0.3.1 cross-OS brick fix): `spt update fetch` stages the signed multi-platform `SignedUpdateSet` (`update-set.json` + every platform artifact it names), never a platform-blind single `SignedRelease`, so local apply selects `current_platform()` and P2P re-serve lets each peer select ITS own platform. Defense-in-depth: `apply_staged` REFUSES a staged single-release artifact unless it is platform-stamped for THIS node (an unstamped pre-v0.3.2 single, or a single stamped for another OS, fail-safe refuses — the guard that alone prevents the v0.3.1 brick where a Linux ELF was applied as `spt.exe`). UX: a friendly post-apply message (`Updated spt-core to vX.Y.Z.` + changelog URL) driven by an additive `product_version` metadata field, with a release-counter fallback when absent.


All green — spt-term 16/16, clippy clean, REQ-TERM-4 `[OK]`. Confirm traceable exit code, then commit the plan doc + the projection primitive.


cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check >$null 2>&1; echo "traceable-reqs EXIT=$?"

Confirm traceable-reqs exit code


/usr/bin/bash: line 4: $null: ambiguous redirect
traceable-reqs EXIT=1


`$null` is PowerShell — wrong in the Bash tool. Re-run with proper redirect:


cd C:/Users/decid/Documents/projects/spt-core && traceable-reqs check >/dev/null 2>&1; echo "traceable-reqs EXIT=$?"

Confirm traceable-reqs exit code cleanly


traceable-reqs EXIT=0


EXIT=0. Commit the plan doc, then the projection primitive:


cd C:/Users/decid/Documents/projects/spt-core && git add M9-WAVE3-JIT.md && git commit -q -m "$(cat <<'EOF'
docs(m9): Wave 3 / G3 JIT plan — digest log-projection (doyle-vetted)

The executable plan for M9 Wave 3 (REQ-TERM-4 re-foundation onto normalized
session logs, ADR-0008 amendment). doyle GREENLIT 2026-06-13; §2 RULED.

Records the published digest-record contract decision (approach A: spt-core
publishes a one-schema record contract projectable adapters target; history
stays opaque; migrate the stray native fixture — no mapping layer), the
surgical keep/delete/replace retire inventory (output types + delta machinery
+ digesthub survive; only the parser/PtyDigest/byte-feed die), the REQ-TERM-4
restoration-style re-point, the G3 gate checklist, and the commit choreography.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
)" && echo "--- commit 0 done ---" && git log --oneline -1

Commit Wave3 JIT plan doc


warning: in the working copy of 'M9-WAVE3-JIT.md', LF will be replaced by CRLF the next time Git touches it
--- commit 0 done ---
714f6b8 docs(m9): Wave 3 / G3 JIT plan — digest log-projection (doyle-vetted)


cd C:/Users/decid/Documents/projects/spt-core && git add crates/spt-term/Cargo.toml crates/spt-term/src/digest.rs crates/spt-term/src/projection.rs crates/spt-term/src/lib.rs && git commit -q -m "$(cat <<'EOF'
feat(term): session-digest log projection primitive + published record contract (T7a)

The log-source projection that re-founds the live activity buffer on normalized
session logs instead of PTY bytes (ADR-0008 amendment, 2026-06-12). New
`spt-term::projection`:

- The published digest-record contract (`DigestRecord`/`DigestRole`/`ToolRef`):
  an spt-core-owned overlay a projectable adapter targets in its normalized
  history line (role in {input,agent,tool}, text, optional tool{name,arg}). One
  schema, no mapping layer; the history subsystem stays opaque (carries `raw`
  verbatim). Unknown fields tolerated (forward-compat); malformed / unknown-role
  / toolless-tool lines skipped, never a panic.
- `project` / `project_lines`: fold records into the same structured Digest the
  superseded parser produced — N-turn window, consecutive-tool collapse into one
  ToolSprint, ~25-char arg truncation (presentation spt-core-owned, extraction
  adapter-owned). Reuses the surviving output types (Digest/Turn/DigestEntry/
  ToolUse) + `truncate_arg`.

Additive: the PTY-parse path (DigestParser) stays green; it is retired and the
brain re-wired onto this projection in T7b. 8 projection units cover collapse,
window slide, truncation, preamble turns, and the forward-compat skips.

// [impl->REQ-TERM-4] // [unit->REQ-TERM-4]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
)" && echo "--- commit 1 done ---" && git log --oneline -3

Commit T7a projection primitive


warning: in the working copy of 'crates/spt-term/src/projection.rs', LF will be replaced by CRLF the next time Git touches it
--- commit 1 done ---
82ead73 feat(term): session-digest log projection primitive + published record contract (T7a)
714f6b8 docs(m9): Wave 3 / G3 JIT plan — digest log-projection (doyle-vetted)
58f034f docs(m9): log T5 twohost honored-path int as required-before-lecturn (gate condition)


Commits 0+1 landed. T7a projection primitive done (8 units green, REQ-TERM-4 still `[OK]`). Next T7a unit: `api digest-entry` push door + the `DigestEngine` re-core onto records. Read the `api history-log` precedent to mirror it faithfully.


Found 7 files
crates\spt-term\src\projection.rs
crates\spt\src\api\mod.rs
crates\spt\src\api\reporting.rs
crates\spt-runtime\manifest.schema.json
crates\spt-runtime\src\manifest.rs
crates\spt-store\src\history.rs
crates\spt-live\src\history.rs


1	//! Path-B native history store — the append-only `history.log` under a perch.
2	//!
3	//! Path B (CONTEXT §history subsystem) is the *native* transcript strategy: the
4	//! adapter (or spt-core itself) pushes already-normalized records and spt-core
5	//! stores them, rather than locating + parsing a version-fragile harness
6	//! transcript. The store is deliberately trivial — one newline-delimited record
7	//! per line, append-only — so it is a commodity store operation, not logic.
8	//!
9	//! Two callers append here today: `api history-log` (an adapter pushing a
10	//! normalized conversation record, REQ-API-2) and the M3b PTY-digest's opt-in
11	//! "Option (C)" persistence (a coarse activity-log record per completed turn,
12	//! ADR-0008 / REQ-TERM-4). Both want the same primitive, and it must live below
13	//! the `spt` binary (the daemon, which is below `spt`, persists digests) — so it
14	//! sits here in the store layer, the lowest common home.
15	// [impl->REQ-API-2]
16
17	use std::io::Write;
18	use std::path::Path;
19
20	/// Append a single normalized `record` to `<perch_path>/history.log`, creating
21	/// the perch dir + file if absent. A trailing newline is ensured (the store is
22	/// one record per line). Returns the number of bytes written (record + newline).
23	pub fn append_record(perch_path: &Path, record: &str) -> std::io::Result<usize> {
24	    std::fs::create_dir_all(perch_path)?;
25	    let mut f = std::fs::OpenOptions::new()
26	        .create(true)
27	        .append(true)
28	        .open(perch_path.join("history.log"))?;
29	    let line = if record.ends_with('\n') {
30	        record.to_string()
31	    } else {
32	        format!("{record}\n")
33	    };
34	    f.write_all(line.as_bytes())?;
35	    Ok(line.len())
36	}
37
38	#[cfg(test)]
39	mod tests {
40	    use super::*;
41
42	    // [unit->REQ-API-2] records append one-per-line, file auto-created.
43	    #[test]
44	    fn appends_one_record_per_line() {
45	        let dir = tempfile::tempdir().unwrap();
46	        let perch = dir.path().join("perch");
47	        append_record(&perch, "{\"role\":\"user\"}").unwrap();
48	        append_record(&perch, "{\"role\":\"assistant\"}\n").unwrap();
49	        let log = std::fs::read_to_string(perch.join("history.log")).unwrap();
50	        assert_eq!(log.lines().count(), 2, "two records, two lines");
51	        assert!(log.lines().all(|l| !l.is_empty()));
52	    }
53	}
54

//! Path-B native history store — the append-only `history.log` under a perch.
//!
//! Path B (CONTEXT §history subsystem) is the *native* transcript strategy: the
//! adapter (or spt-core itself) pushes already-normalized records and spt-core
//! stores them, rather than locating + parsing a version-fragile harness
//! transcript. The store is deliberately trivial — one newline-delimited record
//! per line, append-only — so it is a commodity store operation, not logic.
//!
//! Two callers append here today: `api history-log` (an adapter pushing a
//! normalized conversation record, REQ-API-2) and the M3b PTY-digest's opt-in
//! "Option (C)" persistence (a coarse activity-log record per completed turn,
//! ADR-0008 / REQ-TERM-4). Both want the same primitive, and it must live below
//! the `spt` binary (the daemon, which is below `spt`, persists digests) — so it
//! sits here in the store layer, the lowest common home.
// [impl->REQ-API-2]

use std::io::Write;
use std::path::Path;

/// Append a single normalized `record` to `<perch_path>/history.log`, creating
/// the perch dir + file if absent. A trailing newline is ensured (the store is
/// one record per line). Returns the number of bytes written (record + newline).
pub fn append_record(perch_path: &Path, record: &str) -> std::io::Result<usize> {
    std::fs::create_dir_all(perch_path)?;
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(perch_path.join("history.log"))?;
    let line = if record.ends_with('\n') {
        record.to_string()
    } else {
        format!("{record}\n")
    };
    f.write_all(line.as_bytes())?;
    Ok(line.len())
}

#[cfg(test)]
mod tests {
    use super::*;

    // [unit->REQ-API-2] records append one-per-line, file auto-created.
    #[test]
    fn appends_one_record_per_line() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        append_record(&perch, "{\"role\":\"user\"}").unwrap();
        append_record(&perch, "{\"role\":\"assistant\"}\n").unwrap();
        let log = std::fs::read_to_string(perch.join("history.log")).unwrap();
        assert_eq!(log.lines().count(), 2, "two records, two lines");
        assert!(log.lines().all(|l| !l.is_empty()));
    }
}