107 lines
7.1 KiB
Python
Executable file
107 lines
7.1 KiB
Python
Executable file
#!/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()
|