Files
nanobot-runtime/skills/reflect/scripts/reflect_distill.py
2026-09-02 10:36:37 +02:00

339 lines
12 KiB
Python

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""reflect_distill.py — mechanical distillation of session logs for the /reflect skill.
Turns `sessions/*.jsonl` into a compact, readable timeline the analysing LLM can scan.
Tool results are ~89% of the corpus by volume and carry almost no diagnostic value, so
they collapse to `name(args) -> ok|ERROR, size`; user and assistant prose is kept whole
because that is where intent shows.
This script makes **no quality judgements** — no error detectors, no ranking, no
"suspicious" flags. Deciding what is a mistake is the LLM's job; mechanical reduction
is this script's job. That split is deliberate (see plans/reflect-skill.md).
"""
import argparse
import base64
import binascii
import itertools
import json
import re
import sys
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
# workspace/skills/reflect/scripts/reflect_distill.py -> parents[3] = workspace root.
WORKSPACE = Path(__file__).resolve().parents[3]
# Session-key prefixes that are machinery or throwaway tests, not conversations worth
# diagnosing. `reflect` is here so the skill never analyses its own runs.
NOISE_PREFIXES = (
"reflect",
"compact-memory-auto",
"detach",
"dream",
"cron",
"cli",
"wiki-compile",
"wiki-capture-test",
"note-compile",
"test",
)
SESSION_MARKER = "━━━ SESSION"
MIN_MESSAGES = 5
REASONING_CHARS = 200
ARG_VALUE_CHARS = 90
# The context window is not what bounds a batch — latency is. Measured on real digests this
# corpus runs about 1.2 characters per token, so 500k chars was ~185k tokens per request, whose
# prefill on glm-5.3:cloud kept overrunning the provider timeout and each retry paid for it
# again (2026-09-02 run: 9 timeouts, two turns lost outright). 200k chars ≈ 70k tokens answers
# in one pass; smaller batches also get a more careful read and, since reflect_auto.py files
# findings after every batch, cost nothing but more commit points. It imports this rather than
# keeping its own copy, so a debugging run batches exactly like the real one.
DEFAULT_BUDGET_CHARS = 200_000
_WORKSPACE_PATH_RE = re.compile(r"/home/[^/]+/\.nanobot/workspace/")
_WHITESPACE_RE = re.compile(r"\s+")
@dataclass(frozen=True)
class SessionDigest:
"""One distilled session: identity, size, and the rendered timeline.
`started` is the raw ISO timestamp exactly as the log holds it, because it doubles as
the cursor `collect_sessions` compares against. Formatting happens only for display.
"""
name: str
started: str
message_count: int
text: str
def __len__(self) -> int:
return len(self.text)
def _decode_session_name(stem: str) -> str:
"""Return the readable session key.
Older sessions are stored under a base64 filename (`ZHJlYW06...` = `dream:...`);
decoding them is what lets prefix filtering catch that generation too.
"""
if "_" in stem or "-" in stem:
return stem
padded = stem + "=" * (-len(stem) % 4)
try:
decoded = base64.urlsafe_b64decode(padded).decode("utf-8")
except (binascii.Error, UnicodeDecodeError, ValueError):
return stem
return decoded if decoded.isprintable() else stem
def is_noise(session_name: str) -> bool:
"""True for machinery sessions that carry no diagnostic value.
Prefix match, not exact: variants like `wiki-capture-test2` or `test-note-search-zzz`
are the same throwaway machinery as their base name.
"""
key = _decode_session_name(session_name)
return key.startswith(NOISE_PREFIXES)
def _iter_records(path: Path) -> Iterator[dict]:
"""Yield the JSON records of a session log, skipping blank and malformed lines."""
with path.open(encoding="utf-8", errors="replace") as handle:
for line in handle:
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
continue
def _read_records(path: Path) -> list[dict]:
return list(_iter_records(path))
def _format_stamp(raw: str) -> str:
"""Display form of the raw ISO timestamp: `2026-07-11 14:02`."""
return raw[:16].replace("T", " ") if raw else "unknown"
def _shorten(text: str, limit: int) -> str:
"""Collapse whitespace and cut to `limit`, marking the cut with an ellipsis."""
flat = _WHITESPACE_RE.sub(" ", text).strip()
return flat if len(flat) <= limit else flat[:limit] + ""
def _shorten_middle(text: str, limit: int) -> str:
"""Shorten from the middle, keeping both ends.
Argument values are usually URLs and paths whose distinguishing part sits at the
*end*; cutting from the front would render two different fetches identical and
hide the difference between a genuine retry and legitimate sequential work.
"""
flat = _WHITESPACE_RE.sub(" ", text).strip()
if len(flat) <= limit:
return flat
head = (limit * 2) // 3
tail = limit - head
return f"{flat[:head]}{flat[-tail:]}"
def _format_size(char_count: int) -> str:
if char_count < 1000:
return f"{char_count} B"
return f"{char_count / 1000:.1f} kB"
def _format_arguments(raw: str) -> str:
"""Render tool arguments compactly, stripping the workspace path prefix.
Falls back to the raw string when arguments are not JSON — some providers emit
partial or malformed argument blobs, and that is itself worth seeing.
"""
try:
parsed = json.loads(raw) if raw else {}
except json.JSONDecodeError:
return _shorten_middle(raw, ARG_VALUE_CHARS)
if not isinstance(parsed, dict):
return _shorten_middle(str(parsed), ARG_VALUE_CHARS)
parts = []
for key, value in parsed.items():
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
text = _WORKSPACE_PATH_RE.sub("", text)
parts.append(f"{key}={_shorten_middle(text, ARG_VALUE_CHARS)}")
return ", ".join(parts)
def _tool_outcome(content: str) -> str:
"""Classify a tool result as ok or ERROR, keeping the error's first line.
Detection is textual because tool results carry no status field; the leading
`Error:` / `Traceback` / `{"error"` shapes are what the runtime actually emits.
"""
head = content.lstrip()[:80]
if re.match(r'(?i)^(error|exception|traceback|failed|\{"error)', head):
return f"ERROR {_shorten(content, ARG_VALUE_CHARS)}"
return "ok"
def distill_session(path: Path) -> SessionDigest | None:
"""Render one session file as a timeline, or None when it should be skipped.
Skipped: machinery sessions (see NOISE_PREFIXES) and sessions shorter than
MIN_MESSAGES, which are too short to show a behavioural pattern.
"""
if is_noise(path.stem):
return None
session_key = _decode_session_name(path.stem)
records = _read_records(path)
started = ""
lines: list[str] = []
message_count = 0
pending_calls: dict[str, str] = {}
for record in records:
if record.get("_type") == "metadata":
started = str(record.get("created_at") or "")
continue
# Slash commands are handled by the runtime, not by the agent's reasoning.
if record.get("_command"):
continue
role = record.get("role")
content = record.get("content") or ""
if not isinstance(content, str):
content = json.dumps(content, ensure_ascii=False)
if not started:
started = str(record.get("timestamp") or "")
if role == "user":
message_count += 1
lines.append(f"u: {content.strip()}")
elif role == "assistant":
message_count += 1
reasoning = record.get("reasoning_content") or ""
if reasoning:
lines.append(f" ~ {_shorten(reasoning, REASONING_CHARS)}")
if content.strip():
lines.append(f"a: {content.strip()}")
for call in record.get("tool_calls") or []:
function = call.get("function") or {}
name = function.get("name") or "?"
pending_calls[call.get("id") or ""] = name
lines.append(f"a: → {name}({_format_arguments(function.get('arguments') or '')})")
elif role == "tool":
message_count += 1
name = record.get("name") or pending_calls.get(record.get("tool_call_id") or "", "?")
lines.append(f"{name}: {_tool_outcome(content)}, {_format_size(len(content))}")
if message_count < MIN_MESSAGES:
return None
# Box-drawing marker, not a markdown heading: assistant prose is full of `###`,
# so a heading would not read as a session boundary.
header = f"{SESSION_MARKER} {session_key} | {_format_stamp(started)} | {message_count} messages"
return SessionDigest(
name=session_key,
started=started,
message_count=message_count,
text="\n".join([header, *lines]),
)
def _session_start(path: Path) -> str:
"""First timestamp inside the file — mtime is unreliable after bulk file moves.
Reads only the opening records: this runs for every session on every run, and parsing
whole files here would mean reading the entire corpus twice.
"""
for record in itertools.islice(_iter_records(path), 5):
stamp = record.get("created_at") or record.get("timestamp")
if stamp:
return str(stamp)
return ""
def collect_sessions(sessions_dir: Path, since: str = "") -> list[Path]:
"""Session files newer than `since` (ISO timestamp), oldest first."""
paths = []
for path in sorted(sessions_dir.glob("*.jsonl")):
if is_noise(path.stem):
continue
start = _session_start(path)
if since and start and start <= since:
continue
paths.append((start, path))
return [path for _, path in sorted(paths)]
def iter_batches(paths: list[Path], budget_chars: int) -> Iterator[list[SessionDigest]]:
"""Group distilled sessions into batches that fit the per-turn character budget.
A single session larger than the budget still gets its own batch — truncating it
would hide exactly the long runaway loops worth finding.
"""
batch: list[SessionDigest] = []
size = 0
for path in paths:
digest = distill_session(path)
if digest is None:
continue
if batch and size + len(digest) > budget_chars:
yield batch
batch, size = [], 0
batch.append(digest)
size += len(digest)
if batch:
yield batch
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--sessions-dir", type=Path, default=WORKSPACE / "sessions")
parser.add_argument("--since", default="", help="ISO timestamp; only sessions started after it")
parser.add_argument("--budget-chars", type=int, default=DEFAULT_BUDGET_CHARS)
parser.add_argument("--out", type=Path, help="write batches to OUT.NNN.md instead of stdout")
parser.add_argument("--stats", action="store_true", help="print a size summary to stderr")
args = parser.parse_args(argv)
if not args.sessions_dir.is_dir():
print(f"sessions dir not found: {args.sessions_dir}", file=sys.stderr)
return 1
paths = collect_sessions(args.sessions_dir, args.since)
batches = list(iter_batches(paths, args.budget_chars))
for index, batch in enumerate(batches):
body = "\n\n".join(digest.text for digest in batch)
if args.out:
target = args.out.with_suffix(f".{index:03d}.md")
target.write_text(body, encoding="utf-8")
else:
print(body)
if args.stats:
sessions = sum(len(batch) for batch in batches)
chars = sum(len(digest) for batch in batches for digest in batch)
print(
f"{datetime.now():%Y-%m-%d %H:%M} candidates {len(paths)}, "
f"distilled {sessions} sessions, {len(batches)} batches, {chars / 1000:.0f} kB",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())