agent-home-kit/bin/wake-ladder.py

384 lines
17 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Лестница пробуждения — контекст подаётся ступенями, не пачкой (agent-home-kit; исходник — дом Дистилла, v0.1 2026-09-02).
Каждая ступень — отдельный вызов, отдельный кусок (<= max байт), по желанию проверенный
линтером (kit.json: lint_cmd), с записью в журнал. Если сессия оборвалась, журнал
показывает последнюю доставленную ступень — это и есть подозреваемый; ступень
выключается одной командой.
wake-ladder.py next <session> [--project <имя>] выдать следующую ступень (и записать в журнал)
wake-ladder.py step <session> <id> выдать конкретную ступень (в т.ч. manual)
wake-ladder.py trace <session> путь пройденных ступеней
wake-ladder.py last путь последней сессии (где остановилось)
wake-ladder.py list ступени, состояние, лимиты (без содержимого)
wake-ladder.py off <id> [причина] / on <id> выключить/включить ступень
wake-ladder.py dry сухой прогон: все ступени, размеры
Манифест: <дом>/wake/ladder.json. Журнал: <дом>/state/wake/ladder/<session>.jsonl
Виды ступеней (поле kind): file, goal, goal-full, project, mail, cmd, dispatch.
"""
from __future__ import annotations
import json
import re
import shlex
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from kit import CFG, HOME, AGENT, cli # noqa: E402
MANIFEST = HOME / "wake" / "ladder.json"
JOURNAL_DIR = HOME / "state" / "wake" / "ladder"
MIRROR_DIR = HOME / "state" / "wake" / "mirror"
def load() -> dict:
return json.loads(MANIFEST.read_text(encoding="utf-8"))
def save(m: dict) -> None:
MANIFEST.write_text(json.dumps(m, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _probe(cmd: str, text: str) -> tuple[bool, str]:
"""Внешний прибор ступени: команда читает stdin, rc=0 — чисто. Пустая команда — прибора нет, ступень проходит."""
if not cmd:
return True, ""
try:
p = subprocess.run(shlex.split(cmd), input=text, text=True, capture_output=True, timeout=30, cwd=str(HOME))
except Exception as exc: # прибор назначен, но сломан — ступень не выдаём, это не «ok»
return False, f"прибор недоступен: {exc}"
if p.returncode == 0:
return True, ""
lines = [l for l in (p.stdout + p.stderr).splitlines() if l.strip()]
return False, "\n".join(lines[:4])
def lint(text: str) -> tuple[bool, str]:
return _probe(CFG.get("lint_cmd", ""), text)
def density_ok(text: str) -> tuple[bool, str]:
return _probe(CFG.get("density_cmd", ""), text)
# ---- источники ступеней -----------------------------------------------------
def src_file(step: dict) -> str:
return (HOME / step["src"]).read_text(encoding="utf-8").strip()
PROJECT_MAP = HOME / "tasks" / "active" / "projects" / "MAP.md"
def session_project(session: str) -> str:
"""Проект, названный сессией при open (session-reg), или ''."""
ev = HOME / "state" / "sessions" / "events.jsonl"
proj = ""
if ev.exists():
for line in ev.read_text(encoding="utf-8").splitlines():
try:
r = json.loads(line)
except Exception:
continue
if r.get("label") == session and r.get("kind") == "open" and r.get("project"):
proj = str(r["project"])
return proj
def anchor_file(session: str) -> tuple[Path, str]:
"""(файл якоря, подпись). Проект из MAP.md (строки «имя | путь»); не назван или не найден → ACTIVE-TASK.md."""
proj = session_project(session)
if proj and PROJECT_MAP.exists():
for line in PROJECT_MAP.read_text(encoding="utf-8").splitlines():
if "|" in line and not line.startswith("#"):
name, rel = [x.strip() for x in line.split("|", 1)]
if name.lower() == proj.lower():
return HOME / rel, f"{rel} (проект «{proj}»)"
return HOME / "ACTIVE-TASK.md", "ACTIVE-TASK.md (общий якорь)"
def src_goal(step: dict) -> str:
"""ЦЕЛЬ + число «следующих шагов» из якоря, без реквизитов и деталей."""
f, label = anchor_file(step.get("_session", ""))
if not f.exists():
return f"Активной задачи нет ({label} отсутствует)."
raw = re.sub(r"<!--.*?-->", "", f.read_text(encoding="utf-8"), flags=re.S)
age_h = (time.time() - f.stat().st_mtime) / 3600
out = [f"({label}, обновлён ~{age_h:.0f} ч назад" + (", ПРОТУХ >48ч" if age_h > 48 else "") + ")"]
for line in raw.splitlines():
s = line.strip()
if s.startswith("# ") or s.upper().startswith("ЦЕЛЬ"):
out.append(s)
m = re.search(r"##[^\n]*[Сс]ледующие шаги[^\n]*\n(.*?)(?:\n##|\Z)", raw, flags=re.S)
n = len(re.findall(r"^\s*\d+\.\s+", m.group(1), flags=re.M)) if m else 0
out.append(f"Следующих шагов: {n}. Полный текст — ступенью `step <session> goal-full`, только если он нужен для дела.")
return "\n".join(out)
def src_goal_full(step: dict) -> str:
f, _ = anchor_file(step.get("_session", ""))
return re.sub(r"<!--.*?-->", "", f.read_text(encoding="utf-8"), flags=re.S).strip() if f.exists() else "нет"
def _run(cmd: list[str], timeout: int = 40) -> str:
try:
p = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout, cwd=str(HOME))
return (p.stdout or p.stderr).strip()
except Exception as exc:
return f"{cmd[1] if len(cmd) > 1 else cmd[0]}: не отработал ({type(exc).__name__})"
def src_project(step: dict) -> str:
"""Якорь проекта, в котором открыта сессия: полный файл, не выжимка."""
session = step.get("_session", "")
proj = session_project(session)
if not proj:
return "проект не назван → wake-ladder.py next <session> --project <имя> (действует на любом вызове)"
f, label = anchor_file(session)
if label.endswith("(общий якорь)"): # строки проекта в карте нет — якорь по умолчанию
return f"проект «{proj}» не в карте tasks/active/projects/MAP.md — добавить строку «{proj} | <путь якоря>» и файл якоря"
body = re.sub(r"<!--.*?-->", "", f.read_text(encoding="utf-8"), flags=re.S).strip() if f.exists() else "файл якоря отсутствует"
age_h = (time.time() - f.stat().st_mtime) / 3600 if f.exists() else 0
return f"({label}, обновлён ~{age_h:.0f} ч назад)\n{body}"
def src_mail(step: dict) -> str:
"""Корреспонденция: письма шины сессий этой сессии + сдачи диспетчера. Шина первой: ступень режется по max, хвост срезается."""
session = step.get("_session", "")
out = [_run([sys.executable, "bin/session-bus.py", "inbox", "--session", session]),
_run([sys.executable, "bin/dispatch.py", "mail"])]
return "\n".join(x for x in out if x.strip())
def src_cmd(step: dict) -> str:
"""Любой прибор дома: поле cmd (строка, запуск из корня дома), timeout — потолок в секундах."""
return _run(shlex.split(step["cmd"]), timeout=int(step.get("timeout", 40)))
def is_coord(session: str) -> bool:
cp = CFG.get("coord_project", "")
return bool(cp) and session_project(session).lower() == cp.lower()
def src_dispatch(step: dict) -> str:
"""Сводка диспетчера (окну-координатору): что разнесено и что сдано."""
return _run([sys.executable, "bin/dispatch.py", "list", "--open"], timeout=30)
SOURCES = {"file": src_file, "goal": src_goal, "goal-full": src_goal_full, "project": src_project,
"mail": src_mail, "cmd": src_cmd, "dispatch": src_dispatch}
def render(step: dict) -> tuple[str, dict]:
"""Содержимое ступени и запись для журнала."""
fn = SOURCES[step.get("kind", "file")]
try:
text = fn(step)
except Exception as exc:
text = f"ступень не собралась: {exc}"
mx = int(step.get("max", 1500))
cut = len(text.encode("utf-8")) > mx
if cut:
b = text.encode("utf-8")[:mx]
text = b.decode("utf-8", errors="ignore") + f"\n[обрезано до {mx} Б, полный источник: {step.get('src') or 'wake-ladder.py step ' + step.get('_session', '<сессия>') + ' ' + step['id']}]"
ok, report = (True, "") if not step.get("lint", True) else lint(text)
dok, dreport = density_ok(text) if ok else (True, "")
rec = {"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "step": step["id"],
"bytes": len(text.encode("utf-8")), "lint": "ok" if ok else "hits",
"density": "ok" if dok else "over", "cut": cut}
if not ok or not dok:
text = (f"СТУПЕНЬ УДЕРЖАНА прибором (не выдана в контекст). Отчёт:\n{report or dreport}\n"
f"Поправь источник ({step.get('src', step.get('kind'))}) или выключи ступень: wake-ladder.py off {step['id']}")
return text, rec
def journal_path(session: str) -> Path:
JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
return JOURNAL_DIR / (re.sub(r"[^A-Za-z0-9_.-]", "_", session) + ".jsonl")
def done_steps(session: str) -> list[dict]:
p = journal_path(session)
if not p.exists():
return []
return [json.loads(l) for l in p.read_text(encoding="utf-8").splitlines() if l.strip()]
def enabled(m: dict, session: str = "") -> list[dict]:
"""coord_only — ступень только окну-координатору (kit.json: coord_project)."""
return [s for s in m["steps"] if s.get("on", True) and not s.get("manual")
and (not s.get("coord_only") or (session and is_coord(session)))]
def mirror(session: str, step: dict, idx: int, total: int, text: str) -> None:
"""Зеркало окна пробуждения: тело ступени рядом с журналом — окно читается и после сессии."""
try:
d = MIRROR_DIR / re.sub(r"[^A-Za-z0-9_.-]", "_", session)
d.mkdir(parents=True, exist_ok=True)
stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
head = f"# ступень {idx}/{total} · {step['id']} · {stamp}\n\n"
(d / f"{idx:02d}-{step['id']}.md").write_text(head + text + "\n", encoding="utf-8")
(MIRROR_DIR / "LAST.md").write_text(
f"# окно пробуждения — {session}, ступень {idx}/{total} ({step['id']}), {stamp}\n\n" + text + "\n",
encoding="utf-8")
except Exception:
pass # зеркало не несущее: его отказ не должен ломать пробуждение
def emit(session: str, step: dict, idx: int, total: int) -> None:
text, rec = render({**step, "_session": session})
rec["session"] = session
with journal_path(session).open("a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
mirror(session, step, idx, total, text)
kb = rec["bytes"] / 1024
print(f"── СТУПЕНЬ {idx}/{total} {step['id']} ({kb:.1f} КБ, линтер {rec['lint']}) — {step.get('title','')} ──")
print(text)
if idx and idx < total:
print(f"── дальше: {cli('wake-ladder.py')} next {session} ──")
elif idx:
print(f"── ЛЕСТНИЦА ПРОЙДЕНА ({total} ступеней). Путь: wake-ladder.py trace {session} ──")
def bind_project(session: str, project: str) -> None:
"""--project действует на ЛЮБОМ вызове, не только на первом."""
if project and session_project(session).lower() != project.lower():
_run([sys.executable, "bin/session-reg.py", "open", session, "--project", project])
def cmd_next(session: str, project: str = "") -> int:
m = load()
bind_project(session, project)
steps = enabled(m, session)
seen = {r["step"] for r in done_steps(session)}
first = not seen
for i, s in enumerate(steps, 1):
if s["id"] not in seen:
if first:
_run([sys.executable, "bin/session-reg.py", "open", session, "--project", project])
print(f"{AGENT}: пробуждение {session}")
emit(session, s, i, len(steps))
if first:
print(_run([sys.executable, "bin/session-reg.py", "check", session]))
return 0
print(f"лестница {session} уже пройдена ({len(steps)} ступеней); trace {session} покажет путь")
return 0
def cmd_step(session: str, sid: str, project: str = "") -> int:
m = load()
bind_project(session, project)
for s in m["steps"]:
if s["id"] == sid:
emit(session, s, 0, 0)
return 0
print(f"нет ступени {sid}")
return 1
def fmt_trace(session: str) -> str:
rows = done_steps(session)
if not rows:
return f"{session}: журнала нет"
out = [f"{session}: {len(rows)} ступеней"]
for r in rows:
out.append(f" {r['ts']} {r['step']:<16} {r['bytes']:>6} Б линтер={r['lint']}" + (" ОБРЕЗАНО" if r.get("cut") else ""))
m = load()
left = [s["id"] for s in enabled(m, session) if s["id"] not in {r["step"] for r in rows}]
out.append(" не дошли: " + (", ".join(left) if left else "—"))
out.append(f" последняя доставленная: {rows[-1]['step']} — если сессию остановило после неё, подозреваемый она")
return "\n".join(out)
def cmd_last() -> int:
JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
files = sorted(JOURNAL_DIR.glob("*.jsonl"), key=lambda p: p.stat().st_mtime)
if not files:
print("журналов нет")
return 0
print(fmt_trace(files[-1].stem))
return 0
def cmd_list() -> int:
m = load()
for s in m["steps"]:
flag = "on" if s.get("on", True) else "OFF"
if s.get("manual"):
flag = "man"
note = s.get("src", s.get("cmd", s.get("kind", "")))
if not s.get("on", True) and s.get("off_reason"):
note += f" [выкл: {s['off_reason']}]"
print(f"{s['id']:<16} {flag:<4} {s.get('kind','file'):<9} {s.get('max',1500):>5} {note}")
return 0
def cmd_toggle(sid: str, on: bool, reason: str) -> int:
m = load()
for s in m["steps"]:
if s["id"] == sid:
s["on"] = on
if not on:
s["off_reason"] = reason or time.strftime("%Y-%m-%d")
else:
s.pop("off_reason", None)
save(m)
print(f"{sid}: {'включена' if on else 'выключена'}")
return 0
print(f"нет ступени {sid}")
return 1
def cmd_dry() -> int:
m = load()
total = bad = 0
for i, s in enumerate(m["steps"], 1):
text, rec = render(s)
total += rec["bytes"]
state = "on " if s.get("on", True) else "OFF"
if s.get("manual"):
state = "man"
mark = "" if rec["lint"] == "ok" else " <- УДЕРЖАНА"
bad += rec["lint"] != "ok"
print(f"{i:>2}. [{state}] {s['id']:<16} {rec['bytes']:>6} Б линтер={rec['lint']}{' обрезано' if rec['cut'] else ''}{mark}")
print(f"итого {total/1024:.1f} КБ по всем ступеням; удержано линтером: {bad}")
return 1 if bad else 0
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
c = argv[0]
project = ""
if "--project" in argv:
i = argv.index("--project")
project = argv[i + 1] if i + 1 < len(argv) else ""
del argv[i:i + 2]
if c == "next" and len(argv) > 1:
return cmd_next(argv[1], project)
if c == "step" and len(argv) > 2:
return cmd_step(argv[1], argv[2], project)
if c == "trace" and len(argv) > 1:
print(fmt_trace(argv[1]))
return 0
if c == "last":
return cmd_last()
if c == "list":
return cmd_list()
if c == "off" and len(argv) > 1:
return cmd_toggle(argv[1], False, " ".join(argv[2:]))
if c == "on" and len(argv) > 1:
return cmd_toggle(argv[1], True, "")
if c == "dry":
return cmd_dry()
print(__doc__)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))