339 lines
12 KiB
Python
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())
|