From 73238ec348f0cde15815841fa66992e25d13354c Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 00:26:56 +0530 Subject: [PATCH 01/17] fix(storage): stop losing writes when several processes share the archive --- context_kernel/memory/storage.py | 13 ++++++++- tests/test_storage.py | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/context_kernel/memory/storage.py b/context_kernel/memory/storage.py index 1128247..2a8088f 100644 --- a/context_kernel/memory/storage.py +++ b/context_kernel/memory/storage.py @@ -89,6 +89,7 @@ def _db(self) -> sqlite3.Connection: def _apply_pragmas(self) -> None: for pragma in ( + "PRAGMA busy_timeout = 5000", "PRAGMA journal_mode = WAL", "PRAGMA synchronous = NORMAL", "PRAGMA foreign_keys = ON", @@ -102,7 +103,7 @@ def _apply_pragmas(self) -> None: def _transaction(self) -> Generator[sqlite3.Connection]: with self._write_lock: db = self._db - db.execute("BEGIN") + db.execute("BEGIN IMMEDIATE") try: yield db db.execute("COMMIT") @@ -189,6 +190,16 @@ def create_session(self, agent_command: str) -> SessionRecord: ) return rec + def ensure_session(self, session_id: str, agent_command: str) -> None: + """Create the session row unless it exists. Safe when several processes + race to create the same session (one per command in an agent session).""" + with self._transaction() as db: + db.execute( + "INSERT OR IGNORE INTO sessions(session_id, agent_command, started_at) " + "VALUES (?,?,?)", + (session_id, agent_command, time.time()), + ) + def close_session(self, session_id: str, tokens_saved: int = 0) -> None: with self._transaction() as db: db.execute( diff --git a/tests/test_storage.py b/tests/test_storage.py index c8c30ba..c0f910a 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,6 +1,7 @@ """StorageEngine tests: schema/CRUD, FTS5 search, WAL concurrency, stats.""" from __future__ import annotations +import multiprocessing import threading import time from pathlib import Path @@ -449,6 +450,53 @@ def writer_worker() -> None: eng.close() +def _write_from_process(db_path: str, session_id: str, count: int) -> None: + eng = StorageEngine(db_path=Path(db_path)) + eng.open() + eng.ensure_session(session_id, "multiprocess-test") + for i in range(count): + eng.insert_entry( + LogEntry(session_id=session_id, raw_content=f"entry {i} {'x' * 200}", + entry_type="stdout") + ) + eng.close() + + +class TestMultiProcessWrites: + """Separate processes each open their own connection, like parallel + `ack exec` calls. A deferred BEGIN upgraded to a write lock fails with + SQLITE_BUSY instead of waiting, which silently lost rows.""" + + def test_no_rows_lost_across_processes(self, db_path: Path) -> None: + ctx = multiprocessing.get_context("fork") + procs = [ + ctx.Process(target=_write_from_process, args=(str(db_path), "shared", 25)) + for _ in range(8) + ] + for p in procs: + p.start() + for p in procs: + p.join(timeout=60) + assert all(p.exitcode == 0 for p in procs) + with StorageEngine(db_path=db_path) as eng: + assert eng.stats("shared")["total_entries"] == 8 * 25 + + +class TestEnsureSession: + def test_creates_missing_session(self, engine: StorageEngine) -> None: + engine.ensure_session("claude-abc", "claude") + rec = engine.get_session("claude-abc") + assert rec is not None + assert rec.agent_command == "claude" + + def test_is_idempotent(self, engine: StorageEngine) -> None: + engine.ensure_session("claude-abc", "claude") + engine.ensure_session("claude-abc", "other") + rec = engine.get_session("claude-abc") + assert rec is not None + assert rec.agent_command == "claude" + + class TestStatsAggregation: """Verify the aggregate SQL query returns correct counts.""" From c8868f33a1d4774ea760bafefaa0cb131d8569fb Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 00:35:28 +0530 Subject: [PATCH 02/17] fix(pruner): keep lines printed after a traceback --- context_kernel/pruners/shell_pruner.py | 29 ++++++--- tests/test_shell_pruner.py | 89 ++++++++++++++++++++++++-- 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/context_kernel/pruners/shell_pruner.py b/context_kernel/pruners/shell_pruner.py index 9e83fc6..ec99211 100644 --- a/context_kernel/pruners/shell_pruner.py +++ b/context_kernel/pruners/shell_pruner.py @@ -83,8 +83,9 @@ def _compress_python_tracebacks(self, text: str) -> str: lines = text.splitlines() blocks: list[dict[str, list[str] | str]] = [] current_block: dict[str, list[str] | str] | None = None + last_exc_idx: int | None = None - for line in lines: + for idx, line in enumerate(lines): if _PY_TRACEBACK_HDR.search(line): current_block = {"frames": [], "exception": ""} blocks.append(current_block) @@ -105,12 +106,13 @@ def _compress_python_tracebacks(self, text: str) -> str: if line and not line.startswith(" ") and not line.startswith("\t"): current_block["exception"] = line.strip() current_block = None + last_exc_idx = idx if not blocks: return text[:800] + "\n… [truncated by ACK shell_pruner]" parts: list[str] = [] - for idx, block in enumerate(blocks, start=1): + for n, block in enumerate(blocks, start=1): frames_raw: list[str] = block["frames"] # type: ignore[assignment] exc: str = str(block["exception"]) @@ -121,7 +123,7 @@ def _compress_python_tracebacks(self, text: str) -> str: relevant = user_frames[-3:] header = ( - f"── Traceback {idx}/{len(blocks)} ──" + f"── Traceback {n}/{len(blocks)} ──" if len(blocks) > 1 else "── Traceback ──" ) @@ -132,22 +134,29 @@ def _compress_python_tracebacks(self, text: str) -> str: (i for i, ln in enumerate(lines) if _PY_TRACEBACK_HDR.search(ln)), 0, ) - preamble = self._condense_preamble(lines[:first_tb]) + preamble = self._condense_context(lines[:first_tb]) + postamble = ( + self._condense_context(lines[last_exc_idx + 1:]) + if last_exc_idx is not None + else "" + ) result: list[str] = [] if preamble: result.append(preamble) result.extend(parts) + if postamble: + result.append(postamble) return "\n\n".join(result) - def _condense_preamble(self, preamble_lines: list[str]) -> str: - """Summarise output that precedes a traceback in the same buffer. + def _condense_context(self, context_lines: list[str]) -> str: + """Summarise output around a traceback in the same buffer. - The common "logs, then a crash" pattern means a log flood often shares a - buffer with the traceback. Collapse a repetitive preamble to a frequency - table instead of dumping it verbatim; keep short, varied preambles as-is. + Logs before a crash and totals after it (a test runner's "2 failed") + often share a buffer with the traceback. Collapse a repetitive block to + a frequency table; keep short, varied text as-is. """ - text = "\n".join(preamble_lines).strip() + text = "\n".join(context_lines).strip() if not text: return "" if self._is_highly_repetitive(text): diff --git a/tests/test_shell_pruner.py b/tests/test_shell_pruner.py index c3b1e79..391ab9a 100644 --- a/tests/test_shell_pruner.py +++ b/tests/test_shell_pruner.py @@ -91,12 +91,14 @@ def _make_python_traceback_massive() -> str: 50-frame traceback that simulates a deep async call chain (Django/FastAPI). This is the worst-case scenario for context-window pollution. """ - user_frames = textwrap.dedent("""\ - File "/app/src/views/checkout.py", line 201, in post - order = cart.checkout(user_id=user.pk, payment=payload) - File "/app/src/models/cart.py", line 88, in checkout - charge_result = payment_gateway.charge(amount, card_token) - """) + user_frames = "\n".join( + [ + ' File "/app/src/views/checkout.py", line 201, in post', + " order = cart.checkout(user_id=user.pk, payment=payload)", + ' File "/app/src/models/cart.py", line 88, in checkout', + " charge_result = payment_gateway.charge(amount, card_token)", + ] + ) framework_frames = "\n".join( f' File "/home/user/.venv/lib/python3.13/site-packages/django/core/handlers/base.py",' f" line {200 + i}, in _get_response\n" @@ -106,6 +108,7 @@ def _make_python_traceback_massive() -> str: return ( "Traceback (most recent call last):\n" + user_frames + + "\n" + framework_frames + "\nKeyError: 'card_token'\n" ) @@ -413,3 +416,77 @@ def test_non_utf8_bytes_handled_via_ansi_stripping(self, pruner: ShellPruner) -> ) result = pruner.compress(latin1_text) assert result is None or isinstance(result, str) + + +_TB = ( + "Traceback (most recent call last):\n" + ' File "/app/src/views/checkout.py", line 201, in post\n' + " order = cart.checkout()\n" + + "".join( + f' File "/venv/lib/python3.11/site-packages/django/base.py", line {200 + i}, in f\n' + " response = cb()\n" + for i in range(20) + ) + + "KeyError: 'card_token'\n" +) + + +class TestLinesAfterTraceback: + """With `ack exec` a command's whole output can arrive as one chunk, so text + after the exception (test totals, exit markers) must survive the summary.""" + + def test_short_trailing_lines_kept(self) -> None: + text = _TB + "2 failed, 3 passed in 0.05s\nEXIT_CODE=1\n" + out = ShellPruner().compress(text) + assert out is not None + assert "KeyError: 'card_token'" in out + assert "2 failed, 3 passed in 0.05s" in out + assert "EXIT_CODE=1" in out + assert out.index("KeyError") < out.index("2 failed") + + def test_trailing_flood_condensed(self) -> None: + text = _TB + "WARNING retrying\n" * 50 + out = ShellPruner().compress(text) + assert out is not None + assert "Repetitive output" in out + assert out.count("WARNING retrying") == 1 + + def test_nothing_after_traceback_adds_nothing(self) -> None: + out = ShellPruner().compress(_TB) + assert out is not None + assert out.rstrip().endswith("KeyError: 'card_token'") + + def test_bare_exception_kept(self) -> None: + """Bare exceptions without a message (like KeyboardInterrupt) must be captured.""" + text = ( + "Traceback (most recent call last):\n" + ' File "/app/main.py", line 5, in main\n' + " wait_forever()\n" + "KeyboardInterrupt\n" + ) + out = ShellPruner().compress(text) + assert out is not None + assert "KeyboardInterrupt" in out + + def test_chained_bare_exception_with_trailing_line(self) -> None: + """Chained traceback with bare exception must not leak raw traceback into postamble.""" + text = ( + "Traceback (most recent call last):\n" + ' File "/app/db.py", line 10, in connect\n' + " self._conn = sqlite3.connect(self.dsn)\n" + "ValueError: invalid database\n" + "\n" + "During handling of the above exception, another exception occurred:\n" + "\n" + "Traceback (most recent call last):\n" + ' File "/app/service.py", line 20, in fetch\n' + " conn = db.connect()\n" + "StopIteration\n" + "CLEANUP_DONE\n" + ) + out = ShellPruner().compress(text) + assert out is not None + assert "↳ StopIteration" in out + assert "CLEANUP_DONE" in out + assert "Traceback (most recent call last):" not in out + assert 'File "' not in out From 112259e9d3e18c3f3a7df421e00f53c8cccdae81 Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 00:52:02 +0530 Subject: [PATCH 03/17] feat(orchestrator): expose feed/flush for pipe callers with plain, honest banners --- context_kernel/core/orchestrator.py | 81 ++++++++++++++++++--- tests/test_orchestrator.py | 108 +++++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 12 deletions(-) diff --git a/context_kernel/core/orchestrator.py b/context_kernel/core/orchestrator.py index e6045af..6bc7c37 100644 --- a/context_kernel/core/orchestrator.py +++ b/context_kernel/core/orchestrator.py @@ -34,13 +34,29 @@ _TRACEBACK_MARKER = "Traceback (most recent call last):" +def _incomplete_utf8_tail(data: bytes) -> bytes: + """Return the trailing bytes of a UTF-8 character that has not fully arrived.""" + for back in range(1, min(4, len(data)) + 1): + byte = data[-back] + if byte & 0xC0 == 0x80: # continuation byte: keep looking back + continue + if byte < 0xC0: # ASCII: nothing pending + return b"" + need = 2 if byte < 0xE0 else 3 if byte < 0xF0 else 4 + return data[-back:] if back < need else b"" + return b"" + + @dataclass class OrchestratorConfig: - pruning_threshold_lines: int = 30 - buffer_flush_timeout: float = 0.15 - read_chunk_bytes: int = 8192 - annotate_injections: bool = True - max_buffer_bytes: int = 262144 + pruning_threshold_lines: int = 30 + buffer_flush_timeout: float = 0.15 + read_chunk_bytes: int = 8192 + annotate_injections: bool = True + max_buffer_bytes: int = 262144 + color: bool = True + recall_hint: str | None = None + archive_unpruned_max_bytes: int | None = None @dataclass @@ -105,6 +121,36 @@ def __init__( def stats(self) -> OrchestratorStats: return self._stats + def feed(self, chunk: bytes) -> None: + """Buffer output read by a caller that runs its own loop (`ack exec`).""" + self._stats.total_bytes_read += len(chunk) + self._buffer.append(chunk) + + @property + def buffered_bytes(self) -> int: + return sum(len(chunk) for chunk in self._buffer) + + def flush(self, fd: int, *, final: bool = False) -> None: + """Emit everything buffered now, pruning it if it qualifies. + + Unless final, a UTF-8 character cut off at the end is held for the next + flush so it is never garbled. If pruning fails for any reason the raw + bytes are emitted instead: the command's output always gets through. + """ + data = b"".join(self._buffer) + tail = b"" if final else _incomplete_utf8_tail(data) + head = data[: len(data) - len(tail)] + self._buffer.clear() + if head: + self._buffer.append(head) + try: + self._flush_buffer(fd, force=True) + except Exception: # noqa: BLE001 + self._buffer.clear() + self._emit(fd, head) + if tail: + self._buffer.append(tail) + def run(self) -> int: """Spawn the agent, block until it exits, and return its exit code. @@ -269,7 +315,7 @@ def _flush_buffer(self, stdout_fd: int, *, force: bool = False) -> None: if below_threshold or self._text_is_prompt(text): self._emit(stdout_fd, raw) - self._persist(text, pruned=False) + self._persist_unpruned(text, len(raw)) if self.text_callback is not None: self.text_callback(text) return @@ -293,7 +339,7 @@ def _flush_buffer(self, stdout_fd: int, *, force: bool = False) -> None: _display = injection else: self._emit(stdout_fd, raw) - self._persist(text, pruned=False) + self._persist_unpruned(text, len(raw)) _display = text if self.text_callback is not None: @@ -337,18 +383,31 @@ def _persist(self, text: str, *, pruned: bool, summary: str = "") -> int | None: except Exception: # noqa: BLE001 return None + def _persist_unpruned(self, text: str, raw_len: int) -> None: + """Archive output that passed through verbatim, unless it is over the cap. + The model already saw it in full, so a huge `cat` is not worth storing.""" + limit = self.config.archive_unpruned_max_bytes + if limit is None or raw_len <= limit: + self._persist(text, pruned=False) + def _format_injection( self, summary: str, original_lines: int, entry_id: int | None = None ) -> str: - body = f"{_CYAN}{summary}{_RESET}\n" + cyan, dim, reset = (_CYAN, _DIM, _RESET) if self.config.color else ("", "", "") + body = f"{cyan}{summary}{reset}\n" if not self.config.annotate_injections: return body summary_lines = len(summary.splitlines()) - recall = f"recall: ack #{entry_id}" if entry_id is not None else "full log stored in DB" + if entry_id is None: + recall = "full log not archived" + elif self.config.recall_hint: + recall = f"full log: {self.config.recall_hint} {entry_id}" + else: + recall = f"recall: ack #{entry_id}" banner = ( - f"{_DIM}[ACK] Compressed {original_lines} lines → " - f"{summary_lines} lines ({recall}){_RESET}\n" + f"{dim}[ACK] Compressed {original_lines} lines → " + f"{summary_lines} lines ({recall}){reset}\n" ) return banner + body diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index 61a7af7..3a5911c 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -153,7 +153,7 @@ def test_banner_falls_back_when_id_missing(self, storage: StorageEngine) -> None orch = _make_orchestrator(storage, annotate=True) result = orch._format_injection("short summary", 80, entry_id=None) # type: ignore[attr-defined] assert "ack #" not in result - assert "full log stored in DB" in result + assert "full log not archived" in result def test_summary_text_always_in_output(self, storage: StorageEngine) -> None: orch = _make_orchestrator(storage, annotate=True) @@ -620,3 +620,109 @@ def test_orchestrator_restores_terminal_after_run(self, tmp_path: Path) -> None: """) out = self._run_driver(driver, tmp_path) assert "terminal_ok=True" in out + + +def _exec_orchestrator( + storage: StorageEngine, + pruners: Optional[list[BasePruner]] = None, + **config: object, +) -> Orchestrator: + session = storage.create_session("exec-test") + return Orchestrator( + command=["exec-test"], + session_id=session.session_id, + storage=storage, + pruners=pruners or [], + config=OrchestratorConfig(pruning_threshold_lines=5, **config), # type: ignore[arg-type] + ) + + +class _AlwaysPrune(BasePruner): + metadata = PrunerMetadata(name="always", description="test") + + def matches(self, text: str) -> bool: + return True + + def compress(self, text: str) -> str | None: + return "SUMMARY" + + +class _Explodes(BasePruner): + metadata = PrunerMetadata(name="explodes", description="test") + + def matches(self, text: str) -> bool: + return True + + def compress(self, text: str) -> str | None: + raise RuntimeError("pruner bug") + + +class TestExecBanner: + def test_plain_banner_has_no_escape_codes(self, storage: StorageEngine) -> None: + orch = _exec_orchestrator(storage, color=False) + out = orch._format_injection("summary", 80, entry_id=3) # type: ignore[attr-defined] + assert "\x1b" not in out + + def test_recall_hint_names_the_command(self, storage: StorageEngine) -> None: + orch = _exec_orchestrator(storage, color=False, recall_hint="ack recall") + out = orch._format_injection("summary", 80, entry_id=3) # type: ignore[attr-defined] + assert "(full log: ack recall 3)" in out + + +class TestFeedAndFlush: + def test_flush_emits_and_prunes(self, storage: StorageEngine, pipe_pair) -> None: + r, w = pipe_pair + orch = _exec_orchestrator(storage, [_AlwaysPrune()], color=False) + orch.feed(b"line\n" * 40) + assert orch.buffered_bytes == 200 + orch.flush(w, final=True) + out = _read_pipe(r).decode() + assert "SUMMARY" in out + assert orch.buffered_bytes == 0 + + def test_split_utf8_char_is_carried_to_next_flush( + self, storage: StorageEngine, pipe_pair + ) -> None: + r, w = pipe_pair + orch = _exec_orchestrator(storage, color=False) + fire = "🔥".encode() + orch.feed(b"ok " + fire[:2]) + orch.flush(w) + orch.feed(fire[2:] + b" done\n") + orch.flush(w, final=True) + assert _read_pipe(r) == b"ok " + fire + b" done\n" + stored = [row["raw_content"] for row in storage.get_recent_entries(orch.session_id)] + assert all("ð" not in text for text in stored) + assert any("🔥" in text for text in stored) + + def test_pruner_exception_falls_back_to_raw( + self, storage: StorageEngine, pipe_pair + ) -> None: + r, w = pipe_pair + orch = _exec_orchestrator(storage, [_Explodes()], color=False) + orch.feed(b"raw line\n" * 40) + orch.flush(w, final=True) + assert _read_pipe(r) == b"raw line\n" * 40 + + def test_large_unpruned_output_not_archived( + self, storage: StorageEngine, pipe_pair + ) -> None: + r, w = pipe_pair + orch = _exec_orchestrator(storage, color=False, archive_unpruned_max_bytes=1000) + big = b"".join(f"unique line {i}\n".encode() for i in range(200)) + orch.feed(big) + orch.flush(w, final=True) + assert _read_pipe(r, timeout=1.0) == big + assert storage.stats(orch.session_id)["total_entries"] == 0 + + def test_pruned_output_always_archived( + self, storage: StorageEngine, pipe_pair + ) -> None: + r, w = pipe_pair + orch = _exec_orchestrator( + storage, [_AlwaysPrune()], color=False, archive_unpruned_max_bytes=10 + ) + orch.feed(b"line\n" * 400) + orch.flush(w, final=True) + _read_pipe(r) + assert storage.stats(orch.session_id)["pruned_entries"] == 1 From b2d69fda279edb9f51bf09631e9ccaff383a5a0b Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 00:58:23 +0530 Subject: [PATCH 04/17] perf(cli): load the TUI and tree-sitter only when used --- context_kernel/cli.py | 139 ++--------------------------------------- context_kernel/tui.py | 140 ++++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 14 +++++ 3 files changed, 159 insertions(+), 134 deletions(-) create mode 100644 context_kernel/tui.py diff --git a/context_kernel/cli.py b/context_kernel/cli.py index d989289..c050482 100644 --- a/context_kernel/cli.py +++ b/context_kernel/cli.py @@ -1,27 +1,15 @@ -"""Command-line entrypoint and the optional Textual stats dashboard. - -In --tui mode the orchestrator runs on a background thread and Textual renders -a stats/log overlay. Because both want the terminal, the agent runs -non-interactively there; plain ack run is the interactive path. -""" +"""Command-line entrypoint.""" from __future__ import annotations import re import sys -import threading import time from collections.abc import Callable from pathlib import Path import click -from textual.app import App, ComposeResult -from textual.binding import Binding -from textual.containers import Horizontal, Vertical -from textual.reactive import reactive -from textual.widgets import Footer, Header, RichLog, Static from .core.orchestrator import Orchestrator, OrchestratorConfig, OrchestratorStats -from .memory.pager import Pager from .memory.storage import StorageEngine from .pruners.shell_pruner import ShellPruner @@ -75,127 +63,6 @@ def _print_session_summary( pass -class StatsPanel(Static): - stats: reactive[OrchestratorStats] = reactive(OrchestratorStats()) - - def render(self) -> str: # type: ignore[override] - s: OrchestratorStats = self.stats - elapsed = max(1, int(time.monotonic() - s.session_start)) - ratio = ( - f"{s.tokens_saved / max(1, s.total_bytes_read // 4):.0%}" - if s.total_bytes_read - else "0%" - ) - return ( - "[bold cyan]ACK[/bold cyan] — Agent Context Kernel\n" - "─────────────────────────────────\n" - f"Bytes intercepted : {s.total_bytes_read:>10,}\n" - f"Bytes injected : {s.total_bytes_injected:>10,}\n" - f"Pruner hits : {s.total_pruner_hits:>10,}\n" - f"Tokens saved : {s.tokens_saved:>10,}\n" - f"Compression ratio : {ratio:>10}\n" - f"Elapsed : {elapsed:>9}s\n" - ) - - -class AckDashboard(App[int]): - """Live stats overlay; polls Orchestrator.stats once a second.""" - - CSS = """ - Screen { - layout: horizontal; - } - - #log-panel { - width: 1fr; - height: 100%; - border: solid $primary; - padding: 0 1; - } - - #stats-panel { - width: 36; - height: 100%; - border: solid $accent; - padding: 1 2; - } - - StatsPanel { - height: auto; - } - - RichLog { - height: 1fr; - } - """ - - BINDINGS = [ - Binding("q", "quit", "Quit"), - Binding("ctrl+c", "quit", "Quit"), - ] - - def __init__(self, orchestrator: Orchestrator) -> None: - super().__init__() - self._orchestrator = orchestrator - self._orch_thread: threading.Thread | None = None - self._stats_panel: StatsPanel | None = None - self._log_widget: RichLog | None = None - self._log_buffer: list[str] = [] - self._log_lock = threading.Lock() - - def compose(self) -> ComposeResult: - yield Header(show_clock=True) - with Horizontal(): - with Vertical(id="log-panel"): - self._log_widget = RichLog( - id="agent-log", - wrap=True, - highlight=True, - markup=True, - ) - yield self._log_widget - with Vertical(id="stats-panel"): - self._stats_panel = StatsPanel() - yield self._stats_panel - yield Footer() - - def on_mount(self) -> None: - self._orchestrator.text_callback = self._enqueue_log - self._orch_thread = threading.Thread( - target=self._orchestrator.run, - daemon=True, - name="ack-orchestrator", - ) - self._orch_thread.start() - self.set_interval(1.0, self._refresh_stats) - self.set_interval(1 / 30, self._flush_log) - - def _refresh_stats(self) -> None: - if self._stats_panel is not None: - self._stats_panel.stats = self._orchestrator.stats - self._stats_panel.refresh() - - if self._orch_thread is not None and not self._orch_thread.is_alive(): - self.exit(0) - - def _enqueue_log(self, text: str) -> None: - with self._log_lock: - self._log_buffer.append(text) - - def _flush_log(self) -> None: - with self._log_lock: - if not self._log_buffer: - return - chunks, self._log_buffer = self._log_buffer, [] - - if self._log_widget is not None: - for chunk in chunks: - self._log_widget.write(chunk) - - async def action_quit(self) -> None: - self.exit(0) - - @click.group() @click.version_option(version="0.1.0", prog_name="ack") def main() -> None: @@ -260,6 +127,8 @@ def cmd_run( ) if tui: + from .tui import AckDashboard + exit_code = AckDashboard(orchestrator=orch).run() or 0 else: exit_code = orch.run() @@ -399,6 +268,8 @@ def cmd_recall( @click.argument("file", type=click.Path(exists=True, path_type=Path)) def cmd_toc(file: Path) -> None: """Print the symbol table-of-contents for a source FILE.""" + from .memory.pager import Pager + pager = Pager() try: fmap = pager.map_file(file) diff --git a/context_kernel/tui.py b/context_kernel/tui.py new file mode 100644 index 0000000..3d44524 --- /dev/null +++ b/context_kernel/tui.py @@ -0,0 +1,140 @@ +"""Optional Textual stats dashboard for `ack run --tui`. + +In --tui mode the orchestrator runs on a background thread and Textual renders +a stats/log overlay. Because both want the terminal, the agent runs +non-interactively there; plain ack run is the interactive path. Imported only +when --tui is used, so plain commands stay fast to start. +""" +from __future__ import annotations + +import threading +import time + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.reactive import reactive +from textual.widgets import Footer, Header, RichLog, Static + +from .core.orchestrator import Orchestrator, OrchestratorStats + + +class StatsPanel(Static): + stats: reactive[OrchestratorStats] = reactive(OrchestratorStats()) + + def render(self) -> str: # type: ignore[override] + s: OrchestratorStats = self.stats + elapsed = max(1, int(time.monotonic() - s.session_start)) + ratio = ( + f"{s.tokens_saved / max(1, s.total_bytes_read // 4):.0%}" + if s.total_bytes_read + else "0%" + ) + return ( + "[bold cyan]ACK[/bold cyan] — Agent Context Kernel\n" + "─────────────────────────────────\n" + f"Bytes intercepted : {s.total_bytes_read:>10,}\n" + f"Bytes injected : {s.total_bytes_injected:>10,}\n" + f"Pruner hits : {s.total_pruner_hits:>10,}\n" + f"Tokens saved : {s.tokens_saved:>10,}\n" + f"Compression ratio : {ratio:>10}\n" + f"Elapsed : {elapsed:>9}s\n" + ) + + +class AckDashboard(App[int]): + """Live stats overlay; polls Orchestrator.stats once a second.""" + + CSS = """ + Screen { + layout: horizontal; + } + + #log-panel { + width: 1fr; + height: 100%; + border: solid $primary; + padding: 0 1; + } + + #stats-panel { + width: 36; + height: 100%; + border: solid $accent; + padding: 1 2; + } + + StatsPanel { + height: auto; + } + + RichLog { + height: 1fr; + } + """ + + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("ctrl+c", "quit", "Quit"), + ] + + def __init__(self, orchestrator: Orchestrator) -> None: + super().__init__() + self._orchestrator = orchestrator + self._orch_thread: threading.Thread | None = None + self._stats_panel: StatsPanel | None = None + self._log_widget: RichLog | None = None + self._log_buffer: list[str] = [] + self._log_lock = threading.Lock() + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Horizontal(): + with Vertical(id="log-panel"): + self._log_widget = RichLog( + id="agent-log", + wrap=True, + highlight=True, + markup=True, + ) + yield self._log_widget + with Vertical(id="stats-panel"): + self._stats_panel = StatsPanel() + yield self._stats_panel + yield Footer() + + def on_mount(self) -> None: + self._orchestrator.text_callback = self._enqueue_log + self._orch_thread = threading.Thread( + target=self._orchestrator.run, + daemon=True, + name="ack-orchestrator", + ) + self._orch_thread.start() + self.set_interval(1.0, self._refresh_stats) + self.set_interval(1 / 30, self._flush_log) + + def _refresh_stats(self) -> None: + if self._stats_panel is not None: + self._stats_panel.stats = self._orchestrator.stats + self._stats_panel.refresh() + + if self._orch_thread is not None and not self._orch_thread.is_alive(): + self.exit(0) + + def _enqueue_log(self, text: str) -> None: + with self._log_lock: + self._log_buffer.append(text) + + def _flush_log(self) -> None: + with self._log_lock: + if not self._log_buffer: + return + chunks, self._log_buffer = self._log_buffer, [] + + if self._log_widget is not None: + for chunk in chunks: + self._log_widget.write(chunk) + + async def action_quit(self) -> None: + self.exit(0) diff --git a/tests/test_cli.py b/tests/test_cli.py index b2f9305..89f9882 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,8 @@ """CLI tests: terminal-escape sanitisation and the `recall` command.""" from __future__ import annotations +import subprocess +import sys from pathlib import Path import pytest @@ -98,3 +100,15 @@ def test_recall_raw_flag_preserves_bytes(self, db_path: Path) -> None: main, ["recall", str(evil), "--raw", "--db", str(db_path)], color=True ) assert "\x1b[31m" in result.output + + +class TestStartupImports: + def test_cli_import_skips_tui_and_tree_sitter(self) -> None: + code = ( + "import sys, context_kernel.cli; " + "print('textual' in sys.modules, 'tree_sitter' in sys.modules)" + ) + out = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, check=True + ).stdout.strip() + assert out == "False False" From 45ee43831b054d9def27b30acd882e76da90d185 Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 01:01:54 +0530 Subject: [PATCH 05/17] feat(core): run a command on a pipe and prune its output as it streams --- context_kernel/core/executor.py | 196 ++++++++++++++++++++++++++ tests/test_executor.py | 237 ++++++++++++++++++++++++++++++++ 2 files changed, 433 insertions(+) create mode 100644 context_kernel/core/executor.py create mode 100644 tests/test_executor.py diff --git a/context_kernel/core/executor.py b/context_kernel/core/executor.py new file mode 100644 index 0000000..4f33a78 --- /dev/null +++ b/context_kernel/core/executor.py @@ -0,0 +1,196 @@ +"""Run one command on a pipe and prune its output before the agent reads it. + +Coding agents run shell commands in their own subprocesses, so ACK has to sit +on that path. Claude Code hands every Bash command to CLAUDE_CODE_SHELL_PREFIX +as one script; `ack exec --claude` runs that script here. Output is flushed on +a short silence or a max hold time rather than on exit, because the agent may +be polling a long-running process such as a dev server. +""" +from __future__ import annotations + +import os +import re +import select +import shlex +import shutil +import signal +import tempfile +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from types import FrameType + +from .orchestrator import Orchestrator + +_BASH_TOOL_MARKERS = (" eval ", "pwd -P >|") +_ACK_READER = re.compile(r"""(?:^|[\s/'"(;&|])ack\s+(?:recall|search)\b""") +_SNAPSHOT_SHELL = re.compile(r"snapshot-(bash|zsh)-") +_FORWARDED_SIGNALS = (signal.SIGTERM, signal.SIGINT, signal.SIGHUP) + + +def is_bash_tool_script(script: str) -> bool: + """True for scripts Claude's Bash tool builds; its hooks look different + and must run untouched because Claude parses their output.""" + return all(marker in script for marker in _BASH_TOOL_MARKERS) + + +def invokes_ack_reader(script: str) -> bool: + """True if the script runs `ack recall` or `ack search`, whose output must + never be pruned again.""" + return bool(_ACK_READER.search(script)) + + +def shell_for(script: str) -> str: + """The shell Claude built the script for (named in its snapshot file).""" + match = _SNAPSHOT_SHELL.search(script) + if match: + found = shutil.which(match.group(1)) + if found: + return found + return os.environ.get("SHELL") or "/bin/sh" + + +def writable_archive(default: Path) -> tuple[Path, bool]: + """Return (db_path, is_fallback). Inside Claude's sandbox the home directory + is read-only, so fall back to the writable TMPDIR it provides.""" + if _can_write(default.parent): + return default, False + base = Path(os.environ.get("TMPDIR") or tempfile.gettempdir()) + return base / "ack" / "kernel.db", True + + +def _can_write(directory: Path) -> bool: + try: + directory.mkdir(parents=True, exist_ok=True) + probe = directory / f".ack-write-{os.getpid()}" + probe.touch() + probe.unlink() + except OSError: + return False + return True + + +def recall_hint(ack_path: Path, db: Path, *, explicit_db: bool) -> str: + """The recall command to print in banners, runnable from the agent's shell.""" + on_path = shutil.which("ack") + same = on_path is not None and Path(on_path).resolve() == ack_path.resolve() + hint = f"{'ack' if same else shlex.quote(str(ack_path))} recall" + if explicit_db: + hint += f" --db {shlex.quote(str(db))}" + return hint + + +@dataclass(frozen=True) +class ExecTiming: + silence: float = 2.0 + max_hold: float = 5.0 + drain_after_exit: float = 0.2 + + +def run_piped( + argv: list[str], orch: Orchestrator, out_fd: int, timing: ExecTiming | None = None +) -> int: + """Run argv with stdout and stderr on one pipe, streaming its output through + orch to out_fd. Returns the command's exit code (128 + signal if killed).""" + timing = timing or ExecTiming() + r, w = os.pipe() + pid = os.fork() + if pid == 0: # child + try: + os.close(r) + os.dup2(w, 1) + os.dup2(w, 2) + os.close(w) + # Python ignores these at startup and exec keeps ignored signals. + for name in ("SIGPIPE", "SIGXFSZ"): + if hasattr(signal, name): + signal.signal(getattr(signal, name), signal.SIG_DFL) + os.execvp(argv[0], argv) + except OSError as exc: + os.write(2, f"ack: cannot run {argv[0]}: {exc.strerror}\n".encode()) + finally: + os._exit(127) + os.close(w) + + def forward(signum: int, _frame: FrameType | None) -> None: + try: + os.kill(pid, signum) + except ProcessLookupError: + pass + + previous = {} + if threading.current_thread() is threading.main_thread(): + previous = {sig: signal.signal(sig, forward) for sig in _FORWARDED_SIGNALS} + try: + status = _pump(r, pid, orch, out_fd, timing) + except BaseException: + os.close(r) + raise + finally: + for sig, handler in previous.items(): + signal.signal(sig, handler) + + orch.flush(out_fd, final=True) + _hand_off(r, out_fd) + code = os.waitstatus_to_exitcode(status) + return 128 - code if code < 0 else code + + +def _pump(r: int, pid: int, orch: Orchestrator, out_fd: int, timing: ExecTiming) -> int: + """Read until the command's shell exits, flushing on silence, on max hold, + or when the buffer is full. Returns the raw wait status.""" + status: int | None = None + exit_deadline = first = last = 0.0 + while True: + now = time.monotonic() + if status is None: + done, wait_status = os.waitpid(pid, os.WNOHANG) + if done: + status, exit_deadline = wait_status, now + timing.drain_after_exit + elif now >= exit_deadline: + return status + + try: + ready, _, _ = select.select([r], [], [], 0.05) + except InterruptedError: + continue + if ready: + chunk = os.read(r, 65536) + if not chunk: + if status is None: + _, status = os.waitpid(pid, 0) + return status + if not orch.buffered_bytes: + first = time.monotonic() + orch.feed(chunk) + last = time.monotonic() + + if orch.buffered_bytes: + now = time.monotonic() + if ( + now - last >= timing.silence + or now - first >= timing.max_hold + or orch.buffered_bytes >= orch.config.max_buffer_bytes + ): + orch.flush(out_fd) + first = last = now + + +def _hand_off(r: int, out_fd: int) -> None: + """Keep forwarding output from processes the command left running. + + The shell has exited, but something it backgrounded may still hold the + pipe. Waiting would hang the agent, and closing the pipe would kill that + process with SIGPIPE on its next write. A detached copier forwards it raw + and exits when the last writer closes. + """ + if os.fork() == 0: + try: + os.setsid() + while chunk := os.read(r, 65536): + os.write(out_fd, chunk) + except OSError: + pass + os._exit(0) + os.close(r) diff --git a/tests/test_executor.py b/tests/test_executor.py new file mode 100644 index 0000000..7c7c3b2 --- /dev/null +++ b/tests/test_executor.py @@ -0,0 +1,237 @@ +"""Executor tests: Claude script routing, archive location, the pipe loop.""" +from __future__ import annotations + +import os +import select +import shutil +import signal +import stat +import subprocess +import threading +import time +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from context_kernel.core import executor +from context_kernel.core.executor import ExecTiming +from context_kernel.core.orchestrator import Orchestrator, OrchestratorConfig +from context_kernel.memory.storage import StorageEngine +from context_kernel.pruners.shell_pruner import ShellPruner + +# Exactly as Claude Code 2.x hands it to CLAUDE_CODE_SHELL_PREFIX (observed). +CLAUDE_SCRIPT = ( + "source /home/u/.claude/shell-snapshots/snapshot-bash-1790-ab.sh 2>/dev/null || true " + "&& { shopt -u extglob || setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL; } >/dev/null 2>&1 " + "|| true && eval 'python3 examples/demo_agent.py' < /dev/null " + "&& pwd -P >| /tmp/claude-a900-cwd" +) +HOOK_SCRIPT = '"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd" session-start' + + +class TestScriptRouting: + def test_real_bash_tool_script_is_recognised(self) -> None: + assert executor.is_bash_tool_script(CLAUDE_SCRIPT) + + def test_hook_script_is_not(self) -> None: + assert not executor.is_bash_tool_script(HOOK_SCRIPT) + + def test_changed_script_shape_is_not(self) -> None: + assert not executor.is_bash_tool_script(CLAUDE_SCRIPT.replace("pwd -P >|", "pwd >")) + + @pytest.mark.parametrize("cmd", [ + "ack recall 3", + "/home/u/.venv/bin/ack recall 3", + "ack recall 'KeyError' | head -n 5", + "poetry run ack search foo", + ]) + def test_ack_readers_detected(self, cmd: str) -> None: + assert executor.invokes_ack_reader(f"x && eval '{cmd}' < /dev/null") + + @pytest.mark.parametrize("cmd", ["echo stack recall", "python3 -m pack recall", "ls"]) + def test_other_commands_not_detected(self, cmd: str) -> None: + assert not executor.invokes_ack_reader(f"x && eval '{cmd}' < /dev/null") + + def test_shell_from_snapshot_name(self) -> None: + assert executor.shell_for(CLAUDE_SCRIPT).endswith("bash") + + def test_shell_falls_back_to_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SHELL", "/bin/sh") + assert executor.shell_for("echo hi") == "/bin/sh" + + +class TestArchiveLocation: + def test_default_when_writable(self, tmp_path: Path) -> None: + default = tmp_path / "ack" / "kernel.db" + assert executor.writable_archive(default) == (default, False) + + @pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions") + def test_tmpdir_fallback_when_read_only( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + locked = tmp_path / "locked" + locked.mkdir() + locked.chmod(stat.S_IRUSR | stat.S_IXUSR) + monkeypatch.setenv("TMPDIR", str(tmp_path / "tmp")) + try: + path, fallback = executor.writable_archive(locked / "ack" / "kernel.db") + finally: + locked.chmod(stat.S_IRWXU) + assert fallback + assert path == tmp_path / "tmp" / "ack" / "kernel.db" + + +class TestRecallHint: + def test_bare_ack_when_on_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + ack = tmp_path / "ack" + ack.write_text("#!/bin/sh\n") + ack.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path)) + assert executor.recall_hint(ack, tmp_path / "k.db", explicit_db=False) == "ack recall" + + def test_absolute_path_and_db_otherwise( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("PATH", "/nonexistent") + ack = tmp_path / "my env" / "ack" + hint = executor.recall_hint(ack, tmp_path / "k.db", explicit_db=True) + assert hint == f"'{ack}' recall --db {tmp_path / 'k.db'}" + + +FAST = ExecTiming(silence=0.3, max_hold=0.8, drain_after_exit=0.1) + + +@pytest.fixture +def orch(tmp_path: Path) -> Orchestrator: + storage = StorageEngine(db_path=tmp_path / "exec.db") + storage.open() + session = storage.create_session("exec-test") + return Orchestrator( + command=["exec-test"], session_id=session.session_id, storage=storage, + pruners=[ShellPruner()], config=OrchestratorConfig(color=False), + ) + + +def _collect(r: int, seconds: float) -> list[tuple[float, bytes]]: + """Read from r for up to `seconds`, stamping each chunk with its arrival time.""" + got: list[tuple[float, bytes]] = [] + end = time.monotonic() + seconds + while time.monotonic() < end: + ready, _, _ = select.select([r], [], [], 0.05) + if ready: + chunk = os.read(r, 65536) + if not chunk: + break + got.append((time.monotonic(), chunk)) + return got + + +@pytest.mark.integration +class TestRunPiped: + def test_exit_code_passthrough(self, orch: Orchestrator) -> None: + r, w = os.pipe() + assert executor.run_piped(["sh", "-c", "echo hi; exit 7"], orch, w, FAST) == 7 + os.close(w) + assert b"".join(c for _, c in _collect(r, 1)) == b"hi\n" + + def test_signal_death_maps_to_128_plus_signal(self, orch: Orchestrator) -> None: + r, w = os.pipe() + try: + assert executor.run_piped(["sh", "-c", "kill -9 $$"], orch, w, FAST) == 137 + finally: + os.close(w) + os.close(r) + + def test_streamed_flood_is_one_banner(self, orch: Orchestrator) -> None: + r, w = os.pipe() + cmd = "for i in $(seq 300); do echo 'WARNING retrying'; done" + executor.run_piped(["sh", "-c", cmd], orch, w, FAST) + os.close(w) + out = b"".join(c for _, c in _collect(r, 1)).decode() + assert out.count("[ACK] Compressed") == 1 + + @pytest.mark.filterwarnings("ignore:This process .* is multi-threaded:DeprecationWarning") + def test_heartbeat_visible_while_running(self, orch: Orchestrator) -> None: + r, w = os.pipe() + cmd = "for i in 1 2 3 4 5 6 7 8 9 10; do echo beat $i; sleep 0.2; done" + start = time.monotonic() + t = threading.Thread(target=executor.run_piped, args=(["sh", "-c", cmd], orch, w, FAST)) + t.start() + got = _collect(r, 3) + t.join(timeout=10) + assert not t.is_alive(), "run_piped did not return" + assert got, "no output at all" + assert got[0][0] - start < 1.5, "first line held until exit" + + def test_background_child_does_not_block(self, orch: Orchestrator) -> None: + r, w = os.pipe() + start = time.monotonic() + code = executor.run_piped( + ["sh", "-c", "(sleep 1.5; echo late) & echo now"], orch, w, FAST + ) + assert code == 0 + assert time.monotonic() - start < 1.0 + os.close(w) + out = b"".join(c for _, c in _collect(r, 3)) + assert b"now" in out and b"late" in out + + def test_command_gets_default_sigpipe(self, orch: Orchestrator) -> None: + r, w = os.pipe() + cmd = "set -o pipefail; yes | head -1; echo pipe=$?" + executor.run_piped(["bash", "-c", cmd], orch, w, FAST) + os.close(w) + out = b"".join(c for _, c in _collect(r, 1)) + os.close(r) + # 141 is what a plain shell reports: yes dies of SIGPIPE, silently. + assert out == b"y\npipe=141\n" + + +def _kill_stray_sleeps() -> None: + """Kill `sleep 5` processes left in this session by the signal tests.""" + ps_bin = shutil.which("ps") + assert ps_bin + ps = subprocess.run( + [ps_bin, "-eo", "pid=,sid=,args="], capture_output=True, text=True, check=True + ) + sid = os.getsid(0) + for line in ps.stdout.splitlines(): + pid, psid, args = line.split(None, 2) + if int(psid) == sid and args.strip() == "sleep 5": + try: + os.kill(int(pid), signal.SIGKILL) + except ProcessLookupError: + pass + + +@pytest.fixture +def self_term() -> Iterator[None]: + """Send SIGTERM to this process after 0.5s, as a user stopping ACK would.""" + timer = threading.Timer(0.5, os.kill, (os.getpid(), signal.SIGTERM)) + timer.start() + yield + timer.cancel() + timer.join() + _kill_stray_sleeps() + + +@pytest.mark.integration +@pytest.mark.filterwarnings("ignore:This process .* is multi-threaded:DeprecationWarning") +class TestSignalForwarding: + def _run(self, orch: Orchestrator, cmd: str) -> int: + r, w = os.pipe() + try: + return executor.run_piped(["sh", "-c", cmd], orch, w, FAST) + finally: + os.close(w) + os.close(r) + + def test_child_status_wins_when_it_traps( + self, orch: Orchestrator, self_term: None + ) -> None: + assert self._run(orch, 'trap "exit 3" TERM; sleep 5 & wait') == 3 + + def test_forwarded_term_kills_child(self, orch: Orchestrator, self_term: None) -> None: + before = signal.getsignal(signal.SIGTERM) + assert self._run(orch, "sleep 5") == 143 + assert signal.getsignal(signal.SIGTERM) is before From 515ab9b601123d7b94fdd5df7ce8127446f1a58c Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 01:31:48 +0530 Subject: [PATCH 06/17] feat(cli): add ack exec to prune a command's output for the agent --- context_kernel/cli.py | 92 ++++++++++++++++++++- tests/test_exec_cli.py | 179 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 tests/test_exec_cli.py diff --git a/context_kernel/cli.py b/context_kernel/cli.py index c050482..b3e43f2 100644 --- a/context_kernel/cli.py +++ b/context_kernel/cli.py @@ -1,16 +1,19 @@ """Command-line entrypoint.""" from __future__ import annotations +import os import re import sys import time from collections.abc import Callable from pathlib import Path +from typing import NoReturn import click +from .core import executor from .core.orchestrator import Orchestrator, OrchestratorConfig, OrchestratorStats -from .memory.storage import StorageEngine +from .memory.storage import _DEFAULT_DB, StorageEngine from .pruners.shell_pruner import ShellPruner _USD_PER_MILLION_INPUT_TOKENS = 3.0 @@ -139,6 +142,93 @@ def cmd_run( sys.exit(exit_code) +_EXEC_ARCHIVE_UNPRUNED_MAX = 64 * 1024 + + +def _exec_or_die(path: str, argv: list[str]) -> NoReturn: + """Replace this process with argv, or report the failure like a shell would.""" + try: + os.execv(path, argv) + except OSError as exc: + click.echo(f"ack: cannot run {path}: {exc.strerror}", err=True) + sys.exit(127) + + +@main.command(name="exec") +@click.argument("command", nargs=-1, required=True) +@click.option( + "--claude", + "claude_script", + is_flag=True, + help="COMMAND is one script from Claude Code's shell prefix (set by `ack hook install`).", +) +@click.option("--session", "session_id", default=None, metavar="ID", + help="Archive under this session id.") +@click.option("--db", default=None, type=click.Path(path_type=Path), + help="Path to the ACK SQLite database.") +def cmd_exec( + command: tuple[str, ...], + claude_script: bool, + session_id: str | None, + db: Path | None, +) -> None: + """Run COMMAND and prune its output before the agent reads it. + + Example: ack exec -- pytest -x + """ + if claude_script: + script = command[-1] + if not executor.is_bash_tool_script(script): + # Claude's hooks run as Claude runs them. + _exec_or_die("/bin/sh", ["/bin/sh", "-c", script]) + shell = executor.shell_for(script) + argv = [shell, "-c", script] + if executor.invokes_ack_reader(script): + _exec_or_die(shell, argv) # ACK's own readers are never pruned again + session_id = session_id or os.environ.get("CLAUDE_CODE_SESSION_ID") + agent = "claude" + else: + argv = list(command) + agent = " ".join(argv) + + try: + db_path, fallback = (db, False) if db else executor.writable_archive(_DEFAULT_DB) + storage = StorageEngine(db_path=db_path) + storage.open() + if session_id: + storage.ensure_session(session_id, agent) + else: + session_id = storage.create_session(agent).session_id + hint = executor.recall_hint( + Path(sys.argv[0]), db_path, explicit_db=fallback or db is not None + ) + orch = Orchestrator( + command=argv, + session_id=session_id, + storage=storage, + pruners=[ShellPruner()], + config=OrchestratorConfig( + color=False, + recall_hint=hint, + archive_unpruned_max_bytes=_EXEC_ARCHIVE_UNPRUNED_MAX, + ), + ) + except Exception: # noqa: BLE001 + # ACK must never be the reason a command fails: run it unmodified. + try: + os.execvp(argv[0], argv) + except OSError as exc: + click.echo(f"ack: cannot run {argv[0]}: {exc.strerror}", err=True) + sys.exit(127) + + code = executor.run_piped(argv, orch, sys.stdout.fileno()) + try: + storage.close() + except Exception: # noqa: BLE001 + pass # the command already ran; a close failure must not change its exit code + sys.exit(code) + + @main.command(name="search") @click.argument("query") @click.option( diff --git a/tests/test_exec_cli.py b/tests/test_exec_cli.py new file mode 100644 index 0000000..2432a74 --- /dev/null +++ b/tests/test_exec_cli.py @@ -0,0 +1,179 @@ +"""End-to-end tests for `ack exec`, run as a real process.""" +from __future__ import annotations + +import contextlib +import os +import shlex +import signal +import sqlite3 +import stat +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).parent.parent +ACK = [sys.executable, "-c", "from context_kernel.cli import main; main()"] +TRACEBACK = ( + "import sys\n" + "print('Traceback (most recent call last):')\n" + "print(' File \"/app/src/views/checkout.py\", line 201, in post')\n" + "print(' order = cart.checkout()')\n" + "for i in range(40):\n" + " print(f' File \"/venv/lib/python3.11/site-packages/django/base.py\", line {200 + i}, in f')\n" # noqa: E501 + " print(' response = cb()')\n" + "print(\"KeyError: 'card_token'\")\n" +) + +pytestmark = pytest.mark.integration + + +def claude_script(cmd: str, tmp: Path) -> str: + """A script shaped like the one Claude Code hands its shell prefix.""" + return ( + f"source {tmp}/snapshot-bash-1.sh 2>/dev/null || true && " + f"eval {shlex.quote(cmd)} < /dev/null && pwd -P >| {tmp}/cwd" + ) + + +def run_ack(args: list[str], tmp: Path, **kw: object) -> subprocess.CompletedProcess[str]: + env = {**os.environ, "CLAUDE_CODE_SESSION_ID": "sess-1", **kw.pop("env", {})} # type: ignore[dict-item] + return subprocess.run( + [*ACK, *args], capture_output=True, text=True, cwd=tmp, env=env, timeout=60, **kw + ) # type: ignore[call-overload] + + +def rows(db: Path) -> list[sqlite3.Row]: + con = sqlite3.connect(db) + con.row_factory = sqlite3.Row + return con.execute("SELECT * FROM log_entries ORDER BY id").fetchall() + + +@pytest.fixture +def tb_file(tmp_path: Path) -> Path: + f = tmp_path / "tb.py" + f.write_text(TRACEBACK) + return f + + +class TestClaudeForm: + def test_prunes_and_archives(self, tmp_path: Path, tb_file: Path) -> None: + db = tmp_path / "k.db" + res = run_ack(["exec", "--claude", "--db", str(db), + claude_script(f"python3 {tb_file}; echo 2 failed", tmp_path)], tmp_path) + assert res.returncode == 0 + assert "[ACK] Compressed" in res.stdout + assert "full log: " in res.stdout and "recall --db" in res.stdout + assert "\x1b" not in res.stdout + assert "line 210" not in res.stdout + assert "2 failed" in res.stdout + stored = rows(db) + assert len(stored) == 1 and stored[0]["was_pruned"] == 1 + assert "line 210" in stored[0]["raw_content"] + assert stored[0]["session_id"] == "sess-1" + + def test_hook_script_untouched(self, tmp_path: Path) -> None: + db = tmp_path / "k.db" + payload = "python3 -c \"import json; print(json.dumps({'a': list(range(60))}, indent=1))\"" + res = run_ack(["exec", "--claude", "--db", str(db), payload], tmp_path) + expected = subprocess.run(payload, shell=True, capture_output=True, text=True).stdout # noqa: S602 + assert res.stdout == expected + assert not db.exists() + + def test_ack_recall_output_not_pruned(self, tmp_path: Path) -> None: + db = tmp_path / "k.db" + cmd = "for i in $(seq 60); do echo same line; done # ack recall 1" + res = run_ack(["exec", "--claude", "--db", str(db), claude_script(cmd, tmp_path)], tmp_path) + assert res.stdout.count("same line") == 60 + + def test_ack_reader_exec_failure_reports_and_exits_127(self, tmp_path: Path) -> None: + # No snapshot-bash marker, so shell_for() falls back to $SHELL, which we + # point at a path that does not exist, forcing the passthrough exec to fail. + script = f"true && eval 'ack recall 1' < /dev/null && pwd -P >| {tmp_path}/cwd" + res = run_ack(["exec", "--claude", "--db", str(tmp_path / "k.db"), script], + tmp_path, env={"SHELL": "/nonexistent/ack-test-shell"}) + assert res.returncode == 127 + assert "ack: cannot run /nonexistent/ack-test-shell" in res.stderr + assert "Traceback" not in res.stdout + res.stderr + + def test_exit_codes(self, tmp_path: Path) -> None: + db = str(tmp_path / "k.db") + assert run_ack(["exec", "--claude", "--db", db, claude_script("exit 7", tmp_path)], + tmp_path).returncode == 7 + assert run_ack(["exec", "--claude", "--db", db, claude_script("kill -9 $$", tmp_path)], + tmp_path).returncode == 137 + + def test_cwd_file_written(self, tmp_path: Path) -> None: + (tmp_path / "sub").mkdir() + run_ack(["exec", "--claude", "--db", str(tmp_path / "k.db"), + claude_script("cd sub", tmp_path)], tmp_path) + assert (tmp_path / "cwd").read_text().strip().endswith("/sub") + + def test_fail_open_when_archive_unusable(self, tmp_path: Path) -> None: + res = run_ack(["exec", "--claude", "--db", str(tmp_path), # a directory: cannot open + claude_script("echo still works; exit 4", tmp_path)], tmp_path) + assert res.stdout == "still works\n" + assert res.returncode == 4 + + @pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions") + def test_sandbox_fallback_archive(self, tmp_path: Path, tb_file: Path) -> None: + home = tmp_path / "home" + home.mkdir() + home.chmod(stat.S_IRUSR | stat.S_IXUSR) + try: + res = run_ack(["exec", "--claude", claude_script(f"python3 {tb_file}", tmp_path)], + tmp_path, env={"HOME": str(home), "TMPDIR": str(tmp_path / "t")}) + finally: + home.chmod(stat.S_IRWXU) + assert f"--db {tmp_path / 't' / 'ack' / 'kernel.db'}" in res.stdout + assert len(rows(tmp_path / "t" / "ack" / "kernel.db")) == 1 + + def test_sigterm_flushes_archives_and_exits_143(self, tmp_path: Path) -> None: + db = tmp_path / "k.db" + proc = subprocess.Popen( + [*ACK, "exec", "--claude", "--db", str(db), + claude_script("echo started; sleep 30", tmp_path)], + stdout=subprocess.PIPE, text=True, cwd=tmp_path, + env={**os.environ, "CLAUDE_CODE_SESSION_ID": "sess-1"}, + start_new_session=True, # ack leads its own group, the way Claude spawns it + ) + time.sleep(1.0) + # Claude signals the whole group on a Bash-tool timeout, not just ack's pid. + os.killpg(proc.pid, signal.SIGTERM) + try: + out, _ = proc.communicate(timeout=10) + finally: + with contextlib.suppress(ProcessLookupError): + os.killpg(proc.pid, signal.SIGKILL) + assert proc.returncode == 143 + assert "started" in out + assert any("started" in r["raw_content"] for r in rows(db)) + + +class TestGenericForm: + def test_prunes_a_plain_command(self, tmp_path: Path, tb_file: Path) -> None: + res = run_ack(["exec", "--db", str(tmp_path / "k.db"), "--", "python3", str(tb_file)], + tmp_path) + assert "[ACK] Compressed" in res.stdout + + def test_stdin_reaches_the_command(self, tmp_path: Path) -> None: + res = run_ack(["exec", "--db", str(tmp_path / "k.db"), "--", "sh", "-c", + 'read x; echo "got:$x"'], tmp_path, input="hello\n") + assert res.stdout == "got:hello\n" + + def test_missing_command_is_127_without_traceback(self, tmp_path: Path) -> None: + res = run_ack(["exec", "--db", str(tmp_path / "k.db"), "--", "no-such-cmd-xyz"], tmp_path) + assert res.returncode == 127 + assert "Traceback" not in res.stdout + res.stderr + assert "no-such-cmd-xyz" in res.stdout + res.stderr + + def test_closed_pipe_is_quiet(self, tmp_path: Path) -> None: + cmd = " ".join(shlex.quote(a) for a in ACK) + res = subprocess.run( + f"{cmd} exec --db {tmp_path / 'k.db'} -- seq 1 200000 | head -1", + shell=True, capture_output=True, text=True, timeout=60, # noqa: S602 + ) + assert res.stdout == "1\n" + assert "Traceback" not in res.stderr From caf35b99f0d002790bfbb75bc24bd16874354517 Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 01:43:12 +0530 Subject: [PATCH 07/17] fix(core): recognise ack recall behind a quoted path --- context_kernel/core/executor.py | 2 +- tests/test_executor.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/context_kernel/core/executor.py b/context_kernel/core/executor.py index 4f33a78..950843d 100644 --- a/context_kernel/core/executor.py +++ b/context_kernel/core/executor.py @@ -24,7 +24,7 @@ from .orchestrator import Orchestrator _BASH_TOOL_MARKERS = (" eval ", "pwd -P >|") -_ACK_READER = re.compile(r"""(?:^|[\s/'"(;&|])ack\s+(?:recall|search)\b""") +_ACK_READER = re.compile(r"""(?:^|[\s/'"(;&|])ack['"]?\s+(?:recall|search)(?=[\s'"]|$)""") _SNAPSHOT_SHELL = re.compile(r"snapshot-(bash|zsh)-") _FORWARDED_SIGNALS = (signal.SIGTERM, signal.SIGINT, signal.SIGHUP) diff --git a/tests/test_executor.py b/tests/test_executor.py index 7c7c3b2..69d67a2 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -45,11 +45,18 @@ def test_changed_script_shape_is_not(self) -> None: "/home/u/.venv/bin/ack recall 3", "ack recall 'KeyError' | head -n 5", "poetry run ack search foo", + "'/home/u/my env/ack' recall 3", ]) def test_ack_readers_detected(self, cmd: str) -> None: assert executor.invokes_ack_reader(f"x && eval '{cmd}' < /dev/null") - @pytest.mark.parametrize("cmd", ["echo stack recall", "python3 -m pack recall", "ls"]) + @pytest.mark.parametrize("cmd", [ + "echo stack recall", + "python3 -m pack recall", + "ls", + 'grep "/var/ack" search.log', + "grep /var/ack search.log", + ]) def test_other_commands_not_detected(self, cmd: str) -> None: assert not executor.invokes_ack_reader(f"x && eval '{cmd}' < /dev/null") From 44cedd61d3d316302ec3d686d958932778685a18 Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 15:36:39 +0530 Subject: [PATCH 08/17] feat(cli): add ack hook install to route Claude Code commands through ACK --- context_kernel/claude_hook.py | 185 ++++++++++++++++++++++++++++++++++ context_kernel/cli.py | 37 +++++++ tests/test_claude_hook.py | 174 ++++++++++++++++++++++++++++++++ 3 files changed, 396 insertions(+) create mode 100644 context_kernel/claude_hook.py create mode 100644 tests/test_claude_hook.py diff --git a/context_kernel/claude_hook.py b/context_kernel/claude_hook.py new file mode 100644 index 0000000..45e264d --- /dev/null +++ b/context_kernel/claude_hook.py @@ -0,0 +1,185 @@ +"""Connect ACK to Claude Code through CLAUDE_CODE_SHELL_PREFIX. + +Claude runs every Bash command through the prefix after its permission checks, +so a prefix is the one place ACK can see command output before the model does. +The prefix is a tiny shell shim: if ACK is ever removed, the shim runs the +command directly so Claude keeps working. +""" +from __future__ import annotations + +import json +import os +import shlex +import stat +import subprocess +import tempfile +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +PREFIX_KEY = "CLAUDE_CODE_SHELL_PREFIX" + + +class HookError(Exception): + """Install or uninstall could not proceed safely.""" + + +def shim_path() -> Path: + return Path.home() / ".local" / "share" / "ack" / "claude-prefix.sh" + + +def settings_paths(project_dir: Path) -> dict[str, Path]: + return { + "local": project_dir / ".claude" / "settings.local.json", + "project": project_dir / ".claude" / "settings.json", + "user": Path.home() / ".claude" / "settings.json", + } + + +def render_shim(python: Path, ack_script: Path) -> str: + py, ack = shlex.quote(str(python)), shlex.quote(str(ack_script)) + return ( + "#!/bin/sh\n" + "# Claude Code shell prefix installed by `ack hook install`.\n" + "# -E keeps a project's PYTHONPATH from breaking ACK; the command itself\n" + "# still gets the full environment. If ACK is gone, run the command as is.\n" + f"if [ -x {py} ] && [ -f {ack} ]; then\n" + f' exec {py} -E {ack} exec --claude -- "$@"\n' + "fi\n" + "for last; do :; done\n" + 'exec "${SHELL:-/bin/sh}" -c "$last"\n' + ) + + +def find_conflicts(project_dir: Path, env: Mapping[str, str]) -> list[str]: + """Places where a different shell prefix is already set.""" + ours = str(shim_path()) + found: list[str] = [] + if env.get(PREFIX_KEY, ours) != ours: + found.append(f"the {PREFIX_KEY} environment variable") + for path in settings_paths(project_dir).values(): + data = _read(path) + value = _env_of(path, data).get(PREFIX_KEY) + if value is not None and value != ours: + found.append(str(path)) + return found + + +def install( + project_dir: Path, *, user: bool, python: Path, ack_script: Path, env: Mapping[str, str] +) -> Path: + """Write the shim, check it works, and point Claude's settings at it.""" + conflicts = find_conflicts(project_dir, env) + if conflicts: + raise HookError( + f"{PREFIX_KEY} is already set to something else in: {', '.join(conflicts)}. " + "Remove it there first." + ) + shim = _install_shim(python, ack_script) + + target = settings_paths(project_dir)["user" if user else "local"] + data = _read(target) + settings_env = _env_of(target, data) + settings_env[PREFIX_KEY] = str(shim) + data["env"] = settings_env + _write(target, data) + return target + + +def uninstall(project_dir: Path, *, user: bool) -> Path | None: + """Remove ACK's prefix from the settings file; return it, or None if absent.""" + target = settings_paths(project_dir)["user" if user else "local"] + data = _read(target) + env = _env_of(target, data) + if env.get(PREFIX_KEY) != str(shim_path()): + return None + del env[PREFIX_KEY] + if env: + data["env"] = env + else: + data.pop("env", None) + if data or target.is_symlink(): + # A symlinked settings file (common with dotfile managers) must keep + # existing: write the emptied object through it instead of unlinking + # the link and leaving the real file behind with our key still in it. + _write(target, data) + else: + target.unlink(missing_ok=True) + return target + + +def _install_shim(python: Path, ack_script: Path) -> Path: + """Render the shim into a temp file next to the real one, self-test that + file, then swap it in atomically. A failed render or self-test leaves the + existing shim (if any) untouched, so a bad reinstall can't break projects + that already point at it. + """ + shim = shim_path() + shim.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=shim.parent, prefix=".claude-prefix-", suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "w") as f: + f.write(render_shim(python, ack_script)) + tmp.chmod(0o755) + _self_test(tmp) + os.replace(tmp, shim) + except BaseException: + tmp.unlink(missing_ok=True) + raise + return shim + + +def _self_test(shim: Path) -> None: + script = "true && eval 'echo ack-ok' < /dev/null && pwd -P >| /dev/null" + try: + result = subprocess.run( # noqa: S603 + [str(shim), script], capture_output=True, text=True, timeout=30, + env={**os.environ, "CLAUDE_CODE_SESSION_ID": "ack-hook-self-test"}, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise HookError(f"self-test could not run the shim: {exc}") from exc + if result.returncode != 0 or "ack-ok" not in result.stdout: + raise HookError(f"self-test failed:\n{result.stdout}{result.stderr}".rstrip()) + + +def _read(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + raise HookError(f"{path} is not valid JSON ({exc}); fix it and retry") from exc + if not isinstance(data, dict): + raise HookError(f"{path} does not hold a JSON object") + return data + + +def _env_of(path: Path, data: dict[str, Any]) -> dict[str, Any]: + """The 'env' object inside loaded settings. A missing key is {}; any + other non-object value is a HookError naming the file.""" + if "env" not in data: + return {} + env = data["env"] + if not isinstance(env, dict): + raise HookError(f'{path} has a non-object "env"') + return env + + +def _write(path: Path, data: dict[str, Any]) -> None: + """Write JSON atomically. Writes through a symlink so the link survives, + and keeps the previous file's permissions when there was one.""" + target = path.resolve() if path.is_symlink() else path + target.parent.mkdir(parents=True, exist_ok=True) + old_mode = target.stat().st_mode if target.exists() else None + fd, tmp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.", suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "w") as f: + f.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n") + if old_mode is not None: + os.chmod(tmp, stat.S_IMODE(old_mode)) + os.replace(tmp, target) + except BaseException: + tmp.unlink(missing_ok=True) + raise diff --git a/context_kernel/cli.py b/context_kernel/cli.py index b3e43f2..86c299e 100644 --- a/context_kernel/cli.py +++ b/context_kernel/cli.py @@ -11,6 +11,7 @@ import click +from . import claude_hook from .core import executor from .core.orchestrator import Orchestrator, OrchestratorConfig, OrchestratorStats from .memory.storage import _DEFAULT_DB, StorageEngine @@ -229,6 +230,42 @@ def cmd_exec( sys.exit(code) +@main.group(name="hook") +def cmd_hook() -> None: + """Connect ACK to Claude Code.""" + + +@cmd_hook.command(name="install") +@click.option("--user", "user_scope", is_flag=True, + help="Install for every project (~/.claude/settings.json).") +def cmd_hook_install(user_scope: bool) -> None: + """Route Claude Code's Bash commands through `ack exec`.""" + try: + target = claude_hook.install( + Path.cwd(), + user=user_scope, + python=Path(sys.executable), + ack_script=Path(sys.argv[0]).resolve(), + env=os.environ, + ) + except claude_hook.HookError as exc: + click.echo(f"ack: {exc}", err=True) + sys.exit(1) + click.echo(f"Installed in {target}. Start a new Claude Code session to use it.") + + +@cmd_hook.command(name="uninstall") +@click.option("--user", "user_scope", is_flag=True, help="Remove the user-wide install.") +def cmd_hook_uninstall(user_scope: bool) -> None: + """Stop routing Claude Code's Bash commands through ACK.""" + try: + target = claude_hook.uninstall(Path.cwd(), user=user_scope) + except claude_hook.HookError as exc: + click.echo(f"ack: {exc}", err=True) + sys.exit(1) + click.echo(f"Removed from {target}." if target else "ACK was not installed there.") + + @main.command(name="search") @click.argument("query") @click.option( diff --git a/tests/test_claude_hook.py b/tests/test_claude_hook.py new file mode 100644 index 0000000..8eb276e --- /dev/null +++ b/tests/test_claude_hook.py @@ -0,0 +1,174 @@ +"""Tests for wiring ACK into Claude Code as its shell prefix.""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from context_kernel import claude_hook +from context_kernel.claude_hook import PREFIX_KEY, HookError + +FAKE_ACK = ( + "import os, sys\n" + "if sys.argv[1:4] != ['exec', '--claude', '--']:\n" + " sys.exit(90)\n" + "os.execv('/bin/sh', ['sh', '-c', sys.argv[-1]])\n" +) + + +@pytest.fixture +def home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + h = tmp_path / "home dir" # a space on purpose + h.mkdir() + monkeypatch.setenv("HOME", str(h)) + return h + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + p = tmp_path / "proj" + p.mkdir() + return p + + +@pytest.fixture +def fake_ack(tmp_path: Path) -> Path: + f = tmp_path / "venv bin" / "ack" # a space on purpose + f.parent.mkdir() + f.write_text(FAKE_ACK) + return f + + +def install(project: Path, fake_ack: Path, env: dict[str, str] | None = None, + user: bool = False) -> Path: + return claude_hook.install(project, user=user, python=Path(sys.executable), + ack_script=fake_ack, env=env or {}) + + +def read(path: Path) -> dict: + return json.loads(path.read_text()) + + +CLAUDE_SCRIPT = "true && eval 'echo hi; exit 3' < /dev/null && pwd -P >| /dev/null" + + +class TestInstall: + def test_writes_shim_and_local_settings( + self, home: Path, project: Path, fake_ack: Path + ) -> None: + target = install(project, fake_ack) + assert target == project / ".claude" / "settings.local.json" + shim = claude_hook.shim_path() + assert read(target)["env"][PREFIX_KEY] == str(shim) + res = subprocess.run([str(shim), CLAUDE_SCRIPT], capture_output=True, text=True) + assert (res.stdout, res.returncode) == ("hi\n", 3) + + def test_keeps_other_settings(self, home: Path, project: Path, fake_ack: Path) -> None: + target = project / ".claude" / "settings.local.json" + target.parent.mkdir() + target.write_text(json.dumps({"model": "x", "env": {"FOO": "1"}})) + install(project, fake_ack) + data = read(target) + assert data["model"] == "x" and data["env"]["FOO"] == "1" + + def test_idempotent(self, home: Path, project: Path, fake_ack: Path) -> None: + first = install(project, fake_ack).read_text() + assert install(project, fake_ack).read_text() == first + + def test_user_scope(self, home: Path, project: Path, fake_ack: Path) -> None: + assert install(project, fake_ack, user=True) == home / ".claude" / "settings.json" + + def test_refuses_env_conflict(self, home: Path, project: Path, fake_ack: Path) -> None: + with pytest.raises(HookError, match=PREFIX_KEY): + install(project, fake_ack, env={PREFIX_KEY: "/other/prefix"}) + + def test_refuses_settings_conflict(self, home: Path, project: Path, fake_ack: Path) -> None: + shared = project / ".claude" / "settings.json" + shared.parent.mkdir() + shared.write_text(json.dumps({"env": {PREFIX_KEY: "/other/prefix"}})) + with pytest.raises(HookError, match="settings.json"): + install(project, fake_ack) + + def test_refuses_malformed_settings_and_leaves_it( + self, home: Path, project: Path, fake_ack: Path + ) -> None: + target = project / ".claude" / "settings.local.json" + target.parent.mkdir() + target.write_text("{not json") + with pytest.raises(HookError): + install(project, fake_ack) + assert target.read_text() == "{not json" + + def test_failed_self_test_writes_no_settings( + self, home: Path, project: Path, tmp_path: Path + ) -> None: + broken = tmp_path / "broken_ack" + broken.write_text("import sys\nsys.exit(1)\n") + with pytest.raises(HookError, match="self-test"): + install(project, broken) + assert not (project / ".claude" / "settings.local.json").exists() + + def test_reinstall_with_broken_ack_leaves_shim_working( + self, home: Path, project: Path, fake_ack: Path, tmp_path: Path + ) -> None: + install(project, fake_ack) + broken = tmp_path / "broken_ack" + broken.write_text("import sys\nsys.exit(1)\n") + with pytest.raises(HookError, match="self-test"): + install(project, broken) + shim = claude_hook.shim_path() + res = subprocess.run([str(shim), CLAUDE_SCRIPT], capture_output=True, text=True) + assert (res.stdout, res.returncode) == ("hi\n", 3) + + def test_non_object_env_is_an_error(self, home: Path, project: Path, fake_ack: Path) -> None: + shared = project / ".claude" / "settings.json" + shared.parent.mkdir() + shared.write_text(json.dumps({"env": None})) + with pytest.raises(HookError, match="settings.json"): + install(project, fake_ack) + assert not (project / ".claude" / "settings.local.json").exists() + + +class TestShimFallback: + def test_runs_command_directly_when_ack_is_gone(self, home: Path, tmp_path: Path) -> None: + shim = tmp_path / "shim.sh" + shim.write_text(claude_hook.render_shim(Path("/gone/python"), Path("/gone/ack"))) + shim.chmod(0o755) + res = subprocess.run([str(shim), CLAUDE_SCRIPT], capture_output=True, text=True) + assert (res.stdout, res.returncode) == ("hi\n", 3) + + +class TestUninstall: + def test_removes_only_ack_key(self, home: Path, project: Path, fake_ack: Path) -> None: + target = project / ".claude" / "settings.local.json" + target.parent.mkdir() + target.write_text(json.dumps({"env": {"FOO": "1"}})) + install(project, fake_ack) + assert claude_hook.uninstall(project, user=False) == target + assert read(target) == {"env": {"FOO": "1"}} + + def test_noop_when_not_installed(self, home: Path, project: Path) -> None: + assert claude_hook.uninstall(project, user=False) is None + + def test_deletes_settings_file_left_empty( + self, home: Path, project: Path, fake_ack: Path + ) -> None: + target = install(project, fake_ack) + claude_hook.uninstall(project, user=False) + assert not target.exists() + + def test_keeps_symlinked_settings_file_left_empty( + self, home: Path, project: Path, fake_ack: Path + ) -> None: + real = project / "real-settings.json" + link = project / ".claude" / "settings.local.json" + link.parent.mkdir() + real.write_text("{}") + link.symlink_to(real) + install(project, fake_ack) + assert claude_hook.uninstall(project, user=False) == link + assert link.is_symlink() + assert PREFIX_KEY not in read(real).get("env", {}) From 91ec8f35f37c3a399a6a876cded15d430e6157dd Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 15:55:13 +0530 Subject: [PATCH 09/17] docs(readme): explain how ACK plugs into agents --- README.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 28e2d85..6c5a3b6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **Intelligent memory middleware for terminal AI coding agents.** -ACK is a transparent proxy between you and any terminal AI agent (Aider, Claude Code, and others). It collapses the noisy, high-token output that pollutes the context window (stack traces, build-error walls, log floods) into its actionable signal, and archives the full untouched stream to a local searchable database. +ACK sits between a terminal AI agent and the commands it runs. It collapses the noisy, high-token output that pollutes the context window (stack traces, build-error walls, log floods) into its actionable signal before the model reads it, and archives the full untouched output to a local searchable database. For Claude Code it plugs in with one command, `ack hook install`; any other agent can run its commands through `ack exec`. Your agent sees the signal. The full log is one `ack recall` away — for you *and* the agent itself. @@ -11,7 +11,7 @@ Your agent sees the signal. The full log is one `ack recall` away — for you *a [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg) ![Platform](https://img.shields.io/badge/platform-POSIX-lightgrey.svg) -![Tests](https://img.shields.io/badge/tests-107%20passing-brightgreen.svg) +![Tests](https://img.shields.io/badge/tests-178%20passing-brightgreen.svg) --- @@ -56,7 +56,11 @@ ACK keeps the signal, drops the noise, and never loses the original. The pruning ## How it works -ACK spawns your agent inside a real pseudo-terminal (`openpty` + `fork` + `setsid`), so prompts, colors, and cursor movement behave exactly as if you launched it directly. A single `select()` loop multiplexes your keyboard, the agent, the screen, and the database. +**Where it plugs in.** Coding agents run shell commands in their own subprocesses and read the output directly, so ACK sits on that path. For Claude Code, `ack hook install` makes ACK its shell prefix (`CLAUDE_CODE_SHELL_PREFIX`): every Bash command runs through `ack exec`, after Claude's permission checks and inside its sandbox when that is on. Output streams through as it arrives (a quiet moment or five seconds at most), so long-running servers stay visible. + +`ack run` is the other mode. It wraps a program's terminal, which is handy for watching output yourself and for programs that print straight to the terminal, but it does not change what an agent's model reads. + +`ack run` spawns the program inside a real pseudo-terminal (`openpty` + `fork` + `setsid`), so prompts, colors, and cursor movement behave exactly as if you launched it directly. A single `select()` loop multiplexes your keyboard, the agent, the screen, and the database. ![Arch diagram](./assets/context-kernel.drawio.png) @@ -80,6 +84,8 @@ When a pruner fires you see exactly what happened — and a stable handle to pag ↳ KeyError: 'card_token' ``` +Through `ack exec` the banner names the exact command instead, for example `(full log: ack recall 42)`, so a model that has never heard of ACK knows how to get the rest. + ### Recall: the archive is readable, not just written Pruning that you can't undo is just lossy compression. ACK stamps every pruned injection with a handle (`ack #42`) and exposes `ack recall` as a plain shell command — so when the summary isn't enough, the exact original bytes come back on demand, by handle or by search: @@ -111,9 +117,11 @@ poetry run ack --help ## Quick start ```bash -# Wrap any agent. Everything after `--` is the agent command. -ack run -- aider --model gpt-4o -ack run -- claude --dangerously-skip-permissions +# Claude Code: route every Bash command it runs through ACK (once per project) +ack hook install # all projects: ack hook install --user; undo: ack hook uninstall + +# Any other agent or script: run a command through ACK +ack exec -- pytest -x # Search the full archive of every session (FTS5 syntax, BM25 ranked) ack search "ImportError OR ModuleNotFoundError" @@ -181,6 +189,10 @@ The built-in `ShellPruner` targets the three biggest offenders. The original byt | `--no-annotate` | off | Suppress the `[ACK]` banners. | | `--tui` | off | Experimental Textual stats dashboard. | +**`ack exec -- `** runs a command and prunes its output before the agent reads it. Options: `--session ID`, `--db`. `--claude` is the form Claude Code's shell prefix uses; you do not call it yourself. Exit codes pass through unchanged, and if ACK itself hits a problem the command runs unmodified. + +**`ack hook install`** / **`ack hook uninstall`** add or remove ACK as Claude Code's shell prefix in `.claude/settings.local.json` (or `~/.claude/settings.json` with `--user`). Install refuses if a different prefix is already set, and checks that the prefix works before saving. + **`ack search ""`** takes an FTS5 expression (`AND`/`OR`/`NOT`, prefix, phrase) and ranks results by BM25. Options: `--session`, `--limit` (default 20), `--db`. **`ack recall `** pages a stored log back. A numeric argument is an `ack #N` handle (exact lookup); anything else is a search, scoped to the most recent session by default. Options: `--session`, `--all` (search every session), `--limit` (default 1), `--raw` (skip escape-sequence sanitisation), `--db`. @@ -232,7 +244,7 @@ Register with `pruners=[MyPruner(), ShellPruner()]` on the `Orchestrator`. Prune ```bash poetry install -poetry run pytest # 107 tests (unit + integration) +poetry run pytest # 178 tests (unit + integration) poetry run pytest -m "not integration" # fast unit tests only poetry run python scripts/run_benchmarks.py # L1 compression / fidelity poetry run python scripts/run_recall_benchmark.py # L2 needle recovery @@ -274,6 +286,9 @@ against any layer are welcome — see [CONTRIBUTING](CONTRIBUTING.md). - **`ack toc` is Python-only** today; tree-sitter is structured to add languages. - **`--tui` is experimental:** Textual and the PTY interceptor both want the terminal, so the agent runs non-interactively in that mode. Plain `ack run` is the recommended interactive path. - **Heuristic by design.** Pruners are fast, deterministic, and free; a new output format needs a new pruner (easy to write, see above). +- **Claude Code first.** `ack hook install` targets Claude Code. Other agents can use `ack exec`; native hooks for Codex and Gemini CLI are planned. +- **pytest's own failure format is not pruned yet.** Plain Python tracebacks, Rust/GCC errors and log floods are. +- **Sandboxed Claude sessions** archive to `$TMPDIR/ack/kernel.db`, because the home directory is read-only there; the banner's recall command points at it. --- From 747afde22cb10afa3f98fd1497780a3d27bda356 Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 16:11:32 +0530 Subject: [PATCH 10/17] fix(core): stop ack exec when its output pipe closes --- context_kernel/core/executor.py | 21 +++++++++++++++++---- context_kernel/core/orchestrator.py | 4 ++++ tests/test_exec_cli.py | 14 ++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/context_kernel/core/executor.py b/context_kernel/core/executor.py index 950843d..d742e88 100644 --- a/context_kernel/core/executor.py +++ b/context_kernel/core/executor.py @@ -131,15 +131,20 @@ def forward(signum: int, _frame: FrameType | None) -> None: for sig, handler in previous.items(): signal.signal(sig, handler) - orch.flush(out_fd, final=True) - _hand_off(r, out_fd) + if not orch.output_closed: + orch.flush(out_fd, final=True) + if orch.output_closed: + os.close(r) + else: + _hand_off(r, out_fd) code = os.waitstatus_to_exitcode(status) return 128 - code if code < 0 else code def _pump(r: int, pid: int, orch: Orchestrator, out_fd: int, timing: ExecTiming) -> int: - """Read until the command's shell exits, flushing on silence, on max hold, - or when the buffer is full. Returns the raw wait status.""" + """Read until the command's shell exits or our output is closed, flushing + on silence, on max hold, or when the buffer is full. Returns the raw wait + status.""" status: int | None = None exit_deadline = first = last = 0.0 while True: @@ -176,6 +181,14 @@ def _pump(r: int, pid: int, orch: Orchestrator, out_fd: int, timing: ExecTiming) orch.flush(out_fd) first = last = now + if orch.output_closed: + # Like a shell pipeline: close our end so the command gets SIGPIPE + # on its next write, and stop reading and archiving. + os.close(r) + if status is None: + _, status = os.waitpid(pid, 0) + return status + def _hand_off(r: int, out_fd: int) -> None: """Keep forwarding output from processes the command left running. diff --git a/context_kernel/core/orchestrator.py b/context_kernel/core/orchestrator.py index 6bc7c37..defc300 100644 --- a/context_kernel/core/orchestrator.py +++ b/context_kernel/core/orchestrator.py @@ -109,6 +109,7 @@ def __init__( self.config = config or OrchestratorConfig() self.stats_callback = stats_callback self.text_callback: Callable[[str], None] | None = None + self.output_closed = False self._stats = OrchestratorStats() self._child_pid: int | None = None @@ -364,6 +365,9 @@ def _emit(self, fd: int, data: bytes) -> None: while offset < len(data): try: offset += os.write(fd, data[offset:]) + except BrokenPipeError: + self.output_closed = True # the reader is gone; callers stop feeding us + break except OSError: break diff --git a/tests/test_exec_cli.py b/tests/test_exec_cli.py index 2432a74..97a093e 100644 --- a/tests/test_exec_cli.py +++ b/tests/test_exec_cli.py @@ -177,3 +177,17 @@ def test_closed_pipe_is_quiet(self, tmp_path: Path) -> None: ) assert res.stdout == "1\n" assert "Traceback" not in res.stderr + + def test_stops_when_reader_goes_away(self, tmp_path: Path) -> None: + cmd = " ".join(shlex.quote(a) for a in ACK) + start = time.monotonic() + res = subprocess.run( + f"{cmd} exec --db {tmp_path / 'k.db'} -- yes | head -1", + shell=True, capture_output=True, text=True, timeout=10, # noqa: S602 + ) + assert time.monotonic() - start < 10 + # The first flush is 256KB of "y" lines, so head sees the summary banner. + assert res.stdout.startswith("[ACK] Compressed") and res.stdout.count("\n") == 1 + assert "Traceback" not in res.stderr + archived = sum(f.stat().st_size for f in tmp_path.glob("k.db*")) + assert archived < 1_000_000 From 1233fca59430becc94ef7580046b62003a8a489d Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 16:13:09 +0530 Subject: [PATCH 11/17] fix(pruner): leave output alone when a summary saves too little --- context_kernel/core/orchestrator.py | 4 +++- context_kernel/pruners/shell_pruner.py | 4 +++- tests/test_orchestrator.py | 24 ++++++++++++++++++++++++ tests/test_shell_pruner.py | 17 +++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/context_kernel/core/orchestrator.py b/context_kernel/core/orchestrator.py index defc300..db2ee79 100644 --- a/context_kernel/core/orchestrator.py +++ b/context_kernel/core/orchestrator.py @@ -32,6 +32,7 @@ _ANSI_ESC = re.compile(r"\x1b\[[0-9;]*[mGKHFJA-Z]") _TRACEBACK_MARKER = "Traceback (most recent call last):" +_MAX_SUMMARY_RATIO = 0.75 # a summary must be at least 25% shorter than its input def _incomplete_utf8_tail(data: bytes) -> bytes: @@ -324,7 +325,8 @@ def _flush_buffer(self, stdout_fd: int, *, force: bool = False) -> None: summary: str | None = None for pruner in self.pruners: result = pruner.compress(text) - if result is not None: + # A summary that saves little is noise on top of the real output. + if result is not None and len(result) <= _MAX_SUMMARY_RATIO * len(text): summary = result saved = max(0, (len(text) - len(summary)) // 4) self._stats.tokens_saved += saved diff --git a/context_kernel/pruners/shell_pruner.py b/context_kernel/pruners/shell_pruner.py index ec99211..49b5d67 100644 --- a/context_kernel/pruners/shell_pruner.py +++ b/context_kernel/pruners/shell_pruner.py @@ -12,7 +12,9 @@ from .base import BasePruner, PrunerMetadata -_PY_TRACEBACK_HDR = re.compile(r"Traceback \(most recent call last\):", re.MULTILINE) +# Anchored so a mention inside code or a string (a `cat` of Python source) is +# not taken for a real traceback; logs may indent one, so allow leading space. +_PY_TRACEBACK_HDR = re.compile(r"^\s*Traceback \(most recent call last\):", re.MULTILINE) _PY_FRAME_LINE = re.compile( r'^\s+File "(?P[^"]+)", line (?P\d+), in (?P.+)', diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index 3a5911c..cd6a538 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -726,3 +726,27 @@ def test_pruned_output_always_archived( orch.flush(w, final=True) _read_pipe(r) assert storage.stats(orch.session_id)["pruned_entries"] == 1 + + def test_summary_that_saves_too_little_passes_through_raw( + self, storage: StorageEngine, pipe_pair + ) -> None: + r, w = pipe_pair + + class BarelyShorter(BasePruner): + metadata = PrunerMetadata(name="barely", description="test") + + def matches(self, text: str) -> bool: + return True + + def compress(self, text: str) -> str | None: + return text[: int(len(text) * 0.9)] + + orch = _exec_orchestrator(storage, [BarelyShorter()], color=False) + raw = b"".join(f"line {i}\n".encode() for i in range(40)) + orch.feed(raw) + orch.flush(w, final=True) + assert _read_pipe(r) == raw + assert orch.stats.total_pruner_hits == 0 + assert orch.stats.tokens_saved == 0 + stats = storage.stats(orch.session_id) + assert stats["total_entries"] == 1 and stats["pruned_entries"] == 0 diff --git a/tests/test_shell_pruner.py b/tests/test_shell_pruner.py index 391ab9a..529c8e3 100644 --- a/tests/test_shell_pruner.py +++ b/tests/test_shell_pruner.py @@ -224,6 +224,23 @@ def test_matches_ansi_coloured_traceback(self, pruner: ShellPruner) -> None: coloured += "RuntimeError: boom\n" assert pruner.matches(coloured) + def test_marker_inside_a_string_is_not_a_traceback(self, pruner: ShellPruner) -> None: + code = [f"value_{i} = compute({i})" for i in range(40)] + code.insert(20, ' if "Traceback (most recent call last):" in text:') + code.insert(21, " return True") + summary = pruner.compress("\n".join(code)) + assert summary is None or "↳" not in summary + + def test_indented_traceback_in_a_log_still_matches(self, pruner: ShellPruner) -> None: + text = ( + "2026-09-29 12:00:01 ERROR worker crashed\n" + " Traceback (most recent call last):\n" + ' File "/app/worker.py", line 12, in run\n' + " job()\n" + " ValueError: bad job\n" + ) + assert pruner.matches(text) + class TestPythonTracebackCompression: """Verify content quality and compression ratio for Python tracebacks.""" From d3b190c7a1d7aa32d16ccf1f03eec3c1d4a82c90 Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 16:15:42 +0530 Subject: [PATCH 12/17] fix(cli): run the command directly when ACK cannot import --- context_kernel/claude_hook.py | 42 +++++++++++--- tests/test_claude_hook.py | 104 +++++++++++++++++++++++++--------- 2 files changed, 111 insertions(+), 35 deletions(-) diff --git a/context_kernel/claude_hook.py b/context_kernel/claude_hook.py index 45e264d..ad6baa4 100644 --- a/context_kernel/claude_hook.py +++ b/context_kernel/claude_hook.py @@ -2,8 +2,9 @@ Claude runs every Bash command through the prefix after its permission checks, so a prefix is the one place ACK can see command output before the model does. -The prefix is a tiny shell shim: if ACK is ever removed, the shim runs the -command directly so Claude keeps working. +The prefix is a tiny shell shim: if ACK is removed or can no longer import, +the shim runs the command directly with the shell Claude built it for, so +Claude keeps working. """ from __future__ import annotations @@ -37,17 +38,40 @@ def settings_paths(project_dir: Path) -> dict[str, Path]: def render_shim(python: Path, ack_script: Path) -> str: - py, ack = shlex.quote(str(python)), shlex.quote(str(ack_script)) + # Runs under `python -E -c`. With -c, sys.path[0] is the current directory, + # where a project's own modules could shadow ACK's imports, so drop it. If + # ACK cannot import, run the script with the shell Claude built it for. + bootstrap = ( + "import os, re, shutil, sys\n" + "del sys.path[0]\n" + "ack, script = sys.argv[1], sys.argv[-1]\n" + "try:\n" + " from context_kernel.cli import main\n" + "except BaseException:\n" + ' m = re.search(r"snapshot-(bash|zsh)-", script)\n' + ' sh = m and shutil.which(m.group(1)) or "/bin/sh"\n' + ' os.execv(sh, [sh, "-c", script])\n' + 'sys.argv = [ack, "exec", "--claude", "--", script]\n' + "main()\n" + ) + py, ack, code = (shlex.quote(str(x)) for x in (python, ack_script, bootstrap)) return ( "#!/bin/sh\n" "# Claude Code shell prefix installed by `ack hook install`.\n" "# -E keeps a project's PYTHONPATH from breaking ACK; the command itself\n" - "# still gets the full environment. If ACK is gone, run the command as is.\n" + "# still gets the full environment. If ACK is gone or cannot import, run\n" + "# the command with the shell Claude built it for.\n" f"if [ -x {py} ] && [ -f {ack} ]; then\n" - f' exec {py} -E {ack} exec --claude -- "$@"\n' + f' exec {py} -E -c {code} {ack} "$@"\n' "fi\n" "for last; do :; done\n" - 'exec "${SHELL:-/bin/sh}" -c "$last"\n' + 'case "$last" in\n' + " *snapshot-zsh-*) sh=zsh ;;\n" + " *snapshot-bash-*) sh=bash ;;\n" + " *) sh=/bin/sh ;;\n" + "esac\n" + 'command -v "$sh" >/dev/null 2>&1 || sh=/bin/sh\n' + 'exec "$sh" -c "$last"\n' ) @@ -99,9 +123,9 @@ def uninstall(project_dir: Path, *, user: bool) -> Path | None: else: data.pop("env", None) if data or target.is_symlink(): - # A symlinked settings file (common with dotfile managers) must keep - # existing: write the emptied object through it instead of unlinking - # the link and leaving the real file behind with our key still in it. + # Dotfile managers often symlink settings files. Deleting the link would + # leave the real file behind with our key in it, so write the empty + # object through the link. _write(target, data) else: target.unlink(missing_ok=True) diff --git a/tests/test_claude_hook.py b/tests/test_claude_hook.py index 8eb276e..cbb48ad 100644 --- a/tests/test_claude_hook.py +++ b/tests/test_claude_hook.py @@ -2,6 +2,9 @@ from __future__ import annotations import json +import os +import shlex +import shutil import subprocess import sys from pathlib import Path @@ -11,12 +14,9 @@ from context_kernel import claude_hook from context_kernel.claude_hook import PREFIX_KEY, HookError -FAKE_ACK = ( - "import os, sys\n" - "if sys.argv[1:4] != ['exec', '--claude', '--']:\n" - " sys.exit(90)\n" - "os.execv('/bin/sh', ['sh', '-c', sys.argv[-1]])\n" -) +# The shim only checks the script exists and passes its path on as argv[0]; +# it imports ACK itself, so the test venv's real context_kernel runs. +ACK_SCRIPT = "import sys\nfrom context_kernel.cli import main\nsys.exit(main())\n" @pytest.fixture @@ -36,23 +36,37 @@ def project(tmp_path: Path) -> Path: @pytest.fixture def fake_ack(tmp_path: Path) -> Path: - f = tmp_path / "venv bin" / "ack" # a space on purpose + f = tmp_path / "venv bin" / "ack's" # a space and a quote on purpose f.parent.mkdir() - f.write_text(FAKE_ACK) + f.write_text(ACK_SCRIPT) return f def install(project: Path, fake_ack: Path, env: dict[str, str] | None = None, - user: bool = False) -> Path: - return claude_hook.install(project, user=user, python=Path(sys.executable), + user: bool = False, python: Path = Path(sys.executable)) -> Path: + return claude_hook.install(project, user=user, python=python, ack_script=fake_ack, env=env or {}) +def archive(home: Path) -> Path: + return home / ".local" / "share" / "ack" / "kernel.db" + + +def run_shim(shim: Path, script: str, **env: str) -> subprocess.CompletedProcess[str]: + return subprocess.run([str(shim), script], capture_output=True, text=True, timeout=60, + env={**os.environ, **env}) + + def read(path: Path) -> dict: return json.loads(path.read_text()) CLAUDE_SCRIPT = "true && eval 'echo hi; exit 3' < /dev/null && pwd -P >| /dev/null" +# Only bash sets BASH_VERSION, so the output shows which shell ran the script. +BASH_SCRIPT = ( + "source /nowhere/snapshot-bash-1.sh 2>/dev/null || true && " + "eval 'echo ${BASH_VERSION:+bash}; exit 3' < /dev/null && pwd -P >| /dev/null" +) class TestInstall: @@ -63,8 +77,9 @@ def test_writes_shim_and_local_settings( assert target == project / ".claude" / "settings.local.json" shim = claude_hook.shim_path() assert read(target)["env"][PREFIX_KEY] == str(shim) - res = subprocess.run([str(shim), CLAUDE_SCRIPT], capture_output=True, text=True) + res = run_shim(shim, CLAUDE_SCRIPT) assert (res.stdout, res.returncode) == ("hi\n", 3) + assert archive(home).exists() def test_keeps_other_settings(self, home: Path, project: Path, fake_ack: Path) -> None: target = project / ".claude" / "settings.local.json" @@ -103,24 +118,19 @@ def test_refuses_malformed_settings_and_leaves_it( assert target.read_text() == "{not json" def test_failed_self_test_writes_no_settings( - self, home: Path, project: Path, tmp_path: Path + self, home: Path, project: Path, fake_ack: Path ) -> None: - broken = tmp_path / "broken_ack" - broken.write_text("import sys\nsys.exit(1)\n") with pytest.raises(HookError, match="self-test"): - install(project, broken) + install(project, fake_ack, python=Path("/bin/false")) assert not (project / ".claude" / "settings.local.json").exists() - def test_reinstall_with_broken_ack_leaves_shim_working( - self, home: Path, project: Path, fake_ack: Path, tmp_path: Path + def test_reinstall_with_broken_python_leaves_shim_working( + self, home: Path, project: Path, fake_ack: Path ) -> None: install(project, fake_ack) - broken = tmp_path / "broken_ack" - broken.write_text("import sys\nsys.exit(1)\n") with pytest.raises(HookError, match="self-test"): - install(project, broken) - shim = claude_hook.shim_path() - res = subprocess.run([str(shim), CLAUDE_SCRIPT], capture_output=True, text=True) + install(project, fake_ack, python=Path("/bin/false")) + res = run_shim(claude_hook.shim_path(), CLAUDE_SCRIPT) assert (res.stdout, res.returncode) == ("hi\n", 3) def test_non_object_env_is_an_error(self, home: Path, project: Path, fake_ack: Path) -> None: @@ -132,14 +142,56 @@ def test_non_object_env_is_an_error(self, home: Path, project: Path, fake_ack: P assert not (project / ".claude" / "settings.local.json").exists() +def write_shim(tmp_path: Path, python: Path, ack_script: Path) -> Path: + shim = tmp_path / "shim.sh" + shim.write_text(claude_hook.render_shim(python, ack_script)) + shim.chmod(0o755) + return shim + + class TestShimFallback: def test_runs_command_directly_when_ack_is_gone(self, home: Path, tmp_path: Path) -> None: - shim = tmp_path / "shim.sh" - shim.write_text(claude_hook.render_shim(Path("/gone/python"), Path("/gone/ack"))) - shim.chmod(0o755) - res = subprocess.run([str(shim), CLAUDE_SCRIPT], capture_output=True, text=True) + shim = write_shim(tmp_path, Path("/gone/python"), Path("/gone/ack")) + res = run_shim(shim, CLAUDE_SCRIPT) assert (res.stdout, res.returncode) == ("hi\n", 3) + def test_hook_script_falls_back_to_bin_sh(self, home: Path, tmp_path: Path) -> None: + shim = write_shim(tmp_path, Path("/gone/python"), Path("/gone/ack")) + res = run_shim(shim, "echo hook ran", SHELL="/nowhere/shell") + assert (res.stdout, res.returncode) == ("hook ran\n", 0) + + @pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash") + def test_missing_ack_uses_the_snapshot_shell(self, home: Path, tmp_path: Path) -> None: + shim = write_shim(tmp_path, Path("/gone/python"), Path("/gone/ack")) + res = run_shim(shim, BASH_SCRIPT, SHELL="/nowhere/shell") + assert (res.stdout, res.returncode) == ("bash\n", 3) + + def test_project_pythonpath_cannot_break_ack( + self, home: Path, tmp_path: Path, fake_ack: Path + ) -> None: + shadow = tmp_path / "shadow" + shadow.mkdir() + (shadow / "click.py").write_text("raise ImportError('shadowed')\n") + shim = write_shim(tmp_path, Path(sys.executable), fake_ack) + res = run_shim(shim, CLAUDE_SCRIPT, PYTHONPATH=str(shadow)) + assert (res.stdout, res.returncode) == ("hi\n", 3) + assert archive(home).exists(), "ACK itself should have run, not the fallback" + + @pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash") + def test_runs_command_when_ack_cannot_import( + self, home: Path, tmp_path: Path, fake_ack: Path + ) -> None: + # -S hides site-packages, so context_kernel is gone but python still runs, + # like an editable install whose clone was moved. + no_site = tmp_path / "python" + no_site.write_text(f'#!/bin/sh\nexec {shlex.quote(sys.executable)} -S "$@"\n') + no_site.chmod(0o755) + shim = write_shim(tmp_path, no_site, fake_ack) + res = run_shim(shim, BASH_SCRIPT, SHELL="/nowhere/shell") + assert (res.stdout, res.returncode) == ("bash\n", 3) + assert "Traceback" not in res.stderr + assert not archive(home).exists() + class TestUninstall: def test_removes_only_ack_key(self, home: Path, project: Path, fake_ack: Path) -> None: From b06fbf9a64e9d36bfb3c17861646c3a62e36fe58 Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 16:16:47 +0530 Subject: [PATCH 13/17] fix(cli): scope ack recall searches to the current Claude session --- README.md | 2 +- context_kernel/cli.py | 15 +++++++++++---- tests/test_cli.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6c5a3b6..8e42c94 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ The built-in `ShellPruner` targets the three biggest offenders. The original byt **`ack search ""`** takes an FTS5 expression (`AND`/`OR`/`NOT`, prefix, phrase) and ranks results by BM25. Options: `--session`, `--limit` (default 20), `--db`. -**`ack recall `** pages a stored log back. A numeric argument is an `ack #N` handle (exact lookup); anything else is a search, scoped to the most recent session by default. Options: `--session`, `--all` (search every session), `--limit` (default 1), `--raw` (skip escape-sequence sanitisation), `--db`. +**`ack recall `** pages a stored log back. A numeric argument is an `ack #N` handle (exact lookup); anything else is a search, scoped by default to the current Claude Code session, or the most recent session outside Claude. Options: `--session`, `--all` (search every session), `--limit` (default 1), `--raw` (skip escape-sequence sanitisation), `--db`. **`ack toc `** prints a symbol table-of-contents (Python today). diff --git a/context_kernel/cli.py b/context_kernel/cli.py index 86c299e..960d1d6 100644 --- a/context_kernel/cli.py +++ b/context_kernel/cli.py @@ -324,7 +324,8 @@ def cmd_search( "session_id", default=None, metavar="SESSION_ID", - help="Session to search when TARGET is a query (default: most recent).", + help="Session to search when TARGET is a query (default: current Claude session, " + "else most recent).", ) @click.option( "--all", @@ -364,7 +365,8 @@ def cmd_recall( TARGET is either a numeric recall handle (the "ack #N" shown on a pruned banner -> `ack recall N`) or an FTS5 query, in which case the best match - from the most recent session is returned. Use --all to widen the search. + from the current Claude session (or else the most recent one) is returned. + Use --all to widen the search. """ render: Callable[[str], str] = (lambda t: t) if raw else _sanitize storage = StorageEngine(db_path=db) if db else StorageEngine() @@ -375,8 +377,13 @@ def cmd_recall( else: scope = session_id if scope is None and not all_sessions: - recent = storage.list_sessions(limit=1) - scope = recent[0]["session_id"] if recent else None + # Under `ack exec`, the latest session may be another Claude window. + current = os.environ.get("CLAUDE_CODE_SESSION_ID") + if current and storage.get_session(current) is not None: + scope = current + else: + recent = storage.list_sessions(limit=1) + scope = recent[0]["session_id"] if recent else None rows = storage.search(target, session_id=scope, limit=limit) if not rows: diff --git a/tests/test_cli.py b/tests/test_cli.py index 89f9882..8ea3bd8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -101,6 +101,36 @@ def test_recall_raw_flag_preserves_bytes(self, db_path: Path) -> None: ) assert "\x1b[31m" in result.output + def _seed_two_sessions(self, db_path: Path) -> None: + with StorageEngine(db_path=db_path) as eng: + for sid, started in (("claude-old", 1000.0), ("claude-new", 2000.0)): + eng.ensure_session(sid, "claude") + eng._db.execute( + "UPDATE sessions SET started_at = ? WHERE session_id = ?", (started, sid) + ) + eng.insert_entry(LogEntry(session_id=sid, raw_content=f"pytest failed in {sid}", + entry_type="stdout")) + + def _query(self, db_path: Path, env: dict[str, str | None]) -> str: + result = CliRunner().invoke( + main, ["recall", "failed", "--db", str(db_path)], env=env + ) + assert result.exit_code == 0 + return result.output + + def test_query_searches_the_current_claude_session(self, db_path: Path) -> None: + self._seed_two_sessions(db_path) + out = self._query(db_path, {"CLAUDE_CODE_SESSION_ID": "claude-old"}) + assert "pytest failed in claude-old" in out + assert "claude-new" not in out + + def test_query_uses_latest_session_without_a_known_claude_session( + self, db_path: Path + ) -> None: + self._seed_two_sessions(db_path) + for env in ({"CLAUDE_CODE_SESSION_ID": None}, {"CLAUDE_CODE_SESSION_ID": "unknown"}): + assert "pytest failed in claude-new" in self._query(db_path, env) + class TestStartupImports: def test_cli_import_skips_tui_and_tree_sitter(self) -> None: From 6907bc2e16e9308c717cf98bfe24b15f4f3146a7 Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 16:17:21 +0530 Subject: [PATCH 14/17] docs(readme): reword the note on when output reaches the agent --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8e42c94..190bfb8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Your agent sees the signal. The full log is one `ack recall` away — for you *a [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg) ![Platform](https://img.shields.io/badge/platform-POSIX-lightgrey.svg) -![Tests](https://img.shields.io/badge/tests-178%20passing-brightgreen.svg) +![Tests](https://img.shields.io/badge/tests-188%20passing-brightgreen.svg) --- @@ -56,7 +56,7 @@ ACK keeps the signal, drops the noise, and never loses the original. The pruning ## How it works -**Where it plugs in.** Coding agents run shell commands in their own subprocesses and read the output directly, so ACK sits on that path. For Claude Code, `ack hook install` makes ACK its shell prefix (`CLAUDE_CODE_SHELL_PREFIX`): every Bash command runs through `ack exec`, after Claude's permission checks and inside its sandbox when that is on. Output streams through as it arrives (a quiet moment or five seconds at most), so long-running servers stay visible. +**Where it plugs in.** Coding agents run shell commands in their own subprocesses and read the output directly, so ACK sits on that path. For Claude Code, `ack hook install` makes ACK its shell prefix (`CLAUDE_CODE_SHELL_PREFIX`): every Bash command runs through `ack exec`, after Claude's permission checks and inside its sandbox when that is on. Output reaches the agent after a short pause, or every five seconds at most, so long-running servers stay visible. `ack run` is the other mode. It wraps a program's terminal, which is handy for watching output yourself and for programs that print straight to the terminal, but it does not change what an agent's model reads. @@ -244,7 +244,7 @@ Register with `pruners=[MyPruner(), ShellPruner()]` on the `Orchestrator`. Prune ```bash poetry install -poetry run pytest # 178 tests (unit + integration) +poetry run pytest # 188 tests (unit + integration) poetry run pytest -m "not integration" # fast unit tests only poetry run python scripts/run_benchmarks.py # L1 compression / fidelity poetry run python scripts/run_recall_benchmark.py # L2 needle recovery From 6a0899e713e260f24199e743e9427daf8421586a Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 16:35:05 +0530 Subject: [PATCH 15/17] fix(cli): make the hook self-test prove ACK actually ran --- context_kernel/claude_hook.py | 21 +++++++++++++++++---- tests/test_claude_hook.py | 13 +++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/context_kernel/claude_hook.py b/context_kernel/claude_hook.py index ad6baa4..7ac19e0 100644 --- a/context_kernel/claude_hook.py +++ b/context_kernel/claude_hook.py @@ -48,6 +48,7 @@ def render_shim(python: Path, ack_script: Path) -> str: "try:\n" " from context_kernel.cli import main\n" "except BaseException:\n" + ' os.environ["ACK_SHIM_FALLBACK"] = "1"\n' ' m = re.search(r"snapshot-(bash|zsh)-", script)\n' ' sh = m and shutil.which(m.group(1)) or "/bin/sh"\n' ' os.execv(sh, [sh, "-c", script])\n' @@ -71,6 +72,7 @@ def render_shim(python: Path, ack_script: Path) -> str: " *) sh=/bin/sh ;;\n" "esac\n" 'command -v "$sh" >/dev/null 2>&1 || sh=/bin/sh\n' + "export ACK_SHIM_FALLBACK=1\n" 'exec "$sh" -c "$last"\n' ) @@ -146,7 +148,7 @@ def _install_shim(python: Path, ack_script: Path) -> Path: with os.fdopen(fd, "w") as f: f.write(render_shim(python, ack_script)) tmp.chmod(0o755) - _self_test(tmp) + _self_test(tmp, python) os.replace(tmp, shim) except BaseException: tmp.unlink(missing_ok=True) @@ -154,8 +156,14 @@ def _install_shim(python: Path, ack_script: Path) -> Path: return shim -def _self_test(shim: Path) -> None: - script = "true && eval 'echo ack-ok' < /dev/null && pwd -P >| /dev/null" +def _self_test(shim: Path, python: Path) -> None: + """Run a script through the shim and check ACK itself handled it. + + Both fallback paths (a missing python/ack file, or ack_script that + cannot import) set ACK_SHIM_FALLBACK before running the script with a + plain shell, so a marker of "1" means ACK never started. + """ + script = "true && eval 'echo ack-ok:${ACK_SHIM_FALLBACK:-0}' < /dev/null && pwd -P >| /dev/null" try: result = subprocess.run( # noqa: S603 [str(shim), script], capture_output=True, text=True, timeout=30, @@ -163,7 +171,12 @@ def _self_test(shim: Path) -> None: ) except (OSError, subprocess.TimeoutExpired) as exc: raise HookError(f"self-test could not run the shim: {exc}") from exc - if result.returncode != 0 or "ack-ok" not in result.stdout: + if "ack-ok:1" in result.stdout: + raise HookError( + f"self-test failed: the shim ran the command without ACK " + f"(ACK could not start with {python})" + ) + if result.returncode != 0 or "ack-ok:0" not in result.stdout: raise HookError(f"self-test failed:\n{result.stdout}{result.stderr}".rstrip()) diff --git a/tests/test_claude_hook.py b/tests/test_claude_hook.py index cbb48ad..1941423 100644 --- a/tests/test_claude_hook.py +++ b/tests/test_claude_hook.py @@ -124,6 +124,19 @@ def test_failed_self_test_writes_no_settings( install(project, fake_ack, python=Path("/bin/false")) assert not (project / ".claude" / "settings.local.json").exists() + def test_self_test_fails_when_ack_cannot_import( + self, home: Path, project: Path, fake_ack: Path, tmp_path: Path + ) -> None: + # -S hides site-packages, so context_kernel is gone but python still + # runs, like an editable install whose clone was moved. The shim's + # fallback then runs the command with plain sh, so ACK never sees it. + no_site = tmp_path / "python" + no_site.write_text(f'#!/bin/sh\nexec {shlex.quote(sys.executable)} -S "$@"\n') + no_site.chmod(0o755) + with pytest.raises(HookError, match="self-test"): + install(project, fake_ack, python=no_site) + assert not (project / ".claude" / "settings.local.json").exists() + def test_reinstall_with_broken_python_leaves_shim_working( self, home: Path, project: Path, fake_ack: Path ) -> None: From 5bc9e0685b659a25da7c91f9a02c22aa559a0c98 Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 16:36:43 +0530 Subject: [PATCH 16/17] fix(orchestrator): only hold output for tracebacks that start a line --- context_kernel/core/orchestrator.py | 10 ++++++---- tests/test_orchestrator.py | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/context_kernel/core/orchestrator.py b/context_kernel/core/orchestrator.py index db2ee79..e087b41 100644 --- a/context_kernel/core/orchestrator.py +++ b/context_kernel/core/orchestrator.py @@ -31,7 +31,9 @@ _RESET = "\033[0m" _ANSI_ESC = re.compile(r"\x1b\[[0-9;]*[mGKHFJA-Z]") -_TRACEBACK_MARKER = "Traceback (most recent call last):" +# Anchored to match the pruner's own header check, so a mention inside code or +# a string (a `cat` of Python source) is not taken for a real traceback. +_TRACEBACK_HDR = re.compile(r"^\s*Traceback \(most recent call last\):", re.MULTILINE) _MAX_SUMMARY_RATIO = 0.75 # a summary must be at least 25% shorter than its input @@ -424,14 +426,14 @@ def _is_incomplete_traceback(self, text: str) -> bool: are still arriving the last non-blank line is an indented frame line, or the header itself. """ - if _TRACEBACK_MARKER not in text: - return False clean = _ANSI_ESC.sub("", text) + if not _TRACEBACK_HDR.search(clean): + return False nonblank = [ln for ln in clean.splitlines() if ln.strip()] if not nonblank: return False last = nonblank[-1] - if _TRACEBACK_MARKER in last: + if _TRACEBACK_HDR.match(last): return True return last[:1].isspace() diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index cd6a538..bad9085 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -367,6 +367,26 @@ def test_complete_traceback_is_flushed( assert b"ValueError" in data, "A finished traceback must be emitted." assert not orch._buffer + def test_traceback_mention_in_code_is_not_held( + self, storage: StorageEngine, pipe_pair: tuple[int, int] + ) -> None: + """A `cat` of source that mentions the marker mid-line, ending on an + indented line, is not a real traceback and must not be held.""" + r_fd, w_fd = pipe_pair + orch = _make_orchestrator(storage, threshold=2) + text = ( + "def foo():\n" + ' msg = "Traceback (most recent call last):"\n' + " return msg\n" + ) + orch._buffer = [text.encode()] + + orch._flush_buffer(w_fd, force=False) # type: ignore[attr-defined] + + data = _read_pipe(r_fd) + assert data == text.encode() + assert not orch._buffer + def test_incomplete_traceback_flushed_when_forced( self, storage: StorageEngine, pipe_pair: tuple[int, int] ) -> None: From 349cf21e6d372d5db27d3733186a4ca2c65dc906 Mon Sep 17 00:00:00 2001 From: Vaibhav Tripathi Date: Tue, 29 Sep 2026 16:39:34 +0530 Subject: [PATCH 17/17] docs(readme): drop the last em-dashes --- README.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 190bfb8..26659ce 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ ACK sits between a terminal AI agent and the commands it runs. It collapses the noisy, high-token output that pollutes the context window (stack traces, build-error walls, log floods) into its actionable signal before the model reads it, and archives the full untouched output to a local searchable database. For Claude Code it plugs in with one command, `ack hook install`; any other agent can run its commands through `ack exec`. -Your agent sees the signal. The full log is one `ack recall` away — for you *and* the agent itself. +Your agent sees the signal. The full log is one `ack recall` away, for you *and* the agent itself. [![Website](https://img.shields.io/badge/website-ack--context--kernel.vercel.app-4f6f4c.svg)](https://ack-context-kernel.vercel.app) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg) ![Platform](https://img.shields.io/badge/platform-POSIX-lightgrey.svg) -![Tests](https://img.shields.io/badge/tests-188%20passing-brightgreen.svg) +![Tests](https://img.shields.io/badge/tests-190%20passing-brightgreen.svg) --- @@ -74,7 +74,7 @@ Every flushed buffer is handled in order: 3. **Large blobs run through ordered pruners.** First match wins: a hit injects a compact summary, a miss passes through. 4. **Everything is persisted.** The summary is a view, never a deletion. -When a pruner fires you see exactly what happened — and a stable handle to page the original back: +When a pruner fires you see exactly what happened, and a stable handle to page the original back: ``` [ACK] Compressed 63 lines → 4 lines (recall: ack #42) @@ -88,7 +88,7 @@ Through `ack exec` the banner names the exact command instead, for example `(ful ### Recall: the archive is readable, not just written -Pruning that you can't undo is just lossy compression. ACK stamps every pruned injection with a handle (`ack #42`) and exposes `ack recall` as a plain shell command — so when the summary isn't enough, the exact original bytes come back on demand, by handle or by search: +Pruning that you can't undo is just lossy compression. ACK stamps every pruned injection with a handle (`ack #42`) and exposes `ack recall` as a plain shell command, so when the summary isn't enough, the exact original bytes come back on demand, by handle or by search: ```bash ack recall 42 # page back the full log behind that banner @@ -126,7 +126,7 @@ ack exec -- pytest -x # Search the full archive of every session (FTS5 syntax, BM25 ranked) ack search "ImportError OR ModuleNotFoundError" -# Page a pruned log back — by its `ack #N` handle, or by content +# Page a pruned log back (by its `ack #N` handle, or by content) ack recall 42 ack recall "ImportError" @@ -244,7 +244,7 @@ Register with `pruners=[MyPruner(), ShellPruner()]` on the `Orchestrator`. Prune ```bash poetry install -poetry run pytest # 188 tests (unit + integration) +poetry run pytest # 190 tests (unit + integration) poetry run pytest -m "not integration" # fast unit tests only poetry run python scripts/run_benchmarks.py # L1 compression / fidelity poetry run python scripts/run_recall_benchmark.py # L2 needle recovery @@ -262,21 +262,21 @@ ACK is one piece of a larger idea: treat the context window as a scarce resource to be managed, and keep deterministic work out of the model's way. The pruner you see today is the first of three layers. -- **L1 — output reduction** *(shipped).* The pruners. Collapse deterministic - noise — tracebacks, build-error walls, log floods — to its signal before it +- **L1: output reduction** *(shipped).* The pruners. Collapse deterministic + noise (tracebacks, build-error walls, log floods) to its signal before it ever reaches the model. -- **L2 — memory paging** *(in progress).* The archive plus `ack recall`. Pruned +- **L2: memory paging** *(in progress).* The archive plus `ack recall`. Pruned detail is recoverable on demand, so compression is never a one-way loss. Shipped: stable `ack #N` handles and recall by id or content. Next: - - **Proactive dedup** — content-hash repeated output so the same error isn't re-paged across turns. - - **Pager narrowing** — recall just the errored function, not the whole log. - - **A context-health signal** — detect repetition and re-run-the-same-command loops as a deterministic paging trigger, instead of a fixed token threshold. -- **L3 — execution offload** *(exploring).* Run well-specified, deterministic + - **Proactive dedup**: content-hash repeated output so the same error isn't re-paged across turns. + - **Pager narrowing**: recall just the errored function, not the whole log. + - **A context-health signal**: detect repetition and re-run-the-same-command loops as a deterministic paging trigger, instead of a fixed token threshold. +- **L3: execution offload** *(exploring).* Run well-specified, deterministic sub-tasks outside the model entirely and hand back only the result. Layers compound: L1 shrinks what enters the window, L2 makes that shrink safe to undo, L3 keeps whole tasks out of the window to begin with. Issues and PRs -against any layer are welcome — see [CONTRIBUTING](CONTRIBUTING.md). +against any layer are welcome (see [CONTRIBUTING](CONTRIBUTING.md)). ---