feat(context): session digest + soft 256K budget steering + post-compaction re-injection#29
Conversation
…action re-injection Interactive hosts expose no programmatic compaction (Claude Code's /compact has no model-invocable or hook-invocable form, hook-injected prompts are plain text, and the auto-compact threshold is fixed), so the compact-equivalent is built from the levers that exist: - digest: nightly_core/digest.py renders a compact key-state summary (run id, counters, markers, plans by status, open Nightly PRs, last cascade pick, branch, contract one-liner) to .nightly/runs/<id>/digest.md — refreshed every keepalive turn boundary (context.digest_every_turns, default 1) and always before a planning-phase/ideate reroute, the natural compaction point. - telemetry: the Stop hook estimates current context tokens from the host-provided transcript JSONL (last assistant usage metadata), stamps ctx=<n> on every keepalive.log heartbeat, persists keepalive.context, and nightly status shows a context line. - soft budget: context.budget_tokens (default 256000, 0 disables) — over budget, the injected continuation prompt is prefixed with a context-diet block: finish delicate in-flight work first, then lean on the fresh digest, background specialists (separate contexts), and lean tool output. Soft by design; never stops the session. - re-injection: the installer merges a Claude Code SessionStart hook (matcher "compact") running `nightly hook session-start` — after any compaction the digest is re-injected as additionalContext, so a freshly compacted context regains the key Nightly session state. hook_install gains matcher-aware merge/remove (back-compat kept). - config: new context: block in .nightly/config.yml; rules block, skill.md, README updated; RFC 011 records the design and the host constraints. Bump 0.0.12. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR ships RFC 011 (interactive context compaction) in v0.0.12 by implementing session digest rendering, transcript context token estimation with soft budgeting, context-diet prompt steering, planning-phase digest refresh, and SessionStart(compact) hook re-injection for lossless recovery after manual compaction. ChangesInteractive Context Compaction — Digest & Budget
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/nightly-core/src/nightly_core/cli.py (1)
2450-2458:⚠️ Potential issue | 🟠 Major | ⚡ Quick winParse
stop_hook_activeexplicitly instead of relying on Python truthiness.
bool(hook_input.get("stop_hook_active"))turns"false","0", and any other non-empty string intoTrue. That misclassifies fresh turn boundaries as in-chain boundaries, soRESPAWN_REQUESTEDandkeepalive.blockscan be left stale instead of being cleared.Suggested fix
- stop_hook_active = bool(hook_input.get("stop_hook_active")) + raw_stop_hook_active = hook_input.get("stop_hook_active") + if isinstance(raw_stop_hook_active, bool): + stop_hook_active = raw_stop_hook_active + elif isinstance(raw_stop_hook_active, str): + stop_hook_active = raw_stop_hook_active.strip().lower() in { + "1", + "true", + "yes", + "on", + } + else: + stop_hook_active = bool(raw_stop_hook_active)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/nightly-core/src/nightly_core/cli.py` around lines 2450 - 2458, The current coercion stop_hook_active = bool(hook_input.get("stop_hook_active")) misparses string values like "false" or "0" as True; replace it with explicit parsing: read raw = hook_input.get("stop_hook_active"), if isinstance(raw, bool) use it, if raw is None treat as False, if isinstance(raw, (int, float)) use bool(raw), otherwise lower the string and treat "false","0","no","none","off","" as False and "true","1","yes","on" as True (default False for unknown), and assign the result to stop_hook_active so RESPWAN_REQUESTED/keepalive.blocks logic is driven by a correctly parsed boolean.packages/nightly-core/src/nightly_core/cascade.py (1)
543-593:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftAvoid a 30s
gh pr listcall on the per-turn digest path.This helper now backs the new digest PR section, and the digest is refreshed on every keepalive boundary by default. A slow or misconfigured
ghprocess can therefore stall each stop-hook turn for up to 30 seconds. The digest path needs a faster fallback or cached PR snapshot instead of reusing the rescue-grade timeout unchanged.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/nightly-core/src/nightly_core/cascade.py` around lines 543 - 593, The gh call in _nightly_open_pr_branches can block up to 30s; change it to accept a timeout_seconds parameter (int) with a low default (e.g. 1s) and use that instead of the hardcoded 30 in subprocess.run, so fast paths (digest/keepalive) can pass a small value; also update the digest-path caller to pass a short timeout or use a cached snapshot instead of calling _nightly_open_pr_branches with the long default; keep existing exception handling (CalledProcessError, TimeoutExpired, OSError) and return [] on failure as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/nightly-core/src/nightly_core/config.py`:
- Around line 346-356: The _coerce_int helper is accidentally treating YAML
booleans as integers because int(True/False) yields 1/0; update _coerce_int to
detect and reject bool values before calling int() (e.g., fetch value =
context.get(key, default), if isinstance(value, bool) return default) so that
boolean YAML entries fall back to defaults; apply this change where
ContextConfig is constructed (budget_tokens, digest_every_turns, etc.) to ensure
bools don't coerce to numeric values.
In `@packages/nightly-core/src/nightly_core/digest.py`:
- Around line 246-260: _render_open_prs currently assumes the fixed nightly/ PR
prefix by calling open_nightly_pr_branches(root) which ignores any configured
GitConfig.branch_prefix; update _render_open_prs to read the repository config
(GitConfig.branch_prefix or equivalent) and pass that prefix into
open_nightly_pr_branches (or call the cascade helper variant that accepts a
prefix) so PR listing respects custom branch prefixes and does not return "_none
open._" for repos using a non-default prefix.
In `@packages/nightly-core/src/nightly_core/keepalive_hook.py`:
- Around line 627-629: The digest is being written before the current turn’s
cascade pick/history is recorded, causing the persisted “last cascade pick” to
be one turn stale; move the write_digest(root) call so it runs after the current
turn’s state is recorded (after calls to next_task() and
_record_and_count_repeats(...)) or, alternatively, render the digest from the
in-memory computed choice (the variable produced by next_task()/choice) rather
than reading disk state, ensuring ctx_cfg.digest_every_turns checks and the
write_digest invocation are updated to persist the freshest cascade state.
- Around line 279-281: The estimate_context_tokens helper currently calls
Path(transcript_path) which can raise TypeError/ValueError for malformed hook
payloads (e.g. list/object); update estimate_context_tokens to guard creation of
Path by wrapping Path(transcript_path) in a try/except that catches TypeError
and ValueError and returns None on failure so the helper preserves its
“best-effort / degrade to None” contract; reference the transcript_path variable
and the estimate_context_tokens function and ensure no exceptions propagate out
of this block (silently skip telemetry by returning None).
---
Outside diff comments:
In `@packages/nightly-core/src/nightly_core/cascade.py`:
- Around line 543-593: The gh call in _nightly_open_pr_branches can block up to
30s; change it to accept a timeout_seconds parameter (int) with a low default
(e.g. 1s) and use that instead of the hardcoded 30 in subprocess.run, so fast
paths (digest/keepalive) can pass a small value; also update the digest-path
caller to pass a short timeout or use a cached snapshot instead of calling
_nightly_open_pr_branches with the long default; keep existing exception
handling (CalledProcessError, TimeoutExpired, OSError) and return [] on failure
as before.
In `@packages/nightly-core/src/nightly_core/cli.py`:
- Around line 2450-2458: The current coercion stop_hook_active =
bool(hook_input.get("stop_hook_active")) misparses string values like "false" or
"0" as True; replace it with explicit parsing: read raw =
hook_input.get("stop_hook_active"), if isinstance(raw, bool) use it, if raw is
None treat as False, if isinstance(raw, (int, float)) use bool(raw), otherwise
lower the string and treat "false","0","no","none","off","" as False and
"true","1","yes","on" as True (default False for unknown), and assign the result
to stop_hook_active so RESPWAN_REQUESTED/keepalive.blocks logic is driven by a
correctly parsed boolean.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 0e47feaf-731a-4319-8c0d-6fedb718abd1
⛔ Files ignored due to path filters (1)
uv.lockis excluded by!**/*.lock
📒 Files selected for processing (29)
.planning/rfcs/011-interactive-context-compaction.mdAGENTS.mdCLAUDE.mdREADME.mdpackages/nightly-core/pyproject.tomlpackages/nightly-core/src/nightly_core/_version.pypackages/nightly-core/src/nightly_core/cascade.pypackages/nightly-core/src/nightly_core/cli.pypackages/nightly-core/src/nightly_core/config.pypackages/nightly-core/src/nightly_core/digest.pypackages/nightly-core/src/nightly_core/hook_install.pypackages/nightly-core/src/nightly_core/keepalive_hook.pypackages/nightly-core/src/nightly_core/rules.pypackages/nightly-core/tests/test_cli.pypackages/nightly-core/tests/test_config.pypackages/nightly-core/tests/test_digest.pypackages/nightly-core/tests/test_hook_install.pypackages/nightly-core/tests/test_hook_session_start.pypackages/nightly-core/tests/test_keepalive_context.pypackages/nightly-host-antigravity/pyproject.tomlpackages/nightly-host-claude/pyproject.tomlpackages/nightly-host-claude/src/nightly_host_claude/integration.pypackages/nightly-host-claude/src/nightly_host_claude/skill.mdpackages/nightly-host-claude/tests/test_integration.pypackages/nightly-host-codex/pyproject.tomlpackages/nightly-host-cursor/pyproject.tomlpackages/nightly-host-gemini/pyproject.tomlpackages/nightly-host-opencode/pyproject.tomlpyproject.toml
| def _coerce_int(key: str, default: int) -> int: | ||
| # A typo'd / non-numeric value should degrade to the default, not | ||
| # crash the loop — same forgiveness as a missing key. | ||
| try: | ||
| return int(context.get(key, default)) | ||
| except (TypeError, ValueError): | ||
| return default | ||
|
|
||
| return ContextConfig( | ||
| budget_tokens=_coerce_int("budget_tokens", defaults.budget_tokens), | ||
| digest_every_turns=_coerce_int("digest_every_turns", defaults.digest_every_turns), |
There was a problem hiding this comment.
Reject YAML booleans before int() coercion.
yaml.safe_load returns real booleans for true/false, and int(False) / int(True) become 0 / 1. So digest_every_turns: false silently disables periodic digest writes, and budget_tokens: true drops the soft budget to 1 token instead of falling back to the documented defaults.
Proposed fix
def _coerce_int(key: str, default: int) -> int:
# A typo'd / non-numeric value should degrade to the default, not
# crash the loop — same forgiveness as a missing key.
+ value = context.get(key, default)
+ if isinstance(value, bool):
+ return default
try:
- return int(context.get(key, default))
+ return int(value)
except (TypeError, ValueError):
return default🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/nightly-core/src/nightly_core/config.py` around lines 346 - 356, The
_coerce_int helper is accidentally treating YAML booleans as integers because
int(True/False) yields 1/0; update _coerce_int to detect and reject bool values
before calling int() (e.g., fetch value = context.get(key, default), if
isinstance(value, bool) return default) so that boolean YAML entries fall back
to defaults; apply this change where ContextConfig is constructed
(budget_tokens, digest_every_turns, etc.) to ensure bools don't coerce to
numeric values.
| def _render_open_prs(root: Path | None) -> list[str]: | ||
| """Open `nightly/*` PRs as `#<n> <branch>` lines, best-effort. | ||
|
|
||
| Reuses the cascade's PR-listing helper so the digest reflects exactly | ||
| what `pr_rescue` sees. Returns a single note line when `gh` is missing | ||
| or the listing fails — never raises.""" | ||
| try: | ||
| from nightly_core.cascade import open_nightly_pr_branches # noqa: PLC0415 - lazy | ||
|
|
||
| branches = open_nightly_pr_branches(root) | ||
| except Exception: | ||
| return ["- _PR listing unavailable (gh missing or errored)._"] | ||
| if not branches: | ||
| return ["- _none open._"] | ||
| return [f"- #{num} `{branch}`" for branch, num, _url in branches] |
There was a problem hiding this comment.
Honor git.branch_prefix when listing PRs for the digest.
This new path hardcodes the default nightly/ prefix via open_nightly_pr_branches(), but GitConfig.branch_prefix is configurable. Repos that changed the prefix will now get _none open._ in the digest and lose that PR state after compaction even though Nightly-authored PRs are still in flight.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/nightly-core/src/nightly_core/digest.py` around lines 246 - 260,
_render_open_prs currently assumes the fixed nightly/ PR prefix by calling
open_nightly_pr_branches(root) which ignores any configured
GitConfig.branch_prefix; update _render_open_prs to read the repository config
(GitConfig.branch_prefix or equivalent) and pass that prefix into
open_nightly_pr_branches (or call the cascade helper variant that accepts a
prefix) so PR listing respects custom branch prefixes and does not return "_none
open._" for repos using a non-default prefix.
| if transcript_path is None: | ||
| return None | ||
| path = Path(transcript_path) |
There was a problem hiding this comment.
Make estimate_context_tokens tolerate non-path hook payloads.
Path(transcript_path) can raise TypeError/ValueError for malformed JSON payloads (for example, a list or object), which breaks the helper’s own “best-effort / degrade to None” contract and can take down the stop hook instead of just skipping telemetry.
Suggested fix
def estimate_context_tokens(transcript_path: str | Path | None) -> int | None:
if transcript_path is None:
return None
- path = Path(transcript_path)
+ try:
+ path = Path(transcript_path)
+ except (TypeError, ValueError):
+ return None📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if transcript_path is None: | |
| return None | |
| path = Path(transcript_path) | |
| if transcript_path is None: | |
| return None | |
| try: | |
| path = Path(transcript_path) | |
| except (TypeError, ValueError): | |
| return None |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/nightly-core/src/nightly_core/keepalive_hook.py` around lines 279 -
281, The estimate_context_tokens helper currently calls Path(transcript_path)
which can raise TypeError/ValueError for malformed hook payloads (e.g.
list/object); update estimate_context_tokens to guard creation of Path by
wrapping Path(transcript_path) in a try/except that catches TypeError and
ValueError and returns None on failure so the helper preserves its “best-effort
/ degrade to None” contract; reference the transcript_path variable and the
estimate_context_tokens function and ensure no exceptions propagate out of this
block (silently skip telemetry by returning None).
| if ctx_cfg.digest_every_turns > 0 and turn_count % ctx_cfg.digest_every_turns == 0: | ||
| with contextlib.suppress(Exception): | ||
| write_digest(root) |
There was a problem hiding this comment.
The interval digest is persisted before this turn’s cascade state exists.
On normal turns, write_digest(root) runs before next_task() and before _record_and_count_repeats(...), so the saved digest’s “last cascade pick” is always one boundary stale. That undermines the compaction-reinjection path: after a compact restart, the freshest routing state is exactly what the digest is supposed to preserve.
Move the interval write until after the current cascade pick/history has been recorded, or render the digest from the already-computed choice instead of disk state. Based on learnings: the digest should be refreshed every N turns and preserve key state across compaction.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/nightly-core/src/nightly_core/keepalive_hook.py` around lines 627 -
629, The digest is being written before the current turn’s cascade pick/history
is recorded, causing the persisted “last cascade pick” to be one turn stale;
move the write_digest(root) call so it runs after the current turn’s state is
recorded (after calls to next_task() and _record_and_count_repeats(...)) or,
alternatively, render the digest from the in-memory computed choice (the
variable produced by next_task()/choice) rather than reading disk state,
ensuring ctx_cfg.digest_every_turns checks and the write_digest invocation are
updated to persist the freshest cascade state.
Source: Learnings
What this is
The operator ask: when Nightly reroutes to ideate, run a compact-equivalent in the interactive session to reduce context while preserving key session info — and do it on an interval too, targeting a soft 256K context budget.
Host constraint (verified against the Claude Code docs): nothing can programmatically trigger
/compactin an interactive session — there's no model-invocable compaction tool, Stop-hookreasontext is not interpreted as a slash command, and the auto-compact threshold isn't configurable. Even if injection worked, a manual compact leaves the session idle, which would stall an unattended overnight run. RFC 011 (included) records this analysis.So v0.0.12 builds the equivalent from the levers that exist:
1. Session digest (
nightly_core/digest.py)A compact key-state markdown — run id, turn/chain counters, markers, plans grouped by status, open Nightly PRs, last cascade pick, current branch, the contract one-liner — written to
.nightly/runs/<id>/digest.md. Refreshed every keepalive turn boundary (context.digest_every_turns, default 1 = the interval) and always before a planning-phase/ideate reroute, which the prompt now calls out as the natural compaction point (nothing in-flight is lost there).2. Context telemetry
The Stop hook estimates current context from the host-provided
transcript_path(last assistantusagemetadata, tail-read of ~256 KiB, fully best-effort). Every heartbeat now carriesctx=<tokens|?>, the latest estimate lands inkeepalive.context, andnightly statusshows a context line.3. Soft budget steering (
context.budget_tokens, default 256000)Over budget, the injected continuation prompt — both "Continue on: X" and the planning-phase prompt — is prefixed with a context-diet block: finish any delicate in-flight step first (soft limit, per the operator's requirement), then lean on the fresh digest, dispatch heavy work to background specialists (their contexts are separate), avoid re-reading large files, persist important state. Never stops the session over context size.
0disables.4. Post-compaction re-injection
The installer now merges a second Claude Code hook:
SessionStartwith matchercompactrunning the newnightly hook session-start. Whenever the host compacts — auto near its limit, or an operator/compact— the digest is re-injected asadditionalContext, so the fresh context immediately regains the key Nightly session state. Emits{}for non-Nightly/unarmed sessions;hook_installgained matcher-aware idempotent merge/remove with full back-compat.5. Config + docs
New commented
context:block in the generated.nightly/config.yml; rules block (AGENTS.md/CLAUDE.md regenerated), skill.md context-hygiene section, README;.planning/rfcs/011-interactive-context-compaction.mdwith the constraint analysis and what was deliberately not done.Testing
make checkclean: ruff, pyrefly, 1095 passed (61 new) — transcript-estimation edge cases (tail windows, garbage lines, missing usage), digest degradation (every subsystem failing still renders), diet-block on/off/disabled paths, interval + reroute digest writes, session-start handler armed/unarmed/garbage, matcher-aware hook install/uninstall alongside the Stop hook and env cap.Upgrade note
Repos need
nightly init//nightly-updatere-run to install the new SessionStart hook and pick up thecontext:config block.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
nightly statusnow displays context token usage against configured soft budgetDocumentation