From 1b77f212502b56b77e1abb2acfbf9def55a90dd9 Mon Sep 17 00:00:00 2001 From: Rocky Moltman Date: Sat, 23 May 2026 20:01:01 -0700 Subject: [PATCH 1/2] fix(cli): keep CLI alive when stdout is redirected on Windows (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module-level `console = Console()` blew up on Windows under any non-interactive context (Scheduled Task, SSH-stdio, `*>>` redirection) because rich's writer routes through `_win32_console.LegacyWindowsTerm` and its fallback path writes through Python's cp1252 stdout encoder, which can't encode the ⚡ in the banner. The watcher exited before it ever ran, silently breaking auto-capture on Windows. Surfaced today wiring Dave Crash Test Moltman (the IGOR-2 OpenClaw test bed) to Mnemo — `mnemo-cortex watch --backfill -f *>> log` died on the banner before the watch loop started. Full repro + stack trace in issue #3. Fix: wrap the Console construction in a helper that respects whether stdout is a TTY. - TTY path → `Console()` with defaults, so colors / boxes / progress bars work exactly as before in interactive shells on any platform. - Non-TTY path → wrap `sys.stdout.buffer` in a UTF-8 `TextIOWrapper` with `errors='replace'` and hand it to `Console(file=..., force_terminal=False)`. Two belts: 1. `force_terminal=False` keeps rich out of the win32 console write path entirely. 2. UTF-8 wrapper with replacement errors means even if some future banner addition contains a character UTF-8 can't represent (impossible, but defensive), we degrade rather than crash. - Defensive fallback for exotic `sys.stdout` replacements that have no `.buffer` (custom stream classes etc.) → `Console(force_terminal=False)` on the as-is file. Worst case: plain text, no crash. Verified locally: - Non-TTY path: replaced `sys.stdout` with a `BytesIO`-backed stub reporting `isatty()==False`, imported `console` + `BANNER`, called `console.print(BANNER)` — banner rendered cleanly with `⚡` preserved in the output, no `UnicodeEncodeError`. - Subprocess-isatty=False construction: `console.print('[bold green]ok[/]')` succeeds (with style stripped since `force_terminal=False`). - `py_compile agentb/cli.py` clean. Windows verification deferred to whoever lands this — I built the workaround for Dave first (a stripped-down launcher that imports `agentb.watcher.run_watcher` / `backfill_sessions` directly), confirmed it captured 3 exchanges to artforge:50001, then designed this fix as the upstream replacement. The local launcher becomes unnecessary once this lands. Closes #3. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentb/cli.py | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/agentb/cli.py b/agentb/cli.py index 22c6fd4..a3fa3ed 100644 --- a/agentb/cli.py +++ b/agentb/cli.py @@ -18,6 +18,7 @@ import os import sys +import io import json import signal import subprocess @@ -31,7 +32,45 @@ from rich.prompt import Prompt, Confirm from rich import print as rprint -console = Console() + +def _make_console() -> Console: + """Construct a Console that works whether stdout is an interactive + terminal or redirected to a file / pipe / Scheduled Task. + + Windows-specific bug worked around here: when stdout is redirected, + rich's writer still routes through ``_win32_console.LegacyWindowsTerm`` + which falls back to ``self.file.write()`` — i.e. Python's stdout + encoder. On Windows that defaults to ``cp1252``, which can't encode + the ⚡ emoji in the banner. The watcher dies before it ever runs, + silently breaking auto-capture under Task Scheduler / piped SSH / + any non-interactive context. + + For interactive TTYs (anywhere) we leave rich on its defaults so + colors / boxes / progress bars work as designed. For non-TTY stdout + we wrap the underlying buffer in a UTF-8 ``TextIOWrapper`` with + ``errors='replace'`` (so even genuinely un-encodable chars degrade + rather than crash) and ask rich to skip terminal-mode writes + entirely via ``force_terminal=False``. + """ + is_tty = bool(getattr(sys.stdout, "isatty", lambda: False)()) + if is_tty: + return Console() + buffer = getattr(sys.stdout, "buffer", None) + if buffer is None: + # Exotic stdout replacement with no underlying byte buffer — + # safest move is plain rich on whatever this is. + return Console(force_terminal=False) + utf8_stdout = io.TextIOWrapper( + buffer, + encoding="utf-8", + errors="replace", + line_buffering=True, + write_through=True, + ) + return Console(file=utf8_stdout, force_terminal=False) + + +console = _make_console() CONFIG_DIR = Path.home() / ".config" / "agentb" CONFIG_FILE = CONFIG_DIR / "agentb.yaml" From d74627d0d4f46202a006d8c492c864ca7e96a65b Mon Sep 17 00:00:00 2001 From: Rocky Moltman Date: Sun, 24 May 2026 15:46:10 -0700 Subject: [PATCH 2/2] fix(wiki-compile): advance last_run cursor even on empty harvest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nightly wiki compiler returned early without saving state when harvest matched zero memories. Single zero-result night wedged the cursor forever: subsequent nights queried the same stale `since`, found the same nothing, returned early before save_state(), cursor never moved. Observed wedge May 17 → May 24 (8 nights of "Harvested 0 memories" with the window stuck at 2026-05-17T10:30:02 in production on artforge). - Empty-harvest path now writes `state["last_run"] = run_started_at` before returning. The window advances; tomorrow night queries fresh memories from today instead of re-querying the same dead window. - Bonus: capture `run_started_at` BEFORE harvest rather than after the compile loop. Memories arriving during a long compile (several minutes for many topics) were previously missed on the next run because state["last_run"] got set to post-compile-time. Now any memory that lands during the compile is picked up next night. Cursor semantics: `last_run` = "the moment we last attempted a compile," not "the moment we last finished one." That's correct because the harvest window is `since=last_run`, and we want every memory whose mtime > last-attempt to be considered next time. Co-Authored-By: Claude Opus 4.7 (1M context) --- mnemo-wiki-compile.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/mnemo-wiki-compile.py b/mnemo-wiki-compile.py index dd63085..9fdc8e3 100755 --- a/mnemo-wiki-compile.py +++ b/mnemo-wiki-compile.py @@ -725,6 +725,10 @@ def main() -> int: try: state = load_state() + # Capture the cursor BEFORE harvest so memories that arrive during + # the compile loop aren't missed next run. + run_started_at = datetime.now(timezone.utc).isoformat() + # Time window if args.full: since = datetime(2000, 1, 1, tzinfo=timezone.utc) @@ -739,6 +743,13 @@ def main() -> int: memories = harvest_agentb(since) + harvest_mnemo_sqlite(since) log.info(f"Harvested {len(memories)} memories") if not memories: + # ADVANCE the cursor even on an empty harvest. Without this, + # a single zero-result night wedges the window forever: + # subsequent runs query the same stale `since`, find the same + # nothing, return early before save_state(), and the cursor + # never moves. Observed May 17 → May 24 wedge in production. + state["last_run"] = run_started_at + save_state(state) log.info("No new memories — nothing to compile") return 0 @@ -827,8 +838,9 @@ def main() -> int: for rel in audit_report["manually_edited"][:20]: log.info(f" manually-edited: {rel}") - # Persist run state - state["last_run"] = datetime.now(timezone.utc).isoformat() + # Persist run state — use the timestamp captured BEFORE harvest so + # memories arriving during the compile loop aren't missed next run. + state["last_run"] = run_started_at save_state(state) # Log summary