diff --git a/Cargo.lock b/Cargo.lock index 11ca0d7d5..2027f2361 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2180,6 +2180,7 @@ dependencies = [ "ast-grep-core", "brush-builtins", "brush-core", + "brush-parser", "clap", "dashmap", "globset", diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index 9867d4b63..3e5779b71 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -20,6 +20,7 @@ napi = { version = "3", features = ["napi10", "tokio_rt", "tokio_time"] } napi-derive = "3" brush-core = { version = "0.4.0", path = "../brush-core-vendored" } brush-builtins = { version = "0.1.0", path = "../brush-builtins-vendored" } +brush-parser = "0.3" parking_lot = "0.12.5" dashmap = "6.1" clap = { version = "4", features = ["derive"] } diff --git a/crates/pi-natives/src/shell.rs b/crates/pi-natives/src/shell.rs index 54d073d19..c4f006ff6 100644 --- a/crates/pi-natives/src/shell.rs +++ b/crates/pi-natives/src/shell.rs @@ -126,6 +126,24 @@ pub struct ShellRunOptions<'env> { pub signal: Option>, } +/// Telemetry for a single minimization, surfaced when the minimizer +/// actually rewrote the command's output. The session layer is expected to +/// persist `original_text` via its `ArtifactManager` and splice the +/// resulting `artifact://` reference into whatever is shown to the +/// agent. +#[napi(object)] +pub struct MinimizerResult { + /// Dispatch label produced by the minimizer (e.g. `"git"`, + /// `"pipeline:gradle"`, `"pipeline+builtin"`). + pub filter: String, + /// The full original capture, before minimization. + pub original_text: String, + /// Captured byte length before minimization. + pub input_bytes: u32, + /// Byte length of the minimized text the consumer received. + pub output_bytes: u32, +} + /// Result of running a shell command. #[napi(object)] pub struct ShellRunResult { @@ -135,6 +153,11 @@ pub struct ShellRunResult { pub cancelled: bool, /// Whether the command timed out before completion. pub timed_out: bool, + /// When the minimizer rewrote the captured output, this carries the + /// original buffer + telemetry so the session layer can persist it as + /// an artifact and splice an `artifact://` reference into the + /// minimized text shown to the agent. `None` when nothing was rewritten. + pub minimized: Option, } /// Persistent brush-core shell session. @@ -261,6 +284,7 @@ async fn run_shell_session( exit_code: None, cancelled: matches!(reason, task::AbortReason::Signal), timed_out: matches!(reason, task::AbortReason::Timeout), + minimized: None, }); } }; @@ -268,11 +292,17 @@ async fn run_shell_session( res.unwrap_or_else(|e| Err(Error::from_reason(format!("Shell execution task failed: {e}")))); abort_state.clear().await; - let keepalive = res.as_ref().is_ok_and(session_keepalive); + let keepalive = res.as_ref().is_ok_and(|pair| session_keepalive(&pair.0)); if !keepalive { *session.lock().await = None; } - Ok(ShellRunResult { exit_code: Some(exit_code(&res?)), cancelled: false, timed_out: false }) + let (exec, minimized) = res?; + Ok(ShellRunResult { + exit_code: Some(exit_code(&exec)), + cancelled: false, + timed_out: false, + minimized, + }) } /// Options for executing a shell command via brush-core. @@ -305,6 +335,8 @@ pub struct ShellExecuteResult { pub cancelled: bool, /// Whether the command timed out before completion. pub timed_out: bool, + /// See [`ShellRunResult::minimized`]. + pub minimized: Option, } /// Execute a brush shell command. @@ -367,6 +399,7 @@ async fn run_shell_oneshot( exit_code: None, cancelled: matches!(reason, task::AbortReason::Signal), timed_out: matches!(reason, task::AbortReason::Timeout), + minimized: None, }) }, }; @@ -374,7 +407,13 @@ async fn run_shell_oneshot( let res = run_result .unwrap_or_else(|e| Err(Error::from_reason(format!("Shell execution task failed: {e}")))); - Ok(ShellExecuteResult { exit_code: Some(exit_code(&res?)), cancelled: false, timed_out: false }) + let (exec, minimized) = res?; + Ok(ShellExecuteResult { + exit_code: Some(exit_code(&exec)), + cancelled: false, + timed_out: false, + minimized, + }) } fn null_file() -> Result { @@ -559,7 +598,7 @@ async fn run_shell_command( options: &ShellRunConfig, on_chunk: Option>, cancel_token: CancellationToken, -) -> Result { +) -> Result<(ExecutionResult, Option)> { if let Some(cwd) = options.cwd.as_deref() { session .shell @@ -714,6 +753,7 @@ async fn run_shell_command( let result = result.map_err(|err| Error::from_reason(format!("Shell execution failed: {err}")))?; + let mut minimized_out: Option = None; if let Some(OutputRead::Buffered(output)) = reader_output && let Some(config) = options.minimizer.as_ref() { @@ -722,11 +762,20 @@ async fn run_shell_command( } else { let minimized = minimizer::apply(&options.command, &output.text, exit_code(&result), config); - let _ = minimized.changed; emit_chunk(&minimized.text, final_callback.as_ref()); + if minimized.changed + && let Some(original) = minimized.original_text + { + minimized_out = Some(MinimizerResult { + filter: minimized.filter.to_string(), + original_text: original, + input_bytes: u32::try_from(minimized.input_bytes).unwrap_or(u32::MAX), + output_bytes: u32::try_from(minimized.text.len()).unwrap_or(u32::MAX), + }); + } } } - Ok(result) + Ok((result, minimized_out)) } #[cfg(unix)] diff --git a/crates/pi-natives/src/shell/minimizer.rs b/crates/pi-natives/src/shell/minimizer.rs index cc8ac796b..9b8ee2f02 100644 --- a/crates/pi-natives/src/shell/minimizer.rs +++ b/crates/pi-natives/src/shell/minimizer.rs @@ -13,6 +13,8 @@ pub mod primitives; pub mod pipeline; +pub mod plan; + use std::borrow::Cow; pub use config::{MinimizerConfig, MinimizerOptions}; @@ -45,6 +47,13 @@ pub struct MinimizerOutput { /// Name of the dispatch path that produced this output (e.g. `"git"`, /// `"pipeline:gradle"`, or `"passthrough"`). Useful for telemetry. pub filter: &'static str, + /// Original (un-minimized) capture, surfaced only when the filter + /// actually rewrote the output. The caller (JS session layer) is expected + /// to persist this via its session-scoped `ArtifactManager` and splice an + /// `artifact://` reference into [`text`](Self::text) before + /// presenting it to the agent. The minimizer itself does not hold onto + /// the original past this struct. + pub original_text: Option, } impl MinimizerOutput { @@ -52,14 +61,28 @@ impl MinimizerOutput { pub fn passthrough<'a>(text: impl Into>) -> Self { let text = text.into().into_owned(); let bytes = text.len(); - Self { text, changed: false, input_bytes: bytes, output_bytes: bytes, filter: "passthrough" } + Self { + text, + changed: false, + input_bytes: bytes, + output_bytes: bytes, + filter: "passthrough", + original_text: None, + } } /// Transformed output. Caller-supplied `input_bytes` lets the savings /// metric compare pre- and post-filter sizes. - pub const fn transformed(text: String, input_bytes: usize) -> Self { + pub fn transformed(text: String, input_bytes: usize) -> Self { let output_bytes = text.len(); - Self { text, changed: true, input_bytes, output_bytes, filter: "" } + Self { + text, + changed: true, + input_bytes, + output_bytes, + filter: "", + original_text: None, + } } /// Attach a `filter` label (e.g. `"git"`, `"pipeline:gradle"`) to an @@ -70,6 +93,17 @@ impl MinimizerOutput { self } + /// Record the original capture buffer on this output so the caller can + /// persist it as a session artifact and surface an `artifact://` + /// reference in [`text`](Self::text). No-op on passthrough outputs. + #[must_use] + pub fn with_original(mut self, original: impl Into) -> Self { + if self.changed { + self.original_text = Some(original.into()); + } + self + } + /// Byte count saved by this filter (0 for passthrough). #[allow(dead_code, reason = "test-only API surface")] pub const fn bytes_saved(&self) -> usize { diff --git a/crates/pi-natives/src/shell/minimizer/engine.rs b/crates/pi-natives/src/shell/minimizer/engine.rs index 73fe20e35..4f0e90d48 100644 --- a/crates/pi-natives/src/shell/minimizer/engine.rs +++ b/crates/pi-natives/src/shell/minimizer/engine.rs @@ -9,13 +9,17 @@ use std::{ }; use crate::shell::minimizer::{ - MinimizerConfig, MinimizerCtx, MinimizerOutput, detect, filters, + MinimizerConfig, MinimizerCtx, MinimizerOutput, detect, filters, plan, pipeline::{self, CompiledPipeline, PipelineRegistry}, }; /// Return true when the command has an enabled built-in filter or a matching -/// declarative pipeline. +/// declarative pipeline, AND the shell command is a single simple command +/// (pipes and compound commands are off-limits for correctness). pub fn should_minimize(command: &str, config: &MinimizerConfig) -> bool { + if !matches!(plan::analyze(command), plan::CommandPlan::Single { .. }) { + return false; + } let Some(identity) = detect::detect(command) else { return false; }; @@ -32,6 +36,13 @@ pub fn should_minimize(command: &str, config: &MinimizerConfig) -> bool { /// /// Panics inside filters are caught and converted to pass-through output so /// minimization can never be the reason a shell command loses output. +/// +/// When a filter actually rewrites the text, the returned +/// [`MinimizerOutput`] carries the original buffer in `original_text` so the +/// JS session layer can persist it via its `ArtifactManager` and splice an +/// `artifact://` reference back into the visible text before showing it +/// to the agent. The minimizer itself never formats the reference — ids are +/// assigned by the session store, not content-addressed. pub fn apply( command: &str, captured: &str, @@ -44,6 +55,24 @@ pub fn apply( return MinimizerOutput::passthrough(captured).labeled("too-large"); } + // Structural guard: only single simple commands are safe to minimize. + // Pipes almost always feed a downstream parser (awk, jq, rg, …) and + // rewriting their input is a correctness bug. Compound commands (`&&`, + // `||`, `;`, `&`) produce interleaved output from multiple programs; + // one filter cannot reason about the combined buffer. + match plan::analyze(command) { + plan::CommandPlan::Single { .. } => {}, + plan::CommandPlan::Piped => { + return MinimizerOutput::passthrough(captured).labeled("piped"); + }, + plan::CommandPlan::Compound => { + return MinimizerOutput::passthrough(captured).labeled("compound"); + }, + plan::CommandPlan::Unsupported => { + return MinimizerOutput::passthrough(captured).labeled("parse-error"); + }, + } + let Some(identity) = detect::detect(command) else { record_unknown_command(command); return MinimizerOutput::passthrough(captured).labeled("unknown"); @@ -62,7 +91,8 @@ pub fn apply( Err(_) => MinimizerOutput::passthrough(captured), }; let label = program_label(&identity.program); - return apply_pipeline_overlay(config, &identity.program, rust_output, label); + let overlaid = apply_pipeline_overlay(config, &identity.program, rust_output, label); + return overlaid.with_original(captured); } if let Some(pipeline) = resolve_pipeline(config, &identity.program, subcommand) { @@ -74,7 +104,9 @@ pub fn apply( if text == captured { return MinimizerOutput::passthrough(captured).labeled("pipeline-noop"); } - return MinimizerOutput::transformed(text, input_bytes).labeled("pipeline"); + return MinimizerOutput::transformed(text, input_bytes) + .labeled("pipeline") + .with_original(captured); } record_unknown_command(command); @@ -167,6 +199,7 @@ fn apply_pipeline_overlay( input_bytes: inner.input_bytes, output_bytes, filter: "pipeline+builtin", + original_text: inner.original_text, } } diff --git a/crates/pi-natives/src/shell/minimizer/plan.rs b/crates/pi-natives/src/shell/minimizer/plan.rs new file mode 100644 index 000000000..46433a084 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/plan.rs @@ -0,0 +1,190 @@ +//! Structural analysis of a shell command using `brush-parser`. +//! +//! The minimizer must not corrupt downstream parsing or stitch together +//! segments that emit interleaved output. This module parses the full +//! command with the same shell parser the vendored brush runtime uses and +//! classifies it into one of a few shapes the engine can reason about. +//! +//! ## Decisions encoded here +//! +//! - **Pipes are opaque.** Any `foo | bar` pipeline is marked as `Piped` +//! regardless of what `bar` is. A user piping through `awk`, `jq`, `rg`, +//! or any other consumer is almost certainly parsing the output; rewriting +//! it would be a correctness bug. The engine falls back to passthrough. +//! - **Compound commands are opaque.** `a && b`, `a ; b`, `a || b`, and +//! `a & b` produce interleaved output and two distinct filter regimes; +//! we don't attempt to slice the capture buffer by segment, so we bail. +//! - **Single simple commands** are the only shape where minimization is +//! safe; the engine dispatches them through `detect.rs` as before. +//! +//! When the command fails to parse (syntax error, unsupported construct), +//! we return `Unsupported` and the engine passes through. + +use brush_parser::{ + ParserOptions, SourceInfo, + ast::{AndOrList, Command, CompoundListItem, Pipeline, Program, SeparatorOperator}, +}; + +/// Outcome of analyzing a raw command string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandPlan { + /// Exactly one simple command. `program` is the leading word (without + /// arguments), verbatim from the parsed AST. + Single { program: String }, + /// The command contains at least one `|` pipeline. We intentionally do + /// NOT identify upstream / downstream programs here \u2014 any pipe defeats + /// safe minimization for this engine. + Piped, + /// The command has multiple segments joined by `&&`, `||`, `;`, or `&`. + Compound, + /// Parse failed, a compound shell construct (for loops, subshells, etc.) + /// was encountered, or the command was empty. + Unsupported, +} + +/// Parse `command` with `brush-parser` and classify its structure. +pub fn analyze(command: &str) -> CommandPlan { + let trimmed = command.trim(); + if trimmed.is_empty() { + return CommandPlan::Unsupported; + } + + let options = ParserOptions::default(); + let source = SourceInfo::default(); + let reader = std::io::Cursor::new(command.as_bytes().to_vec()); + let mut parser = brush_parser::Parser::new(reader, &options, &source); + + let program = match parser.parse_program() { + Ok(p) => p, + Err(_) => return CommandPlan::Unsupported, + }; + + classify(&program) +} + +fn classify(program: &Program) -> CommandPlan { + // Count separator-separated top-level items across all complete_commands. + let items: Vec<&CompoundListItem> = program + .complete_commands + .iter() + .flat_map(|cl| cl.0.iter()) + .collect(); + + if items.is_empty() { + return CommandPlan::Unsupported; + } + + if items.len() > 1 { + // `a ; b` or `a & b` produces multiple compound list items. + return CommandPlan::Compound; + } + + // Exactly one CompoundListItem: check the separator and the AndOrList. + let CompoundListItem(and_or, separator) = items[0]; + + // Async separator (`&`) backgrounds the command; treat as compound since + // the parent shell's stdout is the foreground command's \u2014 we don't know + // which one we're capturing. Conservative bail. + if matches!(separator, SeparatorOperator::Async) { + return CommandPlan::Compound; + } + + // AndOrList.additional holds the `&&` / `||` continuations. + if !and_or.additional.is_empty() { + return CommandPlan::Compound; + } + + // Only a single pipeline at this point. + classify_pipeline(&and_or.first).unwrap_or_else(|| classify_andorlist(and_or)) +} + +fn classify_pipeline(pipeline: &Pipeline) -> Option { + if pipeline.seq.len() > 1 { + return Some(CommandPlan::Piped); + } + let single = pipeline.seq.first()?; + match single { + Command::Simple(simple) => { + let program_word = simple.word_or_name.as_ref()?; + let program_text = program_word.to_string(); + if program_text.trim().is_empty() { + return None; + } + Some(CommandPlan::Single { program: program_text }) + }, + // Compound shell syntax (if / for / while / subshell / { ... }) is + // not something the minimizer should touch. + Command::Compound(_, _) | Command::Function(_) | Command::ExtendedTest(_) => { + Some(CommandPlan::Compound) + }, + } +} + +fn classify_andorlist(_and_or: &AndOrList) -> CommandPlan { + CommandPlan::Unsupported +} + +#[cfg(test)] +mod tests { + use super::*; + + fn program_of(plan: CommandPlan) -> Option { + match plan { + CommandPlan::Single { program } => Some(program), + _ => None, + } + } + + #[test] + fn single_simple_command() { + let plan = analyze("git status --short"); + assert_eq!(program_of(plan), Some("git".to_string())); + } + + #[test] + fn env_prefix_is_still_single() { + // env assignments are prefix, the program is `git`. + let plan = analyze("FOO=1 git status"); + assert!(matches!(plan, CommandPlan::Single { .. })); + } + + #[test] + fn pipe_is_piped() { + assert_eq!(analyze("git status | cat"), CommandPlan::Piped); + assert_eq!(analyze("ls -la | awk '{print $1}'"), CommandPlan::Piped); + } + + #[test] + fn and_or_is_compound() { + assert_eq!(analyze("cd foo && cargo test"), CommandPlan::Compound); + assert_eq!(analyze("foo || bar"), CommandPlan::Compound); + } + + #[test] + fn sequence_is_compound() { + assert_eq!(analyze("echo a ; echo b"), CommandPlan::Compound); + } + + #[test] + fn async_is_compound() { + assert_eq!(analyze("sleep 1 &"), CommandPlan::Compound); + } + + #[test] + fn empty_is_unsupported() { + assert_eq!(analyze(""), CommandPlan::Unsupported); + assert_eq!(analyze(" "), CommandPlan::Unsupported); + } + + #[test] + fn subshell_is_compound_not_single() { + // `(cmd)` is a compound-command variant, not Simple. + let plan = analyze("(cd foo && make)"); + assert!(matches!(plan, CommandPlan::Compound | CommandPlan::Unsupported)); + } + + #[test] + fn malformed_is_unsupported() { + assert_eq!(analyze("a && && b"), CommandPlan::Unsupported); + } +} diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7ec220590..c3335b952 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,9 @@ # Changelog ## [Unreleased] - ### Added +- Added full-output retrieval metadata to minimized shell command output by appending an `artifact://` footer with byte counts, allowing users to open the original unminimized command output - Added streaming preview API exports from the package (`resolveEditMode`, `EDIT_MODE_STRATEGIES`, and chunk preview helpers) so editors can reuse mode-aware edit preview logic programmatically - Added `shellMinimizer` configuration options (`enabled`, `settingsPath`, `only`, `except`, and `maxCaptureBytes`) so users can control shell output minimization behavior diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index 1d8e193fb..c6c0ae6e6 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -22,6 +22,17 @@ export interface BashExecutorOptions { /** Artifact path/id for full output storage */ artifactPath?: string; artifactId?: string; + /** + * Invoked when the native minimizer rewrote the command's output, giving + * the caller a chance to persist the lossless original capture (typically + * via the session's `ArtifactManager`). The returned id is spliced into + * the sink output as `artifact://` so the agent can retrieve the raw + * bytes. Return `undefined` to skip the footer. + */ + onMinimizedSave?: ( + originalText: string, + info: { filter: string; inputBytes: number; outputBytes: number }, + ) => Promise; } export interface BashResult { @@ -223,6 +234,21 @@ export async function executeBash(command: string, options?: BashExecutorOptions }; } + // When the native minimizer rewrote the output, persist the original + // as a session artifact and splice an `artifact://` footer into the + // visible text so the agent can retrieve the raw bytes losslessly. + const minimized = winner.result.minimized; + if (minimized && options?.onMinimizedSave) { + const artifactId = await options.onMinimizedSave(minimized.originalText, { + filter: minimized.filter, + inputBytes: minimized.inputBytes, + outputBytes: minimized.outputBytes, + }); + if (artifactId) { + sink.push(`\n… full output: artifact://${artifactId} (${minimized.inputBytes} → ${minimized.outputBytes} bytes)\n`); + } + } + // Normal completion return { exitCode: winner.result.exitCode, diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 7e3bd4fca..b4ce10c56 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -656,6 +656,16 @@ export class BashTool implements AgentTool { artifactPath, artifactId, onChunk: streamTailUpdates(tailBuffer, onUpdate), + onMinimizedSave: async originalText => { + try { + const alloc = await this.session.allocateOutputArtifact?.("bash-original"); + if (!alloc?.path || !alloc.id) return undefined; + await Bun.write(alloc.path, originalText); + return alloc.id; + } catch { + return undefined; + } + }, }); if (result.cancelled) { if (signal?.aborted) { diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index ef98e1346..d22d06b8d 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,11 +1,18 @@ # Changelog ## [Unreleased] + ### Added +- Added `settingsHash` to `MinimizerOptions` to verify the minimizer `settingsPath` contents against a xxHash64 digest before applying them +- Added `minimized` output telemetry via `MinimizerResult` on `ShellExecuteResult` and `ShellRunResult`, exposing the applied minimizer filter and original/minimized byte counts when output is rewritten - Added a new `minimizer` option to `ShellExecuteOptions` and `ShellOptions` to configure per-command output minimization - Added the `MinimizerOptions` API with controls for enabling minimization, overriding settings via `settingsPath`, allow/deny lists (`only`, `except`), and `maxCaptureBytes` capture limits +### Security + +- Added trust-gated loading for minimizer settings by requiring a matching `settingsHash` before accepting a settings file + ## [14.2.0] - 2026-04-23 ### Added diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 0ccb1cce5..0cd8ded48 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -1016,6 +1016,12 @@ export interface MinimizerOptions { * field-level defaults. `~` is expanded. */ settingsPath?: string + /** + * Optional xxHash64 digest (hex) of the settings file contents. When + * supplied, the engine refuses to honor a settings file whose hash does + * not match — a lightweight trust gate for agent-controllable paths. + */ + settingsHash?: string /** * Opt-in allowlist of program names (e.g. `"git"`). When empty or * absent, all built-in filters are active. @@ -1030,6 +1036,27 @@ export interface MinimizerOptions { maxCaptureBytes?: number } +/** + * Telemetry for a single minimization, surfaced when the minimizer + * actually rewrote the command's output. The session layer is expected to + * persist `original_text` via its `ArtifactManager` and splice the + * resulting `artifact://` reference into whatever is shown to the + * agent. + */ +export interface MinimizerResult { + /** + * Dispatch label produced by the minimizer (e.g. `"git"`, + * `"pipeline:gradle"`, `"pipeline+builtin"`). + */ + filter: string + /** The full original capture, before minimization. */ + originalText: string + /** Captured byte length before minimization. */ + inputBytes: number + /** Byte length of the minimized text the consumer received. */ + outputBytes: number +} + /** Parsed Kitty keyboard protocol sequence result for a Kitty input sequence. */ export interface ParsedKittyResult { /** Primary codepoint associated with the key. */ @@ -1282,6 +1309,8 @@ export interface ShellExecuteResult { cancelled: boolean /** Whether the command timed out before completion. */ timedOut: boolean + /** See [`ShellRunResult::minimized`]. */ + minimized?: MinimizerResult } /** Options for configuring a persistent shell session. */ @@ -1316,6 +1345,13 @@ export interface ShellRunResult { cancelled: boolean /** Whether the command timed out before completion. */ timedOut: boolean + /** + * When the minimizer rewrote the captured output, this carries the + * original buffer + telemetry so the session layer can persist it as + * an artifact and splice an `artifact://` reference into the + * minimized text shown to the agent. `None` when nothing was rewritten. + */ + minimized?: MinimizerResult } /**