lcc compact --mode tool-calls)The message-level sibling of block compaction. Block mode (--mode blocks, the default)
scores text blocks inside one document; this mode scores the tool calls of an agent
session: it pairs every tool_use with its tool_result by id and drops the pairs a
semantic judge says the session no longer needs.
Nothing is summarized. User and assistant text is never scored, never trimmed and never rewritten; a kept result comes back byte for byte; a dropped pair disappears whole. This is the mode a Claude Code hook drives, and the one to compare against summarization-based compaction.
lcc compact transcript.json -q "finish the parser fix" --mode tool-calls \
-o compacted.json -r report.json
lcc explain report.json # why every call was kept, trimmed or dropped
Input is a JSON transcript: either a bare messages array or {"messages": [...]}.
{
"messages": [
{"role": "user", "text": "Fix the failing parser test. Never edit src/generated."},
{"role": "assistant", "text": "Running the suite.",
"toolUses": [{"tool_use_id": "toolu_1", "tool": "Bash", "input": {"command": "pytest -q"}}]},
{"role": "user", "toolResults": [{"tool_use_id": "toolu_1", "text": "1 failed, 24 passed"}]}
]
}
Snake_case equivalents (tool_calls, tool_results) are accepted too, and a call id may be
tool_use_id or id. Output is the same shape, in the same order, with the decided pairs
removed or trimmed — so a caller can map it back onto its own session. Messages that lose
all of their content are dropped; results stay in the message they arrived in.
Malformed input fails loudly (exit 2): a decision taken over a half-parsed transcript is not auditable. A call without a result yet is left alone — there is nothing to judge.
For every non-pinned pair the judge answers two typed noul questions: does the call
still matter (knowing it happened, with its input), and does its result still need to be
there verbatim (its contents are needed and re-running would not do).
| Probabilities | Decision | Effect |
|---|---|---|
keep_result >= threshold |
keep |
call and result stay, byte for byte |
keep_call >= threshold (result below) |
trim |
call stays; result is cut to --trim-head-chars plus one note line |
both below threshold |
drop |
call and result are removed together |
Defaults: --threshold 0.5, --trim-head-chars 300. A result is never left without its
call, and a call is never dropped without its result.
The first message and the newest --preserve-recent messages (default 6) are pinned: their
calls are not scored and not mutated, and they appear in the report as source: pin with
reason pinned_first_message / pinned_recent. Pinning is the honest way to protect a live
turn: the objective and the tool you just ran are usually the ones that matter most.
The judge is shown the whole conversation, oldest first, with every tool result replaced by a
short note (ok, 4213 chars) — inputs and texts stay, payloads do not. That state is resent
with each request, so it is fitted into --max-state-tokens (default 25000) in stages,
cheapest first:
| Stage | What changes |
|---|---|
full |
as written |
inputs-200 |
tool inputs cut to 200 chars; long non-pinned texts abridged (head + tail) |
calls-one-line |
non-pinned calls become one line each; old call-only messages collapse |
If even the last stage does not fit, the pass fails closed (TranscriptFitError, exit 2)
instead of judging a conversation the judge could not see. Questions are split into as many
requests as needed so state plus one batch stays under --max-request-tokens (default
30000, Jev’s request limit is 32K); --batch-size caps calls per request, --max-workers
runs requests concurrently.
| Situation | provider_used |
degraded |
semantic_guarantee |
What happens |
|---|---|---|---|---|
no key / LCC_DISABLE_NETWORK=1 |
jev |
true | none |
every call kept, reason jev_unavailable_fail_safe |
| a batch fails | degraded when nothing was judged, jev+partial_fail_safe otherwise |
true | none / partial |
unjudged calls kept, judged calls keep their verdict |
| answer missing / malformed / outside 0–1 | — | — | — | that call is kept and the reason is a warning (jev_missing_answer:…, jev_answer_out_of_range:…) |
| nothing to score (all pinned) | jev |
false | none |
reason no_candidates; no call is claimed as judged |
mechanical is refused (exit 2) in this mode: “is this tool output still needed”
is a semantic question, and a lexical scorer cannot answer it honestly. Use --provider jev,
laya (local judge), auto (prefers Jev), or rules (offline policy, below).
--provider rulesNo key, no model, no network. Three modes (rules_mode, MCP argument / Python API):
lossless (default) — src/lcc/relevance/transcript_lossless.py. A tool result is
replaced by a pointer to a later result that is kept unchanged and has the same is_error
flag: [identical output kept at <id>] when the two are the same, or
[output = lines a-b of <id>] when the original is exactly raw lines a..b (1-based,
inclusive) of <id>. The note alone rebuilds the original, wherever <id> came from. Calls, inputs, texts and messages are never touched; nothing is
trimmed; a pointer never targets a replaced result. The only lines ignored are the Codex
Chunk ID: / Wall time: / Original token count: header lines (exit codes are compared).
verify_lossless(original, compacted) re-checks each note’s exact claim from the two payloads; the pass runs it
on its own output and keeps everything if it reports a violation. semantic_guarantee is
lossless; decisions are keep or dedupe (with kept_at).
recoverable — src/lcc/relevance/transcript_recoverable.py. Everything lossless does,
plus an old result may become [removed: re-read <path> (lines a-b) to recover] (a single-file
read whose path no later non-read call names or writes) or [removed: re-run `<cmd>` to
recover] (a command on the read-only allowlist: ls, find without -exec/-delete, rg,
grep, git status|diff|log|show, cat/head/tail/sed -n 'a,bp', wc, tree without
-o, pwd, <tool> --version; no pipes, redirects, substitutions or env prefixes). Never
replaced: other commands, MCP/web tools, edit results, failed results, the latest error and the
call before it, the last preserve_recent turns, calls named in the goal or recent user turns.
Decisions are replace (with note and recover), dedupe or keep; verify_recoverable
checks texts/inputs are unchanged and each pointer names its own call’s path or allowlisted
command. Command pointers are off unless allow_command_pointers.
The file-pointer check has two forms (recoverability_check in the report):
transcript_proxy (no workspace_root): a read is replaced only when no later call can have
written the file — no write, patch or delete naming it, no worktree-wide git command, no
opaque call (script, build, test run, unknown tool) that may write under its directory.
Benched offline as v0.8 (LCC 4c39161): 0/0/0 verification on 847 items, pooled reduction
0.0123; most removed pointers (1862 of 1978) are blocked by file_written_later.disk (workspace_root set and disk_check on, the default): the disk decides instead.
A file read is replaced iff the file currently exists, its path has no .. component, a
final symlink does not escape the file’s own directory (files outside the workspace root
are allowed: workspace_root only switches the disk check on, it does not contain reads),
it is a regular file of at most 4 MiB
(DISK_MAX_BYTES), decodes as UTF-8, and its bytes hold exactly the removed output — the
whole file, or the read’s line range. The only normalisation is the one the read tool
applied: Read’s N\t/N→ line-number prefixes and a Codex exec header are stripped;
CRLF never matches LF. Later writes, opaque or not, then do not matter: what the agent
re-reads is what was removed. The pointer says so:
[removed: re-read <path> to recover; content verified on disk at compaction time].
Reasons: replace_disk_verified, or keep_disk_mismatch, keep_disk_missing,
keep_disk_not_regular, keep_disk_outside_root, keep_disk_too_large,
keep_disk_unreadable. Right before emitting, every disk-verified file is re-hashed; one that
changed since its check is kept (keep_disk_changed_before_emit). Offline benches cannot
measure this form: the historical files have changed since the transcripts were recorded, so
real-use numbers come from the opt-in request log.Recommended config for live sessions: provider: rules, rules_mode: recoverable,
workspace_root = the session’s cwd (the Claude Code hook sends it), command pointers off.
lossless stays the default rules_mode: it needs no disk and makes no recovery claim.
What each mode measured on real agent sessions: REAL_SESSION_BENCHMARKS.md.
lossy (EXPERIMENTAL) — failed two session-disjoint blind audits (bench v0.4: 21/150
decisions flagged; v0.5: 36/132), because its trims and drops remove output an agent may still
need. A deterministic policy (src/lcc/relevance/transcript_rules.py) decides each unpinned call from the transcript’s
structure and records the rule as the reason: it keeps the latest error result and the call
before it, the latest git status|diff|log, plan/todo, edit, question to the user, the most
recent result, and calls on files/URLs named in the goal or recent turns; it drops outputs
superseded by a later successful whole-file Read/Write of the same file (a partial Read with
offset/limit or a failed one never supersedes) or the same call run again with the same
output, ignoring Codex chunk/timing header lines (superseded_by_later_call:<id>; a rerun
whose output changed, such as a poll or a test before and after a fix, is not dropped)
and successful reads far from recent work (stale_read_far_from_recent_work); it trims other
outputs over 4000 chars to a 1000-char head + 1000-char tail (large_old_output_trimmed),
except outputs from the last 40 messages, write_stdin/wait polls, error-bearing outputs,
reads of a file a later edit or apply_patch body (sent through any tool) changes, and calls naming a path from the goal
or recent turns (trim_guard_*, kept whole). semantic_guarantee is
none. Pass is_error: true on a tool result when the host knows it failed: when any result
carries the flag it alone marks errors; otherwise an error regex is applied to command output
only, never to Read/Grep of source.
Honored: --question, --provider jev|laya|auto|rules, --threshold, --trim-head-chars,
--preserve-recent, --max-state-tokens, --max-request-tokens, --batch-size,
--max-workers, --min-reduction (sets worth_it), --model (token counting),
--jev-model, -o/--output, -r/--report, --dry-run.
Refused with exit 2 (they would otherwise be silently ignored): --trim-threshold,
--preserve-tail, --min-block-chars, --keep-regex, --protect-prefix,
--prefix-marker, --decisions-cache, --no-marker, --marker-scores, --append-to,
--require-exact-tokens, --semantic-verify, --no-sufficiency, --max-restorations,
--confidence-threshold, and the --laya-* family.
The report is transcript-compaction-1.0 with "mode": "tool-calls". It carries the same
keys the block report uses where they map (decisions, blocks_* counts a call pair as one
block, chars_before/chars_after, reduction_ratio, tokens_*, worth_it,
semantic_guarantee, warnings), plus mode-specific ones: messages_before/messages_after,
tool_calls_total/kept/trimmed/dropped, tool_chars_before/after,
tool_reduction_ratio (the judged payload only), state_tokens, state_fit_stage,
requests. Because decisions keeps the block schema, lcc explain report.json works on it
unchanged, offline.
from lcc.relevance import TranscriptCompactionRequest, compact_transcript, messages_to_payload
result = compact_transcript(
TranscriptCompactionRequest(
payload=transcript, # dict or list
question="finish the parser fix",
provider="jev", # needs TYPESAFE_API_KEY
preserve_recent=6,
)
)
print(result.report["tool_calls_dropped"], result.report["tool_reduction_ratio"])
compacted_messages = messages_to_payload(result.messages)
MCP clients get the same pass as the tool compact_transcript (see docs/MCP.md).
Measured against the reference implementation (fast-jev-compaction 0.4.0) on the same three
synthetic transcripts, with matching options and live Jev: this mode removed 13.3 / 39.2 /
55.7% of the transcript at fact recall 1.00 / 1.00 / 1.00, while that library removed
70.5 / 78.9 / 83.4% at recall 0.00 — it dropped every evidence pair. Reduction alone would rank
them backwards. Method, stability runs and limits: benchmarks/research/TRANSCRIPT_AB.md; the
canonical row is in benchmarks/research/RESEARCH_STATUS.md. The largest session ranged
55.7–61.5% across four runs, and one of those runs dropped one evidence pair, which is why a
pair only falls when both questions score low.
--provider laya (local judge) and --provider rules (no model).benchmarks/research/REAL_SESSIONS.md (a real Claude Code
session: −66.1%, all 42 texts preserved; this release’s own 337-message session: −81.7%, all
58 texts preserved).(objective, block) outcomes to keep
output byte-stable; this mode re-scores each pass. Add a cache when a consumer needs
byte-stability across runs (--decisions-cache would be the flag).benchmarks/research/RESEARCH_STATUS.md); the same
warn applies here, which is why a call is only dropped when both questions are low.