Skip to main content

supercode/
reduce.rs

1//! The reduction layer's vocabulary (SPEC.md A4): addresses, sidecar
2//! pointers, reduction kinds/records, the reduction log, the stub sentinel,
3//! and the `sc.` metadata discipline. Everything downstream (`project`,
4//! `invert`, the CLI) is built on these types; nothing here mutates a
5//! session or a sidecar — this module only describes the shapes.
6//!
7//! Ground rules this module exists to uphold (SPEC.md §1.3): reductions
8//! never touch the sidecar, every reduction is a reversible pointer, and no
9//! reduction is ever silent — it always carries an in-transcript stub
10//! ([`stub`]).
11
12pub mod handoff;
13pub mod normalize;
14pub mod rehydrate;
15pub mod stub;
16pub mod summarize;
17pub(crate) mod supersede;
18
19use std::collections::{HashMap, HashSet};
20use std::path::PathBuf;
21
22use serde::{Deserialize, Serialize};
23
24use crate::message::ChatMessage;
25use crate::session::{Session, SessionFormat};
26use crate::{Error, Result, Role};
27
28/// Address in the CANONICAL full view (the `Session` reconstructed from the
29/// sidecar).
30///
31/// NOT a raw-line index: normalization is not 1:1 with raw lines (Claude
32/// `tool_result` blocks split off from the enclosing user record,
33/// `session.rs:726-765`; Codex's `compacted` record clears messages while the
34/// raw log keeps every line, `session.rs:464-471`). `role` rides along as an
35/// integrity cross-check — if the message at `index` isn't `role` any more,
36/// the addressed view has drifted out from under the pointer.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38pub struct MessageAddr {
39    /// Position in the canonical full view's message list.
40    pub index: usize,
41    /// The role expected at that position (integrity cross-check).
42    pub role: Role,
43}
44
45/// A pointer from a reduced placeholder back to its original content in the
46/// sidecar.
47///
48/// `invert` (A6) resolves `addr` against the sidecar-reconstructed `Session`
49/// and verifies `content_hash` before ever substituting content back in — a
50/// stale or foreign sidecar can never silently produce the wrong content.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct SidecarPtr {
53    /// Where the original message lives in the canonical full view.
54    pub addr: MessageAddr,
55    /// Removed byte range within the original content; `None` means the
56    /// whole content was removed (as opposed to a sub-span of it).
57    pub span: Option<(usize, usize)>,
58    /// blake3 hex digest of the full original content.
59    pub content_hash: String,
60}
61
62impl SidecarPtr {
63    /// Verify that `candidate` — the content this pointer is presumed to
64    /// resolve to — still hashes to [`Self::content_hash`].
65    ///
66    /// This is the hash-verify primitive `invert` (A6) calls before
67    /// substituting any original back into the full view. Returns `Err` on
68    /// any mismatch (tampered/stale/foreign content) rather than ever
69    /// substituting wrong content silently.
70    pub fn verify(&self, candidate: &[u8]) -> Result<()> {
71        self.verify_hash(&content_hash(candidate))
72    }
73
74    /// Like [`Self::verify`], but takes an already-computed hash directly —
75    /// for callers (e.g. [`hash_turns_range`], A10) whose hash formula isn't
76    /// simply "hash these raw bytes" (it's a hash over several messages'
77    /// concatenated wire bytes) but must still fail exactly the same way on
78    /// mismatch.
79    pub fn verify_hash(&self, actual: &str) -> Result<()> {
80        if actual == self.content_hash {
81            Ok(())
82        } else {
83            Err(Error::Other(format!(
84                "sidecar pointer hash mismatch: expected {}, got {actual}",
85                self.content_hash
86            )))
87        }
88    }
89}
90
91/// What kind of reduction produced a placeholder, and the data specific to
92/// that kind. String forms (for the [`stub`] grammar) map 1:1 onto these
93/// variants.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub enum ReductionKind {
96    /// A7: an oversized tool result truncated in the view (sidecar keeps the
97    /// full bytes).
98    ToolOutputTruncated {
99        /// Total size of the original content, in bytes.
100        original_bytes: usize,
101        /// Size of the kept prefix, in bytes.
102        kept_bytes: usize,
103    },
104    /// A8: a read-type tool result elided because the file is unchanged on
105    /// disk since it was read.
106    FileReadElided {
107        /// The file that was read.
108        path: PathBuf,
109        /// The read-log entry recording what was read and when.
110        read_log: ReadLogEntry,
111    },
112    /// A9: an image content part redacted to a stub.
113    ImageRedacted {
114        /// Index of the redacted part within the message's `content_parts`.
115        part_index: usize,
116    },
117    /// A10: a contiguous run of old turns cleared from the view.
118    TurnsCleared {
119        /// Address of the first cleared message (inclusive).
120        first: usize,
121        /// Address of the last cleared message (inclusive).
122        last: usize,
123        /// TR-7 (T20): present only when this span's placeholder carries an
124        /// LLM-generated summary paragraph rather than the deterministic
125        /// `[turns cleared]` stub — `None` whenever
126        /// [`ReductionPolicy::summarize_cleared_turns`] is off (the default,
127        /// SPEC.md TR-7 dev/01), or the side-call was skipped/fell back for
128        /// any other reason (below the cost-guard floor, errored, timed
129        /// out). Purely a view-layer/audit annotation: `invert`/`verify_log`
130        /// ignore this field entirely and restore/verify byte-exact
131        /// originals from `first`/`last`/[`Reduction::ptr`] alone, same as
132        /// before this field existed (SPEC.md TR-7 dev/02).
133        summary: Option<SpanSummary>,
134    },
135    /// TR-10: an already-executed, successful tool_use call's disk-persisted
136    /// payload argument (e.g. `write_file`'s `content`) elided from its
137    /// serialized `arguments`, leaving every other argument (e.g. `path`)
138    /// verbatim. The assistant-side twin of A7 (tool RESULTS) / A8 (stale
139    /// file READS): the reduced slot here is a tool_use's arguments, not a
140    /// tool_result's content.
141    ///
142    /// Beyond the spec statement's `original_bytes`/`path`/`content_hash`,
143    /// this carries `call_id`/`field` so a single reduction can address one
144    /// specific tool_call's one specific payload field — necessary since a
145    /// single assistant message can carry more than one `tool_calls` entry
146    /// (mirrors [`Self::ImageRedacted`]'s `part_index` playing the same role
147    /// for `content_parts`).
148    ToolInputElided {
149        /// Byte length of the elided payload field's ORIGINAL value (not the
150        /// whole `arguments` string) — the stub's size figure.
151        original_bytes: usize,
152        /// The file path the payload was written to, when recoverable from a
153        /// sibling `path` argument — `None` if the tool's schema has none.
154        path: Option<PathBuf>,
155        /// blake3 hex digest of the elided payload field's ORIGINAL value.
156        /// Verified before ever restoring it (mirrors [`SidecarPtr::content_hash`],
157        /// but hashes just the field's value — the sub-span actually
158        /// removed); also what [`probe_tool_input_fresh`] compares a fresh
159        /// disk read against for the freshness matrix.
160        content_hash: String,
161        /// The elided tool_call's stable [`crate::message::ToolCall::id`]
162        /// within the addressed assistant message's `tool_calls` —
163        /// disambiguates when a single assistant turn issues more than one
164        /// tool call.
165        call_id: String,
166        /// Name of the elided payload argument field (e.g. `"content"`) —
167        /// which key inside `arguments` was replaced.
168        field: String,
169    },
170    /// T30/TR-4: a bash/exec tool result whose ANSI color codes and
171    /// carriage-return/erase-line/cursor-up redraws were collapsed down to
172    /// their final rendered content ([`normalize::normalize`]). A VIEW
173    /// normalization (SPEC.md B10: lossy presentation over a lossless
174    /// sidecar) — the rendered CONTENT is fully preserved; only presentation
175    /// bytes (escape sequences, superseded redraw frames) are removed.
176    OutputNormalized {
177        /// Total byte size of the raw captured output before normalization.
178        original_bytes: usize,
179        /// Byte size of the normalized (final-rendered) text, excluding the
180        /// honesty trailer appended alongside it in the view.
181        normalized_bytes: usize,
182    },
183    /// TR-3 (T26): a read-type tool result for a file already read earlier
184    /// this session, whose content has since changed — replaced with a
185    /// unified diff against that prior (base) read rather than shown in full,
186    /// because the diff is materially smaller than the full content (see
187    /// [`ReductionPolicy::diff_max_percent`]).
188    ///
189    /// The base is always a genuine full read, resolved straight from the
190    /// canonical message slice (`project_messages`'s `msgs` parameter, never
191    /// mutated) — never a previously-diffed or -elided reduction's own
192    /// (reduced) content, so diffs never compound (SPEC.md TR-3 dev/04:
193    /// "no diff-of-diff").
194    ///
195    /// `ptr` (on the containing [`Reduction`]) addresses THIS read (the
196    /// re-read being replaced) and pins `new_hash` as its content hash —
197    /// `invert`/`expand_reduction` restore the full re-read byte-exact
198    /// through it, identically to [`ReductionKind::FileReadElided`].
199    /// `base`/`base_hash` are extra provenance: which prior read the diff is
200    /// against, and its content hash at diff-mint time, so a caller can tell
201    /// whether that base itself has since drifted.
202    FileReadDiffed {
203        /// The file that was read.
204        path: PathBuf,
205        /// Where the base (prior full) read lives in the canonical full view.
206        base: MessageAddr,
207        /// Hash of the base read's content, at the time this diff was minted.
208        base_hash: ContentHash,
209        /// Hash of this (new) read's full content — equal to `ptr.content_hash`
210        /// on the containing [`Reduction`].
211        new_hash: ContentHash,
212        /// Size of the full new (re-read) content, in bytes.
213        original_bytes: usize,
214        /// Size of the projected unified-diff text, in bytes (excludes the
215        /// stub placeholder line itself).
216        diff_bytes: usize,
217    },
218    /// TR-2 (T15): a tool result byte-identical to an earlier one still
219    /// addressable in the view, replaced by a stub naming the earlier
220    /// ("canonical") instance. `canonical` is informational only — display
221    /// (the stub summary) and rehydration context — never part of the
222    /// restore path: like every other kind, [`SidecarPtr::addr`] on this
223    /// reduction's own [`Reduction::ptr`] points at THIS message's own
224    /// address, so `invert`/`expand_reduction` recover it independent of
225    /// whatever later happens to `canonical`'s own slot (SPEC.md TR-2
226    /// dev/04: the canonical instance may itself be truncated or cleared
227    /// afterward without ever affecting this pointer).
228    DuplicateOutput {
229        /// Where the earlier, byte-identical instance lives in the
230        /// canonical full view, at the moment this reduction was minted.
231        canonical: MessageAddr,
232        /// Total size of the original (duplicated) content, in bytes.
233        original_bytes: usize,
234    },
235    /// TR-6 (T16): a tool result superseded by a LATER result of the SAME
236    /// tool called with the SAME canonicalized arguments
237    /// ([`supersede::canonical_key`]) — an old failing `cargo test` run
238    /// obsoleted by the newest run, a stale directory listing, an outdated
239    /// `git diff`. Unlike [`Self::DuplicateOutput`] (TR-2), the two contents
240    /// are NOT required to be byte-identical — a stale FAILING run and a
241    /// later PASSING one of the identical command are exactly the case this
242    /// exists for.
243    ///
244    /// `by` is informational only — display (the stub summary) and
245    /// provenance — never part of the restore path: like every other kind,
246    /// [`SidecarPtr::addr`] on this reduction's own [`Reduction::ptr`] points
247    /// at THIS message's own address, so `invert`/`expand_reduction` recover
248    /// it independent of whatever later happens to `by`'s own slot (mirrors
249    /// TR-2 dev/04's guarantee for `DuplicateOutput::canonical`: the
250    /// successor may itself be truncated, superseded again, or cleared
251    /// afterward without ever affecting this pointer).
252    Superseded {
253        /// Where the newer (successor) result lives in the canonical full
254        /// view, at the moment this reduction was minted.
255        by: MessageAddr,
256        /// Total size of the original (superseded) content, in bytes.
257        original_bytes: usize,
258    },
259}
260
261/// A blake3 hex digest, as produced by [`content_hash`]. A type alias only
262/// (not a newtype) — matches every existing hash field in this module
263/// (`SidecarPtr::content_hash`, `ReadLogEntry::content_hash`), which stayed
264/// plain `String` rather than retrofit this alias in place (SPEC.md TR-3:
265/// "keep enum/match additions minimal and localized").
266pub type ContentHash = String;
267
268/// TR-7 (T20) audit metadata for a [`ReductionKind::TurnsCleared`] span whose
269/// placeholder carries an LLM-generated summary. Recorded on the reduction
270/// itself (persisted in the `<name>.reduction.json` sidecar-family file) so
271/// the audit trail survives independent of the exact placeholder rendering
272/// (SPEC.md TR-7 dev/04: "reduction log records model id, prompt version,
273/// and summary hash for every summarized span").
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct SpanSummary {
276    /// Identifier of the model that generated the summary (e.g.
277    /// `"claude-haiku-4-5"`), taken verbatim from
278    /// [`summarize::SpanSummarizer::model_id`].
279    pub model_id: String,
280    /// Version of the fixed, in-repo summarization prompt used
281    /// ([`summarize::PROMPT_VERSION`] at mint time) — never recomputed
282    /// later, so a prompt-wording change never rewrites history for spans
283    /// already summarized under an earlier version.
284    pub prompt_version: String,
285    /// blake3 hex digest of the summary paragraph text (BEFORE the honesty
286    /// banner/id are appended) — verifiable independent of the placeholder's
287    /// exact surrounding punctuation.
288    pub summary_hash: ContentHash,
289}
290
291/// A record of a file read, kept in [`ReductionLog::read_log`] so an
292/// exporter or a later model can always answer "what was read" even when the
293/// read result itself was elided from the view (A8).
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub struct ReadLogEntry {
296    /// The file that was read.
297    pub path: PathBuf,
298    /// Where the full read result lives in the canonical full view.
299    pub addr: MessageAddr,
300    /// Hash of the read result's content, taken at read time.
301    pub content_hash: String,
302    /// The file's mtime as observed at projection time, if available.
303    pub mtime: Option<i64>,
304}
305
306/// One applied reduction: what kind it was, where it points, its stable id,
307/// and the exact placeholder text standing in for it in the reduced view.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub struct Reduction {
310    /// Stable id, e.g. `"r0042-9f3c"` — ordinal + 4-hex content-hash prefix
311    /// (D2). Unique per session; stable across re-projection.
312    pub id: String,
313    /// What was reduced and the kind-specific data.
314    pub kind: ReductionKind,
315    /// Pointer back to the original content in the sidecar.
316    pub ptr: SidecarPtr,
317    /// The exact stub text ([`stub::format`]) standing in for the original
318    /// content in the reduced view.
319    pub placeholder: String,
320}
321
322/// Durable, content-free accounting for one reduction pass. This is proof
323/// metadata only: inversion and projection depend exclusively on
324/// [`ReductionLog::reductions`]. Keeping it with the log lets an offline
325/// inspector distinguish a disabled pass, an enabled pass with no candidate,
326/// and a candidate later subsumed by a higher-order pass such as A10.
327#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
328pub struct ReductionPassAttribution {
329    /// Stable stub-grammar pass name.
330    pub kind: String,
331    /// Whether the final configured policy enabled this pass.
332    pub enabled: bool,
333    /// Claims produced immediately before later cardinality-changing passes.
334    pub candidate_count: usize,
335    /// Original bytes addressed by those claims.
336    pub candidate_original_bytes: u64,
337    /// Claims retained in the final persisted reduction index.
338    pub applied_count: usize,
339    /// Original bytes addressed by retained claims.
340    pub applied_original_bytes: u64,
341    /// Earlier claims subsumed by a later pass.
342    pub suppressed_by_later_pass_count: usize,
343    /// Original bytes addressed by those subsumed claims.
344    pub suppressed_by_later_pass_bytes: u64,
345    /// Bytes this pass would save in isolation.
346    pub standalone_saved_bytes: u64,
347    /// Estimated tokens this pass would save in isolation.
348    pub standalone_saved_tokens: u64,
349    /// Bytes this pass adds after earlier persisted passes.
350    pub marginal_saved_bytes: u64,
351    /// Estimated tokens this pass adds after earlier persisted passes.
352    pub marginal_saved_tokens: u64,
353    /// Standalone byte claim removed by overlap or pass order.
354    pub suppressed_bytes: u64,
355    /// Standalone token claim removed by overlap or pass order.
356    pub suppressed_tokens: u64,
357    /// Projected bytes retained after this pass in pipeline order.
358    pub retained_bytes: u64,
359    /// Estimated tokens retained after this pass in pipeline order.
360    pub retained_tokens: u64,
361}
362
363/// Durable aggregate for [`ReductionPassAttribution`]. Byte and token
364/// aggregates are marginal sums, never sums of overlapping standalone
365/// claims.
366#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
367pub struct ReductionAttribution {
368    /// Serialized bytes before reduction.
369    pub full_bytes: u64,
370    /// Serialized bytes after all persisted reductions.
371    pub view_bytes: u64,
372    /// Estimated tokens before reduction.
373    pub full_tokens: u64,
374    /// Estimated tokens after all persisted reductions.
375    pub view_tokens: u64,
376    /// Actual aggregate byte savings.
377    pub aggregate_saved_bytes: u64,
378    /// Actual aggregate estimated-token savings.
379    pub aggregate_saved_tokens: u64,
380    /// Sum of byte savings claimed by passes in isolation.
381    pub standalone_saved_bytes: u64,
382    /// Sum of estimated-token savings claimed by passes in isolation.
383    pub standalone_saved_tokens: u64,
384    /// Standalone byte claims excluded from the aggregate.
385    pub overlap_suppressed_bytes: u64,
386    /// Standalone estimated-token claims excluded from the aggregate.
387    pub overlap_suppressed_tokens: u64,
388    /// One row per pass in production pipeline order.
389    pub passes: Vec<ReductionPassAttribution>,
390}
391
392/// The persisted index of every reduction applied to a session, plus the A8
393/// read-log. This is the `<name>.reduction.json` sidecar-family file (D1);
394/// `invert` needs it (with the sidecar) to reconstruct the full view.
395#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
396pub struct ReductionLog {
397    /// Every reduction applied so far, in application order.
398    pub reductions: Vec<Reduction>,
399    /// Reductions explicitly rehydrated by the user. They remain persisted
400    /// so a later projection/restart can distinguish "deliberately
401    /// expanded" from "never reduced" without re-stubbing the address.
402    #[serde(default, skip_serializing_if = "Vec::is_empty")]
403    pub expanded: Vec<Reduction>,
404    /// Every file read observed during projection (A8), independent of
405    /// whether that particular read ended up elided.
406    pub read_log: Vec<ReadLogEntry>,
407    /// Optional content-free pass attribution captured by a driving surface.
408    /// Older logs omit it and remain fully compatible.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub attribution: Option<ReductionAttribution>,
411}
412
413/// The sentinel prefix every reduction placeholder starts with (D2).
414///
415/// Defined once, here. `A11`'s export leak-guard greps for this string; the
416/// projection layer never writes it into genuine (non-reduced) content.
417pub const REDUCTION_SENTINEL: &str = "[sc-reduced";
418
419/// The reserved `ChatMessage.metadata` key carrying a reduced message's
420/// [`Reduction::id`] (the `sc.` prefix is reserved for this reduction
421/// layer's own bookkeeping).
422///
423/// This rides `ChatMessage`'s guarantee that `metadata` never serializes to
424/// the wire (`message.rs:49-54`, `57-79`) — the pointer reaches every
425/// in-process consumer (CLI inspection, `invert`) but can never enter a
426/// request body or a persisted transcript.
427pub const REDUCTION_METADATA_KEY: &str = "sc.reduction";
428
429/// Stamp `msg` with the `sc.reduction` metadata key pointing at `id`. This is
430/// the one place a reduction's id is attached to a message; every A/B
431/// emitter should go through this rather than writing the key by hand.
432pub fn set_reduction_id(msg: &mut ChatMessage, id: &str) {
433    msg.metadata
434        .insert(REDUCTION_METADATA_KEY.to_string(), id.to_string());
435}
436
437/// Read back a message's `sc.reduction` id, if it was reduced.
438pub fn reduction_id(msg: &ChatMessage) -> Option<&str> {
439    msg.metadata.get(REDUCTION_METADATA_KEY).map(String::as_str)
440}
441
442/// TR-10: reserved `ChatMessage.metadata` key marking a `Role::Tool` result
443/// message as an ERROR result — the call it answers did not execute
444/// successfully. [`ReductionKind::ToolInputElided`]'s candidate rule needs
445/// this success/failure signal, and `ChatMessage` otherwise has no
446/// structural slot for it: a Claude `tool_result` wire block carries
447/// `is_error` (captured on import, `session.rs`), and the live agent knows
448/// `is_error` only transiently as `Agent::run_tool`'s return value
449/// (`agent.rs`) — neither previously survived onto the `ChatMessage` the
450/// reduction layer sees. Set at message-creation time by the importer/agent;
451/// the reduction layer reads it through [`tool_outcome`].
452pub const TOOL_ERROR_METADATA_KEY: &str = "sc.tool_error";
453
454/// Reserved `ChatMessage.metadata` key marking a `Role::Tool` result whose
455/// success/failure outcome is UNKNOWN. Codex v1 `function_call_output` and
456/// `custom_tool_call_output` records carry only free-form output text, so
457/// inferring success from that text would be brittle. Importers stamp this
458/// marker instead and reduction candidates fail closed until a harness gives
459/// us a structured outcome.
460pub const TOOL_OUTCOME_UNKNOWN_METADATA_KEY: &str = "sc.tool_outcome_unknown";
461
462/// The structural outcome known for a tool-result message.
463///
464/// `KnownSuccess` deliberately remains the default for messages with no
465/// marker: Claude v1 omits `is_error` on successful results, and live tool
466/// execution already marks only its error branch. Codex v1 imports opt into
467/// `Unknown` explicitly because their output records expose no structured
468/// success bit.
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub enum ToolOutcome {
471    /// The harness supplied success semantics, or uses the legacy convention
472    /// where an absent structured error flag means success.
473    KnownSuccess,
474    /// The harness supplied a structured error signal.
475    KnownError,
476    /// The harness supplied a result but no structured outcome signal.
477    Unknown,
478}
479
480/// Stamp `msg` (expected to be a `Role::Tool` result) as an error result —
481/// see [`TOOL_ERROR_METADATA_KEY`].
482pub fn mark_tool_error(msg: &mut ChatMessage) {
483    msg.metadata.remove(TOOL_OUTCOME_UNKNOWN_METADATA_KEY);
484    msg.metadata
485        .insert(TOOL_ERROR_METADATA_KEY.to_string(), "true".to_string());
486}
487
488/// Stamp `msg` as having no structurally-known success/failure outcome.
489pub fn mark_tool_outcome_unknown(msg: &mut ChatMessage) {
490    msg.metadata.remove(TOOL_ERROR_METADATA_KEY);
491    msg.metadata.insert(
492        TOOL_OUTCOME_UNKNOWN_METADATA_KEY.to_string(),
493        "true".to_string(),
494    );
495}
496
497/// Whether `msg` was marked an error result via [`mark_tool_error`].
498pub fn is_tool_error(msg: &ChatMessage) -> bool {
499    msg.metadata
500        .get(TOOL_ERROR_METADATA_KEY)
501        .map(String::as_str)
502        == Some("true")
503}
504
505/// Return the explicit structural outcome for a tool-result message.
506pub fn tool_outcome(msg: &ChatMessage) -> ToolOutcome {
507    if is_tool_error(msg) {
508        ToolOutcome::KnownError
509    } else if msg
510        .metadata
511        .get(TOOL_OUTCOME_UNKNOWN_METADATA_KEY)
512        .map(String::as_str)
513        == Some("true")
514    {
515        ToolOutcome::Unknown
516    } else {
517        ToolOutcome::KnownSuccess
518    }
519}
520
521/// Hash helper used consistently across the reduction layer: blake3 hex
522/// digest of `bytes`. Used both for [`SidecarPtr::content_hash`] and
523/// [`ReadLogEntry::content_hash`].
524pub fn content_hash(bytes: &[u8]) -> String {
525    blake3::hash(bytes).to_hex().to_string()
526}
527
528/// Build a [`Reduction::id`]: a zero-padded 4-digit ordinal plus the first 4
529/// hex characters of a content hash (D2), e.g. `"r0042-9f3c"`.
530///
531/// `ordinal` is the reduction's position among reductions applied to this
532/// session; it wraps decoratively past 9999 (id uniqueness within a session
533/// still holds in practice because the hash prefix disambiguates, and no
534/// real session approaches that many reductions).
535pub fn make_id(ordinal: usize, hash: &str) -> String {
536    let ord = ordinal % 10_000;
537    let prefix: String = hash.chars().take(4).collect();
538    format!("r{ord:04}-{prefix}")
539}
540
541/// Export a (possibly reduced) live session in `format`.
542///
543/// A11 (SPEC.md §6): export **always** reconstructs from `sidecar` — the
544/// full-fidelity append-only log (`Session::from_sidecar_str`) — never from a
545/// projected/reduced view or a `ReductionLog`. Those are not inputs to this
546/// function at all, so a reduced view can only ever reach an exporter through
547/// a bug in the caller, not through this code path.
548///
549/// **Leak guard (fail-closed):** canonical message fields are checked for
550/// grammar-valid reduction placeholders before serialization. A mere mention
551/// of [`REDUCTION_SENTINEL`] inside ordinary historical text is not a stub and
552/// must remain exportable; exact placeholders (including ones nested in tool
553/// arguments/content parts) still fail closed. Some reductions retain a
554/// visible prefix or diff around the placeholder, so every standalone line is
555/// checked rather than only parsing the complete field.
556pub fn export_session(sidecar: &str, format: SessionFormat) -> Result<String> {
557    let session = Session::from_sidecar_str(sidecar)?;
558    if session_contains_reduction_stub(&session) {
559        return Err(Error::Other(format!(
560            "export_session: refusing to export — the sidecar contains a grammar-valid \
561             reduction stub beginning with {REDUCTION_SENTINEL:?}; a reduced view leaked \
562             into an export path that must only read the full-fidelity sidecar"
563        )));
564    }
565    session.to_jsonl(format)
566}
567
568fn string_contains_reduction_stub(value: &str) -> bool {
569    value.lines().any(|line| stub::parse(line).is_some())
570}
571
572fn json_value_contains_reduction_stub(value: &serde_json::Value) -> bool {
573    match value {
574        serde_json::Value::String(s) => string_contains_reduction_stub(s),
575        serde_json::Value::Array(values) => values.iter().any(json_value_contains_reduction_stub),
576        serde_json::Value::Object(fields) => {
577            fields.values().any(json_value_contains_reduction_stub)
578        }
579        _ => false,
580    }
581}
582
583fn session_contains_reduction_stub(session: &Session) -> bool {
584    session.messages.iter().any(|message| {
585        message
586            .content
587            .as_deref()
588            .is_some_and(string_contains_reduction_stub)
589            || message
590                .content_parts
591                .as_ref()
592                .is_some_and(|parts| parts.iter().any(json_value_contains_reduction_stub))
593            || message.tool_calls().iter().any(|call| {
594                serde_json::from_str::<serde_json::Value>(&call.function.arguments)
595                    .map(|value| json_value_contains_reduction_stub(&value))
596                    .unwrap_or_else(|_| string_contains_reduction_stub(&call.function.arguments))
597            })
598    })
599}
600
601// ---------------------------------------------------------------------------
602// A5 — project(): session -> reduced view + reduction log
603// ---------------------------------------------------------------------------
604
605/// Knobs controlling [`project`]. Defaults match SPEC.md A5/A7, stacked per
606/// D14 with the levers that don't need an external I/O probe to be safe
607/// on-by-default (`redact_images`; contrast `elide_stale_reads`, below).
608///
609/// `elide_stale_reads` (A8) and `clear_turns_older_than` (A10) are plumbed
610/// through the struct but inert by default — `elide_stale_reads` needs a
611/// freshness probe ([`probe_read_freshness`]) to mean anything, and
612/// `clear_turns_older_than` is populated per-agent from
613/// `compact_after_messages` (`Agent::maybe_compact`), not from this default.
614#[derive(Debug, Clone, PartialEq, Eq)]
615pub struct ReductionPolicy {
616    /// Bytes kept from the front of an oversized tool result (A7). Default
617    /// `4096`.
618    pub tool_output_keep_bytes: usize,
619    /// Only tool results strictly larger than this are truncation candidates
620    /// (A7). Default `8192`.
621    pub tool_output_trigger_bytes: usize,
622    /// Never reduce the newest `N` tool results (#1 "keep"). Default `3`.
623    pub protect_last_n_tool_results: usize,
624    /// A8 — gate for stale-file-read elision. Consults
625    /// [`Self::read_freshness`] for the actual per-message verdicts; setting
626    /// this without ever populating `read_freshness` (via
627    /// [`probe_read_freshness`]) elides nothing, since the empty default
628    /// freshness map treats every read as not-yet-verified.
629    pub elide_stale_reads: bool,
630    /// A9 — gate for `data:` URL image redaction. Default `true` (D14:
631    /// reduced mode stacks every lossless lever on together) — unlike
632    /// `elide_stale_reads`, this rule is a pure function of the message
633    /// content already in view, so it carries none of A8's "meaningless
634    /// without a probe" caveat and can safely default on.
635    pub redact_images: bool,
636    /// A9 — minimum byte length of a candidate `image_url` part's `url`
637    /// string for it to become a redaction candidate; inert unless
638    /// `redact_images` is set. Default `8192` (mirrors
639    /// `tool_output_trigger_bytes`'s scale: small inline icons stay in view,
640    /// real screenshots/photos get redacted).
641    pub image_redact_min_bytes: usize,
642    /// A10 — inert until turn-clearing lands.
643    pub clear_turns_older_than: Option<usize>,
644    /// A8 — the disk-probe pre-pass's output ([`probe_read_freshness`]),
645    /// consulted by [`project_messages`] only when [`Self::elide_stale_reads`]
646    /// is set. This is *data*, not a config knob: it is meant to be
647    /// recomputed by the caller before every `project`/`project_messages`
648    /// call (disk state can change turn to turn) — `project_messages` itself
649    /// never performs the I/O; that happens once, up front, in
650    /// `probe_read_freshness`. Default: empty (fails closed — nothing is
651    /// considered fresh without an accompanying probe).
652    pub read_freshness: ReadFreshness,
653    /// TR-3 (T26) — gate for diff-only re-read representation
654    /// (`ReductionKind::FileReadDiffed`). Like `redact_images` (and unlike
655    /// `elide_stale_reads`), this rule is a pure function of the message
656    /// content already in view — a re-read's content compared against the
657    /// prior read of the same path recorded in `log.read_log` — with no
658    /// disk-probe caveat, so it can safely default on. Default `true`.
659    pub diff_rereads: bool,
660    /// TR-3 — a candidate diff must be no more than this percentage of the
661    /// full re-read's size to replace it; otherwise the full re-read stays
662    /// untouched (SPEC.md TR-3 dev/03's "large-change guard"). An integer
663    /// percentage (rather than a float) so [`ReductionPolicy`] keeps its
664    /// `Eq` derive (`f64` has none). Default `50` ("diff ≤ 50% of full
665    /// content").
666    pub diff_max_percent: u32,
667    /// B7 coordination clamp: when a [`crate::CachePlan::ImportedPrefix`] is
668    /// active, the count of leading messages (of the slice passed to
669    /// [`project_messages`]) that make up the imported session prefix. A10
670    /// turn-clearing must never establish a clear range that dips into them,
671    /// since doing so would bust the prefix's cache breakpoint (and its
672    /// fidelity). `None` (the default) applies no clamp, matching today's
673    /// behavior for callers that never set a [`crate::CachePlan`]. Set by
674    /// [`crate::Agent`] from its own `imported_prefix_len`, not a
675    /// user-facing knob.
676    pub protect_imported_prefix: Option<usize>,
677    /// TR-10 — gate for tool-INPUT elision ([`ReductionKind::ToolInputElided`]).
678    /// Default `true`. Unlike `elide_stale_reads`, the candidate rule
679    /// (executed successfully + oversized payload + a disk-persisting tool)
680    /// is a pure function of the view plus [`Self::tool_input_elidable_fields`]
681    /// — no external disk probe is needed to decide elision itself (a probe
682    /// only matters later, for the freshness-matrix ESCALATION decision, see
683    /// [`probe_tool_input_fresh`]) — so, like `redact_images`, this can
684    /// safely default on (D14: reduced mode stacks every lossless lever
685    /// together).
686    pub elide_tool_inputs: bool,
687    /// TR-10 — only a candidate tool_call's payload field whose value
688    /// exceeds this many bytes becomes an elision candidate. Default `8192`
689    /// (mirrors A7/A9's scale).
690    pub tool_input_trigger_bytes: usize,
691    /// TR-10 — table of tool name -> its elidable (disk-persisted) payload
692    /// argument field. Defaults to the built-in write-family tools
693    /// (`write_file` -> `content`, see [`default_tool_input_elidable_fields`]);
694    /// an MCP tool opts in by inserting its own `(name, field)` entry
695    /// (SPEC.md TR-10: "per-MCP-tool opt-in").
696    pub tool_input_elidable_fields: HashMap<String, String>,
697    /// T30/TR-4 — gate for [`ReductionKind::OutputNormalized`] (ANSI/redraw
698    /// collapse over terminal tool output). Default `true`: like
699    /// `redact_images`, this is a pure function of already-in-view content
700    /// (no I/O probe needed) and content-lossless (rendered CONTENT is fully
701    /// preserved, only presentation bytes are removed), so it stacks on by
702    /// default per D14.
703    pub normalize_terminal_output: bool,
704    /// T30/TR-4 — minimum byte savings (`original_bytes - normalized_bytes`)
705    /// for a candidate to actually become an
706    /// [`ReductionKind::OutputNormalized`] reduction; below this floor the
707    /// output is left untouched rather than raced through the reduction
708    /// machinery for a few bytes (SPEC.md TR-4's "savings floor" knob).
709    /// Default [`normalize::DEFAULT_MIN_SAVINGS`].
710    pub terminal_output_min_savings: usize,
711    /// TR-2 — minimum byte length of a duplicate tool-result candidate's
712    /// content for it to become a [`ReductionKind::DuplicateOutput`]
713    /// candidate. Below this, both the canonical and the would-be duplicate
714    /// are left alone: a stub's own bytes are not free, so deduping a tiny
715    /// output would spend more than it saves (the "savings floor"). Default
716    /// `256`.
717    pub duplicate_output_min_bytes: usize,
718    /// TR-2 — gate for [`ReductionKind::DuplicateOutput`]. Default `true`:
719    /// duplicate detection is a pure function of the recorded tool outputs,
720    /// so it stacks on in ordinary reduced mode. Composable capability
721    /// profiles can disable it without changing the savings-floor knob.
722    pub deduplicate_outputs: bool,
723    /// TR-6 (T16) — gate for [`ReductionKind::Superseded`] (same tool + same
724    /// canonicalized arguments, keep only the newest result). Default
725    /// `true`: like `redact_images`/`elide_tool_inputs`, the candidate rule
726    /// is a pure function of the view plus [`Self::supersede_command_fields`]
727    /// — no external disk probe needed — so it stacks on by default (D14).
728    pub supersede_enabled: bool,
729    /// TR-6 — protected recency zone (opencode's `PRUNE_PROTECT` spirit): the
730    /// newest `N` tool RESULT messages (by position, mirrors
731    /// [`Self::protect_last_n_tool_results`]'s own construction) are never a
732    /// *new* `Superseded` candidate, regardless of how many older same-key
733    /// occurrences exist. A DEDICATED knob rather than reusing
734    /// `protect_last_n_tool_results` — TR-6.md's spec calls this out as its
735    /// own independently tunable "protected recency zone," and the two
736    /// passes run at different points in the pipeline (Superseded runs
737    /// before A7 truncation ever computes its own candidates). Default `3`
738    /// (mirrors `protect_last_n_tool_results`'s own default).
739    pub supersede_protect_last_n: usize,
740    /// TR-6 — minimum byte length of a superseded-candidate's OWN content for
741    /// it to become a [`ReductionKind::Superseded`] candidate (the "savings
742    /// floor," mirrors [`Self::duplicate_output_min_bytes`]). Below this the
743    /// older result is left untouched — a stub's own bytes are not free.
744    /// Default `256`.
745    pub supersede_min_bytes: usize,
746    /// TR-6 — table of tool name -> its command-bearing argument field (e.g.
747    /// `bash`/`shell`/`exec_command` -> `"command"`), consulted by
748    /// [`supersede::canonical_key`] to canonicalize (trim + collapse internal
749    /// whitespace) just that one field's value rather than the whole
750    /// arguments string. A tool absent from this table still participates in
751    /// supersession — its whole (trimmed-only) arguments string becomes the
752    /// key — this table only controls whitespace-collapse scope, not
753    /// eligibility. Defaults to [`supersede::default_command_fields`].
754    pub supersede_command_fields: HashMap<String, String>,
755    /// TR-6 — gate for errored-call input pruning: a FAILED tool call's
756    /// oversized payload argument ([`Self::tool_input_elidable_fields`],
757    /// shared with TR-10) becomes a [`ReductionKind::ToolInputElided`]
758    /// candidate once [`Self::errored_input_prune_after_turns`] assistant
759    /// turns have elapsed since the failed call — the disjoint, FAILURE-side
760    /// complement of TR-10's `elide_tool_inputs` (which only ever considers
761    /// SUCCESSFUL calls; see `detect_tool_inputs`'s own doc comment for the
762    /// success/failure boundary). The failed call's own error-result message
763    /// is never touched by this — only the assistant-side input argument —
764    /// so the error itself stays visible exactly as TR-6.md requires. Default
765    /// `true` (pure function of the view + the age clock, no I/O probe
766    /// needed, D14).
767    pub prune_errored_inputs: bool,
768    /// TR-6 — how many LATER `Role::Assistant` messages must appear after a
769    /// failed call's own message before its oversized input becomes a
770    /// pruning candidate (the "N turns" aging clock in TR-6.md's errored-call
771    /// case) — a message-count proxy for "turns elapsed," the same
772    /// convention [`Self::clear_turns_older_than`] (A10) already uses (this
773    /// codebase has no other structural definition of a conversational
774    /// turn). Default `3`.
775    pub errored_input_prune_after_turns: usize,
776    /// TR-7 (T20) — the `summaries: on|off` config knob: gate for rendering
777    /// an established [`ReductionKind::TurnsCleared`] span's placeholder as
778    /// an LLM-generated summary paragraph instead of the deterministic
779    /// `[turns cleared]` stub. **Default `false`** — SPEC.md TR-7 dev/01:
780    /// with this off, the A10 stub must stay byte-identical to pre-TR-7
781    /// behavior, so `project_messages` never even looks at
782    /// [`Self::cleared_turns_summary`] while this is unset, regardless of
783    /// what a caller precomputed. Turning this on with no matching
784    /// [`Self::cleared_turns_summary`] entry (e.g. the side-call was never
785    /// run, or failed) is exactly as safe: the deterministic stub is still
786    /// what gets rendered (dev/03's failure-fallback guarantee).
787    pub summarize_cleared_turns: bool,
788    /// TR-7 — cost-guard floor (dev/05): a candidate cleared span's ORIGINAL
789    /// byte size (the same `range_bytes` the deterministic stub's own
790    /// summary clause already reports) must exceed
791    /// `expected_summary_bytes * summary_cost_floor_multiple` before
792    /// [`prepare_cleared_turns_summary`] ever calls the injected
793    /// [`summarize::SpanSummarizer`] — below the floor, a summarization
794    /// side-call would be negative-ROI (the stub it replaces is already
795    /// small) and is skipped outright, never attempted. Default `400`
796    /// (a rough paragraph-sized estimate).
797    pub expected_summary_bytes: usize,
798    /// TR-7 — see [`Self::expected_summary_bytes`]; the floor multiplier.
799    /// Default `4` (the span must be at least ~4 summaries' worth of bytes).
800    pub summary_cost_floor_multiple: usize,
801    /// TR-7 — the side-call preparer's output
802    /// ([`prepare_cleared_turns_summary`]), consulted by
803    /// [`project_messages`] only when [`Self::summarize_cleared_turns`] is
804    /// set AND the prepared entry's `(first, last)` matches EXACTLY the
805    /// range `project_messages` independently (re)computes for this call —
806    /// any other prepared entry (stale, wrong range, or simply absent) is
807    /// silently ignored and the deterministic stub is rendered instead. This
808    /// is *data*, not a config knob (mirrors [`Self::read_freshness`]):
809    /// recomputed by the caller (via `prepare_cleared_turns_summary`, the
810    /// one place TR-7's side-call happens) before every
811    /// `project`/`project_messages` call that might establish a NEW
812    /// `TurnsCleared` range. Default: `None`.
813    pub cleared_turns_summary: Option<PreparedClearSummary>,
814}
815
816impl Default for ReductionPolicy {
817    fn default() -> Self {
818        ReductionPolicy {
819            tool_output_keep_bytes: 4096,
820            tool_output_trigger_bytes: 8192,
821            protect_last_n_tool_results: 3,
822            elide_stale_reads: false,
823            redact_images: true,
824            image_redact_min_bytes: 8192,
825            clear_turns_older_than: None,
826            read_freshness: ReadFreshness::default(),
827            diff_rereads: true,
828            diff_max_percent: 50,
829            protect_imported_prefix: None,
830            elide_tool_inputs: true,
831            tool_input_trigger_bytes: 8192,
832            tool_input_elidable_fields: default_tool_input_elidable_fields(),
833            normalize_terminal_output: true,
834            terminal_output_min_savings: normalize::DEFAULT_MIN_SAVINGS,
835            duplicate_output_min_bytes: 256,
836            deduplicate_outputs: true,
837            supersede_enabled: true,
838            supersede_protect_last_n: 3,
839            supersede_min_bytes: 256,
840            supersede_command_fields: supersede::default_command_fields(),
841            prune_errored_inputs: true,
842            errored_input_prune_after_turns: 3,
843            summarize_cleared_turns: false,
844            expected_summary_bytes: 400,
845            summary_cost_floor_multiple: 4,
846            cleared_turns_summary: None,
847        }
848    }
849}
850
851/// The built-in write-family default for
852/// [`ReductionPolicy::tool_input_elidable_fields`]: `write_file` -> `content`
853/// (`tools/builtins.rs`'s `WriteFileTool` schema — the one built-in tool
854/// whose entire payload is disk-persisted verbatim), plus Claude Code's own
855/// native `Write` tool (imported sessions carry Claude's tool names verbatim,
856/// `session.rs::push_claude_assistant` — never remapped to this crate's own
857/// builtin names), which shares the same `content` payload field name.
858fn default_tool_input_elidable_fields() -> HashMap<String, String> {
859    let mut m = HashMap::new();
860    m.insert("write_file".to_string(), "content".to_string());
861    m.insert("Write".to_string(), "content".to_string());
862    m
863}
864
865// ---------------------------------------------------------------------------
866// TR-7 (T20) — LLM-written summary placeholders over cleared spans
867// ---------------------------------------------------------------------------
868
869/// The output of [`prepare_cleared_turns_summary`] — one summarized span,
870/// ready for [`project_messages`] to apply IF (and only if) it independently
871/// recomputes the exact same `(first, last)` range for its own
872/// [`ReductionKind::TurnsCleared`] candidate this call. Threaded through
873/// [`ReductionPolicy::cleared_turns_summary`]; see that field's doc comment
874/// for the full data-vs-config-knob split (mirrors [`ReadFreshness`]).
875#[derive(Debug, Clone, PartialEq, Eq)]
876pub struct PreparedClearSummary {
877    /// Address of the first message the summarized span covers (must match
878    /// `project_messages`'s own candidate range exactly to be applied).
879    pub first: usize,
880    /// Address of the last message the summarized span covers.
881    pub last: usize,
882    /// The summarizer's output, already sanitized into the [`stub`] grammar's
883    /// one-line/no-`]` contract ([`prepare_cleared_turns_summary`] does this
884    /// once, here, so `project_messages` never needs to).
885    pub text: String,
886    /// [`summarize::SpanSummarizer::model_id`], carried through for the
887    /// audit trail ([`SpanSummary::model_id`]).
888    pub model_id: String,
889}
890
891/// Compute the A10 candidate clear range `[first, last]` (inclusive) for
892/// `msgs` under `policy.clear_turns_older_than`/`policy.protect_imported_prefix`,
893/// or `None` if clearing doesn't trigger (below threshold, or no room once
894/// the system-prefix/tool-boundary/imported-prefix guards are applied).
895///
896/// A pure function of `msgs.len()` and each message's `role` alone — never
897/// affected by any in-place content reduction (A7/A8/A9/TR-2/TR-3/TR-4/TR-6/
898/// TR-10 all preserve `role`, only ever rewriting `content`/`tool_calls`), so
899/// it is safe to call directly against the pristine `msgs` slice from BOTH
900/// call sites that must agree on the identical range or a prepared summary
901/// could silently apply to the wrong span: [`project_messages`] itself
902/// (called against `view`, whose roles are identical to `msgs`'s at the
903/// point A10 runs — see its own comment on pass order) and
904/// [`prepare_cleared_turns_summary`] (called against `msgs` directly, before
905/// any projection has run at all). Sharing this one function is what
906/// guarantees the two agree BY CONSTRUCTION, never by convention.
907fn compute_clear_range(msgs: &[ChatMessage], policy: &ReductionPolicy) -> Option<(usize, usize)> {
908    let threshold = policy.clear_turns_older_than?;
909    if msgs.len() <= threshold {
910        return None;
911    }
912    // Never target a leading system/developer message (A5).
913    let mut first = 0;
914    while first < msgs.len() && msgs[first].role == Role::System {
915        first += 1;
916    }
917    // B7 coordination clamp (see `ReductionPolicy::protect_imported_prefix`).
918    if let Some(protected) = policy.protect_imported_prefix {
919        first = first.max(protected);
920    }
921    let keep_recent = (threshold / 2).max(2);
922    let mut cut = msgs.len().saturating_sub(keep_recent);
923    // Never begin the kept (surviving) window on a tool result.
924    while cut < msgs.len() && msgs[cut].role == Role::Tool {
925        cut += 1;
926    }
927    if cut > first && cut < msgs.len() {
928        Some((first, cut - 1))
929    } else {
930        None
931    }
932}
933
934/// Render a message range as the plain, human-legible text fed to
935/// [`summarize::SpanSummarizer::summarize`] (via [`summarize::render_prompt`]
936/// for a real implementation) — one `role: content` line per message. Content
937/// only (not tool-call argument JSON): keeps the side-call's input compact,
938/// matching what TR-7's spec calls "what content existed" rather than a
939/// byte-exact re-serialization (the sidecar, not this rendering, is the
940/// byte-exact source of truth `invert`/`expand_reduction` always use).
941fn render_span_text(msgs: &[ChatMessage]) -> String {
942    let mut out = String::new();
943    for m in msgs {
944        let role = match m.role {
945            Role::System => "system",
946            Role::User => "user",
947            Role::Assistant => "assistant",
948            Role::Tool => "tool",
949        };
950        out.push_str(role);
951        out.push_str(": ");
952        out.push_str(m.content.as_deref().unwrap_or(""));
953        out.push('\n');
954    }
955    out
956}
957
958/// TR-7's one side-call site (SPEC.md: "an explicit, budgeted, injectable
959/// side-call... never blocking the main loop"). Call this against the exact
960/// same `msgs` slice about to be projected (mirrors
961/// [`probe_read_freshness`]'s own calling convention), thread the result
962/// through [`ReductionPolicy::cleared_turns_summary`] before calling
963/// [`project`]/[`project_messages`]. Only ever does anything when
964/// `policy.summarize_cleared_turns` is set; callers that never enable TR-7
965/// can skip calling this entirely (dev/01: `project_messages` behaves
966/// identically either way when the policy gate is off).
967///
968/// Returns `None` — meaning `project_messages` will render the deterministic
969/// stub — whenever: TR-7 is off; a `TurnsCleared` range is already
970/// established (A10 fires at most once per session, singleton, so no
971/// side-call is ever needed for an already-decided span); no clear range
972/// currently triggers; the candidate span is below the cost-guard floor
973/// (dev/05, no side-call attempted at all); or the summarizer itself errors
974/// or returns an empty/blank result (dev/03's fault-injection contract —
975/// this NEVER propagates an error to the caller, by design).
976pub fn prepare_cleared_turns_summary(
977    msgs: &[ChatMessage],
978    policy: &ReductionPolicy,
979    prior: &ReductionLog,
980    summarizer: &dyn summarize::SpanSummarizer,
981) -> Option<PreparedClearSummary> {
982    if !policy.summarize_cleared_turns {
983        return None;
984    }
985    if prior
986        .reductions
987        .iter()
988        .any(|r| matches!(r.kind, ReductionKind::TurnsCleared { .. }))
989    {
990        return None; // Already established; never recomputed (A10 singleton).
991    }
992    let (first, last) = compute_clear_range(msgs, policy)?;
993    let range = &msgs[first..=last];
994    let (_hash, range_bytes) = hash_turns_range(range).ok()?;
995    // Cost guard (dev/05): skip the side-call outright for a span too small
996    // to be positive-ROI once the summary's own stub overhead is counted —
997    // never merely discard a result already paid for.
998    let floor = policy
999        .expected_summary_bytes
1000        .saturating_mul(policy.summary_cost_floor_multiple);
1001    if range_bytes <= floor {
1002        return None;
1003    }
1004    let span_text = render_span_text(range);
1005    let text = match summarizer.summarize(&span_text) {
1006        Ok(t) if !t.trim().is_empty() => t,
1007        _ => return None, // dev/03: error or blank result -> deterministic fallback.
1008    };
1009    // Sanitize into the `stub` grammar's one-line, no-`]` contract
1010    // (SPEC.md C2/D2) regardless of what the summarizer produced — a
1011    // formatting quirk in the model's output must never fail the pass.
1012    let sanitized = text
1013        .split_whitespace()
1014        .collect::<Vec<_>>()
1015        .join(" ")
1016        .replace(']', ")");
1017    if sanitized.is_empty() {
1018        return None;
1019    }
1020    Some(PreparedClearSummary {
1021        first,
1022        last,
1023        text: sanitized,
1024        model_id: summarizer.model_id().to_string(),
1025    })
1026}
1027
1028// ---------------------------------------------------------------------------
1029// A8 — stale-file-read detection + the disk-probe pre-pass
1030// ---------------------------------------------------------------------------
1031
1032/// Read-type tool names A8 elision applies to. A tool result is a candidate
1033/// only when its paired assistant `tool_calls` entry names one of these
1034/// (`tools/builtins.rs:39-104`'s `read_file`). `B6` must keep this in sync
1035/// with any built-in tool rename.
1036pub const READ_TOOLS: &[&str] = &["read_file"];
1037
1038/// One read-type tool result found in a message slice: the index of the
1039/// `Role::Tool` result, the file path pulled from the paired assistant
1040/// call's `path` argument (the `read_file` schema's one required field), and
1041/// whether that call was a partial-window read (`offset` and/or `limit` set
1042/// — `tools/builtins.rs`'s `ReadArgs`).
1043#[derive(Debug, Clone)]
1044struct DetectedRead {
1045    index: usize,
1046    path: PathBuf,
1047    /// TR-3 v1 scope guard: `true` when the paired call set `offset` and/or
1048    /// `limit` (a partial-window read, `tools/builtins.rs`'s `ReadArgs`).
1049    /// TR-3.md's frozen spec excludes partial-window reads from v1 ("full-file
1050    /// reads only... document the exclusion in the stub logic"): two windowed
1051    /// reads of the same path may cover different line ranges entirely, so a
1052    /// unified diff between them would present a diff between two arbitrary
1053    /// windows as if it were a file change. Consulted ONLY by the TR-3
1054    /// diffing candidate rule below — A8 elision and TR-2 dedup are
1055    /// unaffected (A8 already fails closed on a windowed read via its
1056    /// hash-mismatch fallback, `probe_read_freshness`'s doc comment above).
1057    windowed: bool,
1058}
1059
1060/// Find every read-type tool result in `msgs`: a `Role::Tool` message paired
1061/// — by `tool_call_id` — to the nearest earlier assistant message whose
1062/// `tool_calls` contains a matching id naming one of [`READ_TOOLS`], with a
1063/// string `path` argument.
1064///
1065/// Pairing rides `tool_call_id` alone. Claude imports also stamp a
1066/// `sourceToolAssistantUUID` metadata edge on the tool-result message
1067/// (`session.rs:876-888`) parallel to `parentUuid`, but that id has no
1068/// corresponding field recoverable on the assistant side through
1069/// `ChatMessage`'s stable shape — and it doesn't need one here: Claude's own
1070/// `tool_use_id` already becomes `tool_call_id` on import
1071/// (`session.rs:876-881`), so `tool_call_id` pairing alone already covers
1072/// both Codex and Claude Code sessions. `sourceToolAssistantUUID` is
1073/// therefore not consulted (SPEC.md A8: "prefer the simple route").
1074fn detect_reads(msgs: &[ChatMessage]) -> Vec<DetectedRead> {
1075    let mut out = Vec::new();
1076    for (i, msg) in msgs.iter().enumerate() {
1077        if msg.role != Role::Tool {
1078            continue;
1079        }
1080        let Some(call_id) = msg.tool_call_id.as_deref() else {
1081            continue;
1082        };
1083        let call = msgs[..i].iter().rev().find_map(|m| {
1084            if m.role != Role::Assistant {
1085                return None;
1086            }
1087            m.tool_calls().iter().find(|c| c.id == call_id).cloned()
1088        });
1089        let Some(call) = call else {
1090            continue;
1091        };
1092        if !READ_TOOLS.contains(&call.function.name.as_str()) {
1093            continue;
1094        }
1095        let Ok(args) = call.function.parsed_arguments() else {
1096            continue;
1097        };
1098        let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
1099            continue;
1100        };
1101        let windowed = args.get("offset").is_some_and(|v| !v.is_null())
1102            || args.get("limit").is_some_and(|v| !v.is_null());
1103        out.push(DetectedRead {
1104            index: i,
1105            path: PathBuf::from(path),
1106            windowed,
1107        });
1108    }
1109    out
1110}
1111
1112/// Find every `Role::Tool` message in `msgs` whose paired assistant
1113/// `tool_calls` entry names one of [`normalize::NORMALIZE_TOOLS`] — T30's
1114/// candidate rule, keyed on tool IDENTITY rather than [`ChatMessage::name`]:
1115/// a live [`crate::Agent`]'s own `history` populates `name` directly
1116/// (`Agent::run_loop` builds tool results via
1117/// `ChatMessage::tool_result(call.id, call.function.name, ...)`), but a
1118/// session reloaded from an imported Claude Code or Codex log never does —
1119/// `session.rs`'s `tool_message` helper always sets `name: None` there (the
1120/// tool identity lives only on the paired assistant `tool_calls` entry in
1121/// both wire formats). Same `tool_call_id` pairing [`detect_reads`] (A8)
1122/// uses, and for the identical reason.
1123fn detect_normalize_candidates(msgs: &[ChatMessage]) -> Vec<usize> {
1124    let mut out = Vec::new();
1125    for (i, msg) in msgs.iter().enumerate() {
1126        if msg.role != Role::Tool {
1127            continue;
1128        }
1129        let Some(call_id) = msg.tool_call_id.as_deref() else {
1130            continue;
1131        };
1132        let named = msgs[..i].iter().rev().find_map(|m| {
1133            if m.role != Role::Assistant {
1134                return None;
1135            }
1136            m.tool_calls()
1137                .iter()
1138                .find(|c| c.id == call_id)
1139                .map(|c| c.function.name.clone())
1140        });
1141        if named.is_some_and(|name| normalize::NORMALIZE_TOOLS.contains(&name.as_str())) {
1142            out.push(i);
1143        }
1144    }
1145    out
1146}
1147
1148/// One message index's freshness verdict from [`probe_read_freshness`].
1149#[derive(Debug, Clone, PartialEq, Eq)]
1150struct FreshEntry {
1151    fresh: bool,
1152    mtime: Option<i64>,
1153}
1154
1155/// The output of [`probe_read_freshness`] (A8): per-message-index freshness
1156/// verdicts, threaded into [`project_messages`] via
1157/// [`ReductionPolicy::read_freshness`]. Opaque on purpose — build it only
1158/// through `probe_read_freshness`; the empty [`Default`] means "nothing is
1159/// fresh," so a policy with `elide_stale_reads` set but no probe run against
1160/// it elides nothing (fails closed).
1161#[derive(Debug, Clone, Default, PartialEq, Eq)]
1162pub struct ReadFreshness {
1163    entries: HashMap<usize, FreshEntry>,
1164}
1165
1166/// The A8 disk-probe pre-pass, deliberately kept OUTSIDE [`project_messages`]
1167/// so the pure projection core never touches the filesystem itself (SPEC.md
1168/// A8's purity requirement). Call this with the exact same `msgs` slice about
1169/// to be projected — indices must line up — and thread the result through
1170/// [`ReductionPolicy::read_freshness`] before calling [`project_messages`] (or
1171/// [`project`]); it is only ever consulted when `policy.elide_stale_reads` is
1172/// set, so callers that never enable A8 can skip calling this entirely.
1173///
1174/// **Staleness check.** For each [`detect_reads`] hit, this re-reads the file
1175/// and compares content_hash("hash of the current bytes, lossy-UTF8-decoded")
1176/// against the hash of the tool result's *recorded* content — exactly the
1177/// transform `read_file` itself applies for a plain whole-file read
1178/// (`tools/builtins.rs:70-97`, no `offset`/`limit`, file under
1179/// `MAX_READ_BYTES`). This one comparison also naturally covers the two
1180/// harder cases without duplicating `read_file`'s own decoration/slicing
1181/// logic (which lives in a file this change does not touch):
1182/// - a sliced read (`offset`/`limit` given): the recorded content is a line
1183///   slice, never byte-identical to a raw whole-file re-read, so the hash
1184///   mismatches and the read is (correctly, conservatively) never fresh;
1185/// - a read whose original result already carried `read_file`'s own
1186///   oversize-truncation notice: same reasoning, the recorded content is not
1187///   raw file bytes, so it never matches a raw re-read.
1188///
1189/// Both are the documented SPEC.md A8 fallback ("mtime+len only, document")
1190/// taken to its simplest safe form: this implementation's fallback for
1191/// anything it cannot cheaply verify is "treat as changed" (never elide),
1192/// which only ever under-elides, never over-elides — the safe direction.
1193///
1194/// Unreadable/deleted files are likewise never fresh. `mtime` is recorded for
1195/// [`ReadLogEntry::mtime`] whenever the file's metadata is readable, even
1196/// when the freshness verdict itself is `false`.
1197pub fn probe_read_freshness(msgs: &[ChatMessage]) -> ReadFreshness {
1198    let mut entries = HashMap::new();
1199    for d in detect_reads(msgs) {
1200        let recorded = msgs[d.index].content.as_deref().unwrap_or("");
1201        let mtime = std::fs::metadata(&d.path)
1202            .ok()
1203            .and_then(|m| m.modified().ok())
1204            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1205            .map(|dur| dur.as_secs() as i64);
1206        let fresh = match std::fs::read(&d.path) {
1207            Err(_) => false, // unreadable/deleted -> never elide
1208            Ok(bytes) => {
1209                let text = String::from_utf8_lossy(&bytes);
1210                content_hash(text.as_bytes()) == content_hash(recorded.as_bytes())
1211            }
1212        };
1213        entries.insert(d.index, FreshEntry { fresh, mtime });
1214    }
1215    ReadFreshness { entries }
1216}
1217
1218/// Populate the external disk-probe input required by A8 before projecting
1219/// `msgs`. All production projection/preflight sites route through this
1220/// helper so the context guard and the eventual provider request judge the
1221/// same stale-read savings. Other policy fields are untouched.
1222pub fn prepare_read_freshness(policy: &mut ReductionPolicy, msgs: &[ChatMessage]) {
1223    if policy.elide_stale_reads {
1224        policy.read_freshness = probe_read_freshness(msgs);
1225    }
1226}
1227
1228// ---------------------------------------------------------------------------
1229// A9 — image redaction: data: URL detection
1230// ---------------------------------------------------------------------------
1231
1232/// Parse a `data:` URL's declared media type: `data:<mediatype>[;base64],<data>`.
1233/// Returns `None` for anything not starting with the `data:` scheme (e.g. an
1234/// `https://…` image link, which A9 never touches — only inline base64
1235/// payloads are a redaction candidate). An empty or missing media type falls
1236/// back to `application/octet-stream` rather than failing the parse.
1237fn parse_data_url_mime(url: &str) -> Option<String> {
1238    let rest = url.strip_prefix("data:")?;
1239    let end = rest.find([';', ',']).unwrap_or(rest.len());
1240    let mime = &rest[..end];
1241    Some(if mime.is_empty() {
1242        "application/octet-stream".to_string()
1243    } else {
1244        mime.to_string()
1245    })
1246}
1247
1248/// One `image_url` content part found in `msgs` whose `url` is a `data:` URL —
1249/// a candidate for [`ReductionKind::ImageRedacted`] once compared against
1250/// [`ReductionPolicy::image_redact_min_bytes`]. Remote (`https://…`) image
1251/// URLs and non-image parts are never candidates.
1252#[derive(Debug, Clone)]
1253struct DetectedImage {
1254    msg_index: usize,
1255    part_index: usize,
1256    mime: String,
1257    url_len: usize,
1258}
1259
1260/// Find every `data:`-URL `image_url` content part in `msgs`. Already-redacted
1261/// parts are structurally excluded for free: [`project_messages`] replaces a
1262/// redacted part's JSON with a `{"type":"text", ...}` object, which this scan
1263/// no longer recognizes as an `image_url` part on a later re-projection — the
1264/// same reason prior reductions never need a separate "already reduced" guard
1265/// here the way A7/A8 do.
1266fn detect_images(msgs: &[ChatMessage]) -> Vec<DetectedImage> {
1267    let mut out = Vec::new();
1268    for (mi, msg) in msgs.iter().enumerate() {
1269        let Some(parts) = msg.content_parts.as_ref() else {
1270            continue;
1271        };
1272        for (pi, part) in parts.iter().enumerate() {
1273            if part.get("type").and_then(|t| t.as_str()) != Some("image_url") {
1274                continue;
1275            }
1276            let Some(url) = part
1277                .get("image_url")
1278                .and_then(|iu| iu.get("url"))
1279                .and_then(|u| u.as_str())
1280            else {
1281                continue;
1282            };
1283            let Some(mime) = parse_data_url_mime(url) else {
1284                continue; // not a data: URL -- e.g. a remote https:// link.
1285            };
1286            out.push(DetectedImage {
1287                msg_index: mi,
1288                part_index: pi,
1289                mime,
1290                url_len: url.len(),
1291            });
1292        }
1293    }
1294    out
1295}
1296
1297// ---------------------------------------------------------------------------
1298// TR-10 — ToolInputElided: assistant-side tool_use argument elision
1299// ---------------------------------------------------------------------------
1300
1301/// One structurally-eligible tool-input elision CANDIDATE found in `msgs`:
1302/// an assistant `tool_calls` entry naming a tool in `fields` (TR-10's
1303/// disk-persisted, write-family-by-default table), whose designated payload
1304/// field is present as a string, and whose paired tool result (matched by
1305/// `tool_call_id`, searched FORWARD from the assistant message — the
1306/// opposite direction from [`detect_reads`], which searches backward from a
1307/// tool result to its assistant call) exists and is
1308/// [`ToolOutcome::KnownSuccess`]. A call with no paired result yet (still
1309/// pending), an error, or an unknown result never appears here at all —
1310/// TR-10's success/failure boundary with TR-6 is enforced at DETECTION time,
1311/// never by a later filter.
1312#[derive(Debug, Clone)]
1313struct DetectedToolInput {
1314    msg_index: usize,
1315    call_id: String,
1316    tool_name: String,
1317    field: String,
1318    path: Option<PathBuf>,
1319    value: String,
1320}
1321
1322/// Find every structurally-eligible [`DetectedToolInput`] in `msgs`. Pure and
1323/// side-effect free — no size/protection/prior-reduction filtering happens
1324/// here (mirrors [`detect_reads`]'s split: detection is unconditional, the
1325/// caller in [`project_messages`] applies the size threshold and the
1326/// already-reduced/protected/cleared-range guards).
1327fn detect_tool_inputs(
1328    msgs: &[ChatMessage],
1329    fields: &HashMap<String, String>,
1330) -> Vec<DetectedToolInput> {
1331    let mut out = Vec::new();
1332    for (i, msg) in msgs.iter().enumerate() {
1333        if msg.role != Role::Assistant {
1334            continue;
1335        }
1336        for call in msg.tool_calls() {
1337            let Some(field) = fields.get(&call.function.name) else {
1338                continue;
1339            };
1340            let Ok(args) = call.function.parsed_arguments() else {
1341                continue;
1342            };
1343            let Some(value) = args.get(field.as_str()).and_then(|v| v.as_str()) else {
1344                continue;
1345            };
1346            let Some(result) = msgs[i + 1..].iter().find(|m| {
1347                m.role == Role::Tool && m.tool_call_id.as_deref() == Some(call.id.as_str())
1348            }) else {
1349                continue; // Still pending: never a candidate.
1350            };
1351            if tool_outcome(result) != ToolOutcome::KnownSuccess {
1352                continue; // Error or unknown: never eligible for TR-10.
1353            }
1354            // `path` (this crate's own `write_file`) and `file_path` (Claude
1355            // Code's native `Write`) are the two real-world spellings; a
1356            // sibling argument under neither name just means the stub's
1357            // `path` field stays `None` (never a hard failure).
1358            let path = args
1359                .get("path")
1360                .or_else(|| args.get("file_path"))
1361                .and_then(|v| v.as_str())
1362                .map(PathBuf::from);
1363            out.push(DetectedToolInput {
1364                msg_index: i,
1365                call_id: call.id.clone(),
1366                tool_name: call.function.name.clone(),
1367                field: field.clone(),
1368                path,
1369                value: value.to_string(),
1370            });
1371        }
1372    }
1373    out
1374}
1375
1376/// TR-6: the FAILURE-side complement of [`detect_tool_inputs`] — find every
1377/// structurally-eligible errored-call oversized-input candidate: an
1378/// assistant `tool_calls` entry naming a tool in `fields`, whose designated
1379/// payload field is present as a string, and whose paired tool result
1380/// (matched by `tool_call_id`, searched forward, identical pairing to
1381/// `detect_tool_inputs`) exists and is [`ToolOutcome::KnownError`]. A call
1382/// with no paired result yet, a known-success result, or an unknown result
1383/// never appears here at all: unknown Codex v1 outcomes fail closed on BOTH
1384/// reduction paths rather than being guessed from free-form text.
1385fn detect_errored_tool_inputs(
1386    msgs: &[ChatMessage],
1387    fields: &HashMap<String, String>,
1388) -> Vec<DetectedToolInput> {
1389    let mut out = Vec::new();
1390    for (i, msg) in msgs.iter().enumerate() {
1391        if msg.role != Role::Assistant {
1392            continue;
1393        }
1394        for call in msg.tool_calls() {
1395            let Some(field) = fields.get(&call.function.name) else {
1396                continue;
1397            };
1398            let Ok(args) = call.function.parsed_arguments() else {
1399                continue;
1400            };
1401            let Some(value) = args.get(field.as_str()).and_then(|v| v.as_str()) else {
1402                continue;
1403            };
1404            let Some(result) = msgs[i + 1..].iter().find(|m| {
1405                m.role == Role::Tool && m.tool_call_id.as_deref() == Some(call.id.as_str())
1406            }) else {
1407                continue; // Still pending: never a candidate (either side).
1408            };
1409            if tool_outcome(result) != ToolOutcome::KnownError {
1410                continue; // Success or unknown: never eligible for TR-6.
1411            }
1412            let path = args
1413                .get("path")
1414                .or_else(|| args.get("file_path"))
1415                .and_then(|v| v.as_str())
1416                .map(PathBuf::from);
1417            out.push(DetectedToolInput {
1418                msg_index: i,
1419                call_id: call.id.clone(),
1420                tool_name: call.function.name.clone(),
1421                field: field.clone(),
1422                path,
1423                value: value.to_string(),
1424            });
1425        }
1426    }
1427    out
1428}
1429
1430/// TR-6: the number of `Role::Assistant` messages appearing strictly after
1431/// `index` in `msgs` — the "N turns elapsed" aging clock for errored-input
1432/// pruning, a message-count proxy for "turns" (this codebase has no other
1433/// structural definition of a conversational turn; A10's own
1434/// `clear_turns_older_than` is likewise a message-count threshold, not a
1435/// literal turn counter).
1436fn assistant_turns_since(msgs: &[ChatMessage], index: usize) -> usize {
1437    msgs.get(index + 1..)
1438        .map(|rest| rest.iter().filter(|m| m.role == Role::Assistant).count())
1439        .unwrap_or(0)
1440}
1441
1442/// A UTF-8-safe ASCII-whitespace skip, byte-indexed — the primitive
1443/// [`find_top_level_string_field`]/[`skip_json_value`] share.
1444fn skip_ws(b: &[u8], mut i: usize) -> usize {
1445    while i < b.len() && b[i].is_ascii_whitespace() {
1446        i += 1;
1447    }
1448    i
1449}
1450
1451/// Parse one JSON string starting at `b[i] == '"'`. Returns
1452/// `(content_start, content_end, after)`: `content_start..content_end` bounds
1453/// the RAW (still `\`-escaped) string body (excluding the surrounding
1454/// quotes), and `after` is the index just past the closing quote. Byte-wise
1455/// scanning is UTF-8-safe here: JSON's only structural bytes inside a string
1456/// (`"` = 0x22, `\` = 0x5c) are ASCII values that can never appear as part of
1457/// a multi-byte UTF-8 continuation/lead byte (those are always >= 0x80), and
1458/// skipping exactly one byte after a `\` is always safe — every JSON escape
1459/// (`\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX`) has an
1460/// unambiguous, never-`"`-or-`\` byte immediately after the backslash.
1461fn parse_json_string(b: &[u8], i: usize) -> Option<(usize, usize, usize)> {
1462    if i >= b.len() || b[i] != b'"' {
1463        return None;
1464    }
1465    let content_start = i + 1;
1466    let mut j = content_start;
1467    while j < b.len() {
1468        match b[j] {
1469            b'\\' => j += 2,
1470            b'"' => return Some((content_start, j, j + 1)),
1471            _ => j += 1,
1472        }
1473    }
1474    None // Unterminated string.
1475}
1476
1477/// Skip over one arbitrary JSON value (string/object/array/number/bool/null)
1478/// starting at (possibly whitespace before) `b[i]`. Returns the index just
1479/// past it. Used by [`find_top_level_string_field`] to jump over sibling
1480/// fields it isn't looking for, however they're shaped, without needing to
1481/// interpret them.
1482fn skip_json_value(b: &[u8], i: usize) -> Option<usize> {
1483    let i = skip_ws(b, i);
1484    if i >= b.len() {
1485        return None;
1486    }
1487    match b[i] {
1488        b'"' => parse_json_string(b, i).map(|(_, _, end)| end),
1489        b'{' | b'[' => {
1490            let open = b[i];
1491            let close = if open == b'{' { b'}' } else { b']' };
1492            let mut depth = 0usize;
1493            let mut j = i;
1494            loop {
1495                if j >= b.len() {
1496                    return None;
1497                }
1498                match b[j] {
1499                    b'"' => {
1500                        let (_, _, end) = parse_json_string(b, j)?;
1501                        j = end;
1502                    }
1503                    c if c == open => {
1504                        depth += 1;
1505                        j += 1;
1506                    }
1507                    c if c == close => {
1508                        depth -= 1;
1509                        j += 1;
1510                        if depth == 0 {
1511                            return Some(j);
1512                        }
1513                    }
1514                    _ => j += 1,
1515                }
1516            }
1517        }
1518        _ => {
1519            // number / true / false / null: scan to the next structural byte.
1520            let mut j = i;
1521            while j < b.len() && !matches!(b[j], b',' | b'}' | b']') && !b[j].is_ascii_whitespace()
1522            {
1523                j += 1;
1524            }
1525            Some(j)
1526        }
1527    }
1528}
1529
1530/// Locate the byte span of the JSON STRING VALUE for top-level key `field`
1531/// within `json` — a serialized tool-call `arguments` string, assumed (like
1532/// every built-in write-family tool's flat schema) to be a JSON object.
1533/// Returns `(value_start, value_end)`: `json[value_start..value_end]` is the
1534/// RAW (still-escaped) string body, excluding the surrounding quotes — so a
1535/// caller can replace ONLY that span, leaving every other byte (key order,
1536/// whitespace, sibling fields, however shaped) untouched. Returns `None` when
1537/// `field` is absent, its value isn't a JSON string, or `json` isn't a
1538/// well-formed object — always a safe "don't touch it" signal, never a
1539/// guess.
1540fn find_top_level_string_field(json: &str, field: &str) -> Option<(usize, usize)> {
1541    let b = json.as_bytes();
1542    let mut i = skip_ws(b, 0);
1543    if i >= b.len() || b[i] != b'{' {
1544        return None;
1545    }
1546    i += 1;
1547    loop {
1548        i = skip_ws(b, i);
1549        if i >= b.len() {
1550            return None;
1551        }
1552        if b[i] == b'}' {
1553            return None; // Field not found.
1554        }
1555        let (key_start, key_end, after_key) = parse_json_string(b, i)?;
1556        let key = &json[key_start..key_end];
1557        i = skip_ws(b, after_key);
1558        if i >= b.len() || b[i] != b':' {
1559            return None;
1560        }
1561        i = skip_ws(b, i + 1);
1562        if i >= b.len() {
1563            return None;
1564        }
1565        if key == field {
1566            return if b[i] == b'"' {
1567                let (val_start, val_end, _) = parse_json_string(b, i)?;
1568                Some((val_start, val_end))
1569            } else {
1570                None // The field exists but isn't a string value.
1571            };
1572        }
1573        i = skip_json_value(b, i)?;
1574        i = skip_ws(b, i);
1575        match b.get(i) {
1576            Some(b',') => {
1577                i += 1;
1578                continue;
1579            }
1580            Some(b'}') => return None, // Reached the end without a match.
1581            _ => return None,          // Malformed / unexpected trailing bytes.
1582        }
1583    }
1584}
1585/// JSON-escape `s` for embedding as a string VALUE (no surrounding quotes) —
1586/// the content half of what `serde_json::to_string` would produce for it.
1587fn json_escape_content(s: &str) -> String {
1588    let quoted = serde_json::to_string(s).unwrap_or_default();
1589    let len = quoted.len();
1590    if len >= 2 {
1591        quoted[1..len - 1].to_string()
1592    } else {
1593        String::new()
1594    }
1595}
1596
1597/// Byte-surgical replacement of ONE top-level string field's value inside a
1598/// serialized JSON object: every other byte (key order, whitespace, sibling
1599/// fields) is preserved character-for-character (SPEC.md TR-10: "replace
1600/// only the payload field's value" — never a full reparse+reserialize, which
1601/// would reformat/reorder the rest of the arguments). Returns `None` (never
1602/// touching `json`) when `field` isn't present as a top-level string-valued
1603/// key.
1604fn replace_top_level_string_field(json: &str, field: &str, new_value: &str) -> Option<String> {
1605    let (start, end) = find_top_level_string_field(json, field)?;
1606    let mut out = String::with_capacity(json.len() + new_value.len());
1607    out.push_str(&json[..start]);
1608    out.push_str(&json_escape_content(new_value));
1609    out.push_str(&json[end..]);
1610    Some(out)
1611}
1612
1613/// Resolve and hash-verify the ORIGINAL value of a
1614/// [`ReductionKind::ToolInputElided`] reduction's payload field: locate the
1615/// addressed message, the tool_call within it named by `call_id`, extract
1616/// `field`'s value, and verify it against `ptr.content_hash` before ever
1617/// handing it back. Takes a message slice rather than a [`Session`] for the
1618/// same reason [`resolve_original_content`] does — both `invert`'s
1619/// sidecar-backed callers and [`rehydrate`]'s `minted_view`-backed caller
1620/// share this one resolver.
1621fn resolve_tool_input_value(
1622    ptr: &SidecarPtr,
1623    call_id: &str,
1624    field: &str,
1625    messages: &[ChatMessage],
1626) -> Result<String> {
1627    let msg = messages.get(ptr.addr.index).ok_or_else(|| {
1628        Error::Other(format!(
1629            "invert: sidecar has no message at index {} (reduction pointer unresolvable)",
1630            ptr.addr.index
1631        ))
1632    })?;
1633    if msg.role != ptr.addr.role {
1634        return Err(Error::Other(format!(
1635            "invert: role mismatch at sidecar index {}: pointer expects {:?}, sidecar has {:?}",
1636            ptr.addr.index, ptr.addr.role, msg.role
1637        )));
1638    }
1639    let call = msg
1640        .tool_calls()
1641        .iter()
1642        .find(|c| c.id == call_id)
1643        .ok_or_else(|| {
1644            Error::Other(format!(
1645                "invert: sidecar message at index {} has no tool_call with id {call_id}",
1646                ptr.addr.index
1647            ))
1648        })?;
1649    let parsed = call.function.parsed_arguments().map_err(|e| {
1650        Error::Other(format!(
1651            "invert: tool_call {call_id} arguments are not valid JSON: {e}"
1652        ))
1653    })?;
1654    let value = parsed
1655        .get(field)
1656        .and_then(|v| v.as_str())
1657        .ok_or_else(|| {
1658            Error::Other(format!(
1659                "invert: tool_call {call_id} has no string field `{field}`"
1660            ))
1661        })?
1662        .to_string();
1663    ptr.verify(value.as_bytes())?;
1664    Ok(value)
1665}
1666
1667/// The decision a future escalation orchestrator (SPEC.md D13/wave-5
1668/// `escalate()` — not yet implemented in this codebase) should take for one
1669/// existing [`ReductionKind::ToolInputElided`] stub, per TR-10's freshness
1670/// matrix (reused from A8's `probe_read_freshness` pattern): does disk still
1671/// match what was written?
1672#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1673pub enum EscalationAction {
1674    /// The stub is still faithful to disk — leave it as a stub (D13's
1675    /// "smallest faithful context", re-derivable at ~0 extra tokens).
1676    KeepStub,
1677    /// The stub is no longer faithful (disk has changed since, or is gone) —
1678    /// only the sidecar's recorded original is still faithful; rehydrate via
1679    /// [`invert_one`].
1680    RehydrateFromSidecar,
1681}
1682
1683/// TR-10's freshness-matrix decision, given whether [`probe_tool_input_fresh`]
1684/// found the file still matching: `fresh` -> [`EscalationAction::KeepStub`],
1685/// `!fresh` -> [`EscalationAction::RehydrateFromSidecar`]. Split from the
1686/// probe itself (which does the actual disk I/O) so this half stays a pure,
1687/// trivially-testable function — the same purity discipline A8's
1688/// `probe_read_freshness`/`project_messages` split follows.
1689pub fn tool_input_escalation_action(fresh: bool) -> EscalationAction {
1690    if fresh {
1691        EscalationAction::KeepStub
1692    } else {
1693        EscalationAction::RehydrateFromSidecar
1694    }
1695}
1696
1697/// The A8-style disk probe behind [`tool_input_escalation_action`]: does the
1698/// file at `path` currently on disk still hash to `content_hash_hex`? Unlike
1699/// [`probe_read_freshness`] (which lossy-UTF8-decodes before hashing, to
1700/// mirror `read_file`'s own transform), this hashes the RAW bytes directly —
1701/// `write_file` writes `content.as_bytes()` with no transform, so the exact
1702/// bytes on disk are the fairer comparison. Unreadable/deleted files are
1703/// never fresh (fails closed, the same direction A8 fails in). A free
1704/// function (no `Reduction`/`ReductionLog` coupling) so it composes with
1705/// whatever wave-5 `escalate()` orchestration eventually calls it.
1706pub fn probe_tool_input_fresh(path: &std::path::Path, content_hash_hex: &str) -> bool {
1707    match std::fs::read(path) {
1708        Err(_) => false,
1709        Ok(bytes) => content_hash(&bytes) == content_hash_hex,
1710    }
1711}
1712
1713/// The largest `end <= target` such that `s.is_char_boundary(end)` — a
1714/// UTF-8-safe truncation point. Mirrors the boundary walk in
1715/// `agent.rs::cap_tool_output`.
1716fn char_boundary_floor(s: &str, target: usize) -> usize {
1717    let mut end = target.min(s.len());
1718    while end > 0 && !s.is_char_boundary(end) {
1719        end -= 1;
1720    }
1721    end
1722}
1723
1724/// Render `n` with `,` thousands separators (e.g. `183204` -> `"183,204"`),
1725/// matching the stub-line style in SPEC.md A7's example. `pub(crate)` so
1726/// `tokens.rs` (C9) can reuse it for `~`-prefixed UX figures instead of
1727/// duplicating the digit-grouping logic.
1728pub(crate) fn format_commas(n: usize) -> String {
1729    let digits = n.to_string();
1730    let bytes = digits.as_bytes();
1731    let mut out = String::with_capacity(bytes.len() + bytes.len() / 3);
1732    for (i, b) in bytes.iter().enumerate() {
1733        if i > 0 && (bytes.len() - i) % 3 == 0 {
1734            out.push(',');
1735        }
1736        out.push(*b as char);
1737    }
1738    out
1739}
1740
1741/// Make an untrusted string fragment safe for interpolation into a stub
1742/// summary: [`stub::format`]'s grammar contract is "one line, no `]`", and a
1743/// tool name arrives verbatim from imported JSONL — attacker-shaped input. A
1744/// name containing `]` or a newline would trip `stub::format`'s
1745/// `debug_assert` (a panic in debug builds) and, in release builds, mint a
1746/// grammar-breaking stub that [`stub::parse`] rejects. Every `]` and every
1747/// control character is replaced with `_` — visible, honest damage instead
1748/// of a broken line.
1749fn sanitize_summary_fragment(s: &str) -> String {
1750    s.chars()
1751        .map(|c| if c == ']' || c.is_control() { '_' } else { c })
1752        .collect()
1753}
1754
1755/// Rebuild the exact reduced content for a [`ReductionKind::ToolOutputTruncated`]
1756/// reduction, given the *original* (unreduced) content at its target address:
1757/// the kept prefix, `"\n\n"`, then the recorded placeholder line — byte-for-byte
1758/// the same construction `project` used the first time it created `r`.
1759fn rebuild_truncated_content(original: &str, r: &Reduction) -> String {
1760    let kept = r.ptr.span.map(|(kept, _total)| kept).unwrap_or(0);
1761    let kept = kept.min(original.len());
1762    let mut s = original[..kept].to_string();
1763    s.push_str("\n\n");
1764    s.push_str(&r.placeholder);
1765    s
1766}
1767
1768/// Rebuild the exact reduced content for a [`ReductionKind::OutputNormalized`]
1769/// reduction, given the *original* (raw, unreduced) content at its target
1770/// address: [`normalize::normalize`] is a pure deterministic function, so
1771/// re-running it against the same original bytes reproduces byte-identical
1772/// normalized text every time; `"\n\n"` then the recorded placeholder
1773/// (carrying the honesty trailer) is appended exactly as `project_messages`
1774/// did the first time it created `r` — the same "recompute, don't store the
1775/// derived text" strategy [`rebuild_truncated_content`] uses for A7.
1776fn rebuild_normalized_content(original: &str, r: &Reduction) -> String {
1777    let mut s = normalize::normalize(original);
1778    s.push_str("\n\n");
1779    s.push_str(&r.placeholder);
1780    s
1781}
1782
1783/// Build the exact reduced content for a [`ReductionKind::FileReadDiffed`]
1784/// reduction: the stored placeholder line, a newline, then a freshly
1785/// recomputed unified diff of `base_text` (the base read's full content)
1786/// against `new_text` (this read's full content). Recomputing the diff
1787/// (rather than storing it) keeps `Reduction` itself small and — since
1788/// `diffy::create_patch` is a pure function of its two inputs — reproduces
1789/// byte-identically every time, the same prefix-stability guarantee
1790/// [`rebuild_truncated_content`] gives A7.
1791fn rebuild_diffed_content(base_text: &str, new_text: &str, r: &Reduction) -> String {
1792    let diff = diffy::create_patch(base_text, new_text);
1793    let mut s = r.placeholder.clone();
1794    s.push('\n');
1795    s.push_str(&diff.to_string());
1796    s
1797}
1798
1799/// Reapply one already-applied reduction from `prior` onto `view` in place,
1800/// exactly reproducing its placeholder — this is the prefix-stability
1801/// guarantee: an older reduction never churns between projections. `msgs` is
1802/// the pristine, never-mutated canonical slice `project_messages` was called
1803/// with — [`ReductionKind::FileReadDiffed`] resolves both its base and new
1804/// text from it (never from `view`), so a base that itself carries some
1805/// OTHER reduction in `view` (e.g. it was `FileReadElided` before later being
1806/// superseded as a diff base) never corrupts the recomputed diff.
1807///
1808/// [`ReductionKind::ToolOutputTruncated`], [`ReductionKind::FileReadElided`],
1809/// [`ReductionKind::ImageRedacted`], [`ReductionKind::OutputNormalized`],
1810/// [`ReductionKind::FileReadDiffed`], [`ReductionKind::DuplicateOutput`], and
1811/// [`ReductionKind::Superseded`] are all cardinality-preserving (content
1812/// mutated in place, `view`'s length and msgs-index alignment are untouched)
1813/// — `FileReadElided` simply
1814/// replaces the whole content with the stored placeholder verbatim
1815/// (whole-content elision, `ptr.span = None`), regardless of the file's
1816/// CURRENT on-disk state: prior-log stability means an already-elided read
1817/// stays elided even after the file changes again — a changed file only ever
1818/// blocks *new* elisions (SPEC.md A8), it never un-elides an existing one.
1819/// `ImageRedacted` likewise replaces the addressed content part with a
1820/// `{"type":"text", ...}` object carrying the stored placeholder verbatim,
1821/// regardless of the current part at that index. `OutputNormalized`
1822/// recomputes the normalized text from the *original* content at that index
1823/// via [`rebuild_normalized_content`] (pure/deterministic, so it reproduces
1824/// byte-identically). `DuplicateOutput` (TR-2) replaces the whole content
1825/// with the stored placeholder verbatim too (`ptr.span = None`, exactly like
1826/// `FileReadElided`) — irrespective of whether its `canonical` address is
1827/// still a plain message, itself now reduced, or has since been swallowed by
1828/// a `TurnsCleared` range: the duplicate's own placeholder never depends on
1829/// the canonical's current shape. [`ReductionKind::TurnsCleared`] (A10) is
1830/// NOT cardinality-preserving: it collapses a whole range `[first..=last]`
1831/// down to the single stored placeholder message via `Vec::splice`, so it
1832/// must be the LAST reapplication performed on `view` in any given
1833/// `project_messages` call (everything else addresses `view` by msgs-index,
1834/// which this invalidates for every index past `first`).
1835fn reapply_reduction(view: &mut Vec<ChatMessage>, r: &Reduction, msgs: &[ChatMessage]) {
1836    match &r.kind {
1837        ReductionKind::ToolOutputTruncated { .. } => {
1838            let idx = r.ptr.addr.index;
1839            let Some(msg) = view.get_mut(idx) else {
1840                return; // Addressed message no longer present; nothing to reapply.
1841            };
1842            if let Some(original) = msg.content.clone() {
1843                msg.content = Some(rebuild_truncated_content(&original, r));
1844            }
1845            set_reduction_id(msg, &r.id);
1846        }
1847        ReductionKind::OutputNormalized { .. } => {
1848            let idx = r.ptr.addr.index;
1849            let Some(msg) = view.get_mut(idx) else {
1850                return; // Addressed message no longer present; nothing to reapply.
1851            };
1852            if let Some(original) = msg.content.clone() {
1853                msg.content = Some(rebuild_normalized_content(&original, r));
1854            }
1855            set_reduction_id(msg, &r.id);
1856        }
1857        ReductionKind::FileReadElided { .. } => {
1858            let idx = r.ptr.addr.index;
1859            let Some(msg) = view.get_mut(idx) else {
1860                return; // Addressed message no longer present; nothing to reapply.
1861            };
1862            msg.content = Some(r.placeholder.clone());
1863            set_reduction_id(msg, &r.id);
1864        }
1865        ReductionKind::FileReadDiffed { base, .. } => {
1866            let idx = r.ptr.addr.index;
1867            if view.get(idx).is_none() {
1868                return; // Addressed message no longer present; nothing to reapply.
1869            }
1870            let (Some(base_text), Some(new_text)) = (
1871                msgs.get(base.index).and_then(|m| m.content.as_deref()),
1872                msgs.get(idx).and_then(|m| m.content.as_deref()),
1873            ) else {
1874                return; // Base or new content no longer resolvable against `msgs`.
1875            };
1876            let content = rebuild_diffed_content(base_text, new_text, r);
1877            let msg = &mut view[idx];
1878            msg.content = Some(content);
1879            set_reduction_id(msg, &r.id);
1880        }
1881        ReductionKind::TurnsCleared { first, last, .. } => {
1882            if *first > *last || *last >= view.len() {
1883                return; // Range no longer resolvable against this `view`; nothing to reapply.
1884            }
1885            let mut placeholder = ChatMessage::system(r.placeholder.clone());
1886            set_reduction_id(&mut placeholder, &r.id);
1887            view.splice(*first..=*last, std::iter::once(placeholder));
1888        }
1889        ReductionKind::ImageRedacted { part_index } => {
1890            let idx = r.ptr.addr.index;
1891            let Some(msg) = view.get_mut(idx) else {
1892                return; // Addressed message no longer present; nothing to reapply.
1893            };
1894            if let Some(parts) = msg.content_parts.as_mut() {
1895                if let Some(part) = parts.get_mut(*part_index) {
1896                    *part = serde_json::json!({"type": "text", "text": r.placeholder});
1897                }
1898            }
1899            set_reduction_id(msg, &r.id);
1900        }
1901        ReductionKind::ToolInputElided { call_id, field, .. } => {
1902            let idx = r.ptr.addr.index;
1903            // Compute the spliced arguments against the pristine `view[idx]`
1904            // (freshly derived from `msgs` at the top of `project_messages`,
1905            // so this always re-derives from the ORIGINAL arguments) before
1906            // taking a mutable borrow, mirroring the immutable-then-mutable
1907            // two-step `ImageRedacted` above uses.
1908            let Some(spliced) = view
1909                .get(idx)
1910                .and_then(|m| m.tool_calls.as_ref())
1911                .and_then(|calls| calls.iter().find(|c| &c.id == call_id))
1912                .and_then(|call| {
1913                    replace_top_level_string_field(&call.function.arguments, field, &r.placeholder)
1914                })
1915            else {
1916                return; // Addressed call no longer present, or reshaped; nothing to reapply.
1917            };
1918            let Some(msg) = view.get_mut(idx) else {
1919                return;
1920            };
1921            if let Some(calls) = msg.tool_calls.as_mut() {
1922                if let Some(call) = calls.iter_mut().find(|c| &c.id == call_id) {
1923                    call.function.arguments = spliced;
1924                }
1925            }
1926            set_reduction_id(msg, &r.id);
1927        }
1928        ReductionKind::DuplicateOutput { .. } => {
1929            let idx = r.ptr.addr.index;
1930            let Some(msg) = view.get_mut(idx) else {
1931                return; // Addressed message no longer present; nothing to reapply.
1932            };
1933            msg.content = Some(r.placeholder.clone());
1934            set_reduction_id(msg, &r.id);
1935        }
1936        ReductionKind::Superseded { .. } => {
1937            let idx = r.ptr.addr.index;
1938            let Some(msg) = view.get_mut(idx) else {
1939                return; // Addressed message no longer present; nothing to reapply.
1940            };
1941            msg.content = Some(r.placeholder.clone());
1942            set_reduction_id(msg, &r.id);
1943        }
1944    }
1945}
1946
1947/// Count how many of `msgs` have each of the three conversational roles
1948/// (user/assistant/tool) — used to build the A10 `turns-cleared` stub's
1949/// `(N messages: A user, B assistant, C tool)` summary clause.
1950fn count_roles(msgs: &[ChatMessage]) -> (usize, usize, usize) {
1951    let mut user = 0;
1952    let mut assistant = 0;
1953    let mut tool = 0;
1954    for m in msgs {
1955        match m.role {
1956            Role::User => user += 1,
1957            Role::Assistant => assistant += 1,
1958            Role::Tool => tool += 1,
1959            Role::System => {}
1960        }
1961    }
1962    (user, assistant, tool)
1963}
1964
1965/// The blake3 hash [`resolve_turns_range`] verifies for a [`ReductionKind::TurnsCleared`]
1966/// pointer — blake3 over each message's wire-serialized (`serde_json`) bytes
1967/// in `msgs`, concatenated in order — plus that concatenation's total byte
1968/// length (the creating side puts it in the stub summary so a reader can
1969/// judge the cleared range's size; the resolving side ignores it). Shared by
1970/// the creating side (here) and the resolving side (`resolve_turns_range`)
1971/// so the two hash formulas can never drift apart.
1972fn hash_turns_range(msgs: &[ChatMessage]) -> Result<(String, usize)> {
1973    let mut combined = Vec::new();
1974    for m in msgs {
1975        let bytes = serde_json::to_vec(m)
1976            .map_err(|e| Error::Other(format!("failed to serialize message: {e}")))?;
1977        combined.extend_from_slice(&bytes);
1978    }
1979    Ok((content_hash(&combined), combined.len()))
1980}
1981
1982/// Pure function of `(msgs, policy, prior)`: the reduction engine's actual
1983/// body (SPEC.md A5, A7, A8, A9, A10, TR-2). [`project`] is a thin delegate
1984/// over `session.messages`; [`crate::agent::Agent::run_loop`] (A7) calls this
1985/// directly against `history[1..]` so a live agent can build the projected
1986/// request view without needing a `Session` wrapper around its own history.
1987///
1988/// Deterministic and side-effect free: identical inputs produce byte-identical
1989/// output; `msgs` is never mutated; nothing here touches the filesystem —
1990/// including for A8: `policy.read_freshness` is precomputed data, populated by
1991/// [`probe_read_freshness`] (the one place disk I/O happens) before this is
1992/// ever called. Every reduction already recorded in `prior` reproduces
1993/// verbatim (same id, same placeholder, byte-identical stub) — new reductions
1994/// only ever target messages older than the protected tail, so the reduced
1995/// prefix stays cache-stable across turns.
1996///
1997/// Pass order: TR-2 ([`ReductionKind::DuplicateOutput`], content-hash dedup)
1998/// runs FIRST, then TR-6 ([`ReductionKind::Superseded`], same-tool/
1999/// canonicalized-args keep-latest), then T30/TR-4
2000/// ([`ReductionKind::OutputNormalized`], ANSI/redraw collapse), then
2001/// [`ReductionKind::ToolOutputTruncated`] (A7), then the read-family passes —
2002/// [`ReductionKind::FileReadElided`] (A8) for an unchanged re-read,
2003/// [`ReductionKind::FileReadDiffed`] (TR-3) for a changed one — then
2004/// [`ReductionKind::ImageRedacted`] (A9), then TR-10
2005/// ([`ReductionKind::ToolInputElided`], both the successful-call case and
2006/// TR-6's failed-call errored-input-pruning complement), then
2007/// [`ReductionKind::TurnsCleared`] (A10) last (the one cardinality-changing
2008/// pass). Each of TR-2, TR-6, T30/TR-4, and A7 claims a message's index in
2009/// `reduced_this_run` the moment it mints a reduction for it, and every pass
2010/// after the first checks that set — so a single message is claimed by
2011/// exactly one pass per `project_messages` call, never two.
2012///
2013/// TR-2 running before TR-6, T30/TR-4, and A7 means a byte-identical
2014/// duplicate is deduped — the cheapest of the four reductions — rather than
2015/// independently superseded, normalized, or truncated. TR-6 running before
2016/// T30/TR-4 and A7 follows the identical reasoning one step further: a
2017/// result about to be superseded down to one small stub never needs
2018/// normalizing or truncating first either. See the TR-2 and TR-6 passes
2019/// below for why this ordering must hold within a single call, not just
2020/// "eventually" (in short: both verdicts are pure functions of `msgs`, so
2021/// they are unaffected by running before or after T30/TR-4, but T30/TR-4 and
2022/// A7 both read from `view`, which TR-2/TR-6 may have already stubbed — so
2023/// TR-2 then TR-6 must go first, or their own claims could lose a race to a
2024/// pass that mutates `view` ahead of them).
2025/// T30/TR-4 running before A7 means a noisy bash/exec output is collapsed to
2026/// its final rendered content BEFORE A7 ever measures it against
2027/// `tool_output_trigger_bytes` — but T30/TR-4 only CLAIMS the message (taking
2028/// it out of A7's candidate pool) when its own normalized rendering already
2029/// fits under `tool_output_trigger_bytes`; that rendering is what rides the
2030/// wire, already bounded, so no truncation is needed on top of it. When the
2031/// normalized rendering is STILL over the trigger — genuinely large, mostly
2032/// distinct content, not just redraw noise — T30/TR-4 deliberately does not
2033/// claim the message at all (raw, untouched) and lets it fall through to A7
2034/// below, which truncates the RAW bytes to `tool_output_keep_bytes`. Either
2035/// way the wire payload for a terminal output is bounded by A7's trigger —
2036/// the P7 runaway-output safety net (SPEC.md/TR-12) is preserved for BOTH
2037/// small-after-normalization and large-after-normalization outputs. TR-2 and
2038/// TR-6 both never claim a read-type tool result (`detect_reads`), even a
2039/// byte-identical or same-args re-read: the read-family passes own that
2040/// address space exclusively, with strictly more information (path-aware
2041/// freshness, a unified diff) than either TR-2's flat "identical to msg #N"
2042/// or TR-6's flat "superseded by msg #N" stub could express; T30/TR-4 is
2043/// likewise scoped to [`normalize::NORMALIZE_TOOLS`] tool identities,
2044/// disjoint from `READ_TOOLS`, so it never contends with the read-family
2045/// passes over the same index either.
2046pub fn project_messages(
2047    msgs: &[ChatMessage],
2048    policy: &ReductionPolicy,
2049    prior: &ReductionLog,
2050) -> (Vec<ChatMessage>, ReductionLog) {
2051    let mut view: Vec<ChatMessage> = msgs.to_vec();
2052    let mut log = prior.clone();
2053
2054    // Reproduce every already-applied ToolOutputTruncated (etc.) reduction
2055    // verbatim first: cardinality-preserving, so `view` stays index-parallel
2056    // to `msgs` while this runs. TurnsCleared (cardinality-changing) is
2057    // handled last, below, once every msgs-indexed operation is done.
2058    for r in &prior.reductions {
2059        if !matches!(r.kind, ReductionKind::TurnsCleared { .. }) {
2060            reapply_reduction(&mut view, r, msgs);
2061        }
2062    }
2063
2064    // A10: old-turn clearing is a one-time context edit for the AUTO-
2065    // COMPACTOR (`Agent::maybe_compact`'s `clear_turns_older_than`), not a
2066    // repeating compaction — once ANY `TurnsCleared` record exists, the
2067    // auto-compactor never mints a new one (below). But a session can carry
2068    // MORE than one `TurnsCleared` record: TR-9 (T24) `handoff` establishes a
2069    // whole set of disjoint spanning clears in one shot (a handoff keep-set
2070    // is generally scattered — system prompt + some named early turns + last
2071    // K — so the non-kept middle forms several contiguous gaps, one
2072    // `TurnsCleared` per gap). So this collects EVERY existing record rather
2073    // than just the first found; each one, once established, is reapplied
2074    // verbatim forever (never widened, never recomputed), so no placeholder
2075    // ever churns between turns.
2076    let mut existing_clears: Vec<(usize, usize)> = prior
2077        .reductions
2078        .iter()
2079        .chain(prior.expanded.iter())
2080        .filter_map(|r| match r.kind {
2081            ReductionKind::TurnsCleared { first, last, .. } => Some((first, last)),
2082            _ => None,
2083        })
2084        .collect();
2085    existing_clears.sort_by_key(|&(f, _)| f);
2086    let in_existing_clear = |i: usize| existing_clears.iter().any(|&(f, l)| i >= f && i <= l);
2087
2088    let already_reduced: HashSet<usize> = prior
2089        .reductions
2090        .iter()
2091        .chain(prior.expanded.iter())
2092        .filter(|r| !matches!(r.kind, ReductionKind::TurnsCleared { .. }))
2093        .map(|r| r.ptr.addr.index)
2094        .collect();
2095
2096    // Protect the newest `protect_last_n_tool_results` tool results from ever
2097    // becoming a *new* candidate (prior reductions on now-recent messages are
2098    // left as-is above — stability wins over re-protecting them).
2099    let protected: HashSet<usize> = view
2100        .iter()
2101        .enumerate()
2102        .filter(|(_, m)| m.role == Role::Tool)
2103        .map(|(i, _)| i)
2104        .rev()
2105        .take(policy.protect_last_n_tool_results)
2106        .collect();
2107
2108    // Indices reduced (of any kind) during THIS call — as opposed to
2109    // `already_reduced` (reduced in a PRIOR call). A message can only ever
2110    // carry one reduction kind at a time, so every pass below must skip
2111    // anything an EARLIER pass this run just claimed, in addition to
2112    // everything `already_reduced`/`protected`/`in_existing_clear` already
2113    // exclude. Declared before TR-2 (the first pass to populate it) rather
2114    // than before A7, so A7's own candidate filter can already respect it.
2115    // `ordinal` is likewise shared by every pass below and only ever consumed
2116    // on an actual mint (a `continue`d candidate never advances it), so ids
2117    // stay dense and deterministic regardless of which passes end up firing.
2118    let mut reduced_this_run: HashSet<usize> = HashSet::new();
2119
2120    // Logs can be sparse after an older client expanded a record by simply
2121    // removing it. Counting records can therefore reuse a still-live
2122    // ordinal (and, with the same hash prefix, the exact same id). Always
2123    // advance past the greatest parseable persisted ordinal across both
2124    // active and explicitly-expanded records.
2125    let mut ordinal = next_reduction_ordinal(
2126        prior
2127            .reductions
2128            .iter()
2129            .chain(prior.expanded.iter())
2130            .map(|r| r.id.as_str()),
2131    );
2132
2133    // ---- TR-2: DuplicateOutput -- content-hash dedup of identical tool
2134    // outputs ----
2135    //
2136    // Runs BEFORE both T30/TR-4 (OutputNormalized, immediately below) and A7:
2137    // deduping a later duplicate is strictly cheaper than either
2138    // independently normalizing or truncating it, and — more importantly —
2139    // it must claim a duplicate's index in `reduced_this_run` before either
2140    // of those passes' own candidate scans run, or they would claim it first
2141    // and that claim would stick forever (prefix stability: `already_reduced`
2142    // never lets a later run downgrade an existing reduction to a cheaper
2143    // kind). Hash comparisons are taken from `msgs` (the ORIGINAL,
2144    // never-mutated slice) rather than `view`, exactly like A10's
2145    // `hash_turns_range` below — so the canonical bytes compared are always
2146    // the true original content, an already-stubbed earlier occurrence's
2147    // placeholder text is never what gets hashed. This also makes TR-2's own
2148    // verdict completely insensitive to relative pass order with
2149    // OutputNormalized: a terminal output that is BOTH a normalize candidate
2150    // (raw ANSI/CR noise) AND byte-identical to an earlier tool result is
2151    // claimed HERE by TR-2 — a `DuplicateOutput` stub is typically far
2152    // smaller than even a normalized rendering — and OutputNormalized's own
2153    // candidate filter (below) skips it via `reduced_this_run`. Neither pass
2154    // ever touches the stored sidecar bytes (both keep the RAW capture
2155    // there, A3), so this precedence is purely about which single stub wins
2156    // the VIEW, never about invert-to-raw correctness for either kind.
2157    //
2158    // The first (chronologically earliest) still-addressable occurrence of a
2159    // content hash is the canonical; every later occurrence with the same
2160    // hash becomes a `DuplicateOutput` candidate, provided it isn't already
2161    // reduced, protected, or about to be swallowed by an existing
2162    // `TurnsCleared` range. `SidecarPtr::addr` on the minted reduction is the
2163    // DUPLICATE's own address (not the canonical's) — same self-addressing
2164    // convention as every other kind — so `invert`/`expand_reduction` always
2165    // resolve it independent of whatever later happens to the canonical's
2166    // own slot (dev/04: the canonical may itself be truncated or cleared in
2167    // a later run without ever affecting this pointer).
2168    //
2169    // ONLY same-path re-reads (`detect_reads` hits whose path already
2170    // appeared in an EARLIER detected read this slice) are excluded from
2171    // this pass entirely — canonical registration AND duplicate candidacy —
2172    // and left for the A8/TR-3 pass below (post-merge reconciliation: TR-2
2173    // landed generalizing A8's OWN prior dedup special-case to "any tool
2174    // output", but a re-read of a file already carries a strictly richer,
2175    // path-aware redundancy mechanism there — freshness-gated elision for an
2176    // unchanged re-read, a unified diff for a changed one — that TR-2's flat
2177    // "identical to msg #N" stub would otherwise pre-empt whenever a re-read
2178    // happens to be byte-identical to its own prior read, silently losing
2179    // the elision-vs-diff distinction SPEC.md TR-3 dev/05 requires).
2180    //
2181    // Deliberately narrower than "every detected read": the FIRST read of a
2182    // given path has no earlier same-path read for the A8/TR-3 pass to
2183    // diff/elide against, so it is not that pass's address space at all —
2184    // TR-2 must stay free to dedup it against a byte-identical output
2185    // anywhere else in the slice (a different path's read, or any other
2186    // tool result), exactly as it would for any other tool. Excluding every
2187    // read unconditionally (the pre-fix behavior) silently disabled TR-2 for
2188    // two content-identical reads of DIFFERENT paths, which A8/TR-3's
2189    // path-keyed matching never claims and never will.
2190    let read_indices: HashSet<usize> = {
2191        let detected = detect_reads(msgs);
2192        let mut seen_paths: HashSet<&std::path::Path> = HashSet::new();
2193        detected
2194            .iter()
2195            .filter(|d| !seen_paths.insert(d.path.as_path()))
2196            .map(|d| d.index)
2197            .collect()
2198    };
2199    let mut first_seen: HashMap<String, usize> = HashMap::new();
2200    for (i, m) in msgs.iter().enumerate() {
2201        if !policy.deduplicate_outputs || m.role != Role::Tool || read_indices.contains(&i) {
2202            continue;
2203        }
2204        let Some(content) = m.content.as_ref() else {
2205            continue;
2206        };
2207        if content.len() < policy.duplicate_output_min_bytes {
2208            // Below the savings floor on EITHER side of an identical pair
2209            // (same hash implies same length): never a dedup candidate,
2210            // canonical or duplicate.
2211            continue;
2212        }
2213        let hash = content_hash(content.as_bytes());
2214        let Some(&canonical_idx) = first_seen.get(&hash) else {
2215            // First occurrence of this hash: a candidate canonical, unless it
2216            // is not genuinely "still visible in the view" as ITS OWN full
2217            // content right now — either already reduced (of any kind, from
2218            // a PRIOR run: its slot shows a stub, not the bytes a new
2219            // duplicate should be judged identical-and-visible against) or
2220            // about to vanish into an existing `TurnsCleared` range. In
2221            // either case leave it unrecorded so the NEXT still-fully-visible
2222            // occurrence becomes canonical instead (or, if there is none,
2223            // this hash simply never gets deduped this run — never a
2224            // correctness issue, only a missed savings opportunity, and
2225            // exactly what keeps this pass from re-litigating an A8/A7
2226            // decision a prior run already made).
2227            if !already_reduced.contains(&i) && !in_existing_clear(i) {
2228                first_seen.insert(hash, i);
2229            }
2230            continue;
2231        };
2232        if already_reduced.contains(&i) || protected.contains(&i) || in_existing_clear(i) {
2233            continue;
2234        }
2235
2236        let original_bytes = content.len();
2237        let id = make_id(ordinal, &hash);
2238        let tool_name = sanitize_summary_fragment(m.name.as_deref().unwrap_or("tool"));
2239        let summary = format!(
2240            "{tool_name} output duplicates msg #{canonical_idx} ({}B) — identical to an \
2241             earlier tool result, full output in session sidecar",
2242            format_commas(original_bytes),
2243        );
2244        let placeholder = stub::format(stub::Kind::Duplicate, &id, &summary);
2245        // Structural negative-savings guard: the savings floor
2246        // (`duplicate_output_min_bytes`) is a heuristic, not a guarantee —
2247        // the tool name is interpolated into the summary, so a long
2248        // (untrusted, imported) name can push the stub past the size of the
2249        // very content it replaces. Never mint a reduction that costs more
2250        // than it saves. `ordinal` is only consumed on an actual mint, so a
2251        // skip here is invisible to later ids (deterministic either way).
2252        if placeholder.len() >= original_bytes {
2253            continue;
2254        }
2255        ordinal += 1;
2256
2257        let reduction = Reduction {
2258            id: id.clone(),
2259            kind: ReductionKind::DuplicateOutput {
2260                canonical: MessageAddr {
2261                    index: canonical_idx,
2262                    role: Role::Tool,
2263                },
2264                original_bytes,
2265            },
2266            ptr: SidecarPtr {
2267                addr: MessageAddr {
2268                    index: i,
2269                    role: Role::Tool,
2270                },
2271                span: None,
2272                content_hash: hash,
2273            },
2274            placeholder,
2275        };
2276
2277        view[i].content = Some(reduction.placeholder.clone());
2278        set_reduction_id(&mut view[i], &reduction.id);
2279
2280        reduced_this_run.insert(i);
2281        log.reductions.push(reduction);
2282    }
2283
2284    // ---- TR-6: Superseded — keep-latest for same tool + canonicalized args ----
2285    //
2286    // Runs immediately after TR-2's dedup pass (immediately above) and BEFORE
2287    // every other pass (T30/TR-4, A7, the read family, A9, TR-10, A10) — the
2288    // TR-6.md frozen ordering ("run after TR-2 dedup ... before A7/A10").
2289    // Placing it ahead of T30/TR-4 too follows the exact same reasoning TR-2
2290    // itself is placed ahead of T30/TR-4 for: superseding a whole message
2291    // down to one small stub is strictly cheaper than independently
2292    // normalizing or truncating content that is about to be evicted anyway,
2293    // and it must claim its candidates' indices in `reduced_this_run` before
2294    // any later pass' own candidate scan runs, or that pass would claim them
2295    // first and the claim would stick forever (prefix stability).
2296    //
2297    // Scoped to the exact same address space TR-2 claims from (`Role::Tool`
2298    // results, excluding `read_indices` — the read-family passes, A8/TR-3,
2299    // own re-reads exclusively, with strictly richer path-aware redundancy
2300    // handling than a flat same-key stub could express). Two occurrences
2301    // sharing a `supersede::canonical_key` are NOT required to be
2302    // byte-identical (unlike TR-2) — an old FAILING `cargo test` run and a
2303    // later PASSING run of the identical command are exactly the case this
2304    // exists for; a byte-identical pair is TR-2's exclusive territory
2305    // (`recurring_hashes`, below) — this pass never mints a `Superseded` for
2306    // content whose hash recurs anywhere else, so it never re-litigates
2307    // TR-2's own decision (see `recurring_hashes`'s doc comment for why this
2308    // must be an explicit content-hash check, not just "TR-2 runs first").
2309    if policy.supersede_enabled {
2310        let supersede_protected: HashSet<usize> = view
2311            .iter()
2312            .enumerate()
2313            .filter(|(_, m)| m.role == Role::Tool)
2314            .map(|(i, _)| i)
2315            .rev()
2316            .take(policy.supersede_protect_last_n)
2317            .collect();
2318
2319        let occurrences = supersede::detect(msgs, &read_indices, &policy.supersede_command_fields);
2320        let mut by_key: HashMap<&str, Vec<usize>> = HashMap::new();
2321        for c in &occurrences {
2322            by_key.entry(c.key.as_str()).or_default().push(c.index);
2323        }
2324
2325        // Content whose hash recurs ANYWHERE among (non-read) tool results is
2326        // TR-2's exclusive territory, full stop — never a Superseded
2327        // candidate, regardless of the two passes' relative protection-zone
2328        // timing. This is not just "TR-2 runs first within one call": TR-2's
2329        // OWN candidate rule only ever mints a duplicate once TWO
2330        // occurrences of the same hash are SIMULTANEOUSLY unprotected in the
2331        // same `project_messages` call (its `first_seen` canonical stays
2332        // unrecorded, and unreduced, until then) — a live agent calls
2333        // `project_messages` incrementally, once per turn, so an occurrence
2334        // can age out of `protect_last_n_tool_results` SOLO, one turn before
2335        // any later identical occurrence does too. Without this guard,
2336        // Superseded's own (intentionally less choosy — it needs no partner,
2337        // just "not the newest") candidate rule would win that race and
2338        // permanently evict the EARLIEST copy of a byte-identical run — the
2339        // exact copy TR-2 means to keep forever as its canonical. Computed
2340        // fresh here (not reused from TR-2's own local `first_seen`, which
2341        // only ever covers hashes TR-2 itself has already deemed candidate-
2342        // eligible at ITS point in time, not "every hash that recurs").
2343        let mut recurring_hashes: HashSet<String> = HashSet::new();
2344        {
2345            let mut seen: HashSet<String> = HashSet::new();
2346            for (i, m) in msgs.iter().enumerate() {
2347                if m.role != Role::Tool || read_indices.contains(&i) {
2348                    continue;
2349                }
2350                let Some(content) = m.content.as_ref() else {
2351                    continue;
2352                };
2353                let h = content_hash(content.as_bytes());
2354                if !seen.insert(h.clone()) {
2355                    recurring_hashes.insert(h);
2356                }
2357            }
2358        }
2359
2360        // All but the newest (highest-index) occurrence of each key are
2361        // candidates; every candidate names the NEWEST occurrence as its
2362        // successor (SPEC.md TR-6 dev/01: "first three ... naming the 4th",
2363        // not each other's immediate successor). Gathered into one flat list
2364        // and sorted by index for deterministic minting order, mirroring
2365        // every other pass's tie-break rule.
2366        let mut mint_candidates: Vec<(usize, usize)> = Vec::new(); // (index, successor_index)
2367        for indices in by_key.values() {
2368            if indices.len() < 2 {
2369                continue; // A lone occurrence of a key has nothing to supersede it.
2370            }
2371            let mut sorted = indices.clone();
2372            sorted.sort_unstable();
2373            let newest = *sorted.last().expect("checked len >= 2 above");
2374            for &idx in &sorted[..sorted.len() - 1] {
2375                mint_candidates.push((idx, newest));
2376            }
2377        }
2378        mint_candidates.sort_by_key(|&(idx, _)| idx);
2379
2380        for (idx, successor_idx) in mint_candidates {
2381            if already_reduced.contains(&idx)
2382                || reduced_this_run.contains(&idx)
2383                || supersede_protected.contains(&idx)
2384                || in_existing_clear(idx)
2385            {
2386                continue;
2387            }
2388            let original = view[idx].content.clone().unwrap_or_default();
2389            let original_bytes = original.len();
2390            if original_bytes < policy.supersede_min_bytes {
2391                continue; // Below the savings floor: never a candidate.
2392            }
2393            let hash = content_hash(original.as_bytes());
2394            if recurring_hashes.contains(&hash) {
2395                continue; // Byte-identical elsewhere: TR-2's territory exclusively.
2396            }
2397            let id = make_id(ordinal, &hash);
2398            let tool_name = sanitize_summary_fragment(
2399                occurrences
2400                    .iter()
2401                    .find(|c| c.index == idx)
2402                    .map(|c| c.tool_name.as_str())
2403                    .unwrap_or("tool"),
2404            );
2405            let summary = format!(
2406                "{tool_name} superseded by newer result at msg #{successor_idx} ({}B) — \
2407                 expand_reduction(\"{id}\") to restore",
2408                format_commas(original_bytes),
2409            );
2410            let placeholder = stub::format(stub::Kind::Superseded, &id, &summary);
2411            // Structural negative-savings guard, mirrors TR-2's own: never
2412            // mint a reduction that costs more than it saves (a long,
2413            // untrusted tool name interpolated into the summary can in
2414            // principle push the stub past the content it replaces).
2415            if placeholder.len() >= original_bytes {
2416                continue;
2417            }
2418            ordinal += 1;
2419
2420            let reduction = Reduction {
2421                id: id.clone(),
2422                kind: ReductionKind::Superseded {
2423                    by: MessageAddr {
2424                        index: successor_idx,
2425                        role: Role::Tool,
2426                    },
2427                    original_bytes,
2428                },
2429                ptr: SidecarPtr {
2430                    addr: MessageAddr {
2431                        index: idx,
2432                        role: Role::Tool,
2433                    },
2434                    span: None,
2435                    content_hash: hash,
2436                },
2437                placeholder,
2438            };
2439
2440            view[idx].content = Some(reduction.placeholder.clone());
2441            set_reduction_id(&mut view[idx], &reduction.id);
2442
2443            reduced_this_run.insert(idx);
2444            log.reductions.push(reduction);
2445        }
2446    }
2447
2448    // ---- T30/TR-4: OutputNormalized — terminal-noise normalization ----
2449    //
2450    // Runs AFTER TR-2's dedup pass (immediately above — see its comment for
2451    // the dedup/normalize precedence rule) but BEFORE A7's truncation
2452    // candidates are even computed: normalizing a noisy bash/exec output
2453    // BEFORE truncating it means that, when the normalized rendering already
2454    // fits under `tool_output_trigger_bytes`, it is claimed HERE (collapsed,
2455    // no ANSI/redraw garbage, already bounded) and needs no truncation at
2456    // all. When the normalized rendering is STILL over the trigger — real,
2457    // mostly-distinct content, not redraw noise — this pass does NOT claim
2458    // the message (see the `normalized_bytes > policy.tool_output_trigger_bytes`
2459    // check below); it is left raw for A7's candidate scan to pick up and
2460    // truncate, so the wire payload for every terminal output stays bounded
2461    // by the trigger either way (the P7 runaway-output safety net).
2462    // Deliberately does NOT check `protected`: unlike A7's truncation (which discards
2463    // real content the model might need next turn), collapsing redraws is
2464    // content-lossless — the rendered text a recent tool result carries is
2465    // fully preserved, only presentation bytes are removed, so there is no
2466    // "protect the recent tail" reason to skip it (matching A9's
2467    // `ImageRedacted`, which likewise never consults `protected`). DOES
2468    // check `reduced_this_run` (as well as `already_reduced`), so a message
2469    // TR-2 just claimed above is never also claimed here — each message is
2470    // claimed by exactly one pass per run.
2471    if policy.normalize_terminal_output {
2472        let candidates: Vec<usize> = detect_normalize_candidates(&view)
2473            .into_iter()
2474            .filter(|i| {
2475                !already_reduced.contains(i)
2476                    && !reduced_this_run.contains(i)
2477                    && !in_existing_clear(*i)
2478            })
2479            .collect();
2480
2481        for idx in candidates {
2482            let original = view[idx].content.clone().unwrap_or_default();
2483            let original_bytes = original.len();
2484            let normalized = normalize::normalize(&original);
2485            let normalized_bytes = normalized.len();
2486            if original_bytes.saturating_sub(normalized_bytes) < policy.terminal_output_min_savings
2487            {
2488                continue; // Below the savings floor: leave untouched.
2489            }
2490            if normalized_bytes > policy.tool_output_trigger_bytes {
2491                // P7 safety net (SPEC.md/TR-12): the wire payload for ANY
2492                // terminal output must stay bounded by A7's trigger. Most
2493                // ANSI/redraw noise collapses to far less than the trigger,
2494                // but when the underlying content is genuinely large and
2495                // mostly distinct (not just redraw noise), the normalized
2496                // rendering can still exceed it — claiming the message here
2497                // would let that uncapped view ride the wire unbounded,
2498                // reintroducing the runaway-output incident A7 exists to
2499                // prevent. Leave it unclaimed (raw, untouched, `ordinal` not
2500                // consumed) so it falls through to A7 below, which truncates
2501                // the RAW bytes to `tool_output_keep_bytes`, bounded and
2502                // reversible exactly as it was pre-TR-4. Only a normalized
2503                // rendering that already fits under the trigger is claimed
2504                // here, as a collapsed-and-already-bounded view.
2505                continue;
2506            }
2507
2508            let hash = content_hash(original.as_bytes());
2509            let id = make_id(ordinal, &hash);
2510            ordinal += 1;
2511
2512            let summary = normalize::summary(original_bytes, normalized_bytes);
2513            let placeholder = stub::format(stub::Kind::OutputNormalized, &id, &summary);
2514
2515            let reduction = Reduction {
2516                id: id.clone(),
2517                kind: ReductionKind::OutputNormalized {
2518                    original_bytes,
2519                    normalized_bytes,
2520                },
2521                ptr: SidecarPtr {
2522                    addr: MessageAddr {
2523                        index: idx,
2524                        role: view[idx].role,
2525                    },
2526                    span: None,
2527                    content_hash: hash,
2528                },
2529                placeholder,
2530            };
2531
2532            let mut new_content = normalized;
2533            new_content.push_str("\n\n");
2534            new_content.push_str(&reduction.placeholder);
2535            view[idx].content = Some(new_content);
2536            set_reduction_id(&mut view[idx], &reduction.id);
2537
2538            reduced_this_run.insert(idx);
2539            log.reductions.push(reduction);
2540        }
2541    }
2542
2543    // Candidates: oversized tool results (never the system prompt, which is
2544    // role `System` and so never matches `role == Role::Tool` anyway), not
2545    // already reduced, not in the protected tail, not already inside an
2546    // established `TurnsCleared` range (about to vanish into its one
2547    // placeholder regardless), and not just claimed by TR-2's dedup pass or
2548    // T30/TR-4's normalize pass above.
2549    let mut candidates: Vec<usize> = view
2550        .iter()
2551        .enumerate()
2552        .filter(|(i, m)| {
2553            m.role == Role::Tool
2554                && !already_reduced.contains(i)
2555                && !reduced_this_run.contains(i)
2556                && !protected.contains(i)
2557                && !in_existing_clear(*i)
2558                && m.content.as_ref().map(|c| c.len()).unwrap_or(0)
2559                    > policy.tool_output_trigger_bytes
2560        })
2561        .map(|(i, _)| i)
2562        .collect();
2563
2564    // Largest-first (#6 "largest wins"); ties broken by ascending index for
2565    // determinism.
2566    let byte_len = |i: usize| view[i].content.as_ref().map(|c| c.len()).unwrap_or(0);
2567    candidates.sort_by(|&a, &b| byte_len(b).cmp(&byte_len(a)).then(a.cmp(&b)));
2568
2569    for idx in candidates {
2570        let original = view[idx].content.clone().unwrap_or_default();
2571        let original_bytes = original.len();
2572        let hash = content_hash(original.as_bytes());
2573        let id = make_id(ordinal, &hash);
2574        ordinal += 1;
2575
2576        let kept_bytes = char_boundary_floor(&original, policy.tool_output_keep_bytes);
2577        let tool_name = sanitize_summary_fragment(view[idx].name.as_deref().unwrap_or("tool"));
2578        let summary = format!(
2579            "{tool_name} output truncated {}B, kept {}B — full output in session sidecar",
2580            format_commas(original_bytes),
2581            format_commas(kept_bytes),
2582        );
2583        let placeholder = stub::format(stub::Kind::ToolOutput, &id, &summary);
2584
2585        let reduction = Reduction {
2586            id: id.clone(),
2587            kind: ReductionKind::ToolOutputTruncated {
2588                original_bytes,
2589                kept_bytes,
2590            },
2591            ptr: SidecarPtr {
2592                addr: MessageAddr {
2593                    index: idx,
2594                    role: view[idx].role,
2595                },
2596                span: Some((kept_bytes, original_bytes)),
2597                content_hash: hash,
2598            },
2599            placeholder,
2600        };
2601
2602        let mut new_content = original[..kept_bytes].to_string();
2603        new_content.push_str("\n\n");
2604        new_content.push_str(&reduction.placeholder);
2605        view[idx].content = Some(new_content);
2606        set_reduction_id(&mut view[idx], &reduction.id);
2607
2608        reduced_this_run.insert(idx);
2609        log.reductions.push(reduction);
2610    }
2611
2612    // ---- A8/TR-3: FileReadElided / FileReadDiffed — re-read handling ----
2613    //
2614    // Runs after A7 (so a message already claimed as an oversized-truncation
2615    // candidate this run is never also elided/diffed here) and before A10's
2616    // TurnsCleared block (which must stay last — see above). Every detected
2617    // read appends a `ReadLogEntry` to `log.read_log` regardless of whether it
2618    // ends up reduced (deduped by address so re-projection never duplicates
2619    // it) — this now runs whenever EITHER `elide_stale_reads` or
2620    // `diff_rereads` is set, since TR-3's read-log lookups must see every
2621    // read even when A8's own elision is disabled (and vice versa).
2622    //
2623    // **A8-vs-TR-3 precedence (SPEC.md TR-3 dev/05).** For a read of a path
2624    // already seen earlier this session (a re-read, per `log.read_log`):
2625    // - content hash UNCHANGED from that prior read -> TR-3 has nothing to
2626    //   show (a zero-hunk diff is never useful) and does not touch this
2627    //   index at all; A8's ordinary disk-freshness elision runs exactly as
2628    //   before, unaffected by TR-3 being enabled.
2629    // - content hash CHANGED from that prior read -> TR-3 takes EXCLUSIVE
2630    //   claim of this index (A8 never runs on it this call), because A8's
2631    //   elision message ("unchanged on disk, re-read on demand") would throw
2632    //   away the very fact that changed — either TR-3 diffs it (below the
2633    //   size guard) or, if the change is too large to compress usefully, the
2634    //   full re-read is left untouched in the view (dev/03's guard: showing
2635    //   the genuine rewrite beats hiding it behind an elision stub the model
2636    //   would have to spend a turn expanding).
2637    // A first-ever read of a path (no prior `log.read_log` entry) is never a
2638    // TR-3 candidate — there is nothing to diff against — and falls straight
2639    // through to A8, unaffected.
2640    if policy.elide_stale_reads || policy.diff_rereads {
2641        for d in detect_reads(&view) {
2642            let idx = d.index;
2643            if already_reduced.contains(&idx)
2644                || reduced_this_run.contains(&idx)
2645                || protected.contains(&idx)
2646                || in_existing_clear(idx)
2647            {
2648                continue;
2649            }
2650
2651            // Always resolved from `msgs` (never `view`): the pristine
2652            // canonical content at this index, regardless of processing
2653            // order within this call or any reduction already reapplied
2654            // onto `view` elsewhere.
2655            let original = msgs[idx].content.clone().unwrap_or_default();
2656            let hash = content_hash(original.as_bytes());
2657            let mtime = policy
2658                .read_freshness
2659                .entries
2660                .get(&idx)
2661                .and_then(|e| e.mtime);
2662            let addr = MessageAddr {
2663                index: idx,
2664                role: view[idx].role,
2665            };
2666
2667            // Most recent prior read of the SAME path, if any — always
2668            // resolved from `log.read_log`, whose `content_hash`/`addr` were
2669            // themselves minted from `msgs` (never from a reduced/diffed
2670            // view), so a diff's base is always a genuine full read, never
2671            // another diff (no diff-of-diff compounding, SPEC.md TR-3
2672            // dev/04).
2673            let prior_read: Option<ReadLogEntry> = log
2674                .read_log
2675                .iter()
2676                .filter(|e| e.path == d.path && e.addr.index < idx)
2677                .max_by_key(|e| e.addr.index)
2678                .cloned();
2679
2680            if !log.read_log.iter().any(|e| e.addr.index == idx) {
2681                log.read_log.push(ReadLogEntry {
2682                    path: d.path.clone(),
2683                    addr,
2684                    content_hash: hash.clone(),
2685                    mtime,
2686                });
2687            }
2688
2689            // ---- TR-3: diff-only re-read representation --------------
2690            //
2691            // `!d.windowed` guards TR-3.md's frozen v1 scope: "Partial-window
2692            // reads (offset/limit) are out of scope for v1 — full-file reads
2693            // only." A partial-window re-read never becomes a diff
2694            // candidate — its content is a slice, not a whole file, so a
2695            // unified diff against a prior read (whole or another slice)
2696            // would present a diff between two arbitrary windows as if it
2697            // were a genuine file change. Falls through to A8 exactly as a
2698            // non-windowed read would (A8 itself already fails closed on a
2699            // windowed read via `probe_read_freshness`'s hash-mismatch
2700            // fallback), and TR-2 is untouched by this check entirely (it
2701            // only ever consults same-path-re-read status, not windowing).
2702            let mut claimed_by_diff = false;
2703            if policy.diff_rereads && !d.windowed {
2704                if let Some(prior) = &prior_read {
2705                    if prior.content_hash != hash {
2706                        // Changed since the prior read of this path: from
2707                        // here, A8 must never touch this index (see the
2708                        // precedence note above) — whether or not the diff
2709                        // itself ends up below the size guard.
2710                        claimed_by_diff = true;
2711                        if let Some(base_text) = msgs
2712                            .get(prior.addr.index)
2713                            .and_then(|m| m.content.as_deref())
2714                        {
2715                            let diff_text = diffy::create_patch(base_text, &original).to_string();
2716                            let diff_bytes = diff_text.len();
2717                            let original_bytes = original.len();
2718                            let within_guard = (diff_bytes as u128).saturating_mul(100)
2719                                <= (original_bytes as u128) * policy.diff_max_percent as u128;
2720                            if within_guard {
2721                                let id = make_id(ordinal, &hash);
2722                                ordinal += 1;
2723                                // explicit is-zero guard is clearer than checked_div here
2724                                #[allow(clippy::manual_checked_ops)]
2725                                let percent = if original_bytes == 0 {
2726                                    0
2727                                } else {
2728                                    diff_bytes * 100 / original_bytes
2729                                };
2730                                let summary = format!(
2731                                    "read {} diffed vs prior read at msg #{} — {}B diff, {}B full ({percent}%)",
2732                                    d.path.display(),
2733                                    prior.addr.index,
2734                                    format_commas(diff_bytes),
2735                                    format_commas(original_bytes),
2736                                );
2737                                let placeholder =
2738                                    stub::format(stub::Kind::FileReadDiffed, &id, &summary);
2739                                let reduction = Reduction {
2740                                    id: id.clone(),
2741                                    kind: ReductionKind::FileReadDiffed {
2742                                        path: d.path.clone(),
2743                                        base: prior.addr,
2744                                        base_hash: prior.content_hash.clone(),
2745                                        new_hash: hash.clone(),
2746                                        original_bytes,
2747                                        diff_bytes,
2748                                    },
2749                                    ptr: SidecarPtr {
2750                                        addr,
2751                                        span: None,
2752                                        content_hash: hash.clone(),
2753                                    },
2754                                    placeholder,
2755                                };
2756
2757                                let mut new_content = reduction.placeholder.clone();
2758                                new_content.push('\n');
2759                                new_content.push_str(&diff_text);
2760                                view[idx].content = Some(new_content);
2761                                set_reduction_id(&mut view[idx], &reduction.id);
2762
2763                                reduced_this_run.insert(idx);
2764                                log.reductions.push(reduction);
2765                            }
2766                            // else: large-change guard tripped (dev/03) — no
2767                            // reduction of any kind; the full re-read stays.
2768                        }
2769                        // else: base unresolvable (should not happen against
2770                        // a stable `msgs`) — fail safe, leave the full
2771                        // re-read untouched rather than guess.
2772                    }
2773                }
2774            }
2775            if claimed_by_diff {
2776                continue; // Never let A8 elide a read TR-3 has claimed.
2777            }
2778
2779            // ---- A8: stale-file-read elision --------------------------
2780            if !policy.elide_stale_reads {
2781                continue;
2782            }
2783            let fresh = policy
2784                .read_freshness
2785                .entries
2786                .get(&idx)
2787                .is_some_and(|e| e.fresh);
2788            if !fresh {
2789                continue; // Changed or unreadable: the transcript copy stays the record.
2790            }
2791
2792            let id = make_id(ordinal, &hash);
2793            ordinal += 1;
2794            let summary = format!(
2795                "read {} elided — file unchanged on disk, re-read on demand",
2796                d.path.display()
2797            );
2798            let placeholder = stub::format(stub::Kind::FileRead, &id, &summary);
2799            let reduction = Reduction {
2800                id: id.clone(),
2801                kind: ReductionKind::FileReadElided {
2802                    path: d.path.clone(),
2803                    read_log: ReadLogEntry {
2804                        path: d.path.clone(),
2805                        addr,
2806                        content_hash: hash.clone(),
2807                        mtime,
2808                    },
2809                },
2810                ptr: SidecarPtr {
2811                    addr,
2812                    span: None,
2813                    content_hash: hash,
2814                },
2815                placeholder,
2816            };
2817
2818            view[idx].content = Some(reduction.placeholder.clone());
2819            set_reduction_id(&mut view[idx], &reduction.id);
2820
2821            reduced_this_run.insert(idx);
2822            log.reductions.push(reduction);
2823        }
2824    }
2825
2826    // ---- A9: ImageRedacted — data: URL image stripped to a stub + pointer ----
2827    //
2828    // Runs after A7/A8 (both cardinality-preserving, like this) and before
2829    // A10's TurnsCleared block (which must stay last — see above). Operates
2830    // on `content_parts`, an axis A7/A8 never touch, so there is no
2831    // cross-kind conflict to guard against the way A7 guards A8.
2832    if policy.redact_images {
2833        for img in detect_images(&view) {
2834            if img.url_len < policy.image_redact_min_bytes || in_existing_clear(img.msg_index) {
2835                continue;
2836            }
2837            let idx = img.msg_index;
2838            let part_index = img.part_index;
2839
2840            let original_part = view[idx].content_parts.as_ref().unwrap()[part_index].clone();
2841            let serialized = serde_json::to_vec(&original_part)
2842                .expect("a content part is always representable as JSON");
2843            let hash = content_hash(&serialized);
2844            let id = make_id(ordinal, &hash);
2845            ordinal += 1;
2846
2847            let size_kb = (img.url_len + 512) / 1024;
2848            let summary = format!("image redacted ({}, {size_kb}KB)", img.mime);
2849            let placeholder = stub::format(stub::Kind::Image, &id, &summary);
2850
2851            let reduction = Reduction {
2852                id: id.clone(),
2853                kind: ReductionKind::ImageRedacted { part_index },
2854                ptr: SidecarPtr {
2855                    addr: MessageAddr {
2856                        index: idx,
2857                        role: view[idx].role,
2858                    },
2859                    span: None,
2860                    content_hash: hash,
2861                },
2862                placeholder,
2863            };
2864
2865            let parts = view[idx].content_parts.as_mut().unwrap();
2866            parts[part_index] = serde_json::json!({"type": "text", "text": reduction.placeholder});
2867            set_reduction_id(&mut view[idx], &reduction.id);
2868
2869            reduced_this_run.insert(idx);
2870            log.reductions.push(reduction);
2871        }
2872    }
2873
2874    // ---- TR-10: ToolInputElided — the assistant-side twin of A7/A8 ----
2875    //
2876    // Runs after A7/A8/A9 (an independent axis: assistant `tool_calls`
2877    // arguments, never a `Role::Tool` result or a `content_parts` image, so
2878    // there is no cross-kind conflict to guard the way A7 guards A8) and
2879    // before A10's TurnsCleared block (which must stay last — see above).
2880    if policy.elide_tool_inputs {
2881        // Tracked per CALL id, not per message index (unlike `already_reduced`
2882        // above): a single assistant message can carry more than one
2883        // tool_calls entry, each independently elidable.
2884        let already_call_ids: HashSet<&str> = prior
2885            .reductions
2886            .iter()
2887            .filter_map(|r| match &r.kind {
2888                ReductionKind::ToolInputElided { call_id, .. } => Some(call_id.as_str()),
2889                _ => None,
2890            })
2891            .collect();
2892
2893        let mut candidates: Vec<DetectedToolInput> =
2894            detect_tool_inputs(&view, &policy.tool_input_elidable_fields)
2895                .into_iter()
2896                .filter(|d| {
2897                    !already_call_ids.contains(d.call_id.as_str())
2898                        && !in_existing_clear(d.msg_index)
2899                        && d.value.len() > policy.tool_input_trigger_bytes
2900                })
2901                .collect();
2902
2903        // Largest-first (#6 "largest wins"), ties broken by call_id for
2904        // determinism.
2905        candidates.sort_by(|a, b| {
2906            b.value
2907                .len()
2908                .cmp(&a.value.len())
2909                .then(a.call_id.cmp(&b.call_id))
2910        });
2911
2912        for d in candidates {
2913            let hash = content_hash(d.value.as_bytes());
2914            let id = make_id(ordinal, &hash);
2915            ordinal += 1;
2916            let original_bytes = d.value.len();
2917            let path_clause = d
2918                .path
2919                .as_ref()
2920                .map(|p| format!(", on disk at {}", p.display()))
2921                .unwrap_or_default();
2922            let hash_prefix: String = hash.chars().take(8).collect();
2923            let summary = format!(
2924                "{} input elided: `{}` field, {}B{path_clause}, blake3={hash_prefix}... — full \
2925                 args in session sidecar",
2926                d.tool_name,
2927                d.field,
2928                format_commas(original_bytes),
2929            );
2930            let placeholder = stub::format(stub::Kind::ToolInput, &id, &summary);
2931
2932            let Some(original_args) = view
2933                .get(d.msg_index)
2934                .and_then(|m| m.tool_calls.as_ref())
2935                .and_then(|calls| calls.iter().find(|c| c.id == d.call_id))
2936                .map(|call| call.function.arguments.clone())
2937            else {
2938                continue; // Addressed call no longer present; skip.
2939            };
2940            let Some(spliced) =
2941                replace_top_level_string_field(&original_args, &d.field, &placeholder)
2942            else {
2943                continue; // Field vanished/reshaped since detection; skip rather than corrupt.
2944            };
2945
2946            let role = view[d.msg_index].role;
2947            let calls = view[d.msg_index]
2948                .tool_calls
2949                .as_mut()
2950                .expect("checked above: this message has tool_calls");
2951            let call = calls
2952                .iter_mut()
2953                .find(|c| c.id == d.call_id)
2954                .expect("checked above: this call_id is present");
2955            call.function.arguments = spliced;
2956            set_reduction_id(&mut view[d.msg_index], &id);
2957
2958            let reduction = Reduction {
2959                id: id.clone(),
2960                kind: ReductionKind::ToolInputElided {
2961                    original_bytes,
2962                    path: d.path.clone(),
2963                    content_hash: hash.clone(),
2964                    call_id: d.call_id.clone(),
2965                    field: d.field.clone(),
2966                },
2967                ptr: SidecarPtr {
2968                    addr: MessageAddr {
2969                        index: d.msg_index,
2970                        role,
2971                    },
2972                    span: None,
2973                    content_hash: hash,
2974                },
2975                placeholder,
2976            };
2977            log.reductions.push(reduction);
2978        }
2979    }
2980
2981    // ---- TR-6: errored-input pruning — the FAILURE-side complement of TR-10 ----
2982    //
2983    // Same address space as TR-10 above (assistant `tool_calls` arguments,
2984    // keyed by `call_id`), but structurally disjoint from it: `detect_tool_inputs`
2985    // only ever matches a call whose paired result is `KnownSuccess`
2986    // (TR-10); `detect_errored_tool_inputs` only ever matches `KnownError`
2987    // (this pass) — an `Unknown` result matches neither, and a call id can never
2988    // satisfy both, so `already_call_ids` (recomputed here rather than shared
2989    // with TR-10's own local binding above, since either gate may be off
2990    // independently of the other) is sufficient with no extra bookkeeping to
2991    // keep the two disjoint. Also gated by an aging clock TR-10 has no
2992    // equivalent of ([`assistant_turns_since`] vs. `policy.errored_input_prune_after_turns`)
2993    // — a freshly-failed call's input stays visible for a while (in case the
2994    // model wants to see exactly what it just tried) and only becomes a
2995    // candidate once it's aged past that. The paired error-result message
2996    // itself (the actual error text) is never touched here — only the
2997    // assistant-side input argument — so the error stays visible exactly as
2998    // TR-6.md requires.
2999    if policy.prune_errored_inputs {
3000        let already_call_ids: HashSet<&str> = prior
3001            .reductions
3002            .iter()
3003            .filter_map(|r| match &r.kind {
3004                ReductionKind::ToolInputElided { call_id, .. } => Some(call_id.as_str()),
3005                _ => None,
3006            })
3007            .collect();
3008
3009        let mut candidates: Vec<DetectedToolInput> =
3010            detect_errored_tool_inputs(&view, &policy.tool_input_elidable_fields)
3011                .into_iter()
3012                .filter(|d| {
3013                    !already_call_ids.contains(d.call_id.as_str())
3014                        && !in_existing_clear(d.msg_index)
3015                        && d.value.len() > policy.tool_input_trigger_bytes
3016                        && assistant_turns_since(&view, d.msg_index)
3017                            >= policy.errored_input_prune_after_turns
3018                })
3019                .collect();
3020
3021        // Largest-first (#6 "largest wins"), ties broken by call_id for
3022        // determinism — identical convention to TR-10's own candidate sort.
3023        candidates.sort_by(|a, b| {
3024            b.value
3025                .len()
3026                .cmp(&a.value.len())
3027                .then(a.call_id.cmp(&b.call_id))
3028        });
3029
3030        for d in candidates {
3031            let hash = content_hash(d.value.as_bytes());
3032            let id = make_id(ordinal, &hash);
3033            ordinal += 1;
3034            let original_bytes = d.value.len();
3035            let turns = assistant_turns_since(&view, d.msg_index);
3036            let path_clause = d
3037                .path
3038                .as_ref()
3039                .map(|p| format!(", on disk at {}", p.display()))
3040                .unwrap_or_default();
3041            let hash_prefix: String = hash.chars().take(8).collect();
3042            let summary = format!(
3043                "{} input elided (errored call, {turns} turns old): `{}` field, {}B{path_clause}, \
3044                 blake3={hash_prefix}... — full args in session sidecar",
3045                d.tool_name,
3046                d.field,
3047                format_commas(original_bytes),
3048            );
3049            let placeholder = stub::format(stub::Kind::ToolInput, &id, &summary);
3050
3051            let Some(original_args) = view
3052                .get(d.msg_index)
3053                .and_then(|m| m.tool_calls.as_ref())
3054                .and_then(|calls| calls.iter().find(|c| c.id == d.call_id))
3055                .map(|call| call.function.arguments.clone())
3056            else {
3057                continue; // Addressed call no longer present; skip.
3058            };
3059            let Some(spliced) =
3060                replace_top_level_string_field(&original_args, &d.field, &placeholder)
3061            else {
3062                continue; // Field vanished/reshaped since detection; skip rather than corrupt.
3063            };
3064
3065            let role = view[d.msg_index].role;
3066            let calls = view[d.msg_index]
3067                .tool_calls
3068                .as_mut()
3069                .expect("checked above: this message has tool_calls");
3070            let call = calls
3071                .iter_mut()
3072                .find(|c| c.id == d.call_id)
3073                .expect("checked above: this call_id is present");
3074            call.function.arguments = spliced;
3075            set_reduction_id(&mut view[d.msg_index], &id);
3076
3077            let reduction = Reduction {
3078                id: id.clone(),
3079                kind: ReductionKind::ToolInputElided {
3080                    original_bytes,
3081                    path: d.path.clone(),
3082                    content_hash: hash.clone(),
3083                    call_id: d.call_id.clone(),
3084                    field: d.field.clone(),
3085                },
3086                ptr: SidecarPtr {
3087                    addr: MessageAddr {
3088                        index: d.msg_index,
3089                        role,
3090                    },
3091                    span: None,
3092                    content_hash: hash,
3093                },
3094                placeholder,
3095            };
3096            log.reductions.push(reduction);
3097        }
3098    }
3099
3100    // ---- A10: TurnsCleared — old-turn clearing, re-founding `maybe_compact` ----
3101    //
3102    // `view` is still fully index-parallel to `msgs` at this point (every
3103    // step above only mutated content in place); this is the one step that
3104    // changes cardinality, so it must run last.
3105    if !existing_clears.is_empty() {
3106        // Already established in a prior projection: reapply EVERY existing
3107        // record verbatim, never recompute or widen any range (prefix
3108        // stability, A5) — descending by `first` so an earlier splice's
3109        // cardinality shrink never invalidates a later, still-`msgs`-indexed
3110        // splice (mirrors TR-9 `handoff`'s own last-gap-first splice order).
3111        let mut to_reapply: Vec<Reduction> = log
3112            .reductions
3113            .iter()
3114            .filter(|r| matches!(r.kind, ReductionKind::TurnsCleared { .. }))
3115            .cloned()
3116            .collect();
3117        to_reapply.sort_by_key(|r| match r.kind {
3118            ReductionKind::TurnsCleared { first, .. } => std::cmp::Reverse(first),
3119            _ => unreachable!("filtered to TurnsCleared above"),
3120        });
3121        for r in &to_reapply {
3122            reapply_reduction(&mut view, r, msgs);
3123        }
3124    } else if let Some((first, last)) = compute_clear_range(&view, policy) {
3125        // `view`'s roles are identical to `msgs`'s at every index up to this
3126        // point (every pass above only mutates content/tool_calls in place),
3127        // so `compute_clear_range` — a pure function of role/length alone —
3128        // derives the identical range whether called against `view` (here)
3129        // or `msgs` directly ([`prepare_cleared_turns_summary`], called
3130        // BEFORE this projection even runs). This is what lets the two
3131        // agree by construction; see `compute_clear_range`'s own doc.
3132        //
3133        // `msgs` is `history[1..]` (via `project_messages`, called from
3134        // `Agent::build_request_messages`). The hash below covers the true
3135        // sidecar bytes — never an already-truncated copy — SOLELY because
3136        // `Agent::run_loop`'s D6/A7 supersession gate (TR-12) keeps
3137        // `cap_tool_output` off whenever a recorder + this policy are both
3138        // active (the only combination `project_messages`/mint ever runs
3139        // under with a durable sidecar behind it): `history` then holds full
3140        // bytes by construction, so this slice already equals
3141        // `sidecar.messages`. Without that gate a legacy `cap_tool_output`
3142        // could shrink `msgs` first, and this comment's claim would be false
3143        // — exactly the land-blocker TR-12 fixed (a hash minted from capped
3144        // bytes can never recompute the same way from the reloaded,
3145        // full-bytes sidecar).
3146        let range = &msgs[first..=last];
3147        let (hash, range_bytes) =
3148            hash_turns_range(range).expect("ChatMessage always serializes to JSON");
3149        let (user, assistant, tool) = count_roles(range);
3150        let id = make_id(ordinal, &hash);
3151        let deterministic_summary = format!(
3152            "turns {first}..{} cleared ({} messages: {user} user, {assistant} \
3153             assistant, {tool} tool; {}B) — full turns in session sidecar",
3154            last + 1,
3155            format_commas(range.len()),
3156            format_commas(range_bytes),
3157        );
3158
3159        // TR-7 (T20): off by default (`summarize_cleared_turns: false`,
3160        // SPEC.md dev/01) — the placeholder below is then byte-identical to
3161        // pre-TR-7 behavior, full stop. When on AND a prepared summary
3162        // exists for EXACTLY this `(first, last)` range (computed by
3163        // `prepare_cleared_turns_summary`, called by the driving caller
3164        // BEFORE this projection — the one place TR-7's side-call happens,
3165        // mirroring A8's `probe_read_freshness` split so this function
3166        // itself stays pure/I-O-free), the placeholder instead carries the
3167        // LLM summary text plus an honesty banner naming this span's
3168        // reduction id and turn count, with `expand_reduction("<id>")`
3169        // spelled out as the verbatim escape hatch. Any mismatch (off,
3170        // absent, or a stale/wrong-range entry) falls back to the
3171        // deterministic stub — dev/03's failure-fallback guarantee applies
3172        // equally to "never prepared" and "errored while preparing".
3173        let prepared = policy
3174            .summarize_cleared_turns
3175            .then_some(policy.cleared_turns_summary.as_ref())
3176            .flatten()
3177            .filter(|p| p.first == first && p.last == last);
3178
3179        let (summary_text, summary_audit) = match prepared {
3180            Some(p) => {
3181                let banner = format!(
3182                    "sc-summary of {id}, original {} messages in sidecar — \
3183                     expand_reduction(\"{id}\") for verbatim",
3184                    format_commas(range.len()),
3185                );
3186                let text = format!("{} ({banner})", p.text);
3187                let audit = SpanSummary {
3188                    model_id: p.model_id.clone(),
3189                    prompt_version: summarize::PROMPT_VERSION.to_string(),
3190                    summary_hash: content_hash(p.text.as_bytes()),
3191                };
3192                (text, Some(audit))
3193            }
3194            None => (deterministic_summary, None),
3195        };
3196        let placeholder = stub::format(stub::Kind::TurnsCleared, &id, &summary_text);
3197
3198        // Any reduction (of any kind) whose address falls inside the
3199        // range about to be collapsed is now subsumed by this single
3200        // placeholder — drop it from the log; `invert` restores the
3201        // WHOLE range straight from the sidecar (`resolve_turns_range`),
3202        // so nothing recorded there is lost.
3203        log.reductions
3204            .retain(|r| !(r.ptr.addr.index >= first && r.ptr.addr.index <= last));
3205
3206        let reduction = Reduction {
3207            id: id.clone(),
3208            kind: ReductionKind::TurnsCleared {
3209                first,
3210                last,
3211                summary: summary_audit,
3212            },
3213            ptr: SidecarPtr {
3214                addr: MessageAddr {
3215                    index: first,
3216                    role: range[0].role,
3217                },
3218                span: None,
3219                content_hash: hash,
3220            },
3221            placeholder,
3222        };
3223
3224        let mut stub_msg = ChatMessage::system(reduction.placeholder.clone());
3225        set_reduction_id(&mut stub_msg, &reduction.id);
3226        view.splice(first..=last, std::iter::once(stub_msg));
3227
3228        log.reductions.push(reduction);
3229    }
3230
3231    (view, log)
3232}
3233
3234fn next_reduction_ordinal<'a>(ids: impl Iterator<Item = &'a str>) -> usize {
3235    ids.filter_map(|id| {
3236        let digits = id.strip_prefix('r')?.get(..4)?;
3237        digits.parse::<usize>().ok()
3238    })
3239    .max()
3240    .map_or(0, |max| max.saturating_add(1))
3241}
3242
3243#[cfg(test)]
3244mod ordinal_tests {
3245    use super::next_reduction_ordinal;
3246
3247    #[test]
3248    fn sparse_legacy_ids_advance_past_the_greatest_live_ordinal() {
3249        let ids = ["r0001-dead", "r0007-beef"];
3250        assert_eq!(next_reduction_ordinal(ids.into_iter()), 8);
3251    }
3252
3253    #[test]
3254    fn malformed_ids_cannot_force_reuse_of_a_valid_live_ordinal() {
3255        let ids = ["legacy", "r0003-cafe", "rxxxx-nope"];
3256        assert_eq!(next_reduction_ordinal(ids.into_iter()), 4);
3257    }
3258}
3259
3260/// PARITY-18 — how many times [`reduce_to_fit`] will re-project with a
3261/// tighter [`ReductionPolicy`] before giving up and returning its best
3262/// (tightest-attempted) effort. Each level roughly halves the byte-based
3263/// knobs (see [`tighten`]), so 5 levels covers a ~32x tightening range —
3264/// past that, more aggression stops buying meaningfully more headroom and
3265/// the caller's preflight guard (`crates/cli`'s `resume_cmd`) should fail
3266/// fast instead of looping forever.
3267const MAX_AGGRESSIVE_LEVELS: u32 = 5;
3268
3269/// PARITY-18 — a strictly tighter variant of `base` for [`reduce_to_fit`]'s
3270/// escalation ladder. Only scales knobs that are pure functions of in-view
3271/// content (byte thresholds, protected-recency windows) — exactly the set
3272/// [`ReductionPolicy::default`] already turns on unconditionally (D14) —
3273/// never touches `elide_stale_reads`/`read_freshness` or
3274/// `summarize_cleared_turns`/`cleared_turns_summary`, both of which need
3275/// data only a live disk probe or an LLM side-call can produce and so stay
3276/// exactly as the caller configured them at every level. `level` is
3277/// 1-indexed (`level=0` would be `base` itself, never called that way here).
3278fn tighten(base: &ReductionPolicy, level: u32) -> ReductionPolicy {
3279    let shift = level.min(5);
3280    let shrink = |n: usize, floor: usize| -> usize { (n >> shift).max(floor) };
3281    ReductionPolicy {
3282        tool_output_keep_bytes: shrink(base.tool_output_keep_bytes, 128),
3283        tool_output_trigger_bytes: shrink(base.tool_output_trigger_bytes, 256),
3284        protect_last_n_tool_results: base
3285            .protect_last_n_tool_results
3286            .saturating_sub(level as usize),
3287        image_redact_min_bytes: shrink(base.image_redact_min_bytes, 256),
3288        tool_input_trigger_bytes: shrink(base.tool_input_trigger_bytes, 256),
3289        duplicate_output_min_bytes: shrink(base.duplicate_output_min_bytes, 16),
3290        supersede_min_bytes: shrink(base.supersede_min_bytes, 16),
3291        supersede_protect_last_n: base.supersede_protect_last_n.saturating_sub(level as usize),
3292        errored_input_prune_after_turns: base
3293            .errored_input_prune_after_turns
3294            .saturating_sub(level as usize),
3295        ..base.clone()
3296    }
3297}
3298
3299/// PARITY-18 v3 — the "aggressive reduction" half of the fix (SPEC.md
3300/// scaling-context-guard): apply [`project_messages`] with `base_policy`
3301/// first, then, if `fits` says the projected view still doesn't pass,
3302/// retry with progressively tighter policies (see [`tighten`]) up to
3303/// [`MAX_AGGRESSIVE_LEVELS`] times, then escalate to A10 turn-clearing,
3304/// keeping whichever attempt is smallest. Never fails and never loops
3305/// unboundedly — it always returns SOME projection (the caller's own
3306/// preflight guard is responsible for deciding whether even the tightest
3307/// attempt still exceeds the target model's context limit and refusing to
3308/// send in that case, PARITY-18 dev/01).
3309///
3310/// `fits` is a closure over the REDUCED VIEW ALONE (this function has no
3311/// knowledge of the system prompt, tool schemas, or a trailing user
3312/// prompt — those live with the caller). It exists to close a v2 defect a
3313/// skeptical review caught (the flagship rescue-path regression): v2 had
3314/// two INDEPENDENTLY-derived boundaries — this function stopped escalating
3315/// once `estimate_view_tokens(view) <= target_tokens` where
3316/// `target_tokens = context_limit - CONTEXT_RESPONSE_RESERVE_TOKENS`, while
3317/// `tokens::context_guard` only ACCEPTS a request when
3318/// `with_guard_margin(view + tools) + CONTEXT_RESPONSE_RESERVE_TOKENS <=
3319/// context_limit` — a materially tighter bound (no margin, no tools, no
3320/// system-prompt overhead counted by the reducer's stop condition at all).
3321/// Any session whose reduced view landed in the band between those two
3322/// boundaries was declared "fits" here and then refused by the guard, with
3323/// D6 turn-clearing never triggered because this function had already
3324/// stopped. Passing `tokens::context_guard` itself (wrapped with the
3325/// caller's real system prompt/tools/prompt) as `fits` makes that
3326/// impossible BY CONSTRUCTION: the reducer's stopping condition and the
3327/// guard's acceptance are now the same boundary, so a session this function
3328/// says it reduced-to-fit is a session the guard will actually send.
3329/// (`resume_cmd` is the sole real caller; see its `fits` closure there.)
3330///
3331/// Returns `(view, log, policy_actually_applied)` so the caller can seed
3332/// [`crate::Agent::set_reduction_policy`]/`set_reduction_log` with the exact
3333/// policy that produced the returned view (subsequent turns keep re-applying
3334/// it, `resume_cmd`'s existing re-reduce path).
3335pub fn reduce_to_fit<F>(
3336    full_msgs: &[ChatMessage],
3337    base_policy: &ReductionPolicy,
3338    prior_log: &ReductionLog,
3339    fits: F,
3340) -> (Vec<ChatMessage>, ReductionLog, ReductionPolicy)
3341where
3342    F: Fn(&[ChatMessage]) -> bool,
3343{
3344    let (mut best_view, mut best_log) = project_messages(full_msgs, base_policy, prior_log);
3345    let mut best_policy = base_policy.clone();
3346    let mut best_tokens = crate::tokens::estimate_view_tokens(&best_view);
3347
3348    let mut level = 1;
3349    while !fits(&best_view) && level <= MAX_AGGRESSIVE_LEVELS {
3350        let candidate_policy = tighten(base_policy, level);
3351        let (view, log) = project_messages(full_msgs, &candidate_policy, prior_log);
3352        let view_tokens = crate::tokens::estimate_view_tokens(&view);
3353        if view_tokens < best_tokens {
3354            best_view = view;
3355            best_log = log;
3356            best_policy = candidate_policy;
3357            best_tokens = view_tokens;
3358        }
3359        level += 1;
3360    }
3361
3362    // PARITY-18 D6 — [`tighten`] only scales byte-based knobs (tool output,
3363    // images, tool input), so a TEXT-heavy session (mostly plain user/
3364    // assistant turns, little/no tool output to shrink) can exhaust every
3365    // level above and still not `fits`. Escalate to A10 turn-clearing
3366    // (`clear_turns_older_than`) as a last resort: stacked on top of
3367    // whatever byte-knob tightening already achieved (`best_policy`),
3368    // progressively HALVING the surviving message-count window until the
3369    // projection fits OR the window bottoms out at
3370    // [`MIN_CLEAR_TURNS_WINDOW`]. Reversible, not lossy —
3371    // `ReductionKind::TurnsCleared` stubs are hash-verified and rehydrate
3372    // byte-exact from the sidecar via `expand_reduction`/`invert`
3373    // (dev/03 fidelity is untouched: nothing here bypasses that path).
3374    // Gated on `base_policy.clear_turns_older_than.is_none()` so this never
3375    // overrides an EXPLICIT threshold a caller already set (e.g.
3376    // `Agent::maybe_compact`'s own live-session clearing, which never calls
3377    // `reduce_to_fit` at all, but the guard costs nothing to keep honest).
3378    //
3379    // PARITY-18 v3 — this loop previously ran at most `MAX_AGGRESSIVE_LEVELS`
3380    // (5) halvings starting from `full_msgs.len()`, so any session over
3381    // ~128 messages bottomed out well above the floor (a 3,000-message
3382    // session stopped at ~93, never reaching 4) — "maximal reduction"
3383    // wasn't actually maximal, which weakened the honesty of a genuine
3384    // refusal (it would refuse having never tried the smallest window).
3385    // The loop now keeps halving unconditionally until `threshold` reaches
3386    // [`MIN_CLEAR_TURNS_WINDOW`] regardless of the starting length — still
3387    // `O(log2(full_msgs.len()))` iterations, so it stays bounded — or until
3388    // `fits` succeeds, whichever comes first.
3389    if !fits(&best_view) && base_policy.clear_turns_older_than.is_none() {
3390        let mut threshold = full_msgs.len();
3391        loop {
3392            if threshold <= MIN_CLEAR_TURNS_WINDOW {
3393                break;
3394            }
3395            threshold = (threshold / 2).max(MIN_CLEAR_TURNS_WINDOW);
3396            let mut candidate_policy = best_policy.clone();
3397            candidate_policy.clear_turns_older_than = Some(threshold);
3398            let (view, log) = project_messages(full_msgs, &candidate_policy, prior_log);
3399            let view_tokens = crate::tokens::estimate_view_tokens(&view);
3400            if view_tokens < best_tokens {
3401                best_view = view;
3402                best_log = log;
3403                best_policy = candidate_policy;
3404                best_tokens = view_tokens;
3405            }
3406            if fits(&best_view) {
3407                break;
3408            }
3409        }
3410    }
3411
3412    (best_view, best_log, best_policy)
3413}
3414
3415/// PARITY-18 D6 — the smallest surviving message-count window
3416/// [`reduce_to_fit`]'s A10 escalation will ever request via
3417/// `clear_turns_older_than`. `compute_clear_range` itself already floors
3418/// `keep_recent` at `2`; `4` here leaves a little more headroom (at least
3419/// one full user/assistant exchange) before giving up rather than shrinking
3420/// the window to the bare minimum every level.
3421const MIN_CLEAR_TURNS_WINDOW: usize = 4;
3422
3423/// Pure function of `(session, policy, prior)`: the single entry point
3424/// producing what the model sees (SPEC.md A5). A thin delegate over
3425/// [`project_messages`] — `session.messages` is exactly the slice
3426/// [`project_messages`] projects.
3427pub fn project(
3428    session: &Session,
3429    policy: &ReductionPolicy,
3430    prior: &ReductionLog,
3431) -> (Vec<ChatMessage>, ReductionLog) {
3432    project_messages(&session.messages, policy, prior)
3433}
3434
3435// ---------------------------------------------------------------------------
3436// A6 — invert() / invert_one(): reduced view + log + sidecar -> full view
3437// ---------------------------------------------------------------------------
3438
3439/// Resolve and hash-verify the full original content standing behind `ptr`
3440/// (kind-agnostic: [`ReductionKind::ToolOutputTruncated`],
3441/// [`ReductionKind::FileReadElided`], [`ReductionKind::OutputNormalized`],
3442/// [`ReductionKind::FileReadDiffed`], and [`ReductionKind::DuplicateOutput`]
3443/// all restore by replacing the reduced message's whole content with this —
3444/// for `OutputNormalized` this is the RAW captured bytes, byte-exact, never
3445/// the normalized/rendered text: the sidecar only ever stores the raw
3446/// capture, so this same kind-agnostic resolve path already restores it
3447/// correctly with no `OutputNormalized`-specific branch needed here).
3448///
3449/// Takes the resolved-against message slice directly (rather than a
3450/// [`Session`]) so [`rehydrate`]'s model-invocable path can resolve against a
3451/// live `Agent`'s own `history[1..]` — which the sidecar invariant
3452/// (`Agent::resume_recorded`'s doc comment) guarantees is byte-identical to
3453/// `Session::from_native_str(sidecar).messages` at every instant **once a
3454/// recorder and a [`ReductionPolicy`] are both installed** (TR-12's D6/A7
3455/// supersession gate in `Agent::run_loop`, the only combination under which
3456/// any reduction here is ever minted with a durable sidecar behind it) —
3457/// without needing to round-trip through disk in that case.
3458/// `invert`/`invert_one`/`verify_log` (the offline, `Session`-backed callers)
3459/// simply pass `sidecar.messages`, so they need no such gate to already hold:
3460/// a hash minted under the gate recomputes identically from either slice by
3461/// construction; a hash minted from a pre-gate legacy sidecar instead fails
3462/// safe here (content_hash mismatch) rather than resolving to the wrong
3463/// bytes.
3464fn resolve_original_content(ptr: &SidecarPtr, messages: &[ChatMessage]) -> Result<String> {
3465    let msg = messages.get(ptr.addr.index).ok_or_else(|| {
3466        Error::Other(format!(
3467            "invert: sidecar has no message at index {} (reduction pointer unresolvable)",
3468            ptr.addr.index
3469        ))
3470    })?;
3471    if msg.role != ptr.addr.role {
3472        return Err(Error::Other(format!(
3473            "invert: role mismatch at sidecar index {}: pointer expects {:?}, sidecar has {:?}",
3474            ptr.addr.index, ptr.addr.role, msg.role
3475        )));
3476    }
3477    let content = msg.content.clone().unwrap_or_default();
3478    ptr.verify(content.as_bytes())?;
3479    Ok(content)
3480}
3481
3482/// Resolve and hash-verify the original image content part standing behind an
3483/// [`ReductionKind::ImageRedacted`] pointer. See [`resolve_original_content`]
3484/// for why this takes a message slice rather than a [`Session`].
3485fn resolve_image_part(
3486    ptr: &SidecarPtr,
3487    part_index: usize,
3488    messages: &[ChatMessage],
3489) -> Result<serde_json::Value> {
3490    let msg = messages.get(ptr.addr.index).ok_or_else(|| {
3491        Error::Other(format!(
3492            "invert: sidecar has no message at index {} (reduction pointer unresolvable)",
3493            ptr.addr.index
3494        ))
3495    })?;
3496    if msg.role != ptr.addr.role {
3497        return Err(Error::Other(format!(
3498            "invert: role mismatch at sidecar index {}: pointer expects {:?}, sidecar has {:?}",
3499            ptr.addr.index, ptr.addr.role, msg.role
3500        )));
3501    }
3502    let part = msg
3503        .content_parts
3504        .as_ref()
3505        .and_then(|parts| parts.get(part_index))
3506        .ok_or_else(|| {
3507            Error::Other(format!(
3508                "invert: sidecar message at index {} has no content part {part_index}",
3509                ptr.addr.index
3510            ))
3511        })?;
3512    let serialized = serde_json::to_vec(part)
3513        .map_err(|e| Error::Other(format!("invert: failed to serialize image part: {e}")))?;
3514    ptr.verify(&serialized)?;
3515    Ok(part.clone())
3516}
3517
3518/// Resolve and hash-verify the original message range `first..=last` standing
3519/// behind a [`ReductionKind::TurnsCleared`] pointer. The hash covers each
3520/// message's wire-serialized bytes (role/content/tool_calls/tool_call_id/name
3521/// — the same shape [`ChatMessage`]'s custom `Serialize` puts on the wire),
3522/// concatenated in order. See [`resolve_original_content`] for why this takes
3523/// a message slice rather than a [`Session`].
3524fn resolve_turns_range(
3525    ptr: &SidecarPtr,
3526    first: usize,
3527    last: usize,
3528    messages: &[ChatMessage],
3529) -> Result<Vec<ChatMessage>> {
3530    let mut msgs = Vec::with_capacity(last.saturating_sub(first) + 1);
3531    for i in first..=last {
3532        let msg = messages.get(i).ok_or_else(|| {
3533            Error::Other(format!(
3534                "invert: sidecar has no message at index {i} (turns-cleared range unresolvable)"
3535            ))
3536        })?;
3537        msgs.push(msg.clone());
3538    }
3539    if let Some(first_msg) = msgs.first() {
3540        if first_msg.role != ptr.addr.role {
3541            return Err(Error::Other(format!(
3542                "invert: role mismatch at sidecar index {first}: pointer expects {:?}, sidecar has {:?}",
3543                ptr.addr.role, first_msg.role
3544            )));
3545        }
3546    }
3547    // Shared with the creating side (`hash_turns_range`, A10) so the two
3548    // formulas can never drift apart: blake3 over each message's wire-
3549    // serialized bytes, concatenated in order. (The byte length only matters
3550    // to the creating side's stub summary — ignored here.)
3551    let (hash, _bytes) = hash_turns_range(&msgs)?;
3552    ptr.verify_hash(&hash)?;
3553    Ok(msgs)
3554}
3555
3556/// Rehydrate one reduction `r` in place within `out`: locate the placeholder
3557/// message by its `sc.reduction` id, then restore it from `sidecar`
3558/// (hash-verified). `TurnsCleared` splices the original message range back in
3559/// place of the single placeholder message.
3560fn invert_one_reduction(
3561    out: &mut Vec<ChatMessage>,
3562    r: &Reduction,
3563    sidecar: &Session,
3564) -> Result<()> {
3565    let pos = out
3566        .iter()
3567        .position(|m| reduction_id(m) == Some(r.id.as_str()))
3568        .ok_or_else(|| {
3569            Error::Other(format!(
3570                "invert: no message in the reduced view carries reduction id {}",
3571                r.id
3572            ))
3573        })?;
3574
3575    match &r.kind {
3576        ReductionKind::ToolOutputTruncated { .. }
3577        | ReductionKind::FileReadElided { .. }
3578        | ReductionKind::OutputNormalized { .. }
3579        | ReductionKind::FileReadDiffed { .. }
3580        | ReductionKind::DuplicateOutput { .. }
3581        | ReductionKind::Superseded { .. } => {
3582            let original = resolve_original_content(&r.ptr, &sidecar.messages)?;
3583            out[pos].content = Some(original);
3584            out[pos].metadata.remove(REDUCTION_METADATA_KEY);
3585        }
3586        ReductionKind::ImageRedacted { part_index } => {
3587            let part = resolve_image_part(&r.ptr, *part_index, &sidecar.messages)?;
3588            let parts = out[pos].content_parts.get_or_insert_with(Vec::new);
3589            if *part_index < parts.len() {
3590                parts[*part_index] = part;
3591            } else {
3592                parts.push(part);
3593            }
3594            out[pos].metadata.remove(REDUCTION_METADATA_KEY);
3595        }
3596        ReductionKind::TurnsCleared { first, last, .. } => {
3597            let msgs = resolve_turns_range(&r.ptr, *first, *last, &sidecar.messages)?;
3598            out.splice(pos..=pos, msgs);
3599        }
3600        ReductionKind::ToolInputElided { call_id, field, .. } => {
3601            let original_value =
3602                resolve_tool_input_value(&r.ptr, call_id, field, &sidecar.messages)?;
3603            let current_args = out[pos]
3604                .tool_calls
3605                .as_ref()
3606                .and_then(|calls| calls.iter().find(|c| &c.id == call_id))
3607                .map(|call| call.function.arguments.clone())
3608                .ok_or_else(|| {
3609                    Error::Other(format!(
3610                        "invert: reduced message at position {pos} has no tool_call with id \
3611                         {call_id}"
3612                    ))
3613                })?;
3614            // Splicing the recovered ORIGINAL value back into the CURRENT
3615            // (reduced) arguments string at the same field's span restores
3616            // the pristine bytes exactly: the reduced string differs from
3617            // the original ONLY in that one field's value (the forward
3618            // splice at project-time never touched anything else), so this
3619            // is byte-exact by construction — no separate "store the whole
3620            // original args" bookkeeping needed.
3621            let restored = replace_top_level_string_field(&current_args, field, &original_value)
3622                .ok_or_else(|| {
3623                    Error::Other(format!(
3624                        "invert: reduced tool_call {call_id} arguments do not contain field \
3625                         `{field}` to restore"
3626                    ))
3627                })?;
3628            let calls = out[pos]
3629                .tool_calls
3630                .as_mut()
3631                .expect("checked above: tool_calls present");
3632            let call = calls
3633                .iter_mut()
3634                .find(|c| &c.id == call_id)
3635                .expect("checked above: call_id present");
3636            call.function.arguments = restored;
3637            out[pos].metadata.remove(REDUCTION_METADATA_KEY);
3638        }
3639    }
3640    Ok(())
3641}
3642
3643/// Reconstruct the full view: every placeholder in `reduced` replaced by the
3644/// original content resolved from `sidecar_session` (the ONE canonical
3645/// model — a `Session` reconstructed via [`Session::from_native_str`]),
3646/// hash-verified against [`SidecarPtr::content_hash`] before ever
3647/// substituting it in.
3648///
3649/// Fails loudly (`Err`) rather than silently partial: on any reduced message
3650/// whose `sc.reduction` id has no matching entry in `log` (an unresolvable
3651/// pointer — e.g. the log entry was deleted), on any pointer that no longer
3652/// resolves in the sidecar, or on any hash mismatch (a stale/foreign/tampered
3653/// sidecar).
3654///
3655/// Strips the `sc.reduction` bookkeeping key from every message's metadata —
3656/// it never survives inversion.
3657pub fn invert(
3658    reduced: &[ChatMessage],
3659    log: &ReductionLog,
3660    sidecar_session: &Session,
3661) -> Result<Vec<ChatMessage>> {
3662    let mut out: Vec<ChatMessage> = reduced.to_vec();
3663
3664    // Every reduced (stub-bearing) message must resolve to a log entry —
3665    // otherwise it is an unresolvable pointer by design (SPEC.md A6(b)).
3666    let by_id: HashMap<&str, &Reduction> =
3667        log.reductions.iter().map(|r| (r.id.as_str(), r)).collect();
3668    for msg in &out {
3669        if let Some(id) = reduction_id(msg) {
3670            if !by_id.contains_key(id) {
3671                return Err(Error::Other(format!(
3672                    "invert: reduced message carries reduction id {id} with no matching entry \
3673                     in the reduction log — unresolvable pointer"
3674                )));
3675            }
3676        }
3677    }
3678
3679    for r in &log.reductions {
3680        invert_one_reduction(&mut out, r, sidecar_session)?;
3681    }
3682
3683    for msg in out.iter_mut() {
3684        msg.metadata.remove(REDUCTION_METADATA_KEY);
3685    }
3686
3687    Ok(out)
3688}
3689
3690/// Verify every reduction in `log` resolves against `sidecar` — the same
3691/// hash-verify path `invert`/`invert_one` walk before ever substituting
3692/// content back in, without needing an actual reduced view to substitute
3693/// into. This is the user-facing detector for a broken transparency
3694/// invariant (C2 contract 3): `sessions show-reductions` (C4) and `convert`
3695/// (C7) both call this before doing anything else with a reduced session, so
3696/// a corrupt/tampered/stale sidecar is reported — naming the offending
3697/// record id — before any output is produced, rather than surfacing as a
3698/// confusing downstream failure (or, worse, silently substituting the wrong
3699/// content).
3700///
3701/// Returns the first offending record's error (which names its `id`); `Ok`
3702/// means every record in `log` resolves and hash-verifies cleanly.
3703pub fn verify_log(log: &ReductionLog, sidecar: &Session) -> Result<()> {
3704    for r in log.reductions.iter().chain(log.expanded.iter()) {
3705        let resolved = match &r.kind {
3706            ReductionKind::ToolOutputTruncated { .. }
3707            | ReductionKind::FileReadElided { .. }
3708            | ReductionKind::OutputNormalized { .. }
3709            | ReductionKind::FileReadDiffed { .. }
3710            | ReductionKind::DuplicateOutput { .. }
3711            | ReductionKind::Superseded { .. } => {
3712                resolve_original_content(&r.ptr, &sidecar.messages).map(|_| ())
3713            }
3714            ReductionKind::ImageRedacted { part_index } => {
3715                resolve_image_part(&r.ptr, *part_index, &sidecar.messages).map(|_| ())
3716            }
3717            ReductionKind::TurnsCleared { first, last, .. } => {
3718                resolve_turns_range(&r.ptr, *first, *last, &sidecar.messages).map(|_| ())
3719            }
3720            ReductionKind::ToolInputElided { call_id, field, .. } => {
3721                resolve_tool_input_value(&r.ptr, call_id, field, &sidecar.messages).map(|_| ())
3722            }
3723        };
3724        if let Err(e) = resolved {
3725            return Err(Error::Other(format!(
3726                "reduction {} unresolvable against the sidecar: {e}",
3727                r.id
3728            )));
3729        }
3730    }
3731    Ok(())
3732}
3733
3734/// Rehydrate a single reduction by id (C4 `/expand <id>`; also used for
3735/// per-range `TurnsCleared` expansion). Returns the updated view and a log
3736/// with that record removed (an expanded reduction is no longer "applied").
3737///
3738/// Other placeholders in `reduced` are untouched — their bytes remain
3739/// identical.
3740pub fn invert_one(
3741    reduced: &[ChatMessage],
3742    log: &ReductionLog,
3743    id: &str,
3744    sidecar_session: &Session,
3745) -> Result<(Vec<ChatMessage>, ReductionLog)> {
3746    let r = log
3747        .reductions
3748        .iter()
3749        .find(|r| r.id == id)
3750        .cloned()
3751        .ok_or_else(|| Error::Other(format!("invert_one: no reduction with id {id} in the log")))?;
3752
3753    let mut out: Vec<ChatMessage> = reduced.to_vec();
3754    invert_one_reduction(&mut out, &r, sidecar_session)?;
3755
3756    let mut new_log = log.clone();
3757    new_log.reductions.retain(|x| x.id != id);
3758    if !new_log.expanded.iter().any(|x| x.id == r.id) {
3759        new_log.expanded.push(r);
3760    }
3761
3762    Ok((out, new_log))
3763}
3764
3765// ---------------------------------------------------------------------------
3766// A12 — export_session_spliced(): prefix-verbatim export to the origin format
3767// ---------------------------------------------------------------------------
3768
3769/// Export a (possibly reduced) live session in `format`, replaying the
3770/// imported prefix verbatim when `format` is the session's own origin
3771/// (`Session::to_jsonl_spliced`, SPEC.md A12).
3772///
3773/// Mirrors [`export_session`] exactly, but through the splice path: **always**
3774/// reconstructs from `sidecar` (the reduced view / `ReductionLog` are not
3775/// inputs), and applies the identical grammar-aware leak guard.
3776pub fn export_session_spliced(
3777    sidecar: &str,
3778    format: SessionFormat,
3779    session_id: Option<&str>,
3780) -> Result<String> {
3781    export_session_spliced_with_overrides(sidecar, format, session_id, None)
3782}
3783
3784/// The same lossless sidecar export as [`export_session_spliced`], with an
3785/// optional explicit working-directory override for stock-harness handoffs.
3786/// This keeps CLI `--cwd` effective even when the saved source format did not
3787/// persist an absolute working directory of its own.
3788pub fn export_session_spliced_with_overrides(
3789    sidecar: &str,
3790    format: SessionFormat,
3791    session_id: Option<&str>,
3792    cwd: Option<&std::path::Path>,
3793) -> Result<String> {
3794    let mut session = Session::from_sidecar_str(sidecar)?;
3795    if session_contains_reduction_stub(&session) {
3796        return Err(Error::Other(format!(
3797            "export_session_spliced: refusing to export — the sidecar contains a grammar-valid \
3798             reduction stub beginning with {REDUCTION_SENTINEL:?}; a reduced view leaked \
3799             into an export path that must only read the full-fidelity sidecar"
3800        )));
3801    }
3802    if let Some(cwd) = cwd {
3803        session.meta.cwd = Some(cwd.to_path_buf());
3804    }
3805    session.to_jsonl_spliced(format, session_id)
3806}
3807
3808#[cfg(test)]
3809mod tool_input_splice_tests {
3810    //! Unit tests for the TR-10 byte-surgical JSON field replacement
3811    //! primitives (private to this module) — the gotcha these exist to
3812    //! satisfy: "replace only the payload field's value, do NOT reserialize
3813    //! the whole args." Exercised directly here since they're not part of
3814    //! the public API; `tests/tool_input_elision.rs` covers the
3815    //! `project_messages`/`invert` integration level.
3816    use super::*;
3817
3818    #[test]
3819    fn finds_and_replaces_only_the_named_fields_value() {
3820        let json = r#"{"path":"src/foo.rs","content":"hello world","flag":true}"#;
3821        let (start, end) = find_top_level_string_field(json, "content").unwrap();
3822        assert_eq!(&json[start..end], "hello world");
3823
3824        let replaced = replace_top_level_string_field(json, "content", "STUB").unwrap();
3825        assert_eq!(
3826            replaced,
3827            r#"{"path":"src/foo.rs","content":"STUB","flag":true}"#
3828        );
3829        // Every other byte (key order, the `path`/`flag` values, punctuation)
3830        // is untouched — not a full reparse+reserialize.
3831        assert!(replaced.contains(r#""path":"src/foo.rs""#));
3832        assert!(replaced.contains(r#""flag":true"#));
3833    }
3834
3835    #[test]
3836    fn preserves_whitespace_and_key_order_around_the_replaced_field() {
3837        // Deliberately unusual formatting a naive reparse+reserialize would
3838        // normalize away (extra spaces, content BEFORE path).
3839        let json = "{ \"content\" : \"big\",   \"path\":\"a/b.rs\" }";
3840        let replaced = replace_top_level_string_field(json, "content", "X").unwrap();
3841        assert_eq!(replaced, "{ \"content\" : \"X\",   \"path\":\"a/b.rs\" }");
3842    }
3843
3844    #[test]
3845    fn handles_escaped_quotes_backslashes_and_unicode_in_the_value() {
3846        let original_value = "line1\nline2 \"quoted\" \\ and unicode caf\u{e9}";
3847        let json = serde_json::json!({"path": "p", "content": original_value}).to_string();
3848        let (start, end) = find_top_level_string_field(&json, "content").unwrap();
3849        // The span is the RAW (still-escaped) body; decoding it must recover
3850        // the original value exactly.
3851        let raw = format!("\"{}\"", &json[start..end]);
3852        let decoded: String = serde_json::from_str(&raw).unwrap();
3853        assert_eq!(decoded, original_value);
3854
3855        let new_value = "replacement with \"quotes\" and \\ backslash and \u{1f600}";
3856        let replaced = replace_top_level_string_field(&json, "content", new_value).unwrap();
3857        let reparsed: serde_json::Value = serde_json::from_str(&replaced).unwrap();
3858        assert_eq!(reparsed["content"], new_value);
3859        assert_eq!(reparsed["path"], "p");
3860    }
3861
3862    #[test]
3863    fn skips_nested_objects_and_arrays_in_sibling_fields() {
3864        let json = r#"{"meta":{"a":[1,2,{"b":"}}}"}]},"content":"payload","tags":["x","y"]}"#;
3865        let (start, end) = find_top_level_string_field(json, "content").unwrap();
3866        assert_eq!(&json[start..end], "payload");
3867        let replaced = replace_top_level_string_field(json, "content", "NEW").unwrap();
3868        let reparsed: serde_json::Value = serde_json::from_str(&replaced).unwrap();
3869        assert_eq!(reparsed["content"], "NEW");
3870        assert_eq!(reparsed["tags"][0], "x");
3871        assert_eq!(reparsed["meta"]["a"][2]["b"], "}}}");
3872    }
3873
3874    #[test]
3875    fn returns_none_when_field_absent_or_not_a_string_or_not_an_object() {
3876        assert_eq!(
3877            find_top_level_string_field(r#"{"path":"a"}"#, "content"),
3878            None
3879        );
3880        assert_eq!(
3881            find_top_level_string_field(r#"{"content":42}"#, "content"),
3882            None
3883        );
3884        assert_eq!(
3885            find_top_level_string_field(r#"["not","an","object"]"#, "content"),
3886            None
3887        );
3888        assert_eq!(
3889            find_top_level_string_field("not json at all", "content"),
3890            None
3891        );
3892        assert_eq!(
3893            replace_top_level_string_field(r#"{"path":"a"}"#, "content", "x"),
3894            None
3895        );
3896    }
3897}