agent-home-kit/bin/witness-log.py

107 lines
7.1 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).
Пара к сенсору на стороне оператора (если он есть).
Дошедшее знаю только я; отправленное знает только он. Расхождение = потеря.
usage: python3 bin/witness-log.py --surface cowork --session <tag> [--turn N] < text
python3 bin/witness-log.py --surface cowork --session <tag> --role assistant --done "<предмет → результат>" --item <пункт плана|вне-плана>
"""
import sys, json, hashlib, argparse, datetime, pathlib, subprocess
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from kit import HOME # корень дома из kit.json (HOME_KIT_ROOT)
LEDGER = HOME / "state/witness/turns.jsonl"
LETTERS = HOME / "journal/letters"
REG = str(HOME / "bin/session-reg.py")
LETTER_MIN = 1500 # замер 16.10: письмо 06.10 — 4 КБ, сборка таблички меньше 1,5 КБ — отписка (порог по ТЗ letters-habit)
COORD_PREFIXES = ("cw-", "chat-", "cha") # метки окон-координаторов (как в ветке --ack)
def main():
p = argparse.ArgumentParser()
p.add_argument("--surface", required=True) # поверхность: cowork | headless | api | tg …
p.add_argument("--session", required=True) # метка сессии (моя, стабильная в пределах сессии)
p.add_argument("--turn", type=int, default=None)
p.add_argument("--attachments", type=int, default=0)
p.add_argument("--client-session", default="") # cse_… из URL клиента: связка с бортом А
p.add_argument("--role", default="user") # user | assistant (мой исходящий ход)
p.add_argument("--done", default=None)
p.add_argument("--item", default=None) # пункт плана проекта, напр. P-01 # сдача эпизода: одна строка «предмет → результат»
# --ack: лёгкая запись «ход дошёл» без текста хода: только первые слова (<=24 знака).
# Текст хода оператора в команду не переписывается (2026-09-02: повтор его текста в моих
# командах — вероятный триггер внешнего фильтра). Сверка таких записей — по счёту ходов в сессии.
p.add_argument("--ack", default=None)
p.add_argument("--close", "--end", "--finish", dest="close", action="store_true") # закрытие сессии; координатору и тяжёлой — только с письмом
p.add_argument("--letter", default=None) # путь письма journal/letters/*.md (cards/templates/letter.md)
p.add_argument("--heavy", action="store_true") # тяжёлая сессия (не по метке)
a = p.parse_args()
if a.close:
if a.session.startswith(COORD_PREFIXES) or a.heavy:
lp = pathlib.Path(a.letter or "")
if not a.letter or not lp.is_file() or lp.resolve().parent != LETTERS or lp.stat().st_size < LETTER_MIN:
p.error(f"--close для сессии-координатора/тяжёлой требует --letter <файл journal/letters/*.md, >= {LETTER_MIN} Б>; закрытие без письма не проходит")
rec = {"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"kind": "close", "role": a.role, "surface": a.surface, "session": a.session, "letter": a.letter or ""}
LEDGER.parent.mkdir(parents=True, exist_ok=True)
with LEDGER.open("a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
print(f"witness: close session={a.session} letter={a.letter or '-'}")
return
if a.done is not None:
if a.ack is not None:
p.error("--done несовместим с --ack")
if not a.done.strip() or "\n" in a.done or "\r" in a.done:
p.error("--done требует одну непустую строку")
# 07.09: сдача без пункта плана не попадала в план и возвращалась кругом —
# 13 таких за 30 суток, число только росло. Пункт обязателен; когда сдача
# действительно вне плана, это говорится словом: --item вне-плана.
if not (a.item or "").strip():
p.error("--done требует --item <пункт плана> (или --item вне-плана, если сдача вне плана направления)")
rec = {
"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"kind": "done", "role": a.role, "surface": a.surface,
"session": a.session, "text": a.done.strip(),
"client_session": a.client_session,
}
LEDGER.parent.mkdir(parents=True, exist_ok=True)
with LEDGER.open("a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
subprocess.run([sys.executable, REG, "done",
a.session, a.done.strip()] + (["--item", a.item] if a.item else []), check=True, capture_output=True, text=True)
print(f"witness: done session={rec['session']}")
return
if a.ack is not None:
rec = {
"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"role": a.role, "surface": a.surface, "session": a.session, "turn": a.turn,
"mode": "ack", "head": a.ack[:24], "attachments": a.attachments,
"client_session": a.client_session,
}
LEDGER.parent.mkdir(parents=True, exist_ok=True)
with LEDGER.open("a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
subprocess.run([sys.executable, REG, "touch",
a.session, "--subject", rec["head"]], capture_output=True, text=True)
print(f"witness: ack turn={rec['turn']} {rec['client_session'] or '-'}")
return
text = sys.stdin.read()
norm = text.strip()
rec = {
"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"role": a.role,
"surface": a.surface,
"session": a.session,
"turn": a.turn,
"chars": len(norm),
"sha256": hashlib.sha256(norm.encode("utf-8")).hexdigest(),
"head": norm[:80],
"tail": norm[-80:] if len(norm) > 160 else "",
"attachments": a.attachments,
"client_session": a.client_session,
}
LEDGER.parent.mkdir(parents=True, exist_ok=True)
with LEDGER.open("a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
print(f"witness: {rec['sha256'][:12]} {rec['chars']}c turn={rec['turn']}")
if __name__ == "__main__":
main()