Compact CLI rendering
Several read commands an agent pulls through Bash(satelle:*) — ledger list, story list, story docs, story messages, story diff --patch —
can render COMPACT instead of indented JSON: a CSV-backed table for a list of
records, and noise stripped from a unified diff patch. Every fold is
lossless-or-nothing: it applies only when decoding it back reproduces the
exact original AND the result is smaller (internal/compact.Fold /
FoldTable); otherwise the command prints the same indented JSON it always
has. Nothing about the underlying verb or its data changes — only how the CLI
prints the response.
Which commands, and when
[output] in satelle.toml:
[output]
compact_for_agents = true
compact_commands = ["ledger-list", "story-list", "story-doc-list", "story-messages", "story-diff"]
long_cell_bytes = 200 # default 200 — per-cell offload threshold
repeat_min = 3 # default 3 — collapse runs of this many identical lines
noise_patterns = ["go.sum"]
compact_commandsopts a verb in by name. A verb absent from the list always prints plain indented JSON,--compactor not.compact_for_agentsmakes compact the default, for a listed verb, when the caller looks like a dispatched or in-loop agent —SATELLE_SCRATCHset (every dispatched agent gets one) orCLAUDECODE=1(an in-loop Claude Code session). A human or script at a terminal still gets plain JSON unless it passes--compactitself.--compact(a persistent flag on every command) forces compact mode for a listed verb regardless ofcompact_for_agentsor the caller.--json(also persistent) always wins: it forces plain indented JSON even over--compact.noise_patternsare globs identifying generated/lockfile files (basename match unless the glob contains/) whose diff hunksstory diff --patchoffloads whole. The binary ships no pattern of its own — an empty list never treats any file as noise.
The zero value of [output] (an absent table) keeps every command exactly as
it always printed: plain indented JSON, unconditionally.
The table form
A JSON array of objects where each column holds one JSON kind throughout (a
null, or a key some rows omit entirely — the ordinary shape of Go's
omitempty, e.g. the ledger's story_id/actor/body/payload — never
disqualifies or fixes a column; only a real type mismatch does) renders as:
[N]{col1:kind1,col2:kind2,...}
<CSV row>
<CSV row>
...
N is the row count; each header entry names a column and its JSON kind
(str, num, bool, null, or json for a nested object/array). A present
cell is the record's exact compact JSON for that field — decoding parses it
back rather than re-deriving it; an absent cell (the key missing on that row)
is the empty CSV field, and decoding leaves the key out rather than inventing
one. A response that is not an array of objects, or has a real type mismatch
in some column, always falls back to plain JSON; there is nothing to fold.
A string cell over long_cell_bytes is replaced with a quoted retrieve marker
(see below) instead of printing the long value inline.
The diff patch form
story diff --patch in compact mode:
- always drops every
index abc..defline — the one piece of unified-diff framing this fold is lossy-without-offload about (there is nothing reviewable to retrieve back for a blob-id pair on its own). - offloads a whitespace-only hunk (every removed/added line pairs up equal once whitespace is stripped) behind a marker.
- offloads a whole file section whose path matches a
noise_patternsglob behind a marker. - collapses runs of
repeat_min+ identical lines to the line once plus... (repeated N times).
An offload never applies unless the marker line it leaves behind is actually shorter than what it replaces — a tiny hunk stays inline.
Ranked diff compression (sty_918e2086)
[output.diff_rank] layers a ranked reducer on top of the diff patch form
above — order-3 noise-stripping (the previous section) always runs first,
then this ranking, so go.sum never consumes a file slot ranking would
otherwise spend on real content. It replaces the gate payload's old unranked
byte cut, and — when enabled — also applies to story diff --patch's compact
rendering:
[output.diff_rank]
enabled = true
passthrough_lines = 50
max_files = 20
max_hunks_per_file = 10
context_lines = 2
priority_patterns = ["(?i)error", "(?i)todo|fixme|bug|fix", "(?i)security|auth|secret"]
- A patch at or under
passthrough_lineslines, or one that fails to parse as a unified diff, rides through unranked. - Otherwise, the
max_filesfiles with the most changed lines are kept (in original order); every other file is dropped behind one retrieve marker naming its path and changed-line count. - Within each kept file, up to
max_hunks_per_filehunks are kept: always the first and the last, then the highest-scored remainder. A contiguous run of dropped hunks collapses to one marker. - A hunk's score is
0.03per changed line (capped at0.3),+0.3when a changed line matches apriority_patternsregex, the total capped at1.0— so a one-line// TODOcan outrank a much larger plain hunk. - Within a kept hunk, a run of unchanged context longer than
context_lineson either side of a change is trimmed to that width; the trimmed run is offloaded behind a marker and the hunk's@@ -a,b +c,d @@header is rewritten to match.
Every threshold, cap, and pattern above is this repo's own configuration —
internal/compact.RankPatch carries no default of its own; an absent or
enabled = false table is a no-op, so a repo that never sets [output.diff_rank]
sees no change from before this story.
On the gate payload, ranking (when enabled) is the PRIMARY reducer for the
patch a reviewer sees; the existing byte ceiling stays only as a backstop, and
even that backstop now offloads its overflow tail behind a marker instead of
dropping it silently. diffFilesCount (500) and diffStatCeiling (4 KiB) —
which bound the separate files list and stat summary, not the patch — are
unchanged by this story.
story diff --patch --full skips BOTH noise-stripping and ranking, printing
the verb's raw patch untouched — reach for it when you need to see exactly
what git produced rather than what a reviewer would.
Crushing an oversized list (lossy)
When [output.crush] is enabled and a compact command's list is STILL larger
than size_threshold_bytes after the lossless table fold, the rows are
sampled: the first/last fractions of max_kept, rows matching an error
keyword, length/numeric outliers, structural outliers and rare status values
are kept (the forced ones outside the budget), the rest is stride-sampled.
Kept rows are untouched; one trailing {"_crushed": "<<ccr:HASH,rows,SIZE>>", "dropped": N, "total": T} element records the rest, and
satelle retrieve HASH returns the FULL original array. Lists under
min_items, and --json, are never crushed. Every value is authored
configuration.
Retrieving offloaded content
An offloaded cell or hunk leaves an EXTENDED retrieve marker:
<<ccr:HASH,KIND,SIZE>> (retrieve.MarkerKind) — retrieve.MarkerRE detects
both this and the plain <<ccr:HASH>> form, so anything already scanning for
markers keeps working unchanged. Resolve it exactly the same way as any other
CCR marker:
satelle retrieve <hash>
See satelle help retrieve for the marker grammar, retention, and which agent
seats can reach the verb.
Mechanism vs. configuration
internal/compact is pure mechanism: the table/diff/line folds and the
round-trip-and-smaller guard, with no config reads and no filename compiled
in. What counts as noise, which verbs compact, and whether an agent gets it by
default are [output] in satelle.toml — this repo's own configuration, not
a binary opinion (satelle-constitution: configuration over code).
Mirrored from satelle’s built-in help. Read it in the binary with
satelle help compact-output, or see the canonical source in the
satelle repo.