1	//! History subsystem (M2b T1) — transcript access behind the `[history]`
2	//! manifest section. Feeds the echo-commune (T3) and the resume briefs (T7).
3	//!
4	//! Three strategies, one normalized output ([`HistoryRecord`] lines):
5	//! - **`fetcher`** — spt-core *pulls*: run the adapter's `fetcher` command
6	//!   bounded (REQ-HAZARD-SUBPROCESS-TIMEOUT); its stdout is already-normalized
7	//!   history.
8	//! - **`locate_normalize`** — spt-core *locates* the raw transcript
9	//!   (`locate_template`), reads it, then pipes it through `normalize_command`
10	//!   bounded (raw transcript on stdin → normalized stdout).
11	//! - **`native`** — Path-B store: the adapter *pushed* records via
12	//!   `api history-log`; spt-core reads the perch's `history.log` (the T7/M2a
13	//!   sink in `spt::api::reporting::append_history`).
14	//!
15	//! A record is one non-empty line of normalized output. spt-core treats each as
16	//! **opaque** — the adapter owns the per-line schema (MANIFEST §Principle); the
17	//! history subsystem only delimits, never parses.
18	//
19	// Clean-room over the M2a seams (the sister's transcript access is `claude -p`
20	// /`.claude/`-coupled). The fetcher/normalize command lines are the adapter's
21	// opaque templates run through `run_bounded_command`; spt-core selects no model.
22	// [impl->REQ-SEAM-HISTORY]
23
24	use spt_runtime::manifest::{History, HistoryStrategy};
25	use spt_runtime::{run_bounded_command, RuntimeError};
26	use spt_store::perch::{resolve_perch_path, ParentHint};
27	use std::collections::BTreeMap;
28	use std::time::Duration;
29
30	/// One normalized transcript record — an opaque line. The adapter defines its
31	/// schema; spt-core carries it verbatim.
32	#[derive(Debug, Clone, PartialEq, Eq)]
33	pub struct HistoryRecord {
34	    pub raw: String,
35	}
36
37	/// A history-access failure. Carries an operator message; never panics, never
38	/// hangs (the bounded commands are timeout-governed).
39	#[derive(Debug)]
40	pub enum HistoryError {
41	    /// The chosen strategy is missing a field the manifest should have required
42	    /// (defensive — `Manifest::validate` already gates this).
43	    MissingField {
44	        strategy: HistoryStrategy,
45	        field: &'static str,
46	    },
47	    /// A `fetcher` / `normalize_command` spawn, fill, or timeout failure.
48	    Runtime(RuntimeError),
49	    /// The bounded command exited non-zero (its stderr, for the operator).
50	    NonZero { code: Option<i32>, stderr: String },
51	    /// Reading the raw transcript (`locate_normalize`) or the native log failed.
52	    Read(std::io::Error),
53	}
54
55	impl std::fmt::Display for HistoryError {
56	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57	        match self {
58	            HistoryError::MissingField { strategy, field } => {
59	                write!(f, "[history] strategy {strategy:?} requires `{field}`")
60	            }
61	            HistoryError::Runtime(e) => write!(f, "history command failed: {e}"),
62	            HistoryError::NonZero { code, stderr } => {
63	                write!(f, "history command exited {code:?}: {}", stderr.trim())
64	            }
65	            HistoryError::Read(e) => write!(f, "reading transcript failed: {e}"),
66	        }
67	    }
68	}
69
70	impl std::error::Error for HistoryError {}
71
72	impl From<RuntimeError> for HistoryError {
73	    fn from(e: RuntimeError) -> Self {
74	        HistoryError::Runtime(e)
75	    }
76	}
77
78	/// Fetch + normalize the perch's history via the manifest's `[history]`
79	/// strategy. `keys` supplies the substitution values (`session_id`, etc.) for
80	/// the `fetcher` / `locate_template` / `normalize_command` templates; `id` +
81	/// `hint` resolve the native Path-B store. A bounded command that hangs is
82	/// killed (never an indefinite block); a missing native log yields *no* records
83	/// (a fresh perch), not an error.
84	pub fn fetch_history(
85	    history: &History,
86	    keys: &BTreeMap<String, String>,
87	    id: &str,
88	    hint: ParentHint<'_>,
89	    timeout: Duration,
90	) -> Result<Vec<HistoryRecord>, HistoryError> {
91	    match history.strategy {
92	        HistoryStrategy::Fetcher => {
93	            let cmd = history
94	                .fetcher
95	                .as_deref()
96	                .ok_or(HistoryError::MissingField {
97	                    strategy: HistoryStrategy::Fetcher,
98	                    field: "fetcher",
99	                })?;
100	            let out = run_bounded_command(cmd, keys, timeout, None)?;
101	            ok_or_nonzero(out).map(|s| records(&s))
102	        }
103	        HistoryStrategy::LocateNormalize => {
104	            let locate = history
105	                .locate_template
106	                .as_deref()
107	                .ok_or(HistoryError::MissingField {
108	                    strategy: HistoryStrategy::LocateNormalize,
109	                    field: "locate_template",
110	                })?;
111	            let normalize =
112	                history
113	                    .normalize_command
114	                    .as_deref()
115	                    .ok_or(HistoryError::MissingField {
116	                        strategy: HistoryStrategy::LocateNormalize,
117	                        field: "normalize_command",
118	                    })?;
119	            // Fill the locate template to find the raw transcript, read it, then
120	            // hand the bytes to the normalizer on stdin.
121	            let path = spt_runtime::runtime::fill_template(locate, keys)?;
122	            let raw = std::fs::read(&path).map_err(HistoryError::Read)?;
123	            let out = run_bounded_command(normalize, keys, timeout, Some(&raw))?;
124	            ok_or_nonzero(out).map(|s| records(&s))
125	        }
126	        HistoryStrategy::Native => {
127	            let log = resolve_perch_path(id, hint).join("history.log");
128	            match std::fs::read_to_string(&log) {
129	                Ok(s) => Ok(records(&s)),
130	                // No log yet ⇒ a fresh perch with no recorded history.
131	                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
132	                Err(e) => Err(HistoryError::Read(e)),
133	            }
134	        }
135	    }
136	}
137
138	/// A bounded command's stdout, or `NonZero` carrying its stderr if it failed.
139	fn ok_or_nonzero(out: spt_runtime::BoundedOutput) -> Result<String, HistoryError> {
140	    if out.success() {
141	        Ok(out.stdout)
142	    } else {
143	        Err(HistoryError::NonZero {
144	            code: out.status_code,
145	            stderr: out.stderr,
146	        })
147	    }
148	}
149
150	/// Split normalized output into records: one per non-empty line, trailing
151	/// whitespace trimmed. The native store is already newline-delimited; fetchers
152	/// and normalizers emit the same shape.
153	fn records(s: &str) -> Vec<HistoryRecord> {
154	    s.lines()
155	        .map(str::trim_end)
156	        .filter(|l| !l.is_empty())
157	        .map(|l| HistoryRecord { raw: l.to_string() })
158	        .collect()
159	}
160
161	#[cfg(test)]
162	mod tests {
163	    use super::*;
164	    use crate::test_support::with_home;
165	    use std::time::Duration;
166
167	    fn no_keys() -> BTreeMap<String, String> {
168	        BTreeMap::new()
169	    }
170
171	    // Cross-platform fixtures: a command that prints two lines, a stdin
172	    // pass-through normalizer, and a sleeper that outlives any test timeout.
173	    #[cfg(windows)]
174	    fn two_line_cmd() -> &'static str {
175	        "cmd /C \"echo rec1&&echo rec2\""
176	    }
177	    #[cfg(unix)]
178	    fn two_line_cmd() -> &'static str {
179	        "sh -c \"printf 'rec1\\nrec2\\n'\""
180	    }
181	    #[cfg(windows)]
182	    fn passthrough() -> &'static str {
183	        // /r "." = regex matching any non-empty line; reads stdin (no file arg).
184	        "cmd /C findstr /r \".\""
185	    }
186	    #[cfg(unix)]
187	    fn passthrough() -> &'static str {
188	        "cat"
189	    }
190	    #[cfg(windows)]
191	    fn sleeper() -> &'static str {
192	        "cmd /C ping 127.0.0.1 -n 30"
193	    }
194	    #[cfg(unix)]
195	    fn sleeper() -> &'static str {
196	        "sh -c \"sleep 30\""
197	    }
198
199	    #[test] // [unit->REQ-SEAM-HISTORY] fetcher: adapter pull → normalized records.
200	    fn fetcher_yields_records() {
201	        let h = History {
202	            strategy: HistoryStrategy::Fetcher,
203	            fetcher: Some(two_line_cmd().to_string()),
204	            locate_template: None,
205	            normalize_command: None,
206	        };
207	        let recs = fetch_history(
208	            &h,
209	            &no_keys(),
210	            "alice",
211	            ParentHint::Infer,
212	            Duration::from_secs(10),
213	        )
214	        .expect("fetch");
215	        let raws: Vec<&str> = recs.iter().map(|r| r.raw.as_str()).collect();
216	        assert_eq!(raws, ["rec1", "rec2"]);
217	    }
218
219	    #[test] // [unit->REQ-SEAM-HISTORY] locate_normalize: read raw transcript, pipe through normalizer.
220	    fn locate_normalize_reads_and_normalizes() {
221	        let raw = tempfile::NamedTempFile::new().unwrap();
222	        std::fs::write(raw.path(), "line-a\nline-b\n").unwrap();
223	        // The locate template resolves to a path read directly (not tokenized),
224	        // so spaces need no quoting.
225	        let locate = raw.path().display().to_string();
226	        let h = History {
227	            strategy: HistoryStrategy::LocateNormalize,
228	            fetcher: None,
229	            locate_template: Some(locate),
230	            normalize_command: Some(passthrough().to_string()),
231	        };
232	        let recs = fetch_history(
233	            &h,
234	            &no_keys(),
235	            "alice",
236	            ParentHint::Infer,
237	            Duration::from_secs(10),
238	        )
239	        .expect("fetch");
240	        let raws: Vec<&str> = recs.iter().map(|r| r.raw.as_str()).collect();
241	        assert_eq!(raws, ["line-a", "line-b"]);
242	    }
243
244	    #[test] // [unit->REQ-SEAM-HISTORY] native Path-B store: read the perch's history.log.
245	    fn native_reads_logged_records() {
246	        with_home(|_| {
247	            let perch = resolve_perch_path("alice", ParentHint::Infer);
248	            std::fs::create_dir_all(&perch).unwrap();
249	            std::fs::write(perch.join("history.log"), "{\"r\":\"u\"}\n{\"r\":\"a\"}\n").unwrap();
250	            let h = History {
251	                strategy: HistoryStrategy::Native,
252	                fetcher: None,
253	                locate_template: None,
254	                normalize_command: None,
255	            };
256	            let recs = fetch_history(
257	                &h,
258	                &no_keys(),
259	                "alice",
260	                ParentHint::Infer,
261	                Duration::from_secs(5),
262	            )
263	            .expect("fetch");
264	            assert_eq!(recs.len(), 2);
265	            assert_eq!(recs[0].raw, "{\"r\":\"u\"}");
266	        });
267	    }
268
269	    #[test] // [unit->REQ-SEAM-HISTORY] a fresh perch (no log) yields no records, not an error.
270	    fn native_missing_log_is_empty() {
271	        with_home(|_| {
272	            let h = History {
273	                strategy: HistoryStrategy::Native,
274	                fetcher: None,
275	                locate_template: None,
276	                normalize_command: None,
277	            };
278	            let recs = fetch_history(
279	                &h,
280	                &no_keys(),
281	                "fresh",
282	                ParentHint::Infer,
283	                Duration::from_secs(5),
284	            )
285	            .expect("fetch");
286	            assert!(recs.is_empty());
287	        });
288	    }
289
290	    #[test] // [unit->REQ-SEAM-HISTORY] a hung fetcher is killed by the timeout — never hangs (5.3).
291	    fn fetcher_timeout_errors() {
292	        let h = History {
293	            strategy: HistoryStrategy::Fetcher,
294	            fetcher: Some(sleeper().to_string()),
295	            locate_template: None,
296	            normalize_command: None,
297	        };
298	        let start = std::time::Instant::now();
299	        let e = fetch_history(
300	            &h,
301	            &no_keys(),
302	            "alice",
303	            ParentHint::Infer,
304	            Duration::from_millis(300),
305	        )
306	        .unwrap_err();
307	        assert!(
308	            matches!(e, HistoryError::Runtime(RuntimeError::Timeout { .. })),
309	            "got {e}"
310	        );
311	        assert!(
312	            start.elapsed() < Duration::from_secs(5),
313	            "must return promptly"
314	        );
315	    }
316
317	    #[test] // [unit->REQ-SEAM-HISTORY] a strategy missing its required field errors defensively.
318	    fn missing_fetcher_field_errors() {
319	        let h = History {
320	            strategy: HistoryStrategy::Fetcher,
321	            fetcher: None,
322	            locate_template: None,
323	            normalize_command: None,
324	        };
325	        let e = fetch_history(
326	            &h,
327	            &no_keys(),
328	            "alice",
329	            ParentHint::Infer,
330	            Duration::from_secs(5),
331	        )
332	        .unwrap_err();
333	        assert!(
334	            matches!(
335	                e,
336	                HistoryError::MissingField {
337	                    field: "fetcher",
338	                    ..
339	                }
340	            ),
341	            "got {e}"
342	        );
343	    }
344	}
345

//! History subsystem (M2b T1) — transcript access behind the `[history]`
//! manifest section. Feeds the echo-commune (T3) and the resume briefs (T7).
//!
//! Three strategies, one normalized output ([`HistoryRecord`] lines):
//! - **`fetcher`** — spt-core *pulls*: run the adapter's `fetcher` command
//!   bounded (REQ-HAZARD-SUBPROCESS-TIMEOUT); its stdout is already-normalized
//!   history.
//! - **`locate_normalize`** — spt-core *locates* the raw transcript
//!   (`locate_template`), reads it, then pipes it through `normalize_command`
//!   bounded (raw transcript on stdin → normalized stdout).
//! - **`native`** — Path-B store: the adapter *pushed* records via
//!   `api history-log`; spt-core reads the perch's `history.log` (the T7/M2a
//!   sink in `spt::api::reporting::append_history`).
//!
//! A record is one non-empty line of normalized output. spt-core treats each as
//! **opaque** — the adapter owns the per-line schema (MANIFEST §Principle); the
//! history subsystem only delimits, never parses.
//
// Clean-room over the M2a seams (the sister's transcript access is `claude -p`
// /`.claude/`-coupled). The fetcher/normalize command lines are the adapter's
// opaque templates run through `run_bounded_command`; spt-core selects no model.
// [impl->REQ-SEAM-HISTORY]

use spt_runtime::manifest::{History, HistoryStrategy};
use spt_runtime::{run_bounded_command, RuntimeError};
use spt_store::perch::{resolve_perch_path, ParentHint};
use std::collections::BTreeMap;
use std::time::Duration;

/// One normalized transcript record — an opaque line. The adapter defines its
/// schema; spt-core carries it verbatim.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryRecord {
    pub raw: String,
}

/// A history-access failure. Carries an operator message; never panics, never
/// hangs (the bounded commands are timeout-governed).
#[derive(Debug)]
pub enum HistoryError {
    /// The chosen strategy is missing a field the manifest should have required
    /// (defensive — `Manifest::validate` already gates this).
    MissingField {
        strategy: HistoryStrategy,
        field: &'static str,
    },
    /// A `fetcher` / `normalize_command` spawn, fill, or timeout failure.
    Runtime(RuntimeError),
    /// The bounded command exited non-zero (its stderr, for the operator).
    NonZero { code: Option<i32>, stderr: String },
    /// Reading the raw transcript (`locate_normalize`) or the native log failed.
    Read(std::io::Error),
}

impl std::fmt::Display for HistoryError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            HistoryError::MissingField { strategy, field } => {
                write!(f, "[history] strategy {strategy:?} requires `{field}`")
            }
            HistoryError::Runtime(e) => write!(f, "history command failed: {e}"),
            HistoryError::NonZero { code, stderr } => {
                write!(f, "history command exited {code:?}: {}", stderr.trim())
            }
            HistoryError::Read(e) => write!(f, "reading transcript failed: {e}"),
        }
    }
}

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

impl From<RuntimeError> for HistoryError {
    fn from(e: RuntimeError) -> Self {
        HistoryError::Runtime(e)
    }
}

/// Fetch + normalize the perch's history via the manifest's `[history]`
/// strategy. `keys` supplies the substitution values (`session_id`, etc.) for
/// the `fetcher` / `locate_template` / `normalize_command` templates; `id` +
/// `hint` resolve the native Path-B store. A bounded command that hangs is
/// killed (never an indefinite block); a missing native log yields *no* records
/// (a fresh perch), not an error.
pub fn fetch_history(
    history: &History,
    keys: &BTreeMap<String, String>,
    id: &str,
    hint: ParentHint<'_>,
    timeout: Duration,
) -> Result<Vec<HistoryRecord>, HistoryError> {
    match history.strategy {
        HistoryStrategy::Fetcher => {
            let cmd = history
                .fetcher
                .as_deref()
                .ok_or(HistoryError::MissingField {
                    strategy: HistoryStrategy::Fetcher,
                    field: "fetcher",
                })?;
            let out = run_bounded_command(cmd, keys, timeout, None)?;
            ok_or_nonzero(out).map(|s| records(&s))
        }
        HistoryStrategy::LocateNormalize => {
            let locate = history
                .locate_template
                .as_deref()
                .ok_or(HistoryError::MissingField {
                    strategy: HistoryStrategy::LocateNormalize,
                    field: "locate_template",
                })?;
            let normalize =
                history
                    .normalize_command
                    .as_deref()
                    .ok_or(HistoryError::MissingField {
                        strategy: HistoryStrategy::LocateNormalize,
                        field: "normalize_command",
                    })?;
            // Fill the locate template to find the raw transcript, read it, then
            // hand the bytes to the normalizer on stdin.
            let path = spt_runtime::runtime::fill_template(locate, keys)?;
            let raw = std::fs::read(&path).map_err(HistoryError::Read)?;
            let out = run_bounded_command(normalize, keys, timeout, Some(&raw))?;
            ok_or_nonzero(out).map(|s| records(&s))
        }
        HistoryStrategy::Native => {
            let log = resolve_perch_path(id, hint).join("history.log");
            match std::fs::read_to_string(&log) {
                Ok(s) => Ok(records(&s)),
                // No log yet ⇒ a fresh perch with no recorded history.
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
                Err(e) => Err(HistoryError::Read(e)),
            }
        }
    }
}

/// A bounded command's stdout, or `NonZero` carrying its stderr if it failed.
fn ok_or_nonzero(out: spt_runtime::BoundedOutput) -> Result<String, HistoryError> {
    if out.success() {
        Ok(out.stdout)
    } else {
        Err(HistoryError::NonZero {
            code: out.status_code,
            stderr: out.stderr,
        })
    }
}

/// Split normalized output into records: one per non-empty line, trailing
/// whitespace trimmed. The native store is already newline-delimited; fetchers
/// and normalizers emit the same shape.
fn records(s: &str) -> Vec<HistoryRecord> {
    s.lines()
        .map(str::trim_end)
        .filter(|l| !l.is_empty())
        .map(|l| HistoryRecord { raw: l.to_string() })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::with_home;
    use std::time::Duration;

    fn no_keys() -> BTreeMap<String, String> {
        BTreeMap::new()
    }

    // Cross-platform fixtures: a command that prints two lines, a stdin
    // pass-through normalizer, and a sleeper that outlives any test timeout.
    #[cfg(windows)]
    fn two_line_cmd() -> &'static str {
        "cmd /C \"echo rec1&&echo rec2\""
    }
    #[cfg(unix)]
    fn two_line_cmd() -> &'static str {
        "sh -c \"printf 'rec1\\nrec2\\n'\""
    }
    #[cfg(windows)]
    fn passthrough() -> &'static str {
        // /r "." = regex matching any non-empty line; reads stdin (no file arg).
        "cmd /C findstr /r \".\""
    }
    #[cfg(unix)]
    fn passthrough() -> &'static str {
        "cat"
    }
    #[cfg(windows)]
    fn sleeper() -> &'static str {
        "cmd /C ping 127.0.0.1 -n 30"
    }
    #[cfg(unix)]
    fn sleeper() -> &'static str {
        "sh -c \"sleep 30\""
    }

    #[test] // [unit->REQ-SEAM-HISTORY] fetcher: adapter pull → normalized records.
    fn fetcher_yields_records() {
        let h = History {
            strategy: HistoryStrategy::Fetcher,
            fetcher: Some(two_line_cmd().to_string()),
            locate_template: None,
            normalize_command: None,
        };
        let recs = fetch_history(
            &h,
            &no_keys(),
            "alice",
            ParentHint::Infer,
            Duration::from_secs(10),
        )
        .expect("fetch");
        let raws: Vec<&str> = recs.iter().map(|r| r.raw.as_str()).collect();
        assert_eq!(raws, ["rec1", "rec2"]);
    }

    #[test] // [unit->REQ-SEAM-HISTORY] locate_normalize: read raw transcript, pipe through normalizer.
    fn locate_normalize_reads_and_normalizes() {
        let raw = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(raw.path(), "line-a\nline-b\n").unwrap();
        // The locate template resolves to a path read directly (not tokenized),
        // so spaces need no quoting.
        let locate = raw.path().display().to_string();
        let h = History {
            strategy: HistoryStrategy::LocateNormalize,
            fetcher: None,
            locate_template: Some(locate),
            normalize_command: Some(passthrough().to_string()),
        };
        let recs = fetch_history(
            &h,
            &no_keys(),
            "alice",
            ParentHint::Infer,
            Duration::from_secs(10),
        )
        .expect("fetch");
        let raws: Vec<&str> = recs.iter().map(|r| r.raw.as_str()).collect();
        assert_eq!(raws, ["line-a", "line-b"]);
    }

    #[test] // [unit->REQ-SEAM-HISTORY] native Path-B store: read the perch's history.log.
    fn native_reads_logged_records() {
        with_home(|_| {
            let perch = resolve_perch_path("alice", ParentHint::Infer);
            std::fs::create_dir_all(&perch).unwrap();
            std::fs::write(perch.join("history.log"), "{\"r\":\"u\"}\n{\"r\":\"a\"}\n").unwrap();
            let h = History {
                strategy: HistoryStrategy::Native,
                fetcher: None,
                locate_template: None,
                normalize_command: None,
            };
            let recs = fetch_history(
                &h,
                &no_keys(),
                "alice",
                ParentHint::Infer,
                Duration::from_secs(5),
            )
            .expect("fetch");
            assert_eq!(recs.len(), 2);
            assert_eq!(recs[0].raw, "{\"r\":\"u\"}");
        });
    }

    #[test] // [unit->REQ-SEAM-HISTORY] a fresh perch (no log) yields no records, not an error.
    fn native_missing_log_is_empty() {
        with_home(|_| {
            let h = History {
                strategy: HistoryStrategy::Native,
                fetcher: None,
                locate_template: None,
                normalize_command: None,
            };
            let recs = fetch_history(
                &h,
                &no_keys(),
                "fresh",
                ParentHint::Infer,
                Duration::from_secs(5),
            )
            .expect("fetch");
            assert!(recs.is_empty());
        });
    }

    #[test] // [unit->REQ-SEAM-HISTORY] a hung fetcher is killed by the timeout — never hangs (5.3).
    fn fetcher_timeout_errors() {
        let h = History {
            strategy: HistoryStrategy::Fetcher,
            fetcher: Some(sleeper().to_string()),
            locate_template: None,
            normalize_command: None,
        };
        let start = std::time::Instant::now();
        let e = fetch_history(
            &h,
            &no_keys(),
            "alice",
            ParentHint::Infer,
            Duration::from_millis(300),
        )
        .unwrap_err();
        assert!(
            matches!(e, HistoryError::Runtime(RuntimeError::Timeout { .. })),
            "got {e}"
        );
        assert!(
            start.elapsed() < Duration::from_secs(5),
            "must return promptly"
        );
    }

    #[test] // [unit->REQ-SEAM-HISTORY] a strategy missing its required field errors defensively.
    fn missing_fetcher_field_errors() {
        let h = History {
            strategy: HistoryStrategy::Fetcher,
            fetcher: None,
            locate_template: None,
            normalize_command: None,
        };
        let e = fetch_history(
            &h,
            &no_keys(),
            "alice",
            ParentHint::Infer,
            Duration::from_secs(5),
        )
        .unwrap_err();
        assert!(
            matches!(
                e,
                HistoryError::MissingField {
                    field: "fetcher",
                    ..
                }
            ),
            "got {e}"
        );
    }
}


1	//! The daemon-held digest hub + its control channel (B8, ADR-0008 / REQ-TERM-4).
2	//!
3	//! The brain runs a [`crate::digest::DigestEngine`] per daemon-hosted session and
4	//! **publishes** each change into this hub; the hub holds the latest structured
5	//! snapshot per endpoint and fans every change out to live subscribers. The
6	//! control channel is the access surface ADR-0008 describes — two modes over one
7	//! transport:
8	//!
9	//! - **snapshot pull** — `spt digest <id>` ([`pull_snapshot`]) returns the current
10	//!   structured buffer for a daemon-hosted session;
11	//! - **delta-stream** — `spt digest <id> --follow` ([`follow`]) subscribes and
12	//!   receives a base snapshot then **only the changes** ([`DigestUpdate`]s) as the
13	//!   agent produces output (the "near-realtime reflection" mode).
14	//!
15	//! The channel rides the same forward-compatible [`Envelope`] + codec + version
16	//! handshake as the broker / seed-control IPC (a transient `spt digest` CLI
17	//! connects as a [`Role::Brain`] logic client). **Local addressing only** — the
18	//! qualified `[subnet:]id@node` cross-node form is M4 (rejected by the CLI before
19	//! it ever reaches here).
20	// [impl->REQ-TERM-4]
21
22	use std::collections::HashMap;
23	use std::io::{self, Read, Write};
24	use std::sync::mpsc::{channel, Receiver, Sender};
25	use std::sync::{Arc, Mutex};
26	use std::thread;
27
28	use serde::{Deserialize, Serialize};
29	use spt_term::{Digest, DigestEntry, Turn};
30
31	use crate::codec::{read_frame, write_frame};
32	use crate::digest::DigestUpdate;
33	use crate::frame::{Envelope, Role};
34	use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};
35
36	/// Client→daemon: pull the current structured digest for an endpoint.
37	pub const KIND_DIGEST_SNAPSHOT: &str = "digest_snapshot";
38	/// Daemon→client: the snapshot reply (digest + version; absent ⇒ no live digest).
39	pub const KIND_DIGEST_RESULT: &str = "digest_result";
40	/// Client→daemon: subscribe to an endpoint's delta-stream from a version floor.
41	pub const KIND_DIGEST_SUBSCRIBE: &str = "digest_subscribe";
42	/// Daemon→client: one structured digest change (the streamed delta / base).
43	pub const KIND_DIGEST_DELTA: &str = "digest_delta";
44
45	/// `digest_snapshot` / `digest_subscribe` request payload.
46	#[derive(Debug, Clone, Serialize, Deserialize)]
47	pub struct DigestReq {
48	    /// The (local) endpoint id whose digest to read.
49	    pub id: String,
50	    /// Subscribe floor: only stream changes with `version > from_version`. The
51	    /// base snapshot is sent regardless so a fresh subscriber has an apply base.
52	    #[serde(default)]
53	    pub from_version: u64,
54	}
55
56	/// `digest_result` payload — the snapshot pull reply.
57	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58	pub struct DigestResult {
59	    /// The current structured digest, absent if the endpoint has no live digest.
60	    #[serde(default, skip_serializing_if = "Option::is_none")]
61	    pub digest: Option<Digest>,
62	    /// The digest version (0 when none).
63	    #[serde(default)]
64	    pub version: u64,
65	}
66
67	/// One endpoint's live digest state in the hub: the latest snapshot + version
68	/// (for pulls) and the set of live subscriber senders (for the delta-stream).
69	#[derive(Default)]
70	struct EndpointState {
71	    snapshot: Digest,
72	    version: u64,
73	    subscribers: Vec<Sender<DigestUpdate>>,
74	}
75
76	/// The daemon's per-endpoint live-digest registry. The brain publishes; the
77	/// control channel reads + subscribes. Holds only the projected digest state —
78	/// the same ephemeral-projection category as the broker's accepted-socket
79	/// bookkeeping, not routing/registry/parse logic (the parse already happened in
80	/// the brain's engine).
81	#[derive(Default)]
82	pub struct DigestHub {
83	    map: Mutex<HashMap<String, EndpointState>>,
84	}
85
86	impl DigestHub {
87	    /// A fresh, empty hub.
88	    pub fn new() -> Self {
89	        DigestHub {
90	            map: Mutex::new(HashMap::new()),
91	        }
92	    }
93
94	    /// Publish a digest change for `id`: store the new `snapshot` + version and
95	    /// fan `update` out to every live subscriber (pruning any whose receiver has
96	    /// been dropped — a disconnected `--follow` client). Called by the brain's
97	    /// feed loop after each [`DigestEngine::feed`](crate::digest::DigestEngine::feed).
98	    pub fn publish(&self, id: &str, snapshot: Digest, update: DigestUpdate) {
99	        let mut map = self.map.lock().unwrap();
100	        let st = map.entry(id.to_string()).or_default();
101	        st.version = update.version;
102	        st.snapshot = snapshot;
103	        st.subscribers.retain(|tx| tx.send(update.clone()).is_ok());
104	    }
105
106	    /// The current snapshot + version for `id`, if a live digest exists.
107	    pub fn snapshot(&self, id: &str) -> Option<(Digest, u64)> {
108	        let map = self.map.lock().unwrap();
109	        map.get(id).map(|st| (st.snapshot.clone(), st.version))
110	    }
111
112	    /// Register a subscriber for `id`, returning the current base snapshot+version
113	    /// (to apply later deltas against) and the receiver future changes arrive on.
114	    /// The base is always returned so a fresh subscriber has an apply anchor.
115	    fn subscribe(&self, id: &str) -> (Digest, u64, Receiver<DigestUpdate>) {
116	        let (tx, rx) = channel();
117	        let mut map = self.map.lock().unwrap();
118	        let st = map.entry(id.to_string()).or_default();
119	        st.subscribers.push(tx);
120	        (st.snapshot.clone(), st.version, rx)
121	    }
122
123	    /// Number of live subscribers for `id` (tests / introspection).
124	    pub fn subscriber_count(&self, id: &str) -> usize {
125	        self.map
126	            .lock()
127	            .unwrap()
128	            .get(id)
129	            .map(|s| s.subscribers.len())
130	            .unwrap_or(0)
131	    }
132	}
133
134	/// Build a `digest_delta` envelope from a [`DigestUpdate`].
135	fn delta_frame(up: &DigestUpdate) -> Envelope {
136	    Envelope::new(
137	        KIND_DIGEST_DELTA,
138	        serde_json::to_value(up).expect("DigestUpdate serializes"),
139	    )
140	}
141
142	/// Bind the digest-control channel at `name` and serve it forever against `hub`.
143	/// Thread-per-connection (mirroring the broker / seed-control): a `--follow`
144	/// subscribe blocks its connection thread streaming deltas, so serving inline
145	/// would stall every other client.
146	pub fn serve_digest_control(name: &str, hub: Arc<DigestHub>) -> io::Result<()> {
147	    let listener = LocalSocketTransport::bind(name)?;
148	    loop {
149	        let conn = listener.accept()?;
150	        let hub = Arc::clone(&hub);
151	        thread::spawn(move || {
152	            let _ = handle_conn(conn, &hub);
153	        });
154	    }
155	}
156
157	/// Drive one control connection: handshake, then service snapshot pulls and
158	/// subscribe streams until the client disconnects.
159	fn handle_conn<C: Read + Write>(mut conn: C, hub: &DigestHub) -> io::Result<()> {
160	    recv_hello(&mut conn, Role::Brain)?;
161	    loop {
162	        let env = match read_frame(&mut conn) {
163	            Ok(e) => e,
164	            Err(_) => return Ok(()), // peer hung up
165	        };
166	        match env.kind.as_str() {
167	            KIND_DIGEST_SNAPSHOT => {
168	                let req: DigestReq = serde_json::from_value(env.payload)
169	                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
170	                let (digest, version) = match hub.snapshot(&req.id) {
171	                    Some((d, v)) => (Some(d), v),
172	                    None => (None, 0),
173	                };
174	                let res = DigestResult { digest, version };
175	                write_frame(
176	                    &mut conn,
177	                    &Envelope::new(KIND_DIGEST_RESULT, serde_json::to_value(res).unwrap()),
178	                )?;
179	            }
180	            KIND_DIGEST_SUBSCRIBE => {
181	                let req: DigestReq = serde_json::from_value(env.payload)
182	                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
183	                // Register, send the base snapshot (full), then stream changes.
184	                let (base, version, rx) = hub.subscribe(&req.id);
185	                if version >= req.from_version {
186	                    let base_update = DigestUpdate {
187	                        version,
188	                        from: 0, // a fresh subscriber replaces its (empty) view
189	                        turns: base.turns,
190	                        newly_completed: Vec::new(),
191	                    };
192	                    write_frame(&mut conn, &delta_frame(&base_update))?;
193	                }
194	                // Forward each published change until the client disconnects (a
195	                // write failure) or the hub drops the sender.
196	                while let Ok(up) = rx.recv() {
197	                    if up.version <= req.from_version {
198	                        continue;
199	                    }
200	                    if write_frame(&mut conn, &delta_frame(&up)).is_err() {
201	                        return Ok(()); // client gone — the next publish prunes us
202	                    }
203	                }
204	                return Ok(());
205	            }
206	            // Forward-compat: an unknown control kind is tolerated, not fatal.
207	            _ => {}
208	        }
209	    }
210	}
211
212	/// Connect to the digest-control channel `name`, announcing ourselves as a brain
213	/// (the `spt digest` CLI is a transient logic client of the daemon).
214	fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
215	    let mut conn = LocalSocketTransport::connect(name)?;
216	    send_hello(&mut conn, Role::Brain)?;
217	    Ok(conn)
218	}
219
220	/// Pull the current structured digest for `id` over the control channel `name`.
221	/// `Ok(None)` ⇒ the daemon holds no live digest for that endpoint.
222	// [impl->REQ-TERM-4]
223	pub fn pull_snapshot(name: &str, id: &str) -> io::Result<Option<(Digest, u64)>> {
224	    let mut conn = connect(name)?;
225	    let req = DigestReq {
226	        id: id.to_string(),
227	        from_version: 0,
228	    };
229	    write_frame(
230	        &mut conn,
231	        &Envelope::new(KIND_DIGEST_SNAPSHOT, serde_json::to_value(req).unwrap()),
232	    )?;
233	    let env = read_frame(&mut conn)?;
234	    let res: DigestResult = serde_json::from_value(env.payload)
235	        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
236	    Ok(res.digest.map(|d| (d, res.version)))
237	}
238
239	/// Subscribe to `id`'s delta-stream over `name`, invoking `on_update` for the base
240	/// snapshot and each subsequent change. Stops when `on_update` returns `false` or
241	/// the stream ends. `from_version` skips changes the caller already has.
242	// [impl->REQ-TERM-4]
243	pub fn follow<F: FnMut(&DigestUpdate) -> bool>(
244	    name: &str,
245	    id: &str,
246	    from_version: u64,
247	    mut on_update: F,
248	) -> io::Result<()> {
249	    let mut conn = connect(name)?;
250	    let req = DigestReq {
251	        id: id.to_string(),
252	        from_version,
253	    };
254	    write_frame(
255	        &mut conn,
256	        &Envelope::new(KIND_DIGEST_SUBSCRIBE, serde_json::to_value(req).unwrap()),
257	    )?;
258	    loop {
259	        let env = match read_frame(&mut conn) {
260	            Ok(e) => e,
261	            Err(_) => return Ok(()), // stream ended
262	        };
263	        if env.kind == KIND_DIGEST_DELTA {
264	            let up: DigestUpdate = serde_json::from_value(env.payload)
265	                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
266	            if !on_update(&up) {
267	                return Ok(());
268	            }
269	        }
270	    }
271	}
272
273	// ───────────────────────────── presentation ─────────────────────────────
274	//
275	// The `spt digest` CLI lives in the `spt` binary, which depends on neither
276	// `serde_json` nor `spt-term` directly — so all rendering (human + JSON) is
277	// provided here, keeping the CLI a thin printer.
278
279	/// Render a structured digest as a human-glanceable block: one `» input` line
280	/// per user turn, agent output collapsed to a single line, and each tool sprint
281	/// as a `used: name(arg), …` line. The at-a-glance "what is this agent doing"
282	/// view (ADR-0008).
283	pub fn render_digest(digest: &Digest) -> String {
284	    render_turns(&digest.turns)
285	}
286
287	/// Render the turns carried by a streamed [`DigestUpdate`] (the delta-stream
288	/// presentation — same per-turn shape as a snapshot).
289	pub fn render_update(update: &DigestUpdate) -> String {
290	    render_turns(&update.turns)
291	}
292
293	/// Serialize a digest to pretty JSON (the `--json` snapshot form).
294	pub fn digest_to_json(digest: &Digest) -> String {
295	    serde_json::to_string_pretty(digest).unwrap_or_else(|_| "{}".to_string())
296	}
297
298	/// Serialize a streamed update to compact JSON (one line per delta for `--json
299	/// --follow`).
300	pub fn update_to_json(update: &DigestUpdate) -> String {
301	    serde_json::to_string(update).unwrap_or_else(|_| "{}".to_string())
302	}
303
304	fn render_turns(turns: &[Turn]) -> String {
305	    let mut out = String::new();
306	    for t in turns {
307	        if let Some(input) = &t.input {
308	            out.push_str(&format!("» {}\n", one_line(input)));
309	        }
310	        for e in &t.entries {
311	            match e {
312	                DigestEntry::Agent(text) => {
313	                    let line = one_line(text);
314	                    if !line.is_empty() {
315	                        out.push_str(&format!("  {line}\n"));
316	                    }
317	                }
318	                DigestEntry::ToolSprint(tools) => {
319	                    let joined = tools
320	                        .iter()
321	                        .map(|tu| {
322	                            if tu.arg.is_empty() {
323	                                tu.name.clone()
324	                            } else {
325	                                format!("{}({})", tu.name, tu.arg)
326	                            }
327	                        })
328	                        .collect::<Vec<_>>()
329	                        .join(", ");
330	                    out.push_str(&format!("  used: {joined}\n"));
331	                }
332	            }
333	        }
334	    }
335	    out
336	}
337
338	/// Collapse internal whitespace/newlines to a single spaced line for glanceable
339	/// rendering (the digest is a summary, not a transcript).
340	fn one_line(s: &str) -> String {
341	    s.split_whitespace().collect::<Vec<_>>().join(" ")
342	}
343
344	#[cfg(test)]
345	mod tests {
346	    use super::*;
347	    use spt_term::{DigestEntry, ToolUse, Turn};
348
349	    fn turn(input: &str) -> Turn {
350	        Turn {
351	            input: Some(input.to_string()),
352	            entries: Vec::new(),
353	        }
354	    }
355
356	    fn an_update(version: u64) -> DigestUpdate {
357	        DigestUpdate {
358	            version,
359	            from: 0,
360	            turns: vec![turn("hi")],
361	            newly_completed: Vec::new(),
362	        }
363	    }
364
365	    // [unit->REQ-TERM-4] publish stores the latest snapshot; a pull reads it back.
366	    #[test]
367	    fn publish_then_snapshot_reads_latest() {
368	        let hub = DigestHub::new();
369	        assert!(
370	            hub.snapshot("doyle").is_none(),
371	            "no digest before any publish"
372	        );
373	        let snap = Digest {
374	            turns: vec![turn("hi")],
375	        };
376	        hub.publish("doyle", snap.clone(), an_update(1));
377	        let (got, v) = hub.snapshot("doyle").expect("a live digest");
378	        assert_eq!(v, 1);
379	        assert_eq!(got, snap);
380	    }
381
382	    // [unit->REQ-TERM-4] a subscriber receives published changes; a dropped
383	    // subscriber is pruned on the next publish.
384	    #[test]
385	    fn subscriber_receives_changes_then_is_pruned() {
386	        let hub = DigestHub::new();
387	        let (_base, _v, rx) = hub.subscribe("doyle");
388	        assert_eq!(hub.subscriber_count("doyle"), 1);
389
390	        hub.publish(
391	            "doyle",
392	            Digest {
393	                turns: vec![turn("a")],
394	            },
395	            an_update(1),
396	        );
397	        assert_eq!(rx.recv().unwrap().version, 1, "the change was fanned out");
398
399	        // Drop the receiver → the next publish's send fails → the subscriber is pruned.
400	        drop(rx);
401	        hub.publish(
402	            "doyle",
403	            Digest {
404	                turns: vec![turn("b")],
405	            },
406	            an_update(2),
407	        );
408	        assert_eq!(hub.subscriber_count("doyle"), 0, "dead subscriber pruned");
409	    }
410
411	    // [unit->REQ-TERM-4] a fresh subscribe returns the current snapshot as its
412	    // base so later deltas have an apply anchor.
413	    #[test]
414	    fn subscribe_returns_current_base() {
415	        let hub = DigestHub::new();
416	        hub.publish(
417	            "doyle",
418	            Digest {
419	                turns: vec![turn("base")],
420	            },
421	            an_update(7),
422	        );
423	        let (base, version, _rx) = hub.subscribe("doyle");
424	        assert_eq!(version, 7);
425	        assert_eq!(base.turns[0].input.as_deref(), Some("base"));
426	    }
427
428	    // [unit->REQ-TERM-4] the human render shows input, collapsed agent text, and a
429	    // single `used:` line per tool sprint.
430	    #[test]
431	    fn render_is_glanceable() {
432	        let digest = Digest {
433	            turns: vec![Turn {
434	                input: Some("fix the\n  bug".to_string()),
435	                entries: vec![
436	                    DigestEntry::Agent("on it\nlooking".to_string()),
437	                    DigestEntry::ToolSprint(vec![
438	                        ToolUse {
439	                            name: "Write".into(),
440	                            arg: "a.rs".into(),
441	                        },
442	                        ToolUse {
443	                            name: "Bash".into(),
444	                            arg: "cargo".into(),
445	                        },
446	                    ]),
447	                ],
448	            }],
449	        };
450	        let out = render_digest(&digest);
451	        assert!(out.contains("» fix the bug"), "input on one line: {out}");
452	        assert!(out.contains("  on it looking"), "agent collapsed: {out}");
453	        assert!(
454	            out.contains("used: Write(a.rs), Bash(cargo)"),
455	            "tool sprint: {out}"
456	        );
457	    }
458	}
459

//! The daemon-held digest hub + its control channel (B8, ADR-0008 / REQ-TERM-4).
//!
//! The brain runs a [`crate::digest::DigestEngine`] per daemon-hosted session and
//! **publishes** each change into this hub; the hub holds the latest structured
//! snapshot per endpoint and fans every change out to live subscribers. The
//! control channel is the access surface ADR-0008 describes — two modes over one
//! transport:
//!
//! - **snapshot pull** — `spt digest <id>` ([`pull_snapshot`]) returns the current
//!   structured buffer for a daemon-hosted session;
//! - **delta-stream** — `spt digest <id> --follow` ([`follow`]) subscribes and
//!   receives a base snapshot then **only the changes** ([`DigestUpdate`]s) as the
//!   agent produces output (the "near-realtime reflection" mode).
//!
//! The channel rides the same forward-compatible [`Envelope`] + codec + version
//! handshake as the broker / seed-control IPC (a transient `spt digest` CLI
//! connects as a [`Role::Brain`] logic client). **Local addressing only** — the
//! qualified `[subnet:]id@node` cross-node form is M4 (rejected by the CLI before
//! it ever reaches here).
// [impl->REQ-TERM-4]

use std::collections::HashMap;
use std::io::{self, Read, Write};
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread;

use serde::{Deserialize, Serialize};
use spt_term::{Digest, DigestEntry, Turn};

use crate::codec::{read_frame, write_frame};
use crate::digest::DigestUpdate;
use crate::frame::{Envelope, Role};
use crate::transport::{recv_hello, send_hello, DaemonTransport, LocalSocketTransport};

/// Client→daemon: pull the current structured digest for an endpoint.
pub const KIND_DIGEST_SNAPSHOT: &str = "digest_snapshot";
/// Daemon→client: the snapshot reply (digest + version; absent ⇒ no live digest).
pub const KIND_DIGEST_RESULT: &str = "digest_result";
/// Client→daemon: subscribe to an endpoint's delta-stream from a version floor.
pub const KIND_DIGEST_SUBSCRIBE: &str = "digest_subscribe";
/// Daemon→client: one structured digest change (the streamed delta / base).
pub const KIND_DIGEST_DELTA: &str = "digest_delta";

/// `digest_snapshot` / `digest_subscribe` request payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DigestReq {
    /// The (local) endpoint id whose digest to read.
    pub id: String,
    /// Subscribe floor: only stream changes with `version > from_version`. The
    /// base snapshot is sent regardless so a fresh subscriber has an apply base.
    #[serde(default)]
    pub from_version: u64,
}

/// `digest_result` payload — the snapshot pull reply.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DigestResult {
    /// The current structured digest, absent if the endpoint has no live digest.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub digest: Option<Digest>,
    /// The digest version (0 when none).
    #[serde(default)]
    pub version: u64,
}

/// One endpoint's live digest state in the hub: the latest snapshot + version
/// (for pulls) and the set of live subscriber senders (for the delta-stream).
#[derive(Default)]
struct EndpointState {
    snapshot: Digest,
    version: u64,
    subscribers: Vec<Sender<DigestUpdate>>,
}

/// The daemon's per-endpoint live-digest registry. The brain publishes; the
/// control channel reads + subscribes. Holds only the projected digest state —
/// the same ephemeral-projection category as the broker's accepted-socket
/// bookkeeping, not routing/registry/parse logic (the parse already happened in
/// the brain's engine).
#[derive(Default)]
pub struct DigestHub {
    map: Mutex<HashMap<String, EndpointState>>,
}

impl DigestHub {
    /// A fresh, empty hub.
    pub fn new() -> Self {
        DigestHub {
            map: Mutex::new(HashMap::new()),
        }
    }

    /// Publish a digest change for `id`: store the new `snapshot` + version and
    /// fan `update` out to every live subscriber (pruning any whose receiver has
    /// been dropped — a disconnected `--follow` client). Called by the brain's
    /// feed loop after each [`DigestEngine::feed`](crate::digest::DigestEngine::feed).
    pub fn publish(&self, id: &str, snapshot: Digest, update: DigestUpdate) {
        let mut map = self.map.lock().unwrap();
        let st = map.entry(id.to_string()).or_default();
        st.version = update.version;
        st.snapshot = snapshot;
        st.subscribers.retain(|tx| tx.send(update.clone()).is_ok());
    }

    /// The current snapshot + version for `id`, if a live digest exists.
    pub fn snapshot(&self, id: &str) -> Option<(Digest, u64)> {
        let map = self.map.lock().unwrap();
        map.get(id).map(|st| (st.snapshot.clone(), st.version))
    }

    /// Register a subscriber for `id`, returning the current base snapshot+version
    /// (to apply later deltas against) and the receiver future changes arrive on.
    /// The base is always returned so a fresh subscriber has an apply anchor.
    fn subscribe(&self, id: &str) -> (Digest, u64, Receiver<DigestUpdate>) {
        let (tx, rx) = channel();
        let mut map = self.map.lock().unwrap();
        let st = map.entry(id.to_string()).or_default();
        st.subscribers.push(tx);
        (st.snapshot.clone(), st.version, rx)
    }

    /// Number of live subscribers for `id` (tests / introspection).
    pub fn subscriber_count(&self, id: &str) -> usize {
        self.map
            .lock()
            .unwrap()
            .get(id)
            .map(|s| s.subscribers.len())
            .unwrap_or(0)
    }
}

/// Build a `digest_delta` envelope from a [`DigestUpdate`].
fn delta_frame(up: &DigestUpdate) -> Envelope {
    Envelope::new(
        KIND_DIGEST_DELTA,
        serde_json::to_value(up).expect("DigestUpdate serializes"),
    )
}

/// Bind the digest-control channel at `name` and serve it forever against `hub`.
/// Thread-per-connection (mirroring the broker / seed-control): a `--follow`
/// subscribe blocks its connection thread streaming deltas, so serving inline
/// would stall every other client.
pub fn serve_digest_control(name: &str, hub: Arc<DigestHub>) -> io::Result<()> {
    let listener = LocalSocketTransport::bind(name)?;
    loop {
        let conn = listener.accept()?;
        let hub = Arc::clone(&hub);
        thread::spawn(move || {
            let _ = handle_conn(conn, &hub);
        });
    }
}

/// Drive one control connection: handshake, then service snapshot pulls and
/// subscribe streams until the client disconnects.
fn handle_conn<C: Read + Write>(mut conn: C, hub: &DigestHub) -> io::Result<()> {
    recv_hello(&mut conn, Role::Brain)?;
    loop {
        let env = match read_frame(&mut conn) {
            Ok(e) => e,
            Err(_) => return Ok(()), // peer hung up
        };
        match env.kind.as_str() {
            KIND_DIGEST_SNAPSHOT => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                let (digest, version) = match hub.snapshot(&req.id) {
                    Some((d, v)) => (Some(d), v),
                    None => (None, 0),
                };
                let res = DigestResult { digest, version };
                write_frame(
                    &mut conn,
                    &Envelope::new(KIND_DIGEST_RESULT, serde_json::to_value(res).unwrap()),
                )?;
            }
            KIND_DIGEST_SUBSCRIBE => {
                let req: DigestReq = serde_json::from_value(env.payload)
                    .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                // Register, send the base snapshot (full), then stream changes.
                let (base, version, rx) = hub.subscribe(&req.id);
                if version >= req.from_version {
                    let base_update = DigestUpdate {
                        version,
                        from: 0, // a fresh subscriber replaces its (empty) view
                        turns: base.turns,
                        newly_completed: Vec::new(),
                    };
                    write_frame(&mut conn, &delta_frame(&base_update))?;
                }
                // Forward each published change until the client disconnects (a
                // write failure) or the hub drops the sender.
                while let Ok(up) = rx.recv() {
                    if up.version <= req.from_version {
                        continue;
                    }
                    if write_frame(&mut conn, &delta_frame(&up)).is_err() {
                        return Ok(()); // client gone — the next publish prunes us
                    }
                }
                return Ok(());
            }
            // Forward-compat: an unknown control kind is tolerated, not fatal.
            _ => {}
        }
    }
}

/// Connect to the digest-control channel `name`, announcing ourselves as a brain
/// (the `spt digest` CLI is a transient logic client of the daemon).
fn connect(name: &str) -> io::Result<interprocess::local_socket::Stream> {
    let mut conn = LocalSocketTransport::connect(name)?;
    send_hello(&mut conn, Role::Brain)?;
    Ok(conn)
}

/// Pull the current structured digest for `id` over the control channel `name`.
/// `Ok(None)` ⇒ the daemon holds no live digest for that endpoint.
// [impl->REQ-TERM-4]
pub fn pull_snapshot(name: &str, id: &str) -> io::Result<Option<(Digest, u64)>> {
    let mut conn = connect(name)?;
    let req = DigestReq {
        id: id.to_string(),
        from_version: 0,
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_DIGEST_SNAPSHOT, serde_json::to_value(req).unwrap()),
    )?;
    let env = read_frame(&mut conn)?;
    let res: DigestResult = serde_json::from_value(env.payload)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    Ok(res.digest.map(|d| (d, res.version)))
}

/// Subscribe to `id`'s delta-stream over `name`, invoking `on_update` for the base
/// snapshot and each subsequent change. Stops when `on_update` returns `false` or
/// the stream ends. `from_version` skips changes the caller already has.
// [impl->REQ-TERM-4]
pub fn follow<F: FnMut(&DigestUpdate) -> bool>(
    name: &str,
    id: &str,
    from_version: u64,
    mut on_update: F,
) -> io::Result<()> {
    let mut conn = connect(name)?;
    let req = DigestReq {
        id: id.to_string(),
        from_version,
    };
    write_frame(
        &mut conn,
        &Envelope::new(KIND_DIGEST_SUBSCRIBE, serde_json::to_value(req).unwrap()),
    )?;
    loop {
        let env = match read_frame(&mut conn) {
            Ok(e) => e,
            Err(_) => return Ok(()), // stream ended
        };
        if env.kind == KIND_DIGEST_DELTA {
            let up: DigestUpdate = serde_json::from_value(env.payload)
                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
            if !on_update(&up) {
                return Ok(());
            }
        }
    }
}

// ───────────────────────────── presentation ─────────────────────────────
//
// The `spt digest` CLI lives in the `spt` binary, which depends on neither
// `serde_json` nor `spt-term` directly — so all rendering (human + JSON) is
// provided here, keeping the CLI a thin printer.

/// Render a structured digest as a human-glanceable block: one `» input` line
/// per user turn, agent output collapsed to a single line, and each tool sprint
/// as a `used: name(arg), …` line. The at-a-glance "what is this agent doing"
/// view (ADR-0008).
pub fn render_digest(digest: &Digest) -> String {
    render_turns(&digest.turns)
}

/// Render the turns carried by a streamed [`DigestUpdate`] (the delta-stream
/// presentation — same per-turn shape as a snapshot).
pub fn render_update(update: &DigestUpdate) -> String {
    render_turns(&update.turns)
}

/// Serialize a digest to pretty JSON (the `--json` snapshot form).
pub fn digest_to_json(digest: &Digest) -> String {
    serde_json::to_string_pretty(digest).unwrap_or_else(|_| "{}".to_string())
}

/// Serialize a streamed update to compact JSON (one line per delta for `--json
/// --follow`).
pub fn update_to_json(update: &DigestUpdate) -> String {
    serde_json::to_string(update).unwrap_or_else(|_| "{}".to_string())
}

fn render_turns(turns: &[Turn]) -> String {
    let mut out = String::new();
    for t in turns {
        if let Some(input) = &t.input {
            out.push_str(&format!("» {}\n", one_line(input)));
        }
        for e in &t.entries {
            match e {
                DigestEntry::Agent(text) => {
                    let line = one_line(text);
                    if !line.is_empty() {
                        out.push_str(&format!("  {line}\n"));
                    }
                }
                DigestEntry::ToolSprint(tools) => {
                    let joined = tools
                        .iter()
                        .map(|tu| {
                            if tu.arg.is_empty() {
                                tu.name.clone()
                            } else {
                                format!("{}({})", tu.name, tu.arg)
                            }
                        })
                        .collect::<Vec<_>>()
                        .join(", ");
                    out.push_str(&format!("  used: {joined}\n"));
                }
            }
        }
    }
    out
}

/// Collapse internal whitespace/newlines to a single spaced line for glanceable
/// rendering (the digest is a summary, not a transcript).
fn one_line(s: &str) -> String {
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_term::{DigestEntry, ToolUse, Turn};

    fn turn(input: &str) -> Turn {
        Turn {
            input: Some(input.to_string()),
            entries: Vec::new(),
        }
    }

    fn an_update(version: u64) -> DigestUpdate {
        DigestUpdate {
            version,
            from: 0,
            turns: vec![turn("hi")],
            newly_completed: Vec::new(),
        }
    }

    // [unit->REQ-TERM-4] publish stores the latest snapshot; a pull reads it back.
    #[test]
    fn publish_then_snapshot_reads_latest() {
        let hub = DigestHub::new();
        assert!(
            hub.snapshot("doyle").is_none(),
            "no digest before any publish"
        );
        let snap = Digest {
            turns: vec![turn("hi")],
        };
        hub.publish("doyle", snap.clone(), an_update(1));
        let (got, v) = hub.snapshot("doyle").expect("a live digest");
        assert_eq!(v, 1);
        assert_eq!(got, snap);
    }

    // [unit->REQ-TERM-4] a subscriber receives published changes; a dropped
    // subscriber is pruned on the next publish.
    #[test]
    fn subscriber_receives_changes_then_is_pruned() {
        let hub = DigestHub::new();
        let (_base, _v, rx) = hub.subscribe("doyle");
        assert_eq!(hub.subscriber_count("doyle"), 1);

        hub.publish(
            "doyle",
            Digest {
                turns: vec![turn("a")],
            },
            an_update(1),
        );
        assert_eq!(rx.recv().unwrap().version, 1, "the change was fanned out");

        // Drop the receiver → the next publish's send fails → the subscriber is pruned.
        drop(rx);
        hub.publish(
            "doyle",
            Digest {
                turns: vec![turn("b")],
            },
            an_update(2),
        );
        assert_eq!(hub.subscriber_count("doyle"), 0, "dead subscriber pruned");
    }

    // [unit->REQ-TERM-4] a fresh subscribe returns the current snapshot as its
    // base so later deltas have an apply anchor.
    #[test]
    fn subscribe_returns_current_base() {
        let hub = DigestHub::new();
        hub.publish(
            "doyle",
            Digest {
                turns: vec![turn("base")],
            },
            an_update(7),
        );
        let (base, version, _rx) = hub.subscribe("doyle");
        assert_eq!(version, 7);
        assert_eq!(base.turns[0].input.as_deref(), Some("base"));
    }

    // [unit->REQ-TERM-4] the human render shows input, collapsed agent text, and a
    // single `used:` line per tool sprint.
    #[test]
    fn render_is_glanceable() {
        let digest = Digest {
            turns: vec![Turn {
                input: Some("fix the\n  bug".to_string()),
                entries: vec![
                    DigestEntry::Agent("on it\nlooking".to_string()),
                    DigestEntry::ToolSprint(vec![
                        ToolUse {
                            name: "Write".into(),
                            arg: "a.rs".into(),
                        },
                        ToolUse {
                            name: "Bash".into(),
                            arg: "cargo".into(),
                        },
                    ]),
                ],
            }],
        };
        let out = render_digest(&digest);
        assert!(out.contains("» fix the bug"), "input on one line: {out}");
        assert!(out.contains("  on it looking"), "agent collapsed: {out}");
        assert!(
            out.contains("used: Write(a.rs), Bash(cargo)"),
            "tool sprint: {out}"
        );
    }
}


Architecture clear. Hub stays; only the SOURCE feeding it changes (PTY bytes → history records). Key nuance: output types (`Digest`/`Turn`/`DigestEntry`/`ToolUse` in spt-term, `DigestUpdate` in daemon) must SURVIVE the retire — only the parser dies. Let me confirm the feed seam + engine shape.


1	//! The PTY-digest engine (B8) — the **brain-side** projection of the live
2	//! activity buffer (ADR-0008, REQ-TERM-4).
3	//!
4	//! ## Why brain-side, not broker-side
5	//!
6	//! ADR-0008 frames the digest as a "broker-pushed structured-delta stream," but
7	//! that wording predates the strict ADR-0004 §B split. Running adapter-supplied
8	//! regexes over a byte stream is **logic** — routing/registry/parse is exactly
9	//! what the broker must NOT hold (M3b-PLAN risk: "a task that finds itself
10	//! putting parse in the broker has crossed the line"). So the digest is a
11	//! brain-side projection: the brain already subscribes to the broker's R-TERM-3
12	//! output stream for a session ([`crate::brain::Brain`]); this engine folds those
13	//! same bytes through the M3a [`DigestParser`] and publishes the structured
14	//! result. Raw bytes (R-TERM-3) and the parsed digest are the "two channels on
15	//! the one session surface" ADR-0008 describes — the broker owns the bytes, the
16	//! brain owns the parse.
17	//!
18	//! ## Snapshot vs delta
19	//!
20	//! [`DigestEngine::feed`] returns a [`DigestUpdate`] only when the digest changed,
21	//! so a subscriber receives **only deltas**, never a re-push of an unchanged
22	//! buffer. The update is index-anchored: `from` is where the changed turns begin
23	//! in the window, and the subscriber applies it by **truncating its view to
24	//! `from`, then appending `turns`**. In the steady state (the active turn grows
25	//! as the agent emits output) `from` points at that last turn and `turns` is just
26	//! it — a one-turn delta. When the rolling window slides (the oldest turn
27	//! evicted) the front turns no longer match, so `from` is `0` and the whole
28	//! current window is sent (a natural full refresh — no separate flag needed).
29	//!
30	//! ## Opt-in Path-B persistence (ADR-0008 "Option (C)")
31	//!
32	//! When enabled, [`DigestUpdate::newly_completed`] carries each turn that settled
33	//! this feed (every turn but the still-active last one, deduped against the last
34	//! persisted turn). The feed driver appends them to the perch history store as a
35	//! **coarse** activity log — explicitly not a replacement for Path A/B transcript
36	//! history. Because the engine is fed every output chunk, a completed turn is
37	//! observed long before the rolling window could evict it, so the dedup-by-value
38	//! tracking never drops or double-writes a turn in practice (a multi-turn single
39	//! feed is the only degenerate case, and it can at worst re-emit in-window turns —
40	//! bounded by the window depth, acceptable for a coarse log).
41	// [impl->REQ-TERM-4]
42
43	use serde::{Deserialize, Serialize};
44
45	use spt_runtime::PtyDigest;
46	use spt_store::perch::{self, ParentHint};
47	use spt_term::{Digest, DigestConfig, DigestError, DigestParser, DigestPatterns, Turn};
48
49	use crate::digesthub::DigestHub;
50
51	/// A structured digest change to push to a subscriber. Sent only when the digest
52	/// actually changed (so the stream carries deltas, not heartbeats).
53	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54	pub struct DigestUpdate {
55	    /// Monotonic version of the digest after this change (starts at 1).
56	    pub version: u64,
57	    /// The window index where `turns` begins. Apply: truncate the view to `from`,
58	    /// then append `turns`. `from == 0` is a full replace (the base snapshot, or a
59	    /// window slide that invalidated old indices).
60	    pub from: usize,
61	    /// The changed turns starting at `from` (the whole window when `from == 0`).
62	    pub turns: Vec<Turn>,
63	    /// Turns that settled this feed (for opt-in Path-B persistence). Server-side
64	    /// only — never crosses the control channel (the subscriber sees the parsed
65	    /// turns via `turns`, not the persistence bookkeeping).
66	    #[serde(skip)]
67	    pub newly_completed: Vec<Turn>,
68	}
69
70	/// The brain-side digest engine for one session: a [`DigestParser`] plus the
71	/// change-tracking that turns "the whole window, re-derived" into pushable
72	/// deltas + completed-turn persistence.
73	pub struct DigestEngine {
74	    parser: DigestParser,
75	    /// The last digest we computed (the diff base + the version anchor).
76	    last: Digest,
77	    /// Monotonic version, bumped on each observed change.
78	    version: u64,
79	    /// Whether opt-in Path-B persistence is requested for this endpoint.
80	    persist: bool,
81	    /// The most recent turn handed to persistence — the dedup floor so a turn is
82	    /// appended exactly once as it settles.
83	    persisted_floor: Option<Turn>,
84	}
85
86	impl DigestEngine {
87	    /// Build an engine from the manifest [`PtyDigest`] seam. Compiles the
88	    /// adapter-supplied patterns (returning [`DigestError`] naming the first that
89	    /// fails) and applies the spt-core presentation defaults, with `window_turns`
90	    /// overridden when the manifest sets it.
91	    // [impl->REQ-TERM-4]
92	    pub fn from_manifest(seam: &PtyDigest) -> Result<Self, DigestError> {
93	        let patterns = DigestPatterns {
94	            input: seam.input_pattern.clone(),
95	            agent: seam.agent_pattern.clone(),
96	            tools: seam.tool_patterns.clone(),
97	            catchall: seam.catchall_pattern.clone(),
98	        };
99	        let mut config = DigestConfig::default();
100	        if let Some(n) = seam.window_turns {
101	            config.window_turns = n;
102	        }
103	        Self::with_parts(patterns, config, seam.persist)
104	    }
105
106	    /// Build directly from compiled parts (the testable seam; also lets a caller
107	    /// override the presentation config wholesale).
108	    pub fn with_parts(
109	        patterns: DigestPatterns,
110	        config: DigestConfig,
111	        persist: bool,
112	    ) -> Result<Self, DigestError> {
113	        Ok(DigestEngine {
114	            parser: DigestParser::new(patterns, config)?,
115	            last: Digest::default(),
116	            version: 0,
117	            persist,
118	            persisted_floor: None,
119	        })
120	    }
121
122	    /// Whether opt-in Path-B persistence is enabled for this engine.
123	    pub fn persist(&self) -> bool {
124	        self.persist
125	    }
126
127	    /// The current digest version (0 before any change).
128	    pub fn version(&self) -> u64 {
129	        self.version
130	    }
131
132	    /// The current structured digest (the snapshot a `spt digest <id>` pull
133	    /// returns).
134	    pub fn snapshot(&self) -> Digest {
135	        self.last.clone()
136	    }
137
138	    /// A base update carrying the whole current snapshot (`from == 0`) — what a
139	    /// fresh subscriber receives first so it has an apply anchor for later deltas.
140	    /// Carries the live version (records no change).
141	    pub fn full_update(&self) -> DigestUpdate {
142	        DigestUpdate {
143	            version: self.version,
144	            from: 0,
145	            turns: self.last.turns.clone(),
146	            newly_completed: Vec::new(),
147	        }
148	    }
149
150	    /// Feed a chunk of the session's PTY output. Re-derives the digest; returns a
151	    /// [`DigestUpdate`] iff it changed (else `None` — nothing to push).
152	    // [impl->REQ-TERM-4]
153	    pub fn feed(&mut self, bytes: &[u8]) -> Option<DigestUpdate> {
154	        self.parser.feed(bytes);
155	        let next = self.parser.digest();
156	        if next == self.last {
157	            return None; // unchanged — emit no delta
158	        }
159	        self.version += 1;
160
161	        // The changed turns begin just past the equal-by-value head the new window
162	        // shares with the old one. A pure in-place/extend keeps the head intact →
163	        // `from` points at the active tail (a minimal delta). A window slide
164	        // evicts the oldest turn → the heads differ from index 0 → `from == 0` and
165	        // the whole current window is sent (a natural full refresh).
166	        let from = common_prefix_len(&self.last.turns, &next.turns);
167	        let turns = next.turns[from..].to_vec();
168
169	        let newly_completed = if self.persist {
170	            self.take_newly_completed(&next)
171	        } else {
172	            Vec::new()
173	        };
174
175	        self.last = next;
176	        Some(DigestUpdate {
177	            version: self.version,
178	            from,
179	            turns,
180	            newly_completed,
181	        })
182	    }
183
184	    /// Completed (settled) turns observed since the last call: every turn but the
185	    /// still-active last one, starting just after the dedup floor. Advances the
186	    /// floor to the newest completed turn so each settles exactly once.
187	    fn take_newly_completed(&mut self, snap: &Digest) -> Vec<Turn> {
188	        if snap.turns.len() < 2 {
189	            return Vec::new(); // only the active turn exists; nothing settled yet
190	        }
191	        let completed = &snap.turns[..snap.turns.len() - 1];
192	        let start = match &self.persisted_floor {
193	            // Resume just after the floor turn if it is still in the window;
194	            // if it rolled off (None), fall back to the window start (coarse).
195	            Some(floor) => completed
196	                .iter()
197	                .position(|t| t == floor)
198	                .map(|i| i + 1)
199	                .unwrap_or(0),
200	            None => 0,
201	        };
202	        let fresh = completed[start..].to_vec();
203	        if let Some(last) = fresh.last() {
204	            self.persisted_floor = Some(last.clone());
205	        }
206	        fresh
207	    }
208	}
209
210	/// Length of the equal-by-value prefix shared by two turn slices.
211	fn common_prefix_len(a: &[Turn], b: &[Turn]) -> usize {
212	    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
213	}
214
215	/// Feed one PTY-output chunk for `endpoint_id` through `engine`, publish any
216	/// resulting change to `hub`, and — when the engine opted into persistence —
217	/// append each newly-completed turn to the endpoint's Path-B history store. This
218	/// is the single per-chunk bridge the brain's digest feed
219	/// ([`crate::brain::Brain::run_digest_feed`]) and any in-process daemon feed
220	/// share, so the publish + persist behavior lives in exactly one place.
221	// [impl->REQ-TERM-4]
222	pub fn ingest_chunk(engine: &mut DigestEngine, hub: &DigestHub, endpoint_id: &str, bytes: &[u8]) {
223	    let Some(update) = engine.feed(bytes) else {
224	        return; // unchanged — nothing to publish or persist
225	    };
226	    if engine.persist() {
227	        for turn in &update.newly_completed {
228	            persist_turn(endpoint_id, turn);
229	        }
230	    }
231	    hub.publish(endpoint_id, engine.snapshot(), update);
232	}
233
234	/// Append one completed turn to `endpoint_id`'s Path-B history store as a coarse
235	/// digest-activity record (ADR-0008 "Option (C)"). Best-effort: a store error is
236	/// swallowed — the digest log is a convenience, never load-bearing.
237	fn persist_turn(endpoint_id: &str, turn: &Turn) {
238	    let perch_path = perch::resolve_perch_path(endpoint_id, ParentHint::Infer);
239	    let record = serde_json::json!({ "kind": "digest_turn", "turn": turn });
240	    let _ = spt_store::history::append_record(&perch_path, &record.to_string());
241	}
242
243	#[cfg(test)]
244	mod tests {
245	    use super::*;
246	    use spt_term::DigestEntry;
247
248	    fn seam() -> PtyDigest {
249	        PtyDigest {
250	            input_pattern: r"\nUSER> ".to_string(),
251	            agent_pattern: r"\nAGENT> ".to_string(),
252	            tool_patterns: vec![r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string()],
253	            catchall_pattern: None,
254	            window_turns: Some(3),
255	            persist: false,
256	        }
257	    }
258
259	    // [unit->REQ-TERM-4] the manifest seam compiles into a working engine, and a
260	    // first feed yields an update (from=0) with the parsed turn.
261	    #[test]
262	    fn from_manifest_feeds_and_snapshots() {
263	        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
264	        let up = e
265	            .feed(b"\nUSER> hello\nAGENT> hi there\n")
266	            .expect("changed");
267	        assert_eq!(up.version, 1);
268	        assert_eq!(
269	            up.from, 0,
270	            "from an empty base, the change starts at index 0"
271	        );
272	        assert_eq!(up.turns.len(), 1);
273	        assert_eq!(up.turns[0].input.as_deref(), Some("hello"));
274	        assert_eq!(e.snapshot().turns.len(), 1);
275	    }
276
277	    // [unit->REQ-TERM-4] an unchanged feed pushes nothing (deltas only — no
278	    // heartbeat re-push of a static buffer).
279	    #[test]
280	    fn unchanged_feed_emits_no_delta() {
281	        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
282	        e.feed(b"\nUSER> hi\nAGENT> yo\n").expect("first change");
283	        let v = e.version();
284	        assert!(e.feed(b"").is_none(), "empty feed changes nothing");
285	        assert!(e.feed(b"   ").is_none(), "whitespace-only adds no marker");
286	        assert_eq!(e.version(), v, "version did not advance on a no-op");
287	    }
288
289	    // [unit->REQ-TERM-4] the active turn growing in place is a minimal tail delta:
290	    // `from` points past the unchanged head, `turns` is just the changed tail.
291	    #[test]
292	    fn active_turn_growth_is_a_tail_delta() {
293	        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
294	        // Two settled turns + an active third.
295	        e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
296	        e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
297	        let up = e.feed(b"\nUSER> c\nAGENT> 3").unwrap();
298	        // 'a' and 'b' are unchanged head; only the new 'c' turn is sent.
299	        assert_eq!(up.from, 2, "the two settled turns are the unchanged head");
300	        assert_eq!(up.turns.len(), 1);
301	        assert_eq!(up.turns.last().unwrap().input.as_deref(), Some("c"));
302	    }
303
304	    // [unit->REQ-TERM-4] when the rolling window slides (oldest turn evicted), the
305	    // head no longer matches → from=0, the whole current window is resent.
306	    #[test]
307	    fn window_slide_resends_from_zero() {
308	        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
309	        for i in 0..3 {
310	            e.feed(format!("\nUSER> u{i}\nAGENT> r{i}\n").as_bytes())
311	                .unwrap();
312	        }
313	        // A 4th turn evicts u0 → the window frame shifts.
314	        let up = e.feed(b"\nUSER> u3\nAGENT> r3\n").unwrap();
315	        assert_eq!(up.from, 0, "a front eviction resends from index 0");
316	        assert_eq!(up.turns.len(), 3, "the full current window");
317	        assert_eq!(up.turns[0].input.as_deref(), Some("u1"), "oldest is now u1");
318	    }
319
320	    // [unit->REQ-TERM-4] applying successive deltas (truncate-to-`from` + append)
321	    // reconstructs the engine's snapshot exactly — the subscriber stays in sync.
322	    #[test]
323	    fn applying_deltas_reconstructs_snapshot() {
324	        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
325	        let mut view: Vec<Turn> = Vec::new();
326	        let feeds: &[&[u8]] = &[
327	            b"\nUSER> a\nAGENT> 1\n",
328	            b"\nUSER> b\nAGENT> 2",
329	            b" more\n",
330	            b"\nUSER> c\nAGENT> 3\n",
331	            b"\nUSER> d\nAGENT> 4\n", // evicts 'a' → from=0 refresh
332	        ];
333	        for f in feeds {
334	            if let Some(up) = e.feed(f) {
335	                view.truncate(up.from);
336	                view.extend(up.turns);
337	            }
338	            assert_eq!(
339	                view,
340	                e.snapshot().turns,
341	                "applied deltas track the snapshot"
342	            );
343	        }
344	    }
345
346	    // [unit->REQ-TERM-4] collapsed tool sprints survive into the structured turn
347	    // (the digest is structured, source-tagged — not flat text).
348	    #[test]
349	    fn tool_sprint_is_structured_in_the_turn() {
350	        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
351	        let up = e
352	            .feed(b"\nUSER> go\nAGENT> ok\nWrite(a.rs) done\n")
353	            .unwrap();
354	        let t = up.turns.last().unwrap();
355	        assert!(t
356	            .entries
357	            .iter()
358	            .any(|x| matches!(x, DigestEntry::ToolSprint(_))));
359	    }
360
361	    // [unit->REQ-TERM-4] with persistence on, a turn is reported newly-completed
362	    // exactly once (as it settles), not re-emitted on later feeds.
363	    #[test]
364	    fn persistence_reports_each_completed_turn_once() {
365	        let mut s = seam();
366	        s.persist = true;
367	        let mut e = DigestEngine::from_manifest(&s).expect("compile");
368
369	        // Turn 'a' is still the active (last) turn → not yet completed.
370	        let up = e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
371	        assert!(
372	            up.newly_completed.is_empty(),
373	            "the active turn has not settled"
374	        );
375
376	        // Opening turn 'b' settles 'a' → reported once.
377	        let up = e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
378	        assert_eq!(up.newly_completed.len(), 1);
379	        assert_eq!(up.newly_completed[0].input.as_deref(), Some("a"));
380
381	        // Growing 'b' in place settles nothing new.
382	        let up = e.feed(b" more").unwrap();
383	        assert!(up.newly_completed.is_empty(), "no new turn settled");
384
385	        // Opening 'c' settles 'b'.
386	        let up = e.feed(b"\nUSER> c\nAGENT> 3\n").unwrap();
387	        assert_eq!(up.newly_completed.len(), 1);
388	        assert_eq!(up.newly_completed[0].input.as_deref(), Some("b"));
389	    }
390
391	    // [unit->REQ-TERM-4] persistence off ⇒ no completed turns are ever surfaced.
392	    #[test]
393	    fn persistence_off_surfaces_nothing() {
394	        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // persist=false
395	        e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
396	        let up = e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
397	        assert!(
398	            up.newly_completed.is_empty(),
399	            "persistence off → never reported"
400	        );
401	    }
402	}
403

//! The PTY-digest engine (B8) — the **brain-side** projection of the live
//! activity buffer (ADR-0008, REQ-TERM-4).
//!
//! ## Why brain-side, not broker-side
//!
//! ADR-0008 frames the digest as a "broker-pushed structured-delta stream," but
//! that wording predates the strict ADR-0004 §B split. Running adapter-supplied
//! regexes over a byte stream is **logic** — routing/registry/parse is exactly
//! what the broker must NOT hold (M3b-PLAN risk: "a task that finds itself
//! putting parse in the broker has crossed the line"). So the digest is a
//! brain-side projection: the brain already subscribes to the broker's R-TERM-3
//! output stream for a session ([`crate::brain::Brain`]); this engine folds those
//! same bytes through the M3a [`DigestParser`] and publishes the structured
//! result. Raw bytes (R-TERM-3) and the parsed digest are the "two channels on
//! the one session surface" ADR-0008 describes — the broker owns the bytes, the
//! brain owns the parse.
//!
//! ## Snapshot vs delta
//!
//! [`DigestEngine::feed`] returns a [`DigestUpdate`] only when the digest changed,
//! so a subscriber receives **only deltas**, never a re-push of an unchanged
//! buffer. The update is index-anchored: `from` is where the changed turns begin
//! in the window, and the subscriber applies it by **truncating its view to
//! `from`, then appending `turns`**. In the steady state (the active turn grows
//! as the agent emits output) `from` points at that last turn and `turns` is just
//! it — a one-turn delta. When the rolling window slides (the oldest turn
//! evicted) the front turns no longer match, so `from` is `0` and the whole
//! current window is sent (a natural full refresh — no separate flag needed).
//!
//! ## Opt-in Path-B persistence (ADR-0008 "Option (C)")
//!
//! When enabled, [`DigestUpdate::newly_completed`] carries each turn that settled
//! this feed (every turn but the still-active last one, deduped against the last
//! persisted turn). The feed driver appends them to the perch history store as a
//! **coarse** activity log — explicitly not a replacement for Path A/B transcript
//! history. Because the engine is fed every output chunk, a completed turn is
//! observed long before the rolling window could evict it, so the dedup-by-value
//! tracking never drops or double-writes a turn in practice (a multi-turn single
//! feed is the only degenerate case, and it can at worst re-emit in-window turns —
//! bounded by the window depth, acceptable for a coarse log).
// [impl->REQ-TERM-4]

use serde::{Deserialize, Serialize};

use spt_runtime::PtyDigest;
use spt_store::perch::{self, ParentHint};
use spt_term::{Digest, DigestConfig, DigestError, DigestParser, DigestPatterns, Turn};

use crate::digesthub::DigestHub;

/// A structured digest change to push to a subscriber. Sent only when the digest
/// actually changed (so the stream carries deltas, not heartbeats).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DigestUpdate {
    /// Monotonic version of the digest after this change (starts at 1).
    pub version: u64,
    /// The window index where `turns` begins. Apply: truncate the view to `from`,
    /// then append `turns`. `from == 0` is a full replace (the base snapshot, or a
    /// window slide that invalidated old indices).
    pub from: usize,
    /// The changed turns starting at `from` (the whole window when `from == 0`).
    pub turns: Vec<Turn>,
    /// Turns that settled this feed (for opt-in Path-B persistence). Server-side
    /// only — never crosses the control channel (the subscriber sees the parsed
    /// turns via `turns`, not the persistence bookkeeping).
    #[serde(skip)]
    pub newly_completed: Vec<Turn>,
}

/// The brain-side digest engine for one session: a [`DigestParser`] plus the
/// change-tracking that turns "the whole window, re-derived" into pushable
/// deltas + completed-turn persistence.
pub struct DigestEngine {
    parser: DigestParser,
    /// The last digest we computed (the diff base + the version anchor).
    last: Digest,
    /// Monotonic version, bumped on each observed change.
    version: u64,
    /// Whether opt-in Path-B persistence is requested for this endpoint.
    persist: bool,
    /// The most recent turn handed to persistence — the dedup floor so a turn is
    /// appended exactly once as it settles.
    persisted_floor: Option<Turn>,
}

impl DigestEngine {
    /// Build an engine from the manifest [`PtyDigest`] seam. Compiles the
    /// adapter-supplied patterns (returning [`DigestError`] naming the first that
    /// fails) and applies the spt-core presentation defaults, with `window_turns`
    /// overridden when the manifest sets it.
    // [impl->REQ-TERM-4]
    pub fn from_manifest(seam: &PtyDigest) -> Result<Self, DigestError> {
        let patterns = DigestPatterns {
            input: seam.input_pattern.clone(),
            agent: seam.agent_pattern.clone(),
            tools: seam.tool_patterns.clone(),
            catchall: seam.catchall_pattern.clone(),
        };
        let mut config = DigestConfig::default();
        if let Some(n) = seam.window_turns {
            config.window_turns = n;
        }
        Self::with_parts(patterns, config, seam.persist)
    }

    /// Build directly from compiled parts (the testable seam; also lets a caller
    /// override the presentation config wholesale).
    pub fn with_parts(
        patterns: DigestPatterns,
        config: DigestConfig,
        persist: bool,
    ) -> Result<Self, DigestError> {
        Ok(DigestEngine {
            parser: DigestParser::new(patterns, config)?,
            last: Digest::default(),
            version: 0,
            persist,
            persisted_floor: None,
        })
    }

    /// Whether opt-in Path-B persistence is enabled for this engine.
    pub fn persist(&self) -> bool {
        self.persist
    }

    /// The current digest version (0 before any change).
    pub fn version(&self) -> u64 {
        self.version
    }

    /// The current structured digest (the snapshot a `spt digest <id>` pull
    /// returns).
    pub fn snapshot(&self) -> Digest {
        self.last.clone()
    }

    /// A base update carrying the whole current snapshot (`from == 0`) — what a
    /// fresh subscriber receives first so it has an apply anchor for later deltas.
    /// Carries the live version (records no change).
    pub fn full_update(&self) -> DigestUpdate {
        DigestUpdate {
            version: self.version,
            from: 0,
            turns: self.last.turns.clone(),
            newly_completed: Vec::new(),
        }
    }

    /// Feed a chunk of the session's PTY output. Re-derives the digest; returns a
    /// [`DigestUpdate`] iff it changed (else `None` — nothing to push).
    // [impl->REQ-TERM-4]
    pub fn feed(&mut self, bytes: &[u8]) -> Option<DigestUpdate> {
        self.parser.feed(bytes);
        let next = self.parser.digest();
        if next == self.last {
            return None; // unchanged — emit no delta
        }
        self.version += 1;

        // The changed turns begin just past the equal-by-value head the new window
        // shares with the old one. A pure in-place/extend keeps the head intact →
        // `from` points at the active tail (a minimal delta). A window slide
        // evicts the oldest turn → the heads differ from index 0 → `from == 0` and
        // the whole current window is sent (a natural full refresh).
        let from = common_prefix_len(&self.last.turns, &next.turns);
        let turns = next.turns[from..].to_vec();

        let newly_completed = if self.persist {
            self.take_newly_completed(&next)
        } else {
            Vec::new()
        };

        self.last = next;
        Some(DigestUpdate {
            version: self.version,
            from,
            turns,
            newly_completed,
        })
    }

    /// Completed (settled) turns observed since the last call: every turn but the
    /// still-active last one, starting just after the dedup floor. Advances the
    /// floor to the newest completed turn so each settles exactly once.
    fn take_newly_completed(&mut self, snap: &Digest) -> Vec<Turn> {
        if snap.turns.len() < 2 {
            return Vec::new(); // only the active turn exists; nothing settled yet
        }
        let completed = &snap.turns[..snap.turns.len() - 1];
        let start = match &self.persisted_floor {
            // Resume just after the floor turn if it is still in the window;
            // if it rolled off (None), fall back to the window start (coarse).
            Some(floor) => completed
                .iter()
                .position(|t| t == floor)
                .map(|i| i + 1)
                .unwrap_or(0),
            None => 0,
        };
        let fresh = completed[start..].to_vec();
        if let Some(last) = fresh.last() {
            self.persisted_floor = Some(last.clone());
        }
        fresh
    }
}

/// Length of the equal-by-value prefix shared by two turn slices.
fn common_prefix_len(a: &[Turn], b: &[Turn]) -> usize {
    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
}

/// Feed one PTY-output chunk for `endpoint_id` through `engine`, publish any
/// resulting change to `hub`, and — when the engine opted into persistence —
/// append each newly-completed turn to the endpoint's Path-B history store. This
/// is the single per-chunk bridge the brain's digest feed
/// ([`crate::brain::Brain::run_digest_feed`]) and any in-process daemon feed
/// share, so the publish + persist behavior lives in exactly one place.
// [impl->REQ-TERM-4]
pub fn ingest_chunk(engine: &mut DigestEngine, hub: &DigestHub, endpoint_id: &str, bytes: &[u8]) {
    let Some(update) = engine.feed(bytes) else {
        return; // unchanged — nothing to publish or persist
    };
    if engine.persist() {
        for turn in &update.newly_completed {
            persist_turn(endpoint_id, turn);
        }
    }
    hub.publish(endpoint_id, engine.snapshot(), update);
}

/// Append one completed turn to `endpoint_id`'s Path-B history store as a coarse
/// digest-activity record (ADR-0008 "Option (C)"). Best-effort: a store error is
/// swallowed — the digest log is a convenience, never load-bearing.
fn persist_turn(endpoint_id: &str, turn: &Turn) {
    let perch_path = perch::resolve_perch_path(endpoint_id, ParentHint::Infer);
    let record = serde_json::json!({ "kind": "digest_turn", "turn": turn });
    let _ = spt_store::history::append_record(&perch_path, &record.to_string());
}

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

    fn seam() -> PtyDigest {
        PtyDigest {
            input_pattern: r"\nUSER> ".to_string(),
            agent_pattern: r"\nAGENT> ".to_string(),
            tool_patterns: vec![r"(?P<name>Write)\((?P<arg>[^)]*)\)".to_string()],
            catchall_pattern: None,
            window_turns: Some(3),
            persist: false,
        }
    }

    // [unit->REQ-TERM-4] the manifest seam compiles into a working engine, and a
    // first feed yields an update (from=0) with the parsed turn.
    #[test]
    fn from_manifest_feeds_and_snapshots() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        let up = e
            .feed(b"\nUSER> hello\nAGENT> hi there\n")
            .expect("changed");
        assert_eq!(up.version, 1);
        assert_eq!(
            up.from, 0,
            "from an empty base, the change starts at index 0"
        );
        assert_eq!(up.turns.len(), 1);
        assert_eq!(up.turns[0].input.as_deref(), Some("hello"));
        assert_eq!(e.snapshot().turns.len(), 1);
    }

    // [unit->REQ-TERM-4] an unchanged feed pushes nothing (deltas only — no
    // heartbeat re-push of a static buffer).
    #[test]
    fn unchanged_feed_emits_no_delta() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        e.feed(b"\nUSER> hi\nAGENT> yo\n").expect("first change");
        let v = e.version();
        assert!(e.feed(b"").is_none(), "empty feed changes nothing");
        assert!(e.feed(b"   ").is_none(), "whitespace-only adds no marker");
        assert_eq!(e.version(), v, "version did not advance on a no-op");
    }

    // [unit->REQ-TERM-4] the active turn growing in place is a minimal tail delta:
    // `from` points past the unchanged head, `turns` is just the changed tail.
    #[test]
    fn active_turn_growth_is_a_tail_delta() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        // Two settled turns + an active third.
        e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
        e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
        let up = e.feed(b"\nUSER> c\nAGENT> 3").unwrap();
        // 'a' and 'b' are unchanged head; only the new 'c' turn is sent.
        assert_eq!(up.from, 2, "the two settled turns are the unchanged head");
        assert_eq!(up.turns.len(), 1);
        assert_eq!(up.turns.last().unwrap().input.as_deref(), Some("c"));
    }

    // [unit->REQ-TERM-4] when the rolling window slides (oldest turn evicted), the
    // head no longer matches → from=0, the whole current window is resent.
    #[test]
    fn window_slide_resends_from_zero() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
        for i in 0..3 {
            e.feed(format!("\nUSER> u{i}\nAGENT> r{i}\n").as_bytes())
                .unwrap();
        }
        // A 4th turn evicts u0 → the window frame shifts.
        let up = e.feed(b"\nUSER> u3\nAGENT> r3\n").unwrap();
        assert_eq!(up.from, 0, "a front eviction resends from index 0");
        assert_eq!(up.turns.len(), 3, "the full current window");
        assert_eq!(up.turns[0].input.as_deref(), Some("u1"), "oldest is now u1");
    }

    // [unit->REQ-TERM-4] applying successive deltas (truncate-to-`from` + append)
    // reconstructs the engine's snapshot exactly — the subscriber stays in sync.
    #[test]
    fn applying_deltas_reconstructs_snapshot() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // window 3
        let mut view: Vec<Turn> = Vec::new();
        let feeds: &[&[u8]] = &[
            b"\nUSER> a\nAGENT> 1\n",
            b"\nUSER> b\nAGENT> 2",
            b" more\n",
            b"\nUSER> c\nAGENT> 3\n",
            b"\nUSER> d\nAGENT> 4\n", // evicts 'a' → from=0 refresh
        ];
        for f in feeds {
            if let Some(up) = e.feed(f) {
                view.truncate(up.from);
                view.extend(up.turns);
            }
            assert_eq!(
                view,
                e.snapshot().turns,
                "applied deltas track the snapshot"
            );
        }
    }

    // [unit->REQ-TERM-4] collapsed tool sprints survive into the structured turn
    // (the digest is structured, source-tagged — not flat text).
    #[test]
    fn tool_sprint_is_structured_in_the_turn() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile");
        let up = e
            .feed(b"\nUSER> go\nAGENT> ok\nWrite(a.rs) done\n")
            .unwrap();
        let t = up.turns.last().unwrap();
        assert!(t
            .entries
            .iter()
            .any(|x| matches!(x, DigestEntry::ToolSprint(_))));
    }

    // [unit->REQ-TERM-4] with persistence on, a turn is reported newly-completed
    // exactly once (as it settles), not re-emitted on later feeds.
    #[test]
    fn persistence_reports_each_completed_turn_once() {
        let mut s = seam();
        s.persist = true;
        let mut e = DigestEngine::from_manifest(&s).expect("compile");

        // Turn 'a' is still the active (last) turn → not yet completed.
        let up = e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
        assert!(
            up.newly_completed.is_empty(),
            "the active turn has not settled"
        );

        // Opening turn 'b' settles 'a' → reported once.
        let up = e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
        assert_eq!(up.newly_completed.len(), 1);
        assert_eq!(up.newly_completed[0].input.as_deref(), Some("a"));

        // Growing 'b' in place settles nothing new.
        let up = e.feed(b" more").unwrap();
        assert!(up.newly_completed.is_empty(), "no new turn settled");

        // Opening 'c' settles 'b'.
        let up = e.feed(b"\nUSER> c\nAGENT> 3\n").unwrap();
        assert_eq!(up.newly_completed.len(), 1);
        assert_eq!(up.newly_completed[0].input.as_deref(), Some("b"));
    }

    // [unit->REQ-TERM-4] persistence off ⇒ no completed turns are ever surfaced.
    #[test]
    fn persistence_off_surfaces_nothing() {
        let mut e = DigestEngine::from_manifest(&seam()).expect("compile"); // persist=false
        e.feed(b"\nUSER> a\nAGENT> 1\n").unwrap();
        let up = e.feed(b"\nUSER> b\nAGENT> 2\n").unwrap();
        assert!(
            up.newly_completed.is_empty(),
            "persistence off → never reported"
        );
    }
}