Skip to content

feat(context): session digest + soft 256K budget steering + post-compaction re-injection#29

Merged
ulmentflam merged 1 commit into
mainfrom
feat/context-compaction
Jun 13, 2026
Merged

feat(context): session digest + soft 256K budget steering + post-compaction re-injection#29
ulmentflam merged 1 commit into
mainfrom
feat/context-compaction

Conversation

@ulmentflam

@ulmentflam ulmentflam commented Jun 12, 2026

Copy link
Copy Markdown
Owner

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 /compact in an interactive session — there's no model-invocable compaction tool, Stop-hook reason text 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 assistant usage metadata, tail-read of ~256 KiB, fully best-effort). Every heartbeat now carries ctx=<tokens|?>, the latest estimate lands in keepalive.context, and nightly status shows 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. 0 disables.

4. Post-compaction re-injection

The installer now merges a second Claude Code hook: SessionStart with matcher compact running the new nightly hook session-start. Whenever the host compacts — auto near its limit, or an operator /compact — the digest is re-injected as additionalContext, so the fresh context immediately regains the key Nightly session state. Emits {} for non-Nightly/unarmed sessions; hook_install gained 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.md with the constraint analysis and what was deliberately not done.

Testing

make check clean: 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-update re-run to install the new SessionStart hook and pick up the context: config block.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added context hygiene and interactive compaction (v0.0.12) with automatic budget tracking and session digest generation
    • nightly status now displays context token usage against configured soft budget
    • SessionStart hook automatically re-injects session digest after compaction for lossless state recovery
  • Documentation

    • RFC 011 documents interactive context compaction design and workflow

…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>
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This 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.

Changes

Interactive Context Compaction — Digest & Budget

Layer / File(s) Summary
RFC 011 and Design Documentation
.planning/rfcs/011-interactive-context-compaction.md, AGENTS.md, CLAUDE.md, README.md, packages/nightly-core/src/nightly_core/rules.py
RFC document specifies five-piece design: session digest rendering with fault isolation, context token estimation from transcript tail, soft budget steering via injected "context diet", planning-phase digest flush, and SessionStart(compact) hook re-injection. Autonomy contracts, system docstrings, and README updated with context hygiene behavior.
Configuration & Session Digest Module
packages/nightly-core/src/nightly_core/config.py, packages/nightly-core/src/nightly_core/digest.py
New ContextConfig dataclass with budget_tokens and digest_every_turns; load_context_config() parses context: YAML block with forgiving defaults. New digest.py module renders markdown session state (version, run id, keepalive counters, git branch, last cascade pick, active plans, open PRs) and persists to digest.md; all subsections fault-isolated via _safe() helper.
Hook Matcher Infrastructure
packages/nightly-core/src/nightly_core/hook_install.py
HookFile gains optional matcher field (default ""); find_nested_hook_index() accepts matcher to discriminate SessionStart(compact) hooks from Stop hooks; merge/remove paths updated to target correct matcher-scoped entries.
Context Estimation and Budget Steering
packages/nightly-core/src/nightly_core/keepalive_hook.py
estimate_context_tokens() scans JSONL transcript tail for token usage; context_diet_block() formats soft-budget warning; compute_stop_hook_decision() gains transcript_path parameter and applies context telemetry/steering; log_heartbeat() writes ctx=<tokens | ?> field; planning-phase routing triggers unconditional digest refresh.
Public PR Branch Listing Wrapper
packages/nightly-core/src/nightly_core/cascade.py
Expose open_nightly_pr_branches() as public wrapper for use by digest and other modules.
CLI Handlers and Context Status
packages/nightly-core/src/nightly_core/cli.py
Add context budget display to status command; add default context: section to generated config; update hook_stop to pass transcript_path and estimate context tokens; add new hook_session_start handler that renders and emits digest as additionalContext when armed and source="compact".
Claude Host SessionStart Hook Integration
packages/nightly-host-claude/src/nightly_host_claude/integration.py, packages/nightly-host-claude/src/nightly_host_claude/skill.md
Add _session_start_hook_file() helper; wire SessionStart(compact) hook into install/uninstall keepalive lifecycle; update skill.md with context hygiene guidance (measuring, budgeting, compaction, background delegation).
Core Functionality Tests
packages/nightly-core/tests/test_config.py, packages/nightly-core/tests/test_digest.py, packages/nightly-core/tests/test_keepalive_context.py
Test ContextConfig loading (defaults, missing keys, malformed YAML); test digest rendering (no run, with plans/PRs, subsystem failures, history inclusion); test context estimation (usage parsing, tail-window scanning, garbage tolerance), diet block formatting, heartbeat ctx= field, budget steering (diet prepend over-budget, no prepend under-budget, budget=0 disables), and digest persistence/interval rules.
Hook Installation and Session-Start Tests
packages/nightly-core/tests/test_hook_install.py, packages/nightly-core/tests/test_hook_session_start.py
Test matcher-based hook coexistence (Stop matcher-less, SessionStart matcher="compact"), idempotency, selective removal; test session-start CLI handler for armed compact (digest emission, audit), missing source, non-compact source, unarmed/no-run edge cases, and invalid JSON robustness.
CLI Status and Host Integration Tests
packages/nightly-core/tests/test_cli.py, packages/nightly-host-claude/tests/test_integration.py
Test status command context display with token estimate present/empty; test Claude host SessionStart hook installation (project scope, idempotency, user-scope exclusion) and uninstall with permission preservation.
Version Bump to v0.0.12
pyproject.toml, packages/nightly-core/pyproject.toml, packages/nightly-core/src/nightly_core/_version.py, packages/nightly-host-*/pyproject.toml
Update version from 0.0.11 to 0.0.12 across root and all host packages.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • ulmentflam/nightly#26: Both PRs modify packages/nightly-core/src/nightly_core/keepalive_hook.py's stop-hook decision flow—main PR adds context-token "context diet" + digest persistence/reinjection, while the retrieved PR changes stop_hook_active/forced-continuation behavior and RESPAWN_REQUESTED handling.

🐰 A digest for sessions long,
To trim the bloat and right the wrong,
Soft budgets guide with care,
No stopping now—just trim with flair!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main implementation: interactive context compaction via session digest, soft budget steering, and post-compaction digest re-injection.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/context-compaction

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Parse stop_hook_active explicitly instead of relying on Python truthiness.

bool(hook_input.get("stop_hook_active")) turns "false", "0", and any other non-empty string into True. That misclassifies fresh turn boundaries as in-chain boundaries, so RESPAWN_REQUESTED and keepalive.blocks can 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 lift

Avoid a 30s gh pr list call 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 gh process 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

📥 Commits

Reviewing files that changed from the base of the PR and between fd2c248 and fa404b7.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (29)
  • .planning/rfcs/011-interactive-context-compaction.md
  • AGENTS.md
  • CLAUDE.md
  • README.md
  • packages/nightly-core/pyproject.toml
  • packages/nightly-core/src/nightly_core/_version.py
  • packages/nightly-core/src/nightly_core/cascade.py
  • packages/nightly-core/src/nightly_core/cli.py
  • packages/nightly-core/src/nightly_core/config.py
  • packages/nightly-core/src/nightly_core/digest.py
  • packages/nightly-core/src/nightly_core/hook_install.py
  • packages/nightly-core/src/nightly_core/keepalive_hook.py
  • packages/nightly-core/src/nightly_core/rules.py
  • packages/nightly-core/tests/test_cli.py
  • packages/nightly-core/tests/test_config.py
  • packages/nightly-core/tests/test_digest.py
  • packages/nightly-core/tests/test_hook_install.py
  • packages/nightly-core/tests/test_hook_session_start.py
  • packages/nightly-core/tests/test_keepalive_context.py
  • packages/nightly-host-antigravity/pyproject.toml
  • packages/nightly-host-claude/pyproject.toml
  • packages/nightly-host-claude/src/nightly_host_claude/integration.py
  • packages/nightly-host-claude/src/nightly_host_claude/skill.md
  • packages/nightly-host-claude/tests/test_integration.py
  • packages/nightly-host-codex/pyproject.toml
  • packages/nightly-host-cursor/pyproject.toml
  • packages/nightly-host-gemini/pyproject.toml
  • packages/nightly-host-opencode/pyproject.toml
  • pyproject.toml

Comment on lines +346 to +356
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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment on lines +246 to +260
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]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment on lines +279 to +281
if transcript_path is None:
return None
path = Path(transcript_path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Suggested change
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).

Comment on lines +627 to +629
if ctx_cfg.digest_every_turns > 0 and turn_count % ctx_cfg.digest_every_turns == 0:
with contextlib.suppress(Exception):
write_digest(root)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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

@ulmentflam ulmentflam merged commit fec7714 into main Jun 13, 2026
3 checks passed
@ulmentflam ulmentflam deleted the feat/context-compaction branch June 13, 2026 03:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant