upravy projektu a skillu

This commit is contained in:
lachtan
2026-09-02 10:36:37 +02:00
parent 0fa619bbbe
commit f77cc2dcfe
19 changed files with 3875 additions and 52 deletions

View File

@@ -0,0 +1,296 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""reflect_apply.py — apply one approved finding, or refuse.
The guarantees around self-modification live here rather than in SKILL.md prose, because
a soft instruction is not a gate: the agent could read "verify old_text occurs exactly
once" and still edit on a near miss. This script cannot. It applies exactly one finding,
only when the patch still matches, and it commits only the file it touched.
The skill calls it after the user approves a specific finding. It never decides anything
on its own — no finding is selected, ranked or approved here.
It is also the audit trail of the review, so every decision leaves a record: why a finding
was rejected, that a patch was applied with the user's own wording rather than the model's,
and how often a finding has been skipped without being decided.
reflect_apply.py --id f7a2 apply that finding
reflect_apply.py --id f7a2 --check print the diff, change nothing
reflect_apply.py --id f7a2 --reject --reason mark rejected with the user's reason
reflect_apply.py --id f7a2 --skip count a deferral, decide nothing
reflect_apply.py --id f7a2 --new-text-file patch with user-edited replacement text
reflect_apply.py --id f7a2 --set-patch file a patch drafted during the review
"""
from __future__ import annotations
import argparse
import difflib
import json
import subprocess
import sys
from contextlib import suppress
from datetime import datetime
from pathlib import Path
WORKSPACE = Path(__file__).resolve().parents[3]
FINDINGS_REL = "reflect/findings.jsonl"
LOG_REL = "log/reflect.log"
STATUS_OPEN = "open"
STATUS_APPLIED = "applied"
STATUS_REJECTED = "rejected"
PATCH_KEYS = ("file", "old_text", "new_text")
class ApplyError(Exception):
"""A refusal, phrased for the agent to relay to the user."""
def _git(workspace: Path, *args: str) -> str:
result = subprocess.run(["git", *args], cwd=workspace, capture_output=True, text=True, check=True)
return result.stdout.strip()
def _load(workspace: Path) -> list[dict]:
path = workspace / FINDINGS_REL
if not path.exists():
raise ApplyError(f"{FINDINGS_REL} does not exist — run the analysis first")
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
def _save(workspace: Path, records: list[dict]) -> None:
path = workspace / FINDINGS_REL
body = "\n".join(json.dumps(record, ensure_ascii=False) for record in records)
path.write_text(body + "\n", encoding="utf-8")
def _audit(workspace: Path, line: str) -> None:
log = workspace / LOG_REL
log.parent.mkdir(parents=True, exist_ok=True)
with log.open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
def _find(records: list[dict], finding_id: str) -> dict:
for record in records:
if record["id"] == finding_id:
return record
raise ApplyError(f"no finding with id {finding_id}")
def _resolve_target(workspace: Path, relative: str) -> Path:
"""Reject anything that would land outside the workspace, symlinks included."""
target = (workspace / relative).resolve()
if not target.is_relative_to(workspace.resolve()):
raise ApplyError(f"{relative} resolves outside the workspace")
if not target.is_file():
raise ApplyError(f"{relative} does not exist")
return target
def check_patch(workspace: Path, record: dict, new_text: str | None = None) -> tuple[Path, str, str]:
"""Return (target, current content, patched content), or raise with the reason."""
if record["status"] != STATUS_OPEN:
raise ApplyError(f"finding {record['id']} is {record['status']}, only open findings can be applied")
patch = record.get("patch")
if not patch:
raise ApplyError(f"finding {record['id']} carries no patch — nothing to apply")
target = _resolve_target(workspace, patch["file"])
content = target.read_text(encoding="utf-8")
occurrences = content.count(patch["old_text"])
if occurrences == 0:
raise ApplyError(f"the original text is no longer in {patch['file']} — the patch does not apply")
if occurrences > 1:
raise ApplyError(f"the original text occurs {occurrences}× in {patch['file']} — too ambiguous to apply")
replacement = patch["new_text"] if new_text is None else new_text
if replacement == patch["old_text"]:
raise ApplyError("the replacement is identical to the original — nothing to change")
return target, content, content.replace(patch["old_text"], replacement, 1)
def _diff(relative: str, current: str, patched: str) -> str:
return "\n".join(
difflib.unified_diff(
current.splitlines(), patched.splitlines(), fromfile=relative, tofile=relative, lineterm=""
)
)
def _parse_patch_file(path: Path) -> dict[str, str]:
"""Read the drafted patch, refusing anything the store would not accept as one."""
try:
patch = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ApplyError(f"{path} is not readable JSON: {error}") from error
if not isinstance(patch, dict) or sorted(patch) != sorted(PATCH_KEYS):
raise ApplyError(f"the patch must be a JSON object with exactly {list(PATCH_KEYS)}")
if not all(isinstance(patch[key], str) for key in PATCH_KEYS):
raise ApplyError("the patch fields must be strings")
return {key: patch[key] for key in PATCH_KEYS}
def set_patch(workspace: Path, finding_id: str, patch: dict[str, str], now: datetime) -> str:
"""File a patch drafted during the review, but only one that already applies.
A finding the analysis left without a patch used to force the agent to edit
findings.jsonl with an ad-hoc script — an LLM writing straight into the audit trail,
and a failed attempt leaving an unapplicable patch behind. Verifying the candidate
before anything is written is what makes that impossible: a patch that does not apply
never reaches the store at all.
"""
records = _load(workspace)
record = _find(records, finding_id)
# check_patch already refuses a non-open finding, resolves the path and counts the
# occurrences of old_text — run it on a candidate copy so nothing is written until it passes.
_, current, patched = check_patch(workspace, {**record, "patch": patch})
record["patch"] = patch
record["patch_drafted_at"] = f"{now:%Y-%m-%d %H:%M}"
_save(workspace, records)
_audit(workspace, f"{now:%Y-%m-%d %H:%M} DRAFTED {finding_id} [{record['pattern']}] {patch['file']}")
return f"drafted a patch for {finding_id} on {patch['file']} — it applies cleanly, nothing written yet\n" + _diff(
patch["file"], current, patched
)
def apply_finding(workspace: Path, finding_id: str, new_text: str | None, now: datetime) -> str:
records = _load(workspace)
record = _find(records, finding_id)
target, original, patched = check_patch(workspace, record, new_text)
relative = record["patch"]["file"]
# Commit unrelated work on this one file first, so the patch commit is only the patch.
if _git(workspace, "status", "--porcelain", "--", relative):
_git(workspace, "add", "--", relative)
_git(workspace, "commit", "-m", f"reflect: checkpoint before {finding_id}", "--", relative)
target.write_text(patched, encoding="utf-8")
try:
_git(workspace, "add", "--", relative)
_git(workspace, "commit", "-m", f"reflect: {record['pattern']} ({finding_id})", "--", relative)
except subprocess.CalledProcessError:
# The caller reports a refusal and moves on, so "refused" has to mean nothing happened.
# Undoing the write is what makes that true; unstaging is best effort and must never
# replace the original error.
target.write_text(original, encoding="utf-8")
with suppress(subprocess.CalledProcessError):
_git(workspace, "restore", "--staged", "--", relative)
raise
sha = _git(workspace, "rev-parse", "--short", "HEAD")
record["status"] = STATUS_APPLIED
record["applied"] = {"at": f"{now:%Y-%m-%d %H:%M}", "sha": sha, "file": relative}
# `patch` stays the model's proposal. Overwriting it with the user's rewrite destroyed the
# only record of what was suggested versus what was actually approved.
if new_text is not None:
record["applied"]["new_text"] = new_text
record["applied"]["edited_by_user"] = True
_save(workspace, records)
verb = "APPLIED-EDITED" if new_text is not None else "APPLIED"
_audit(workspace, f"{now:%Y-%m-%d %H:%M} {verb} {finding_id} [{record['pattern']}] {relative}{sha}")
return f"applied {finding_id} to {relative}, commit {sha} (revert: git revert {sha})"
def reject_finding(workspace: Path, finding_id: str, reason: str, now: datetime) -> str:
"""Close a pattern for good, with the reason on the record.
The reason is mandatory because it is the only feedback on the analysis itself: half of the
first eight findings were rejected, and without knowing why, that number says nothing about
what to change in the analysis prompt.
"""
records = _load(workspace)
record = _find(records, finding_id)
if record["status"] != STATUS_OPEN:
raise ApplyError(f"finding {finding_id} is {record['status']}, not open")
record["status"] = STATUS_REJECTED
record["rejected"] = {"at": f"{now:%Y-%m-%d %H:%M}", "reason": reason}
_save(workspace, records)
_audit(workspace, f"{now:%Y-%m-%d %H:%M} REJECTED {finding_id} [{record['pattern']}] — {reason}")
return f"rejected {finding_id} — this pattern will not open again"
def skip_finding(workspace: Path, finding_id: str, now: datetime) -> str:
"""Count a deferral. Changes no file and no status — a skip is not a decision.
Without it a finding skipped five times is indistinguishable from one never shown, so the
review keeps re-presenting it with nothing to say about the history.
"""
records = _load(workspace)
record = _find(records, finding_id)
if record["status"] != STATUS_OPEN:
raise ApplyError(f"finding {finding_id} is {record['status']}, not open")
count = record.get("skipped", {}).get("count", 0) + 1
record["skipped"] = {"count": count, "last": f"{now:%Y-%m-%d %H:%M}"}
_save(workspace, records)
_audit(workspace, f"{now:%Y-%m-%d %H:%M} SKIPPED {finding_id} [{record['pattern']}] ×{count}")
return f"skipped {finding_id} — still open, skipped {count}× so far"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--id", required=True, help="finding id from reflect/findings.jsonl")
parser.add_argument("--check", action="store_true", help="verify the patch still applies, change nothing")
parser.add_argument("--reject", action="store_true", help="mark the finding rejected, change no file")
parser.add_argument("--reason", help="why the finding was rejected — required with --reject")
parser.add_argument("--skip", action="store_true", help="count a deferral; leaves the finding open")
parser.add_argument("--new-text-file", type=Path, help="file holding replacement text edited by the user")
parser.add_argument("--set-patch", type=Path, help="JSON file with the drafted {file, old_text, new_text}")
parser.add_argument("--workspace", type=Path, default=WORKSPACE)
args = parser.parse_args(argv)
if sum([args.check, args.reject, args.skip, bool(args.set_patch)]) > 1:
parser.error("--check, --reject, --skip and --set-patch are mutually exclusive")
if args.reject and args.new_text_file:
parser.error("--reject changes no file, so --new-text-file makes no sense with it")
if args.set_patch and args.new_text_file:
parser.error("--set-patch already carries new_text, so --new-text-file makes no sense with it")
if args.reject and not (args.reason or "").strip():
parser.error("--reject needs --reason: ask the user why and pass it through")
if args.reason and not args.reject:
parser.error("--reason only records why a finding was rejected, so it needs --reject")
now = datetime.now()
try:
if args.reject:
print(reject_finding(args.workspace, args.id, args.reason.strip(), now))
return 0
if args.skip:
print(skip_finding(args.workspace, args.id, now))
return 0
if args.set_patch:
print(set_patch(args.workspace, args.id, _parse_patch_file(args.set_patch), now))
return 0
new_text = args.new_text_file.read_text(encoding="utf-8") if args.new_text_file else None
if args.check:
record = _find(_load(args.workspace), args.id)
_, current, patched = check_patch(args.workspace, record, new_text)
relative = record["patch"]["file"]
print(f"ok: patch applies cleanly to {relative}")
print(_diff(relative, current, patched))
return 0
print(apply_finding(args.workspace, args.id, new_text, now))
return 0
except ApplyError as error:
print(f"refused: {error}", file=sys.stderr)
return 2
except subprocess.CalledProcessError as error:
print(f"refused: git failed: {error.stderr.strip() or error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,897 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["nanobot-ai"]
# ///
"""reflect_auto.py — unattended analysis run for the /reflect skill.
Distils new sessions, asks the agent to diagnose recurring mistakes, and files the
findings. **It never applies anything** — there is no edit path in this script at all.
Applying a fix happens only in the interactive `/reflect` review, one finding at a time,
after the user approves it.
Two guards protect that boundary:
* the agent is told not to write, and a git fingerprint of the workspace is compared
before and after the turn — if the agent wrote anything, nothing is filed;
* a finding seen only once is filed as `watch` and stays silent. Telegram is notified
only once a pattern repeats, so a daily run does not mean a daily notification.
Same external-script pattern as skills/compact-memory/scripts/compact_memory_auto.py:
fresh never-reused session_key, delivery straight to the Telegram Bot API, and the
message composed here rather than by the model.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import re
import subprocess
import sys
import time
import traceback
import urllib.parse
import urllib.request
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any
sys.path.insert(0, str(Path(__file__).resolve().parent))
from reflect_distill import DEFAULT_BUDGET_CHARS, SessionDigest, collect_sessions, iter_batches
if TYPE_CHECKING:
from nanobot import Nanobot # ty: ignore[unresolved-import]
# A digest prompt is tens of thousands of tokens, and prefilling it on glm-5.3:cloud overruns
# nanobot's 120s per-request default — whose retry then throws the finished prefill away and
# starts over. Set before nanobot is imported (`_open_bot` defers it); overridable from the shell.
os.environ.setdefault("NANOBOT_OPENAI_COMPAT_TIMEOUT_S", "600")
os.environ.setdefault("NANOBOT_LLM_TIMEOUT_S", "900")
CONFIG = Path.home() / ".nanobot" / "config.json"
WORKSPACE_FALLBACK = Path.home() / ".nanobot" / "workspace"
STATE_REL = "reflect/state.json"
FINDINGS_REL = "reflect/findings.jsonl"
RESULTS_REL = "results"
FALLBACK_CHAT_ID = "8826147089"
MODEL_PRESET = "glm53"
# The soft deadline is what actually bounds a run: no new batch starts past it, and everything
# already analysed is on disk. TIMEOUT_SECONDS only catches a single batch that hangs.
DEFAULT_DEADLINE_MINUTES = 20
# The cursor is the floor of a run, this is the ceiling: findings are meant to describe recent
# behaviour, and a months-deep backlog walked one batch a night never gets there (2026-09-02:
# the cursor sat on 05-29 while 3 runs of findings were presented as current).
DEFAULT_WINDOW_DAYS = 21
TIMEOUT_SECONDS = 45 * 60
MAX_ATTEMPTS = 3
MAX_LLM_ERROR_RETRIES = 2
SEVERITIES = ("low", "medium", "high")
STATUS_WATCH = "watch"
STATUS_OPEN = "open"
STATUS_APPLIED = "applied"
STATUS_REJECTED = "rejected"
PATCH_KEYS = frozenset({"file", "old_text", "new_text"})
FINDING_KEYS = frozenset(
{"pattern", "severity", "diagnosis", "evidence", "occurrences", "sessions_affected", "proposal", "patch"}
)
MAX_DIAGNOSIS_CHARS = 600
MAX_PROPOSAL_CHARS = 400
MAX_FINDINGS_PER_BATCH = 12
# Folded findings keep evidence from earlier slices too, or a cumulative count is unauditable.
MAX_EVIDENCE = 6
PATTERN_RE = re.compile(r"^[a-z][a-z0-9-]{2,48}$")
# `when` is a free string the model fills in; the shapes seen so far are `2026-08-31` and
# `2026-08-31 13:53`, plus the occasional non-date. Only the leading date is usable.
EVIDENCE_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")
JSON_FENCE = re.compile(r"```(?:json)?\s*\n(.*?)```", re.DOTALL)
GOAL = """\
You are auditing your own past behaviour. Below is a distilled log of {count} of your earlier
sessions: user and assistant messages in full, tool calls collapsed to `name(args) -> ok|ERROR`.
Find **recurring mistakes worth fixing** — for example a tool retried with identical arguments
after an error instead of being diagnosed, a runaway loop, a tool chosen where a better one
existed, a multi-step request answered without doing the work, or an instruction you clearly
missed. Judge severity by wasted turns and by the harm to the user's result.
Rules:
- Report a repeated pattern, with concrete evidence: which sessions, which turns. A single
occurrence is worth reporting only when it matches a pattern in "Known patterns" below — reuse
its exact id and give the honest count for this slice (1 is fine), because those counts add up
across slices. A novel slip you have seen only once here, skip.
- These {count} sessions are one slice of a longer history, not all of it. Count only what you can
see in this slice; the store sums the counts across slices for you.
- Do NOT report a pattern already listed in "Known patterns" below unless you see new occurrences;
reuse its exact `pattern` identifier when you do.
- `pattern` is a short stable kebab-case id (e.g. `retry-without-diagnosis`). Reuse existing ids
from the list rather than inventing a new name for the same thing.
- Include a `patch` only when you are confident: `old_text` must be copied character-for-character
from the current file and must occur exactly once in it. When unsure, give `proposal` alone.
- **Read at most 2 files**, and only to copy `old_text` for a patch. Every extra read costs another
full pass over this whole digest, so spend those two reads on a patch you are sure about.
- At most {max_findings} findings. Fewer, well-evidenced findings are better than a long list.
- **Use no quotation marks of any kind inside JSON string values.** Quote a log line by
writing it plainly, without wrapping it in quotes. A stray `"` breaks the whole answer.
**Write nothing.** Do not edit, create or delete any file, and do not run any command that
changes state. This is an analysis-only run; a human reviews and applies your suggestions later.
Answer with exactly one ```json code block and nothing else — no narration before or after:
```json
{{"findings": [{{"pattern": "retry-without-diagnosis", "severity": "medium",
"diagnosis": "After an HTTP error the same call is repeated with identical arguments…",
"evidence": [{{"session": "websocket_e5a6aa…", "when": "2026-07-11", "excerpt": "web_fetch → ERROR 403 ×4"}}],
"occurrences": 7, "sessions_affected": 4,
"proposal": "Add a hard STOP gate to the Fetching section",
"patch": {{"file": "skills/flight-search/SKILL.md", "old_text": "", "new_text": ""}}}}]}}
```
If you find nothing worth reporting, answer with an empty `findings` list.
## Known patterns
{known_patterns}
## Sessions
{digest}
"""
RETRY_PROMPT = """\
Your findings were rejected by the validator:
{errors}
Answer again with exactly one corrected ```json code block and nothing else.
"""
class ReflectError(Exception):
"""A failure worth reporting to the user in one line."""
class FindingsError(ReflectError):
"""The agent's answer is malformed."""
@dataclass(frozen=True)
class Finding:
"""One diagnosed pattern, as filed in reflect/findings.jsonl."""
id: str
status: str
created: str
last_seen: str
pattern: str
severity: str
diagnosis: str
evidence: tuple[dict[str, str], ...]
occurrences: int
sessions_affected: int
proposal: str
patch: dict[str, str] | None = None
regression_of: str | None = None
history: tuple[str, ...] = field(default_factory=tuple)
def to_json(self) -> dict[str, Any]:
record = {
"id": self.id,
"status": self.status,
"created": self.created,
"last_seen": self.last_seen,
"pattern": self.pattern,
"severity": self.severity,
"diagnosis": self.diagnosis,
"evidence": list(self.evidence),
"occurrences": self.occurrences,
"sessions_affected": self.sessions_affected,
"proposal": self.proposal,
}
if self.patch:
record["patch"] = self.patch
if self.regression_of:
record["regression_of"] = self.regression_of
if self.history:
record["history"] = list(self.history)
return record
def _config() -> dict[str, Any]:
return json.loads(CONFIG.read_text(encoding="utf-8"))
def _workspace(config: dict[str, Any]) -> Path:
configured = config.get("agents", {}).get("defaults", {}).get("workspace")
return Path(configured).expanduser() if configured else WORKSPACE_FALLBACK
def _telegram_config(config: dict[str, Any]) -> tuple[str, str]:
telegram = config["channels"]["telegram"]
allow_from = telegram.get("allowFrom") or []
chat_id = str(allow_from[0]) if allow_from else FALLBACK_CHAT_ID
return telegram["token"], chat_id
def _send_telegram(text: str, token: str, chat_id: str) -> None:
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode()
request = urllib.request.Request(url, data=payload, method="POST")
with urllib.request.urlopen(request, timeout=15) as response:
response.read()
def _git_fingerprint(workspace: Path) -> str:
"""HEAD plus the porcelain status — an otherwise unchanged workspace hashes identically.
This is the guard that the analysis turn stayed read-only. It covers the whole tree,
not just the files the agent was asked about.
"""
try:
head = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=workspace, capture_output=True, text=True, check=True
).stdout
status = subprocess.run(
["git", "status", "--porcelain"], cwd=workspace, capture_output=True, text=True, check=True
).stdout
except (subprocess.CalledProcessError, FileNotFoundError) as error:
raise ReflectError(f"workspace git is unavailable, refusing to run unguarded: {error}") from error
return head + status
def _load_state(workspace: Path) -> dict[str, Any]:
path = workspace / STATE_REL
if not path.exists():
return {"cursor": "", "runs": []}
return json.loads(path.read_text(encoding="utf-8"))
def _save_state(workspace: Path, state: dict[str, Any]) -> None:
path = workspace / STATE_REL
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _load_findings(workspace: Path) -> list[dict[str, Any]]:
path = workspace / FINDINGS_REL
if not path.exists():
return []
records = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line:
records.append(json.loads(line))
return records
def _write_findings(workspace: Path, records: list[dict[str, Any]]) -> None:
path = workspace / FINDINGS_REL
path.parent.mkdir(parents=True, exist_ok=True)
body = "\n".join(json.dumps(record, ensure_ascii=False) for record in records)
path.write_text(body + "\n" if body else "", encoding="utf-8")
def _known_patterns(records: list[dict[str, Any]]) -> str:
"""Render the pattern vocabulary handed to the model, so it reuses ids instead of renaming."""
if not records:
return "_(none yet)_"
seen: dict[str, dict[str, Any]] = {}
for record in records:
current = seen.get(record["pattern"])
if not current or record.get("occurrences", 0) > current.get("occurrences", 0):
seen[record["pattern"]] = record
rows = []
for pattern, record in sorted(seen.items()):
rows.append(f"- `{pattern}` [{record['status']}] — {record['diagnosis'][:120]}")
return "\n".join(rows)
def _new_id(existing: set[str]) -> str:
while True:
candidate = f"f{uuid.uuid4().hex[:4]}"
if candidate not in existing:
return candidate
def _text_field(raw: Any, name: str, index: int, limit: int) -> str:
"""Trimmed non-empty string, cut to `limit` — an overlong answer is shortened, not discarded."""
if not isinstance(raw, str) or not raw.strip():
raise FindingsError(f'- finding {index}: "{name}" must be a non-empty string')
text = raw.strip()
return text if len(text) <= limit else text[: limit - 1] + ""
def _positive_int(raw: Any, name: str, index: int) -> int:
if not isinstance(raw, int) or isinstance(raw, bool) or raw < 1:
raise FindingsError(f'- finding {index}: "{name}" must be an integer >= 1')
return raw
def _parse_evidence(raw: Any, index: int) -> tuple[dict[str, str], ...]:
if not isinstance(raw, list) or not raw:
raise FindingsError(f'- finding {index}: "evidence" must be a non-empty list')
items = []
for item in raw:
if not isinstance(item, dict) or not item.get("session"):
raise FindingsError(f'- finding {index}: every evidence item needs a "session"')
items.append({key: str(value) for key, value in item.items() if key in ("session", "when", "excerpt")})
return tuple(items)
def _parse_patch(raw: Any, index: int) -> dict[str, str] | None:
"""Validate a proposed patch. Shape only — whether it still applies is checked at review time."""
if raw is None:
return None
if not isinstance(raw, dict):
raise FindingsError(f'- finding {index}: "patch" must be an object or omitted')
unknown = sorted(str(key) for key in set(raw) - PATCH_KEYS)
if unknown:
raise FindingsError(f"- finding {index}: patch has unknown fields {unknown}")
missing = sorted(PATCH_KEYS - set(raw))
if missing:
raise FindingsError(f"- finding {index}: patch is missing {missing}")
if not all(isinstance(raw[key], str) for key in PATCH_KEYS):
raise FindingsError(f"- finding {index}: patch fields must be strings")
if not raw["old_text"].strip():
raise FindingsError(f'- finding {index}: patch "old_text" must not be empty')
if raw["old_text"] == raw["new_text"]:
raise FindingsError(f"- finding {index}: patch changes nothing")
if Path(raw["file"]).is_absolute() or ".." in Path(raw["file"]).parts:
raise FindingsError(f'- finding {index}: patch "file" must be a path inside the workspace')
return {key: raw[key] for key in ("file", "old_text", "new_text")}
def _decode_payload(content: str) -> tuple[Any, str]:
"""Return (payload, "") or (None, an error the model can act on).
Naming the offending line, column and surrounding text matters: the usual failure is
an unescaped `"` inside a Czech quotation (`„…"`) in a diagnosis, and a bare
"no parseable json" message gives the retry nothing to fix.
"""
# Fenced blocks first, last one first: that is where the answer is supposed to be, so
# both the successful parse and the reported error come from there rather than from
# the whole reply (whose column numbers would be meaningless to the model).
candidates = [*reversed(JSON_FENCE.findall(content)), content]
best_error = ""
for candidate in candidates:
text = candidate.strip()
if not text:
continue
try:
return json.loads(text), ""
except json.JSONDecodeError as error:
if not best_error:
excerpt = text[max(0, error.pos - 90) : error.pos + 30].replace("\n", " ")
best_error = (
f"- the JSON is invalid: {error.msg} at line {error.lineno} column {error.colno}\n"
f"- around: …{excerpt}\n"
'- most likely an unescaped double quote inside a string value; write \\" '
"or drop the quotes entirely"
)
return None, best_error or "- the answer contains no parseable ```json block"
def _parse_finding(raw: Any, index: int, session_count: int) -> tuple[dict[str, Any], list[str]]:
"""Validate one finding, returning it with any notes about what had to be salvaged.
Raises only when the record itself is unusable. A bad patch costs the patch, not the
finding — the diagnosis and the proposal are still worth putting in front of the user.
"""
if not isinstance(raw, dict):
raise FindingsError(f"- finding {index}: must be a JSON object")
pattern = raw.get("pattern")
if not isinstance(pattern, str) or not PATTERN_RE.match(pattern):
raise FindingsError(f'- finding {index}: "pattern" must be a kebab-case id like `retry-without-diagnosis`')
severity = raw.get("severity")
if severity not in SEVERITIES:
raise FindingsError(f'- finding {index}: "severity" must be one of {list(SEVERITIES)}')
notes = []
unknown = sorted(str(key) for key in set(raw) - FINDING_KEYS)
if unknown:
notes.append(f"- finding {index}: ignored unknown fields {unknown}")
try:
patch = _parse_patch(raw.get("patch"), index)
except FindingsError as error:
patch = None
notes.append(f"{error} — kept the finding without it")
# The counts are the model's own arithmetic and drive both the threshold and the ranking, so
# they get the one check that needs no second opinion: a pattern cannot touch more sessions
# than the times it occurred, nor more than the slice even held. Clamping beats re-asking —
# a whole turn is far too expensive to spend on one wrong integer.
occurrences = _positive_int(raw.get("occurrences"), "occurrences", index)
sessions = _positive_int(raw.get("sessions_affected"), "sessions_affected", index)
ceiling = min(occurrences, session_count)
if sessions > ceiling:
notes.append(f'- finding {index}: "sessions_affected" {sessions} exceeds {ceiling}, clamped')
sessions = ceiling
finding = {
"pattern": pattern,
"severity": severity,
"diagnosis": _text_field(raw.get("diagnosis"), "diagnosis", index, MAX_DIAGNOSIS_CHARS),
"evidence": _parse_evidence(raw.get("evidence"), index),
"occurrences": occurrences,
"sessions_affected": sessions,
"proposal": _text_field(raw.get("proposal"), "proposal", index, MAX_PROPOSAL_CHARS),
"patch": patch,
}
return finding, notes
def parse_findings(content: str, session_count: int) -> tuple[list[dict[str, Any]], list[str]]:
"""Parse the agent's answer into raw finding dicts, plus notes on anything dropped.
Only an unusable *answer* raises: a single malformed finding is discarded and the rest of
the batch survives. Re-asking costs a whole ~420k token turn, far too much to spend on one
bad record — the plan calls for discarding it (plans/reflect-skill.md).
"""
payload, decode_error = _decode_payload(content)
if payload is None:
raise FindingsError(decode_error)
if not isinstance(payload, dict) or not isinstance(payload.get("findings"), list):
raise FindingsError('- the JSON must be an object with a "findings" list')
raw_findings = payload["findings"]
problems: list[str] = []
if len(raw_findings) > MAX_FINDINGS_PER_BATCH:
problems.append(f"- kept the first {MAX_FINDINGS_PER_BATCH} of {len(raw_findings)} findings")
raw_findings = raw_findings[:MAX_FINDINGS_PER_BATCH]
parsed = []
for index, raw in enumerate(raw_findings, start=1):
try:
finding, notes = _parse_finding(raw, index, session_count)
except FindingsError as error:
problems.append(f"{error} — finding dropped")
continue
parsed.append(finding)
problems += notes
if raw_findings and not parsed:
raise FindingsError("\n".join(problems))
return parsed, problems
def _fold_evidence(fresh: tuple[dict[str, str], ...], previous: Any) -> tuple[dict[str, str], ...]:
"""Newest evidence first, older kept behind it, deduplicated and capped.
`occurrences` sums across runs while the evidence used to be replaced, so a folded finding
claimed 18 occurrences and showed the two examples from the last batch — a count the user
had no way to audit during review.
"""
seen: set[tuple[str, str]] = set()
kept: list[dict[str, str]] = []
for item in (*fresh, *(previous or ())):
key = (item.get("session", ""), item.get("excerpt", ""))
if key in seen:
continue
seen.add(key)
kept.append(item)
if len(kept) == MAX_EVIDENCE:
break
return tuple(kept)
def _last_seen(evidence: tuple[dict[str, str], ...], created: str) -> str:
"""The newest date the evidence actually carries — `created` only says when it was filed."""
dates = [item["when"][:10] for item in evidence if EVIDENCE_DATE_RE.match(item.get("when", ""))]
return max(dates, default=created)
def merge_findings(existing: list[dict[str, Any]], parsed: list[dict[str, Any]], today: str) -> list[Finding]:
"""Apply the notification threshold and fold repeats into the record they repeat.
A pattern seen once stays `watch` and silent; the second sighting promotes it to `open`.
Folding sums the counts, so it also carries the older evidence forward — a cumulative count
with only the newest batch's examples behind it cannot be checked by the person reviewing it.
A pattern that was already applied and comes back is a regression and opens immediately —
but only when the evidence is newer than the fix; evidence from before it describes
behaviour already dealt with, so the finding waits on `watch` until the window catches up.
A pattern the user rejected never opens again — rejection is a decision, not a deferral;
it keeps being counted and stays visible in the report and the pattern list. Rejection is
checked against *every* record of the pattern, not just the newest one: the `watch` record
filed after a rejection would otherwise supersede it and promote the pattern right back.
"""
by_pattern: dict[str, dict[str, Any]] = {}
for record in existing:
current = by_pattern.get(record["pattern"])
if not current or record["created"] >= current["created"]:
by_pattern[record["pattern"]] = record
used_ids = {record["id"] for record in existing}
rejected_patterns = {record["pattern"] for record in existing if record["status"] == STATUS_REJECTED}
results = []
for item in parsed:
previous = by_pattern.get(item["pattern"])
occurrences = item["occurrences"]
sessions = item["sessions_affected"]
evidence = item["evidence"]
regression_of = None
history: tuple[str, ...] = ()
stale_after_fix = False
if previous and previous["status"] in (STATUS_WATCH, STATUS_OPEN):
occurrences += previous.get("occurrences", 0)
sessions += previous.get("sessions_affected", 0)
evidence = _fold_evidence(item["evidence"], previous.get("evidence"))
history = (*previous.get("history", []), f"{previous['created']}:{previous['id']}")
# A pattern that already came back after a fix stays a regression until it is
# dealt with; folding the repeat in must not quietly drop the flag.
regression_of = previous.get("regression_of")
last_seen = _last_seen(evidence, today)
if previous and previous["status"] == STATUS_APPLIED:
# Only a sighting *after* the fix is a regression. The window still reaches back over
# sessions that predate it, and flagging those made the fix look undone — worse, it
# opened findings whose whole evidence is about behaviour already dealt with.
if last_seen > previous.get("applied", {}).get("at", "")[:10]:
regression_of = previous["id"]
else:
stale_after_fix = True
was_rejected = item["pattern"] in rejected_patterns
repeats = occurrences >= 2 and sessions >= 2
silenced = was_rejected or stale_after_fix
status = STATUS_OPEN if (repeats or regression_of) and not silenced else STATUS_WATCH
results.append(
Finding(
id=_new_id(used_ids),
status=status,
created=today,
last_seen=last_seen,
pattern=item["pattern"],
severity=item["severity"],
diagnosis=item["diagnosis"],
evidence=evidence,
occurrences=occurrences,
sessions_affected=sessions,
proposal=item["proposal"],
patch=item["patch"],
regression_of=regression_of,
history=history,
)
)
used_ids.add(results[-1].id)
return results
def supersede(existing: list[dict[str, Any]], merged: list[Finding]) -> list[dict[str, Any]]:
"""Drop the watch/open records that the new findings fold in, then append the new ones."""
folded = {record_id for finding in merged for entry in finding.history for record_id in [entry.split(":")[-1]]}
kept = [record for record in existing if record["id"] not in folded]
return kept + [finding.to_json() for finding in merged]
def _rate_line(rate: float, previous: float | None) -> str:
"""How often already-known patterns still recur — the one number that says whether this pays off.
Without a trend the report is only a restatement of findings.jsonl; with it, an applied fix
that did nothing becomes visible in the next run.
"""
trend = f" (previous run {previous:.1f})" if previous is not None else ""
return f"Known patterns: {rate:.1f} occurrences / 100 sessions{trend}."
def _window_line(stats: dict[str, Any]) -> str:
"""What the findings below actually cover — without it the report reads as if it saw everything."""
scope = f"from {stats['window_from'][:10]}" if stats.get("window_from") else "from the cursor, no window"
return f"Window: {scope}, batches {stats['batches']}/{stats.get('batches_total', stats['batches'])}."
def _seen_line(finding: Finding) -> str:
"""First and last sighting: `occurrences` is cumulative, so the count alone hides staleness.
"Last seen" is the newest date in the evidence, not `created` — a record refiled every night
reported today's date whatever the evidence behind it said.
"""
first = finding.history[0].split(":")[0] if finding.history else finding.created
return f"first seen {first}, last seen {finding.last_seen}"
def _is_stale(finding: Finding, stats: dict[str, Any]) -> bool:
"""Nothing in the run's window backs this finding any more, so no run will refresh it either."""
window_from = str(stats.get("window_from") or "")[:10]
return bool(window_from) and finding.last_seen < window_from
def render_report(merged: list[Finding], stats: dict[str, Any], previous_rate: float | None, when: datetime) -> str:
"""The dated results/ report — the human-readable record behind the Telegram one-liner."""
lines = [
f"# Self-reflection {when:%Y-%m-%d}",
"",
f"Analysed {stats['sessions']} sessions in {stats['batches']} batches. Findings: {len(merged)} "
f"({sum(1 for f in merged if f.status == STATUS_OPEN)} to review, "
f"{sum(1 for f in merged if f.status == STATUS_WATCH)} watched).",
"",
_window_line(stats),
_rate_line(stats["repeat_per_100"], previous_rate),
"",
]
if not merged:
lines.append("Nothing to report.")
return "\n".join(lines) + "\n"
for finding in sorted(merged, key=lambda f: (f.status != STATUS_OPEN, SEVERITIES.index(f.severity) * -1)):
flag = " — REGRESSION" if finding.regression_of else ""
if _is_stale(finding, stats):
flag += " — STALE"
lines += [
f"## {finding.id} · `{finding.pattern}` [{finding.status}/{finding.severity}]{flag}",
"",
finding.diagnosis,
"",
f"**Occurrences:** {finding.occurrences}× in {finding.sessions_affected} sessions · {_seen_line(finding)}",
"",
"**Evidence:**",
]
lines += [
f"- `{item.get('session', '?')}` {item.get('when', '')}{item.get('excerpt', '')}".rstrip("")
for item in finding.evidence
]
lines += ["", f"**Proposal:** {finding.proposal}", ""]
if finding.patch:
lines += [
f"**Patch:** `{finding.patch['file']}`",
"",
"```diff",
*[f"- {line}" for line in finding.patch["old_text"].splitlines()],
*[f"+ {line}" for line in finding.patch["new_text"].splitlines()],
"```",
"",
]
return "\n".join(lines) + "\n"
async def _resolve_findings(
bot: Nanobot, session_key: str, prompt: str, workspace: Path, session_count: int
) -> list[dict[str, Any]]:
"""Ask the agent for findings, retrying with validator feedback in the same session.
A provider failure is counted separately from a malformed answer. The two look alike from
here — nanobot hands back `Error calling LLM: …` as the reply — but blaming the model for
text it never wrote burns a validator attempt and sends it a nonsensical correction.
"""
fingerprint = _git_fingerprint(workspace)
message = prompt
last_error: FindingsError = FindingsError("- no attempt was made")
attempt = 0
llm_errors = 0
while attempt < MAX_ATTEMPTS:
result = await bot.run(message, session_key=session_key)
if result.stop_reason == "error" or result.error:
llm_errors += 1
detail = (result.error or result.content or "unknown").splitlines()[0]
print(f"[llm-error {llm_errors}/{MAX_LLM_ERROR_RETRIES}] {detail}", file=sys.stderr)
if llm_errors > MAX_LLM_ERROR_RETRIES:
raise ReflectError(f"model unavailable: {detail}")
# The failed turn left an error message and the whole digest in that session; a fresh
# key re-asks the original question instead of paying for the poisoned history.
session_key = f"{session_key}r{llm_errors}"
message = prompt
continue
attempt += 1
content = result.content or ""
print(f"[attempt {attempt}/{MAX_ATTEMPTS}] {len(content)} chars", file=sys.stderr)
if _git_fingerprint(workspace) != fingerprint:
raise ReflectError("the agent modified the workspace during an analysis run — nothing filed")
try:
parsed, problems = parse_findings(content, session_count)
except FindingsError as error:
last_error = error
print(f"[attempt {attempt}/{MAX_ATTEMPTS}] rejected:\n{error}", file=sys.stderr)
message = RETRY_PROMPT.format(errors=error)
continue
for problem in problems:
print(f"[attempt {attempt}/{MAX_ATTEMPTS}] {problem}", file=sys.stderr)
return parsed
raise ReflectError(f"after {MAX_ATTEMPTS} attempts: {str(last_error).splitlines()[0].lstrip('- ')}")
def build_prompt(digest: str, known_patterns: str, session_count: int) -> str:
return GOAL.format(
count=session_count,
max_findings=MAX_FINDINGS_PER_BATCH,
known_patterns=known_patterns,
digest=digest,
)
def _open_bot(preset: str):
"""Deferred import so a dry run works on a machine without nanobot-ai installed."""
from nanobot import Nanobot # ty: ignore[unresolved-import]
return Nanobot.from_config(model_preset=preset)
async def _run(workspace: Path, args: argparse.Namespace, now: datetime) -> tuple[str, int]:
state = _load_state(workspace)
# Cursor is the floor, window the ceiling: nothing is analysed twice and nothing older than
# the window is analysed at all, so a run always describes recent behaviour and the backlog
# cannot starve it. Sessions the window skips are skipped for good — the cursor moves past them.
window_from = f"{now - timedelta(days=args.window_days):%Y-%m-%dT%H:%M:%S}" if args.window_days else ""
since = "" if args.all else max(str(state.get("cursor") or ""), window_from)
paths = collect_sessions(workspace / "sessions", since=since)
batches = list(iter_batches(paths, args.budget_chars))
if args.max_batches:
batches = batches[: args.max_batches]
if not batches:
return "", 0
existing = _load_findings(workspace)
known_before = {record["pattern"] for record in existing}
bot = None if args.dry_run else _open_bot(MODEL_PRESET)
runs = state.setdefault("runs", [])
previous_rate = runs[-1].get("repeat_per_100") if runs else None
stats: dict[str, Any] = {
"at": f"{now:%Y-%m-%d %H:%M}",
"window_from": window_from,
"sessions": 0,
"batches": 0,
"batches_total": len(batches),
"open": 0,
"watch": 0,
"repeat_per_100": 0.0,
}
# Keyed by pattern: a pattern found in five batches is one finding, and only the last fold
# of it is the complete one. Appending every batch's copy inflated the report headings, the
# open/watch counts and the Telegram number to several times what the store actually holds.
merged: dict[str, Finding] = {}
session_count = 0
repeat_occurrences = 0
deadline_seconds = args.deadline_minutes * 60
started_at = time.monotonic()
def commit(batch: list[SessionDigest]) -> None:
"""Persist everything analysed so far, after every batch.
A run that gets cut short — by the deadline, the hard timeout or a dead provider — must
keep the batches it did finish and leave the cursor past them. Doing this once at the end
meant a timeout threw away finished work and left the cursor still pointing at it.
"""
_write_findings(workspace, existing)
stats.update(
sessions=session_count,
batches=stats["batches"] + 1,
open=sum(1 for f in merged.values() if f.status == STATUS_OPEN),
watch=sum(1 for f in merged.values() if f.status == STATUS_WATCH),
repeat_per_100=round(repeat_occurrences / session_count * 100, 1) if session_count else 0.0,
)
report = workspace / RESULTS_REL / f"{now:%Y-%m-%d}_reflect.md"
report.parent.mkdir(parents=True, exist_ok=True)
report.write_text(render_report(list(merged.values()), stats, previous_rate, now), encoding="utf-8")
# Batches run oldest-first, so the newest start inside one is a safe cursor. Never move it
# backwards: an `--all` run walks the whole backlog and must not undo the nightly progress
# if it dies halfway.
newest = max((item.started for item in batch), default="")
state["cursor"] = max(newest, str(state.get("cursor") or ""))
if stats["batches"] == 1:
runs.append(stats)
_save_state(workspace, state)
for index, batch in enumerate(batches):
if index and time.monotonic() - started_at > deadline_seconds:
print(f"deadline: stopping after {index}/{len(batches)} batches", file=sys.stderr)
break
digest = "\n\n".join(item.text for item in batch)
session_count += len(batch)
prompt = build_prompt(digest, _known_patterns(existing), len(batch))
if args.dry_run:
target = workspace / "tmp" / f"reflect-batch.{index:03d}.md"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(prompt, encoding="utf-8")
print(f"dry-run: {target} ({len(prompt) / 1000:.0f} kB)", file=sys.stderr)
continue
session_key = f"reflect:{now:%Y%m%d-%H%M%S}-{index}"
parsed = await _resolve_findings(bot, session_key, prompt, workspace, len(batch))
# Newly observed occurrences only — the cumulative counts on `merged` carry earlier
# runs with them and would inflate the rate every time a pattern is folded in.
repeat_occurrences += sum(item["occurrences"] for item in parsed if item["pattern"] in known_before)
batch_findings = merge_findings(existing, parsed, f"{now:%Y-%m-%d}")
existing = supersede(existing, batch_findings)
merged.update({finding.pattern: finding for finding in batch_findings})
commit(batch)
if args.dry_run:
return "", session_count
pending = len(batches) - stats["batches"]
opened = [f for f in merged.values() if f.status == STATUS_OPEN]
if not opened:
# Silence is how the starved cursor went unnoticed for three nights: a run that cannot
# keep up finds nothing new, and used to say nothing about the backlog it left behind.
if pending:
scope = window_from[:10] or "the cursor"
return f"🔍 reflect: 0 findings, {pending} batches left (window from {scope}).", session_count
return "", session_count
patchable = sum(1 for f in opened if f.patch)
regressions = sum(1 for f in opened if f.regression_of)
text = f"🔍 reflect: {len(opened)} findings to review ({patchable} with a patch)"
if regressions:
text += f", {regressions} regressions"
done = stats["batches"]
progress = "" if done == len(batches) else f" Analysed {done}/{len(batches)} batches."
return f"{text}.{progress} Type /reflect.", session_count
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--dry-run", action="store_true", help="write prompts to tmp/, call no model")
parser.add_argument("--all", action="store_true", help="ignore the cursor and re-read everything (dry runs only)")
parser.add_argument(
"--window-days",
type=int,
default=DEFAULT_WINDOW_DAYS,
help=f"analyse only sessions from the last N days (default {DEFAULT_WINDOW_DAYS}, 0 = from the cursor)",
)
parser.add_argument("--budget-chars", type=int, default=DEFAULT_BUDGET_CHARS)
parser.add_argument("--max-batches", type=int, default=0, help="stop after N batches (0 = no limit)")
parser.add_argument(
"--deadline-minutes",
type=int,
default=DEFAULT_DEADLINE_MINUTES,
help="start no new batch past this many minutes (0 = one batch per run)",
)
parser.add_argument("--workspace", type=Path, help="override the configured workspace (for dry runs)")
args = parser.parse_args(argv)
if args.all and not args.dry_run:
# merge_findings folds by pattern and sums the counts, so re-reading analysed sessions
# inflates every occurrence count. It is a debugging view, not a way to recount.
parser.error("--all re-reads sessions already counted and would inflate the counts; use it with --dry-run")
now = datetime.now()
# A dry run must work without the server config, so it can be checked from a dev machine.
config = {} if (args.dry_run and args.workspace) else _config()
workspace = args.workspace or _workspace(config)
try:
message, sessions = asyncio.run(asyncio.wait_for(_run(workspace, args, now), timeout=TIMEOUT_SECONDS))
failed = False
except TimeoutError:
message = f"🔍 reflect: ERROR — timed out after {TIMEOUT_SECONDS // 60} min, finished batches are saved."
sessions, failed = 0, True
except ReflectError as error:
message, sessions = f"🔍 reflect: ERROR — {error}.", 0
failed = True
except Exception as error: # noqa: BLE001 — an unattended job must report, not just die
traceback.print_exc()
message, sessions = f"🔍 reflect: ERROR — {type(error).__name__}: {error}.", 0
failed = True
if not message:
print(f"reflect_auto: {sessions} sessions, nothing to report", file=sys.stderr)
return 0
if failed:
print(f"reflect_auto: {message}", file=sys.stderr)
if args.dry_run:
return 1 if failed else 0
token, chat_id = _telegram_config(config)
try:
_send_telegram(message, token, chat_id)
except Exception as error: # noqa: BLE001 — delivery failure must not hide the original outcome
print(f"reflect_auto: telegram delivery failed: {error}", file=sys.stderr)
return 1
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,338 @@
#!/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())