feat(orchestrator): rename kvb-hub to Agent Orchestrator with the phase 2 surface
Surface (requirement 001 phase 2): navigation Stream (Inbox, Regeln) / Agents / Artifacts with keys 1-3 and hash routes (#graph and #tree redirect), header with title, command palette and settings only, server-side badges (unsnoozed inbox, beats within 900 s, open jobs), a Mayday banner with "aufheben", four stream chips with agent:/art:/seit: search, Filter and time menus, the inbox in main, an agents toolbar (Funk an alle with Newsletter, Broadcast, Pan-Pan, Security, Mayday; Priorität; Tag/Nacht), Post or Call in the agent panel, Ambiente in the settings and read-aloud over the seven classes with Mayday locked. Rename (decision 9): hub/ is orchestrator/, the package kvb_orchestrator, the unit kvb-orchestrator-runner.service, scripts/orchestrator.sh, environment KVB_ORCHESTRATOR_*, make targets orchestrator-*, the MCP server, image and compose project kvb-orchestrator, the guard header X-Kvb-Orchestrator and the message marker [kvb-orchestrator msg <id>]. install runs migrate first: it stops the old container, removes its image and code, moves state, approvals and pinned keys, swaps the MCP registration, the PermissionRequest hook and the runner unit, and changes nothing on a second run (scratch-HOME test with stub docker, claude and systemctl). Saved views fall back to the old localStorage key once. MEDUSA acceptances carried to the renamed paths: 82 rows whose lines are unchanged (reason prefixed "renamed from <old path>, line unchanged"), and 7 rows whose text changed only in the product name, the package name, a temp prefix or a new class argument, each upheld by one kvb-think-criticize skeptic (approval.py:60, messaging.py:53, server.py:56, tests/hub_support.py:64, passkey.py:205, page-agent.js:75, language.py:46). Cost 101 %: the longer name adds 15 tokens of skill text; the operator approved it with the commit. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 101 % ⏱️ Time: – 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 87 🚦 Gate: ⚠️ cost 101 % > 100: ask the operator before the commit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54f3725aa1
commit
6f940b4415
125 files changed
+1725
-782
No files matched your search
@@ -0,0 +1 @@
|
||||
NAME = "kvb-orchestrator"
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from .asking import ask_state
|
||||
from .errors import Conflict, Missing, ToolError
|
||||
from .fields import decode, expect, field
|
||||
from .records import digest, shown
|
||||
from .register import REGISTER, operator_rows, read_register, runnable
|
||||
from .rules import rule_matches
|
||||
from .signed import ask_signed, operator_signed, review_signed, signing
|
||||
|
||||
BATCH_LIMIT = 100
|
||||
INBOX_KINDS = ("review", "ask", "decision", "operator")
|
||||
VERBS = ("approve", "reject")
|
||||
|
||||
|
||||
class Approving:
|
||||
def keys(self) -> list[dict]:
|
||||
try:
|
||||
entries = signing.load_pinned(self.approvers)
|
||||
except signing.Refused:
|
||||
return []
|
||||
return [{"id": entry["id"], "alg": signing.ALGORITHMS[entry["alg"]]} for entry in entries]
|
||||
|
||||
def covered(self, proof: object, signed: list[dict], now: float) -> dict | None:
|
||||
"""Args:
|
||||
proof: {"payload", "assertion"} from the page, or None.
|
||||
signed: the executor items the approval must list.
|
||||
now: the current time in epoch seconds.
|
||||
|
||||
Returns:
|
||||
The proof to relay, None when nothing needs a signature. The hub
|
||||
checks the shape only; the executors verify the signature.
|
||||
"""
|
||||
if not signed:
|
||||
if proof is not None:
|
||||
raise ToolError("proof must be null when no item needs a signature")
|
||||
return None
|
||||
if not isinstance(proof, dict):
|
||||
raise ToolError("nicht signiert: an approval of an ask, review or operator command needs the passkey signature")
|
||||
expect(proof, ("payload", "assertion"))
|
||||
try:
|
||||
payload = signing.checked_approval(proof["payload"], now)
|
||||
except signing.Refused as error:
|
||||
raise ToolError(f"proof: {error}") from None
|
||||
if not isinstance(proof["assertion"], dict) or any(signing.item_hash(item) not in payload["items"] for item in signed):
|
||||
raise ToolError("proof must carry the assertion and list every approved item")
|
||||
return {"payload": payload, "assertion": proof["assertion"]}
|
||||
|
||||
def automate(self, kind_name: str, record: dict, subject: dict) -> None:
|
||||
now = time.time()
|
||||
rule = next((rule for rule in self.rules.listed() if rule_matches(rule, kind_name, subject, now)), None)
|
||||
if rule is None:
|
||||
return
|
||||
title = {"review": record.get("title"), "ask": f"{record.get('tool')} {record.get('command')}", "decision": record.get("question")}[kind_name]
|
||||
signed = None if kind_name == "decision" else subject["item"]
|
||||
item = {"kind": kind_name, "id": record["id"], "verb": "approve", "label": record.get("recommended", ""), "sha256": "", "title": shown(title)[:200], "gate": record.get("gate", ""), "signed": signed}
|
||||
batch = self.batches.add([item], f"rule:{rule['id']}", rule.get("proof"), self.apply)
|
||||
words = f"{rule['kind']} {rule['tool']} {rule['pattern']}".replace(" ", " ")
|
||||
self.log.add({"kind": "rule", "title": f"Regel gibt frei: {item['title']}", "text": words, "ref": {"rule": rule["id"], "batch": batch["id"], "item": item["id"]}})
|
||||
|
||||
def apply(self, batch: dict) -> None:
|
||||
for item in batch["items"]:
|
||||
try:
|
||||
outcome = self.settle_item(item, batch["proof"] if item["signed"] is not None else None)
|
||||
except (OSError, ToolError) as error:
|
||||
outcome = f"fehlgeschlagen: {error}"
|
||||
verb = "Freigabe" if item["verb"] == "approve" else "Ablehnung"
|
||||
label = f" → {item['label']}" if item["label"] else ""
|
||||
ref = {"kind": item["kind"], "id": item["id"], "verb": item["verb"], "source": batch["source"], "outcome": outcome}
|
||||
self.log.add({"kind": "approval", "title": f"{verb} {item['kind']}: {item['title']}{label}", "text": f"{outcome} ({batch['source']})", "ref": ref})
|
||||
|
||||
def settle_item(self, item: dict, proof: dict | None) -> str:
|
||||
approve = item["verb"] == "approve"
|
||||
if item["kind"] == "operator":
|
||||
return self.queue_job(item, proof)
|
||||
if item["kind"] == "ask":
|
||||
record = self.asks.get(item["id"])
|
||||
if record is None or ask_state(record, time.time())["status"] == "expired":
|
||||
return "abgelaufen"
|
||||
record, changed = self.asks.decide(item["id"], {"status": "allowed" if approve else "denied", "proof": proof})
|
||||
elif item["kind"] == "review":
|
||||
record, changed = self.reviews.decide(item["id"], {"status": "approved" if approve else "rejected", "proof": proof})
|
||||
else:
|
||||
record, changed = self.decisions.decide(item["id"], {"status": "decided", "choice": item["label"]})
|
||||
if record is None:
|
||||
return "unbekannt"
|
||||
return record["status"] if changed else f"schon {record['status']}"
|
||||
|
||||
def item_of(self, raw: object, tasks: dict[str, dict]) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
raise ToolError("every item must be an object with kind, id, verb, label and sha256")
|
||||
expect(raw, ("kind", "id", "verb", "label", "sha256"))
|
||||
kind_name, verb = raw["kind"], raw["verb"]
|
||||
if kind_name not in INBOX_KINDS or verb not in VERBS or not isinstance(raw["label"], str) or not isinstance(raw["sha256"], str):
|
||||
raise ToolError(f"kind must be one of {', '.join(INBOX_KINDS)}, verb approve or reject, label and sha256 text")
|
||||
if kind_name == "operator":
|
||||
row = next((row for row in operator_rows(tasks) if row["task"] == raw["id"]), None)
|
||||
if row is None:
|
||||
raise Missing(str(raw["id"]))
|
||||
if verb != "approve" or not runnable(row):
|
||||
raise ToolError("an operator row is approved only, and only when it holds a directory and a command")
|
||||
if digest(row["command"]) != raw["sha256"]:
|
||||
raise Conflict({"id": row["task"], "status": "changed"})
|
||||
if row["task"] in self.open_jobs():
|
||||
raise Conflict({"id": row["task"], "status": "queued"})
|
||||
return {"kind": kind_name, "id": row["task"], "verb": verb, "label": "", "sha256": raw["sha256"], "title": row["command"][:200], "gate": "", "signed": operator_signed(row)}
|
||||
item_id = field(raw, "id")
|
||||
record = {"review": self.reviews, "ask": self.asks, "decision": self.decisions}[kind_name].get(item_id)
|
||||
if record is None:
|
||||
raise Missing(item_id)
|
||||
status = ask_state(record, time.time())["status"] if kind_name == "ask" else record["status"]
|
||||
if status != "pending":
|
||||
raise Conflict({"id": item_id, "status": status})
|
||||
label = ""
|
||||
if kind_name == "decision":
|
||||
if verb != "approve" or raw["label"] not in [option["label"] for option in record["options"]]:
|
||||
raise ToolError("a decision is approved with one of its option labels")
|
||||
label = raw["label"]
|
||||
title = {"review": record.get("title"), "ask": f"{record.get('tool')} {record.get('command')}", "decision": record.get("question")}[kind_name]
|
||||
signed = None
|
||||
if verb == "approve" and kind_name != "decision":
|
||||
signed = ask_signed(record) if kind_name == "ask" else review_signed(record)
|
||||
return {"kind": kind_name, "id": item_id, "verb": verb, "label": label, "sha256": "", "title": shown(title)[:200], "gate": record.get("gate", ""), "signed": signed}
|
||||
|
||||
def checked(self, raw: object) -> list[dict]:
|
||||
if not isinstance(raw, list) or not 1 <= len(raw) <= BATCH_LIMIT:
|
||||
raise ToolError(f"items must be a list of 1 to {BATCH_LIMIT} objects")
|
||||
tasks = read_register(self.council / REGISTER)
|
||||
items = [self.item_of(entry, tasks) for entry in raw]
|
||||
keys = [(item["kind"], item["id"]) for item in items]
|
||||
if len(set(keys)) != len(keys):
|
||||
raise ToolError("items must be distinct")
|
||||
if len(items) > 1 and any(item["kind"] == "review" and item["verb"] == "approve" and item["gate"] != "pass" for item in items):
|
||||
raise ToolError("a review whose gate is not green is approved only on its own")
|
||||
if any(self.batches.holds(kind_name, item_id) for kind_name, item_id in keys):
|
||||
raise Conflict({"status": "held"})
|
||||
return items
|
||||
|
||||
def sign(self, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("items",))
|
||||
hashes = [signing.item_hash(item["signed"]) for item in self.checked(payload["items"]) if item["signed"] is not None]
|
||||
credentials = [key["id"] for key in self.keys()]
|
||||
if not hashes:
|
||||
return {"payload": None, "challenge": None, "credentials": credentials}
|
||||
request = signing.approval_payload(hashes, secrets.token_hex(16), int(time.time()) + signing.LIFETIME)
|
||||
return {"payload": request, "challenge": signing.challenge(request), "credentials": credentials}
|
||||
|
||||
def approve(self, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("items", "proof"))
|
||||
items = self.checked(payload["items"])
|
||||
proof = self.covered(payload["proof"], [item["signed"] for item in items if item["signed"] is not None], time.time())
|
||||
return self.batches.add(items, "operator", proof, self.apply)
|
||||
|
||||
def undo(self, batch_id: str) -> dict:
|
||||
batch = self.batches.undo(batch_id)
|
||||
titles = ", ".join(item["title"][:80] for item in batch["items"])
|
||||
self.log.add({"kind": "approval", "title": f"Rückgängig: {titles}", "text": batch["source"], "ref": {"batch": batch_id}})
|
||||
return {"id": batch_id, "status": "undone"}
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .errors import Conflict, Missing, ToolError
|
||||
from .fields import (
|
||||
COMMAND_LIMIT,
|
||||
PATH_LIMIT,
|
||||
WAIT_CAP,
|
||||
bounded,
|
||||
decode,
|
||||
expect,
|
||||
field,
|
||||
instant,
|
||||
message_text,
|
||||
seconds,
|
||||
)
|
||||
from .signed import ask_signed
|
||||
|
||||
ASK_LIFETIME = 3600
|
||||
DESCRIPTION_LIMIT = 1000
|
||||
OPTION_LIMIT = 12
|
||||
ASK_TEXTS = {"command": COMMAND_LIMIT, "cwd": PATH_LIMIT, "session": 200, "agent": 200}
|
||||
ANSWERS = {"allow": "allowed", "deny": "denied"}
|
||||
|
||||
|
||||
def ask_state(ask: dict, now: float) -> dict:
|
||||
status = "expired" if ask["status"] == "pending" and ask["expires"] <= now else ask["status"]
|
||||
return {"id": ask["id"], "status": status, "decided": ask["decided"], "proof": ask.get("proof")}
|
||||
|
||||
|
||||
def ask_row(ask: dict, names: dict[str, str]) -> dict:
|
||||
keys = ("id", "tool", "command", "cwd", "session", "created_ts", "expires")
|
||||
return {**{key: ask[key] for key in keys}, "agent": names.get(ask["session"], ""), "subagent": ask["agent"]}
|
||||
|
||||
|
||||
def decision_state(decision: dict) -> dict:
|
||||
return {key: decision[key] for key in ("id", "status", "choice", "decided")}
|
||||
|
||||
|
||||
def decision_row(decision: dict) -> dict:
|
||||
return {key: decision[key] for key in ("id", "question", "options", "agent", "created_ts")}
|
||||
|
||||
|
||||
def option_of(option: object) -> dict:
|
||||
if not isinstance(option, dict):
|
||||
raise ToolError("every option must be an object with label and description")
|
||||
expect(option, ("label", "description"))
|
||||
label = field(option, "label").strip()
|
||||
return {"label": label, "description": bounded(option, "description", DESCRIPTION_LIMIT).strip()}
|
||||
|
||||
|
||||
class Asking:
|
||||
def ask(self, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("tool", *ASK_TEXTS, "timeout"))
|
||||
values = {name: bounded(payload, name, limit) for name, limit in ASK_TEXTS.items()}
|
||||
lifetime = seconds(payload, "timeout", ASK_LIFETIME)
|
||||
created = time.time()
|
||||
fields = {**values, "tool": field(payload, "tool").strip(), "status": "pending", "decided": None, "proof": None}
|
||||
record = self.asks.add({**fields, "created_ts": created, "expires": created + lifetime})
|
||||
self.automate("ask", record, {"item": ask_signed(record), "gate": ""})
|
||||
return {"id": record["id"], "status": record["status"]}
|
||||
|
||||
def ask_wait(self, ask_id: str, query: dict[str, list[str]]) -> dict:
|
||||
wait = instant(query, "wait")
|
||||
record = self.asks.get(ask_id)
|
||||
if record is None:
|
||||
raise Missing(ask_id)
|
||||
left = max(record["expires"] - time.time(), 0.0)
|
||||
record = self.asks.wait(ask_id, min(wait or 0.0, WAIT_CAP, left))
|
||||
return ask_state(record, time.time())
|
||||
|
||||
def settle(self, kind_name: str, item_id: str, verb: str, body: bytes) -> dict:
|
||||
if kind_name == "asks" and verb in ANSWERS:
|
||||
record = self.asks.get(item_id)
|
||||
if record is None:
|
||||
raise Missing(item_id)
|
||||
if ask_state(record, time.time())["status"] == "expired":
|
||||
raise Conflict(ask_state(record, time.time()))
|
||||
proof = None
|
||||
if verb == "allow":
|
||||
payload = decode(body)
|
||||
expect(payload, ("proof",))
|
||||
proof = self.covered(payload["proof"], [ask_signed(record)], time.time())
|
||||
decided, changed = self.asks.decide(item_id, {"status": ANSWERS[verb], "proof": proof})
|
||||
if not changed:
|
||||
raise Conflict(ask_state(decided, time.time()))
|
||||
return ask_state(decided, time.time())
|
||||
if kind_name != "decisions" or verb != "choose":
|
||||
raise Missing(verb)
|
||||
payload = decode(body)
|
||||
expect(payload, ("label",))
|
||||
label = field(payload, "label")
|
||||
record = self.decisions.get(item_id)
|
||||
if record is None:
|
||||
raise Missing(item_id)
|
||||
if label not in [option["label"] for option in record["options"]]:
|
||||
raise ToolError(f"label must be one of the options of decision {item_id}")
|
||||
decided, changed = self.decisions.decide(item_id, {"status": "decided", "choice": label})
|
||||
if not changed:
|
||||
raise Conflict(decision_state(decided))
|
||||
return decision_state(decided)
|
||||
|
||||
def decision_request(self, arguments: dict) -> dict:
|
||||
expect(arguments, ("question", "options", "agent", "recommended"))
|
||||
question = message_text(arguments, "question")
|
||||
options = arguments["options"]
|
||||
if not isinstance(options, list) or not 1 <= len(options) <= OPTION_LIMIT:
|
||||
raise ToolError(f"options must be a list of 1 to {OPTION_LIMIT} objects")
|
||||
parsed = [option_of(option) for option in options]
|
||||
if len({option["label"] for option in parsed}) != len(parsed):
|
||||
raise ToolError("option labels must be distinct")
|
||||
recommended = arguments["recommended"]
|
||||
if not isinstance(recommended, str) or recommended.strip() not in ["", *(option["label"] for option in parsed)]:
|
||||
raise ToolError("recommended must be one of the option labels or an empty string")
|
||||
fields = {"question": question, "options": parsed, "agent": field(arguments, "agent").strip(), "recommended": recommended.strip()}
|
||||
record = self.decisions.add({**fields, "status": "pending", "choice": None, "decided": None, "created_ts": time.time()})
|
||||
self.automate("decision", record, {"recommended": record["recommended"], "agent": record["agent"]})
|
||||
return {"id": record["id"], "status": record["status"], "url": self.url()}
|
||||
|
||||
def decision_wait(self, arguments: dict) -> dict:
|
||||
expect(arguments, ("id", "timeout_seconds"))
|
||||
decision_id = field(arguments, "id")
|
||||
record = self.decisions.wait(decision_id, seconds(arguments, "timeout_seconds", WAIT_CAP))
|
||||
if record is None:
|
||||
raise ToolError(f"no decision {decision_id}")
|
||||
return decision_state(record)
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
from .errors import Conflict, Missing
|
||||
|
||||
UNDO = 3.0
|
||||
|
||||
|
||||
class Pending:
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.Lock()
|
||||
self.batches: dict[str, dict] = {}
|
||||
self.applied: set[str] = set()
|
||||
|
||||
def add(self, items: list[dict], source: str, proof: dict | None, apply: Callable[[dict], None]) -> dict:
|
||||
"""Args:
|
||||
items: the checked inbox items.
|
||||
source: operator, or rule:<id> for an automatic approval.
|
||||
proof: the signature relayed to the executors once the undo
|
||||
window passed; None when no item needs one.
|
||||
apply: settles the batch after the undo window.
|
||||
|
||||
Returns:
|
||||
The batch without its proof and timer.
|
||||
"""
|
||||
batch_id = secrets.token_hex(8)
|
||||
timer = threading.Timer(UNDO, self.fire, args=(batch_id, apply))
|
||||
timer.daemon = True
|
||||
batch = {"id": batch_id, "items": items, "source": source, "due": time.time() + UNDO}
|
||||
with self.lock:
|
||||
self.batches[batch_id] = {**batch, "proof": proof, "timer": timer}
|
||||
timer.start()
|
||||
return dict(batch)
|
||||
|
||||
def fire(self, batch_id: str, apply: Callable[[dict], None]) -> None:
|
||||
with self.lock:
|
||||
batch = self.batches.pop(batch_id, None)
|
||||
if batch is not None:
|
||||
self.applied.add(batch_id)
|
||||
if batch is not None:
|
||||
apply(batch)
|
||||
|
||||
def undo(self, batch_id: str) -> dict:
|
||||
with self.lock:
|
||||
batch = self.batches.pop(batch_id, None)
|
||||
if batch is None and batch_id in self.applied:
|
||||
raise Conflict({"id": batch_id, "status": "applied"})
|
||||
if batch is None:
|
||||
raise Missing(batch_id)
|
||||
batch["timer"].cancel()
|
||||
return batch
|
||||
|
||||
def holds(self, kind_name: str, item_id: str) -> bool:
|
||||
with self.lock:
|
||||
return any(item["kind"] == kind_name and item["id"] == item_id for batch in self.batches.values() for item in batch["items"])
|
||||
|
||||
def listed(self) -> list[dict]:
|
||||
with self.lock:
|
||||
return [{key: batch[key] for key in ("id", "items", "source", "due")} for batch in self.batches.values()]
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .records import epoch, read_json, shown
|
||||
|
||||
NEWS = "news.jsonl"
|
||||
HUMAN = "Operator"
|
||||
SESSION = " session"
|
||||
NOTE = re.compile(r"(\S.*?)\s+\([^()]*\)")
|
||||
PREFIXES = (("MAYDAY", "mayday"), ("PAN-PAN", "pan-pan"))
|
||||
CLASSES = ("post", "call", "newsletter", "broadcast", "mayday", "pan-pan", "security")
|
||||
RECEIPT = "receipt"
|
||||
ALARMS = ("mayday", "pan-pan", "security")
|
||||
VERSION = 2
|
||||
MODES = ("evening", "sunset")
|
||||
RADIO_LIMIT = 5000
|
||||
GRANT_LIMIT = 50
|
||||
MAYDAY_ACTIVE = 7200
|
||||
|
||||
|
||||
def agent(name: object) -> str:
|
||||
if not isinstance(name, str):
|
||||
return ""
|
||||
text = name.strip()
|
||||
noted = NOTE.fullmatch(text)
|
||||
if noted and not subagent(text):
|
||||
text = noted.group(1)
|
||||
if text.lower().endswith(SESSION):
|
||||
return text[: -len(SESSION)].strip()
|
||||
return text
|
||||
|
||||
|
||||
def subagent(name: str) -> bool:
|
||||
return name.lower().startswith("subagent")
|
||||
|
||||
|
||||
def ordered(names: set[str]) -> list[str]:
|
||||
return sorted(names, key=lambda name: (name.lower(), name))
|
||||
|
||||
|
||||
def kind(row: dict, names: list[str]) -> str:
|
||||
"""Args:
|
||||
row: a news.jsonl row; rows without the version mark predate the
|
||||
split of radio and post and keep their old meaning.
|
||||
names: the addressees of the row.
|
||||
|
||||
Returns:
|
||||
One of CLASSES or RECEIPT, the same reading as radio.py's kind().
|
||||
"""
|
||||
cls = row.get("class")
|
||||
if row.get("v") == VERSION and cls in (*CLASSES, RECEIPT):
|
||||
return cls
|
||||
if cls in ALARMS:
|
||||
return cls
|
||||
text = row.get("text")
|
||||
for prefix, alarm in PREFIXES:
|
||||
if cls is None and isinstance(text, str) and text.lstrip().startswith(prefix):
|
||||
return alarm
|
||||
if cls == "direct" or (cls != "broadcast" and names):
|
||||
return "call" if row.get("wake") else "post"
|
||||
return "newsletter"
|
||||
|
||||
|
||||
def radio_row(row: dict, ts: float) -> dict:
|
||||
to = row.get("to")
|
||||
names = [name for name in map(agent, to) if name] if isinstance(to, list) else []
|
||||
return {"ts": ts, "from": agent(row.get("from")), "to": names, "class": kind(row, names), "text": shown(row.get("text"))}
|
||||
|
||||
|
||||
def radio_rows(rows: list[dict], begin: float, end: float) -> list[dict]:
|
||||
found = [radio_row(row, ts) for row in rows if (ts := epoch(row.get("ts"))) is not None and begin <= ts <= end]
|
||||
return sorted(found, key=lambda row: row["ts"])[-RADIO_LIMIT:]
|
||||
|
||||
|
||||
def claim_row(data: dict) -> dict:
|
||||
name = data.get("name")
|
||||
return {
|
||||
"office": shown(data.get("office")),
|
||||
"agent": agent(name),
|
||||
"address": name.strip() if isinstance(name, str) else "",
|
||||
"session": shown(data.get("session")),
|
||||
"runtime": shown(data.get("runtime")),
|
||||
"since": shown(data.get("since")),
|
||||
"beat": epoch(data.get("beat")),
|
||||
"work": shown(data.get("work")),
|
||||
}
|
||||
|
||||
|
||||
def read_claims(council: Path) -> list[dict]:
|
||||
paths = sorted([*council.glob("cluster/*/claim.json"), *council.glob("work/*/claim.json")])
|
||||
return [claim_row(data) for data in map(read_json, paths) if isinstance(data, dict)]
|
||||
|
||||
|
||||
def sessions(claims: list[dict]) -> dict[str, str]:
|
||||
return {claim["session"]: claim["agent"] for claim in claims if claim["agent"] and claim["session"] != "-"}
|
||||
|
||||
|
||||
def home(workdir: object, homes: dict[str, str]) -> str:
|
||||
if not isinstance(workdir, str):
|
||||
return ""
|
||||
matches = [path for path in homes if workdir == path or workdir.startswith(f"{path}/")]
|
||||
return homes[max(matches, key=len)] if matches else ""
|
||||
|
||||
|
||||
def grants(rows: list[dict], window: tuple[float, float], claims: list[dict], tasks: dict[str, dict]) -> list[dict]:
|
||||
begin, end = window
|
||||
homes = {}
|
||||
for claim in claims:
|
||||
workdir = tasks.get(claim["office"], {}).get("workdir")
|
||||
if claim["agent"] and isinstance(workdir, str) and workdir.rstrip("/"):
|
||||
homes[workdir.rstrip("/")] = claim["agent"]
|
||||
found = []
|
||||
for row in rows:
|
||||
start = epoch(row.get("start"))
|
||||
holder = home(row.get("workdir"), homes)
|
||||
if start is not None and begin <= start <= end and holder:
|
||||
found.append({"ts": start, "to": holder, "kind": shown(row.get("kind")), "id": shown(row.get("id"))})
|
||||
return found[-GRANT_LIMIT:]
|
||||
|
||||
|
||||
def night_mode(council: Path) -> str:
|
||||
data = read_json(council / "night.json")
|
||||
mode = data.get("mode") if isinstance(data, dict) else None
|
||||
return mode if mode in MODES else "day"
|
||||
|
||||
|
||||
def silence_of(council: Path, now: float) -> dict | None:
|
||||
data = read_json(council / "radio-silence.json")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
until = epoch(data.get("until"))
|
||||
if until is None or until <= now:
|
||||
return None
|
||||
return {"by": shown(data.get("by")), "text": shown(data.get("text")), "until": until}
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .council import agent
|
||||
from .events import event
|
||||
from .names import Directory
|
||||
from .records import epoch, read_json, shown
|
||||
|
||||
CRON_ACTIVE = 900
|
||||
TIMER_KEYS = ("OnCalendar", "OnActiveSec", "OnBootSec", "OnStartupSec", "OnUnitActiveSec", "OnUnitInactiveSec")
|
||||
CRON_LABELS = {"cron": "Cron", "wakeup": "Wakeup", "background": "Hintergrund", "timer": "Timer", "queue": "Queue"}
|
||||
|
||||
|
||||
def cron_state(cron: dict, beats: dict[str, float], now: float) -> dict:
|
||||
beat = beats.get(cron["session"])
|
||||
alive = cron["deleted"] is None and (cron["until"] is None or cron["until"] > now)
|
||||
return {**cron, "active": alive and beat is not None and now - beat < CRON_ACTIVE}
|
||||
|
||||
|
||||
def timer_rows(folder: Path) -> list[dict]:
|
||||
try:
|
||||
paths = sorted(folder.glob("*.timer"))
|
||||
except OSError:
|
||||
return []
|
||||
rows = []
|
||||
for path in paths:
|
||||
try:
|
||||
lines = [line.strip() for line in path.read_text(encoding="utf-8", errors="replace").splitlines()]
|
||||
created = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
pairs = [line.partition("=") for line in lines]
|
||||
schedule = [value.strip() for key, _, value in pairs if key.strip() in TIMER_KEYS]
|
||||
description = next((value.strip() for key, _, value in pairs if key.strip() == "Description"), "")
|
||||
enabled = (folder / "timers.target.wants" / path.name).exists()
|
||||
rows.append({
|
||||
"key": f"timer:{path.name}", "type": "timer", "session": "", "sub": "", "created": created, "deleted": None, "until": None,
|
||||
"job": "", "expression": ", ".join(schedule) or "-", "prompt": description, "reason": path.name, "active": enabled,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def queue_rows(council: Path) -> list[dict]:
|
||||
data = read_json(council / "queue.json")
|
||||
entries = data.get("entries") if isinstance(data, dict) else None
|
||||
rows = []
|
||||
for entry in entries if isinstance(entries, list) else []:
|
||||
created = epoch(entry.get("enqueued")) if isinstance(entry, dict) else None
|
||||
if created is None:
|
||||
continue
|
||||
running = "läuft" if entry.get("status") == "active" else "wartet"
|
||||
rows.append({
|
||||
"key": f"queue:{shown(entry.get('id'))}", "type": "queue", "session": shown(entry.get("session")), "sub": "", "created": created,
|
||||
"deleted": None, "until": None, "job": shown(entry.get("id")), "expression": f"{shown(entry.get('kind'))} · {running}",
|
||||
"prompt": shown(entry.get("cmd")), "reason": shown(entry.get("task")), "active": True, "agent": agent(entry.get("name")),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def cron_event(cron: dict, directory: Directory) -> dict:
|
||||
text = "\n".join(part for part in (cron["prompt"], cron["reason"]) if part and part != "-")
|
||||
return event({
|
||||
"id": f"cron:{cron['key']}", "ts": cron["created"], "kind": "cron", "agent": directory.at(cron["session"], cron["created"]) or cron.get("agent", ""),
|
||||
"now": directory.now(cron["session"]), "session": cron["session"], "sub": cron["sub"], "title": f"{CRON_LABELS[cron['type']]}: {cron['expression']}", "text": text,
|
||||
"ref": {key: cron[key] for key in ("type", "expression", "created", "deleted", "until", "active", "job")},
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import threading
|
||||
import wave
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .council import HUMAN
|
||||
from .errors import EngineError, ToolError, Unavailable
|
||||
from .fields import TEXT_LIMIT, bounded, decode, expect
|
||||
from .phrasing import SPEECH_LIMIT, SPOKEN_LINE, phrase
|
||||
from .records import digest, read_json
|
||||
from .skill_scripts import SAFE_PATH
|
||||
|
||||
MODEL_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}")
|
||||
SPEAKER_LIMIT = 10000
|
||||
TTS_TIMEOUT = 60
|
||||
TTS_KEEP = 200
|
||||
STT_RATE = 16000
|
||||
STT_SECONDS = 120
|
||||
STT_LIMIT = 44 + STT_RATE * 2 * STT_SECONDS
|
||||
STT_TIMEOUT = 120
|
||||
NOISE = re.compile(r"\[[^\]\n]{0,40}\]")
|
||||
|
||||
|
||||
def voice_models(folder: Path) -> list[Path]:
|
||||
try:
|
||||
return sorted(folder.glob("*.onnx"))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def installed_voices(folder: Path) -> dict[str, tuple[str, int | None]]:
|
||||
"""Args:
|
||||
folder: directory holding <model>.onnx files with <model>.onnx.json.
|
||||
|
||||
Returns:
|
||||
voice id <model>:<speaker> -> (model, speaker id or None for a model
|
||||
with one speaker), in model and speaker order; a model without a
|
||||
readable config or with an unsafe name is left out.
|
||||
"""
|
||||
found: dict[str, tuple[str, int | None]] = {}
|
||||
for model in voice_models(folder):
|
||||
data = read_json(model.with_name(f"{model.name}.json"))
|
||||
count = data.get("num_speakers") if isinstance(data, dict) else None
|
||||
if not MODEL_NAME.fullmatch(model.stem) or isinstance(count, bool) or not isinstance(count, int) or not 1 <= count <= SPEAKER_LIMIT:
|
||||
continue
|
||||
for speaker in range(count):
|
||||
found[f"{model.stem}:{speaker}"] = (model.stem, speaker if count > 1 else None)
|
||||
return found
|
||||
|
||||
|
||||
def unreachable(path: Path, part: str, mode: int, ability: str) -> str:
|
||||
"""Args:
|
||||
path: a file or directory an engine needs.
|
||||
part: what path is, such as "the Piper executable".
|
||||
mode: the os.access mode the hub needs on path.
|
||||
ability: what path must be for that mode, such as "executable".
|
||||
|
||||
Returns:
|
||||
"" when the hub may use path, otherwise a message naming path and
|
||||
the first directory above it the hub may not search, if any.
|
||||
"""
|
||||
if os.access(path, mode):
|
||||
return ""
|
||||
closed = next((folder for folder in reversed(path.parents) if not os.access(folder, os.X_OK)), None)
|
||||
if closed is not None:
|
||||
return f"{part} {path} lies under {closed}, which the hub may not search"
|
||||
return f"{part} {path} is missing or not {ability}"
|
||||
|
||||
|
||||
def voice_of(key: str, pool: list[str], operator: str) -> str:
|
||||
"""Args:
|
||||
key: the speaker's session id or directory key; HUMAN is the operator.
|
||||
pool: the installed voice ids other than the operator's, in their
|
||||
fixed order.
|
||||
operator: the operator's voice; empty when no voice is installed.
|
||||
|
||||
Returns:
|
||||
operator for HUMAN or when the pool is empty, otherwise the voice
|
||||
sha256(key) picks from the pool.
|
||||
"""
|
||||
if key == HUMAN or not pool:
|
||||
return operator
|
||||
return pool[int(digest(key), 16) % len(pool)]
|
||||
|
||||
|
||||
def wav_seconds(body: bytes) -> float:
|
||||
try:
|
||||
with wave.open(io.BytesIO(body), "rb") as reader:
|
||||
shape = (reader.getnchannels(), reader.getsampwidth(), reader.getframerate(), reader.getcomptype())
|
||||
frames = reader.getnframes()
|
||||
except (wave.Error, EOFError):
|
||||
raise ToolError("the recording must be a WAV file") from None
|
||||
if shape != (1, 2, STT_RATE, "NONE"):
|
||||
raise ToolError(f"the recording must be {STT_RATE} Hz mono 16-bit PCM")
|
||||
if not 0 < frames <= STT_RATE * STT_SECONDS:
|
||||
raise ToolError(f"the recording must last more than 0 and at most {STT_SECONDS} seconds")
|
||||
return frames / STT_RATE
|
||||
|
||||
|
||||
def playable(path: Path) -> bool:
|
||||
try:
|
||||
with wave.open(str(path), "rb") as reader:
|
||||
return reader.getnframes() > 0
|
||||
except (OSError, wave.Error, EOFError):
|
||||
return False
|
||||
|
||||
|
||||
def heard(output: str) -> str:
|
||||
return " ".join(NOISE.sub(" ", output).split())[:TEXT_LIMIT]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Engines:
|
||||
piper: Path
|
||||
voices: Path
|
||||
operator_voice: str
|
||||
whisper: Path
|
||||
whisper_model: Path
|
||||
scratch: Path
|
||||
|
||||
|
||||
class Speech:
|
||||
def __init__(self, engines: Engines, cache: Path) -> None:
|
||||
self.engines = engines
|
||||
self.cache = cache
|
||||
cache.mkdir(exist_ok=True)
|
||||
self.voices = installed_voices(engines.voices)
|
||||
if self.voices and engines.operator_voice not in self.voices:
|
||||
raise ToolError(f"the operator voice {engines.operator_voice!r} is not installed in {engines.voices}")
|
||||
self.operator = engines.operator_voice if self.voices else ""
|
||||
self.pool = [voice for voice in self.voices if voice != self.operator]
|
||||
self.speaking = threading.Lock()
|
||||
self.hearing = threading.Lock()
|
||||
|
||||
def env(self) -> dict[str, str]:
|
||||
scratch = str(self.engines.scratch)
|
||||
return {"PATH": SAFE_PATH, "HOME": scratch, "TMPDIR": scratch, "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "PYTHONIOENCODING": "utf-8", "PYTHONDONTWRITEBYTECODE": "1"}
|
||||
|
||||
def output_gap(self) -> str:
|
||||
"""Returns:
|
||||
"" when speech output works, otherwise why not, naming the Piper
|
||||
executable, the voice directory or a voice config with its path.
|
||||
"""
|
||||
folder = self.engines.voices
|
||||
gap = unreachable(self.engines.piper, "the Piper executable", os.X_OK, "executable") or unreachable(folder, "the voice directory", os.R_OK | os.X_OK, "searchable")
|
||||
if gap or self.voices:
|
||||
return gap
|
||||
models = voice_models(folder)
|
||||
if not models:
|
||||
return f"the voice directory {folder} holds no <model>.onnx voice"
|
||||
return f"the voice config {models[0]}.json is missing, unreadable or names no 1 to {SPEAKER_LIMIT} speakers"
|
||||
|
||||
def input_gap(self) -> str:
|
||||
"""Returns:
|
||||
"" when speech input works, otherwise why not, naming the
|
||||
whisper.cpp executable or model with its path.
|
||||
"""
|
||||
model = self.engines.whisper_model
|
||||
gap = unreachable(self.engines.whisper, "the whisper.cpp executable", os.X_OK, "executable") or unreachable(model, "the whisper.cpp model", os.R_OK, "readable")
|
||||
return gap or ("" if os.path.isfile(model) else f"the whisper.cpp model {model} is not a file")
|
||||
|
||||
def voice_of(self, key: str) -> str:
|
||||
return voice_of(key, self.pool, self.operator)
|
||||
|
||||
def describe(self) -> dict:
|
||||
operator = self.voice_of(HUMAN)
|
||||
sample = phrase("broadcast", HUMAN, [], "radio check")
|
||||
silent, deaf = self.output_gap(), self.input_gap()
|
||||
return {
|
||||
"tts": not silent, "stt": not deaf, "tts_off": silent, "stt_off": deaf, "voices": len(self.voices), "operator": operator, "sample": sample,
|
||||
"limit": SPEECH_LIMIT, "rate": STT_RATE, "seconds": STT_SECONDS,
|
||||
}
|
||||
|
||||
def synthesize(self, body: bytes) -> bytes:
|
||||
gap = self.output_gap()
|
||||
if gap:
|
||||
raise Unavailable(f"speech output is off: {gap}")
|
||||
payload = decode(body)
|
||||
expect(payload, ("text", "voice"))
|
||||
voice = payload["voice"]
|
||||
if not isinstance(voice, str) or voice not in self.voices:
|
||||
raise ToolError("voice must be one of the installed voices")
|
||||
line = " ".join(bounded(payload, "text", SPEECH_LIMIT).split())
|
||||
if not SPOKEN_LINE.fullmatch(line):
|
||||
raise ToolError(f"text must be 1 to {SPEECH_LIMIT} printable characters")
|
||||
key = digest(f"{voice}\n{line}")
|
||||
target = self.cache / f"{key}.wav"
|
||||
with self.speaking:
|
||||
if not target.is_file():
|
||||
self.render(voice, line, target)
|
||||
self.trim()
|
||||
os.utime(target)
|
||||
return target.read_bytes()
|
||||
|
||||
def render(self, voice: str, line: str, target: Path) -> None:
|
||||
model, speaker = self.voices[voice]
|
||||
onnx = self.engines.voices / f"{model}.onnx"
|
||||
partial = self.cache / f".{target.stem}-{secrets.token_hex(4)}.wav"
|
||||
command = [str(self.engines.piper), "--model", str(onnx), "--config", f"{onnx}.json", "--output-file", str(partial)]
|
||||
command += [] if speaker is None else ["--speaker", str(speaker)]
|
||||
try:
|
||||
done = subprocess.run(
|
||||
command,
|
||||
input=f"{line}\n",
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=TTS_TIMEOUT,
|
||||
env=self.env(),
|
||||
cwd=self.engines.scratch,
|
||||
check=False,
|
||||
)
|
||||
if done.returncode != 0 or not playable(partial):
|
||||
raise EngineError(f"piper failed with exit code {done.returncode}: {done.stderr.strip()[-300:]}")
|
||||
os.replace(partial, target)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise EngineError(f"piper took longer than {TTS_TIMEOUT} seconds") from None
|
||||
except OSError as error:
|
||||
raise EngineError(f"piper did not start: {error.strerror}") from None
|
||||
finally:
|
||||
partial.unlink(missing_ok=True)
|
||||
|
||||
def trim(self) -> None:
|
||||
kept = sorted(self.cache.glob("*.wav"), key=lambda path: path.stat().st_mtime, reverse=True)
|
||||
for path in kept[TTS_KEEP:]:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def transcribe(self, body: bytes) -> dict:
|
||||
gap = self.input_gap()
|
||||
if gap:
|
||||
raise Unavailable(f"speech input is off: {gap}")
|
||||
wav_seconds(body)
|
||||
command = [str(self.engines.whisper), "--model", str(self.engines.whisper_model), "--file", "-", "--output-txt", "--language", "auto", "--no-prints", "--suppress-nst"]
|
||||
with self.hearing:
|
||||
try:
|
||||
done = subprocess.run(command, input=body, capture_output=True, timeout=STT_TIMEOUT, env=self.env(), cwd=self.engines.scratch, check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise EngineError(f"whisper.cpp took longer than {STT_TIMEOUT} seconds") from None
|
||||
except OSError as error:
|
||||
raise EngineError(f"whisper.cpp did not start: {error.strerror}") from None
|
||||
if done.returncode != 0:
|
||||
raise EngineError(f"whisper.cpp failed with exit code {done.returncode}: {done.stderr.decode('utf-8', errors='replace').strip()[-300:]}")
|
||||
return {"text": heard(done.stdout.decode("utf-8", errors="replace"))}
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Missing(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Conflict(Exception):
|
||||
def __init__(self, payload: dict) -> None:
|
||||
super().__init__("conflict")
|
||||
self.payload = payload
|
||||
|
||||
|
||||
class RpcError(Exception):
|
||||
def __init__(self, code: int, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
class Unavailable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class EngineError(Exception):
|
||||
pass
|
||||
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
from .council import agent
|
||||
from .errors import ToolError
|
||||
from .fields import single
|
||||
from .records import read_jsonl, shown
|
||||
from .register import status_of
|
||||
from .transcripts import LOG_KEEP, STREAM_CUT, STREAM_KINDS
|
||||
|
||||
KINDS = (
|
||||
("radio", "Funk"), ("review", "Reviews"), ("ask", "Asks"), ("decision", "Entscheidungen"), ("operator", "Operator"), ("register", "Register"),
|
||||
("rename", "Namen"), ("cron", "Crons"), ("rule", "Regeln"), ("approval", "Freigaben"), ("run", "Runner"), ("transcript", "Transkripte"),
|
||||
)
|
||||
EVENT_KINDS = tuple(name for name, _ in KINDS)
|
||||
WORK = [name for name in EVENT_KINDS if name not in ("radio", "transcript")]
|
||||
VIEWS = {
|
||||
"fuer-dich": {"label": "Für dich", "kinds": ["radio", *WORK], "waiting": True},
|
||||
"funk": {"label": "Funk", "kinds": ["radio"], "waiting": False},
|
||||
"arbeit": {"label": "Arbeit", "kinds": WORK, "waiting": False},
|
||||
"transkripte": {"label": "Transkripte", "kinds": ["transcript"], "waiting": False},
|
||||
}
|
||||
ART = {word: name for name, label in KINDS for word in (name, label.lower())}
|
||||
SPAN = re.compile(r"(\d{1,4})([mhd])")
|
||||
UNITS = {"m": 60, "h": 3600, "d": 86400}
|
||||
|
||||
|
||||
def event(fields: dict) -> dict:
|
||||
return {"part": "", "agent": "", "now": "", "session": "", "sub": "", "text": "", "waiting": False, "ref": {}, **fields}
|
||||
|
||||
|
||||
def since_of(word: str, now: float) -> float:
|
||||
span = SPAN.fullmatch(word)
|
||||
if span:
|
||||
return now - int(span.group(1)) * UNITS[span.group(2)]
|
||||
try:
|
||||
return datetime.datetime.fromisoformat(word).timestamp()
|
||||
except ValueError:
|
||||
raise ToolError("seit: takes 30m, 2h, 3d or a date like 2026-11-06 or 2026-11-06T08:30") from None
|
||||
|
||||
|
||||
def search(text: str, now: float) -> dict:
|
||||
"""Args:
|
||||
text: the search line: words, `agent:<name>`, `art:<kind or label>`
|
||||
and `seit:<span or date>`.
|
||||
now: the clock `seit:` spans count back from.
|
||||
|
||||
Returns:
|
||||
The plain words as `q`, plus `agent`, `kinds` and `since` for each
|
||||
token the line holds.
|
||||
"""
|
||||
found: dict = {"q": []}
|
||||
for word in text.split():
|
||||
key, colon, value = word.partition(":")
|
||||
if not colon or key not in ("agent", "art", "seit"):
|
||||
found["q"].append(word)
|
||||
elif not value:
|
||||
raise ToolError(f"{key}: needs a value")
|
||||
elif key == "agent":
|
||||
found["agent"] = value
|
||||
elif key == "art":
|
||||
if value.lower() not in ART:
|
||||
raise ToolError(f"art: takes one of {', '.join(EVENT_KINDS)}")
|
||||
found.setdefault("kinds", set()).add(ART[value.lower()])
|
||||
else:
|
||||
found["since"] = since_of(value, now)
|
||||
found["q"] = " ".join(found["q"]).lower()
|
||||
return found
|
||||
|
||||
|
||||
def event_filters(query: dict[str, list[str]]) -> dict:
|
||||
view = single(query, "view") or ""
|
||||
if view:
|
||||
if view not in VIEWS:
|
||||
raise ToolError(f"view must be one of {', '.join(VIEWS)}")
|
||||
kinds, waiting = set(VIEWS[view]["kinds"]), VIEWS[view]["waiting"]
|
||||
else:
|
||||
listed = single(query, "kinds")
|
||||
kinds = set(EVENT_KINDS) if not listed else set(listed.split(","))
|
||||
if not kinds <= set(EVENT_KINDS):
|
||||
raise ToolError(f"kinds must be a comma list of {', '.join(EVENT_KINDS)}")
|
||||
waiting = single(query, "waiting") == "1"
|
||||
parts = single(query, "parts")
|
||||
chosen = set(STREAM_KINDS) if not parts else set(parts.split(","))
|
||||
if not chosen <= set(STREAM_KINDS):
|
||||
raise ToolError(f"parts must be a comma list of {', '.join(STREAM_KINDS)}")
|
||||
texts = {name: single(query, name) or "" for name in ("agent", "session", "sub")}
|
||||
line = search(single(query, "q") or "", time.time())
|
||||
texts["agent"] = line.get("agent", texts["agent"])
|
||||
kinds &= line.get("kinds", kinds)
|
||||
return {"kinds": kinds, "waiting": waiting, "parts": chosen, "q": line["q"], "since": line.get("since"), "aliases": set(), **texts}
|
||||
|
||||
|
||||
def wanted(entry: dict, filters: dict) -> bool:
|
||||
return all(
|
||||
(
|
||||
entry["kind"] in filters["kinds"],
|
||||
filters["since"] is None or entry["ts"] >= filters["since"],
|
||||
not filters["waiting"] or entry["waiting"],
|
||||
not filters["agent"] or entry["agent"] == filters["agent"] or entry["session"] in filters["aliases"],
|
||||
not filters["session"] or entry["session"] == filters["session"],
|
||||
not filters["sub"] or entry["sub"] == filters["sub"],
|
||||
entry["kind"] != "transcript" or entry["part"] in filters["parts"],
|
||||
not filters["q"] or filters["q"] in f"{entry['title']}\n{entry['text']}".lower(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def described(value: object) -> str:
|
||||
if value is None or isinstance(value, str | int | float | bool):
|
||||
return shown(value)
|
||||
return "…"
|
||||
|
||||
|
||||
def register_changes(before: dict[str, dict], after: dict[str, dict], now: float) -> list[dict]:
|
||||
found = []
|
||||
for task in sorted(set(before) | set(after)):
|
||||
old, new = before.get(task), after.get(task)
|
||||
if old == new:
|
||||
continue
|
||||
if old is None or new is None:
|
||||
row = new if old is None else old
|
||||
change = f"neu: {shown(row.get('title'))}" if old is None else "entfernt"
|
||||
else:
|
||||
keys = sorted(key for key in set(old) | set(new) if old.get(key) != new.get(key))
|
||||
change = "; ".join(f"{key}: {described(old.get(key))} → {described(new.get(key))}" for key in keys)
|
||||
row = new if new is not None else old
|
||||
found.append(event({
|
||||
"id": f"register:{task}:{now}", "ts": now, "kind": "register", "agent": agent(row.get("worker")),
|
||||
"title": task, "text": change[:STREAM_CUT], "ref": {"task": task, "status": status_of(row)},
|
||||
}))
|
||||
return found
|
||||
|
||||
|
||||
class Watch:
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.Lock()
|
||||
self.last: dict[str, dict] | None = None
|
||||
self.seen: dict[str, float] = {}
|
||||
self.changes: deque[dict] = deque(maxlen=LOG_KEEP)
|
||||
|
||||
def see(self, tasks: dict[str, dict], now: float) -> None:
|
||||
with self.lock:
|
||||
for task in tasks:
|
||||
self.seen.setdefault(task, now)
|
||||
if self.last is not None and self.last != tasks:
|
||||
self.changes.extend(register_changes(self.last, tasks, now))
|
||||
self.last = json.loads(json.dumps(tasks))
|
||||
|
||||
def first(self, task: str, now: float) -> float:
|
||||
with self.lock:
|
||||
return self.seen.get(task, now)
|
||||
|
||||
def listed(self) -> list[dict]:
|
||||
with self.lock:
|
||||
return list(self.changes)
|
||||
|
||||
|
||||
class Log:
|
||||
def __init__(self, file: Path) -> None:
|
||||
self.file = file
|
||||
self.lock = threading.Lock()
|
||||
self.rows: deque[dict] = deque(read_jsonl(file)[-LOG_KEEP:], maxlen=LOG_KEEP)
|
||||
|
||||
def add(self, row: dict) -> dict:
|
||||
entry = event({**row, "id": f"{row['kind']}:{secrets.token_hex(8)}", "ts": time.time()})
|
||||
with self.lock:
|
||||
self.rows.append(entry)
|
||||
with self.file.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(entry) + "\n")
|
||||
return entry
|
||||
|
||||
def listed(self) -> list[dict]:
|
||||
with self.lock:
|
||||
return list(self.rows)
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from . import NAME
|
||||
from .errors import Conflict, EngineError, Missing, ToolError, Unavailable
|
||||
from .hub import Hub
|
||||
|
||||
GUARD = "X-Kvb-Orchestrator"
|
||||
PLAIN_CSP = "default-src 'none'; frame-ancestors 'none'"
|
||||
LENGTH = re.compile(r"[0-9]{1,9}")
|
||||
QUERY_FIELDS = 32
|
||||
|
||||
|
||||
class HubServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
hub: Hub
|
||||
|
||||
|
||||
class Exchange(BaseHTTPRequestHandler):
|
||||
server: HubServer
|
||||
server_version = NAME
|
||||
sys_version = ""
|
||||
timeout = 60
|
||||
|
||||
def send(self, status: HTTPStatus, content_type: str, body: bytes, headers: dict[str, str]) -> None:
|
||||
self.send_response(status)
|
||||
common = {
|
||||
"Content-Type": content_type,
|
||||
"Content-Length": str(len(body)),
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
}
|
||||
for name, value in {**common, **headers}.items():
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def send_text(self, status: HTTPStatus, message: str) -> None:
|
||||
self.send(status, "text/plain; charset=utf-8", f"{message}\n".encode(), {"Content-Security-Policy": PLAIN_CSP})
|
||||
|
||||
def send_json(self, status: HTTPStatus, payload: dict) -> None:
|
||||
self.send(status, "application/json", json.dumps(payload).encode(), {"Content-Security-Policy": PLAIN_CSP})
|
||||
|
||||
def not_allowed(self) -> None:
|
||||
self.send_response(HTTPStatus.METHOD_NOT_ALLOWED)
|
||||
self.send_header("Allow", "POST")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def answer(self, produce: Callable[[], dict], status: HTTPStatus) -> None:
|
||||
self.attempt(produce, lambda payload: self.send_json(status, payload))
|
||||
|
||||
def attempt(self, produce: Callable[[], object], deliver: Callable[[object], None]) -> None:
|
||||
try:
|
||||
result = produce()
|
||||
except ToolError as error:
|
||||
self.send_text(HTTPStatus.BAD_REQUEST, str(error))
|
||||
except Missing:
|
||||
self.send_text(HTTPStatus.NOT_FOUND, "not found")
|
||||
except Conflict as error:
|
||||
self.send_json(HTTPStatus.CONFLICT, error.payload)
|
||||
except Unavailable as error:
|
||||
self.send_text(HTTPStatus.SERVICE_UNAVAILABLE, str(error))
|
||||
except EngineError as error:
|
||||
self.send_text(HTTPStatus.BAD_GATEWAY, str(error))
|
||||
except OSError as error:
|
||||
self.send_text(HTTPStatus.INTERNAL_SERVER_ERROR, f"storage failed: {error.strerror}")
|
||||
else:
|
||||
deliver(result)
|
||||
|
||||
def query(self) -> dict[str, list[str]]:
|
||||
try:
|
||||
return parse_qs(urlsplit(self.path).query, keep_blank_values=True, max_num_fields=QUERY_FIELDS)
|
||||
except ValueError as error:
|
||||
raise ToolError(str(error)) from error
|
||||
|
||||
def host(self) -> str | None:
|
||||
hosts = self.headers.get_all("Host")
|
||||
if hosts is None or len(hosts) != 1 or hosts[0] not in self.server.hub.hosts():
|
||||
return None
|
||||
return hosts[0]
|
||||
|
||||
def guarded(self, host: str) -> bool:
|
||||
return self.headers.get_all(GUARD) == ["1"] and self.headers.get_all("Origin") == [f"http://{host}"]
|
||||
|
||||
def local(self, host: str) -> bool:
|
||||
origins = self.headers.get_all("Origin")
|
||||
return self.headers.get_all(GUARD) == ["1"] and (origins is None or origins == [f"http://{host}"])
|
||||
|
||||
def client(self) -> bool:
|
||||
return self.headers.get_all(GUARD) == ["1"] and self.headers.get_all("Origin") is None
|
||||
|
||||
def body_length(self, limit: int) -> int | None:
|
||||
length = self.headers.get("Content-Length")
|
||||
if length is None or not LENGTH.fullmatch(length):
|
||||
self.send_text(HTTPStatus.LENGTH_REQUIRED, "Content-Length required")
|
||||
return None
|
||||
if int(length) > limit:
|
||||
self.send_text(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "body too large")
|
||||
return None
|
||||
return int(length)
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
|
||||
from .errors import ToolError
|
||||
|
||||
WAIT_CAP = 300
|
||||
TEXT_LIMIT = 20_000
|
||||
COMMAND_LIMIT = 4000
|
||||
PATH_LIMIT = 4096
|
||||
NAME_LIMIT = 120
|
||||
COUNT = re.compile(r"[0-9]{1,12}")
|
||||
PATH = re.compile(r"/[^\x00-\x1f\x7f]{0,4095}")
|
||||
LINE = r"(?=[^\x00-\x1f\x7f]*\S)[^\x00-\x1f\x7f]{{1,{0}}}"
|
||||
ORDER_NAME = re.compile(r"(?![\s-])[^,\x00-\x1f\x7f]{1,200}")
|
||||
FIELDS = {
|
||||
"repo": (PATH, "an absolute path"),
|
||||
"worktree": (PATH, "an absolute path"),
|
||||
"page": (PATH, "an absolute path"),
|
||||
"branch": (re.compile(r"[\w./@+-]{1,255}"), "a branch name"),
|
||||
"tree": (re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}"), "the hash git write-tree prints"),
|
||||
"title": (re.compile(LINE.format(200)), "one line of 1 to 200 characters"),
|
||||
"agent": (re.compile(LINE.format(200)), "one line of 1 to 200 characters"),
|
||||
"to": (re.compile(LINE.format(200)), "an agent name, one line of 1 to 200 characters"),
|
||||
"label": (re.compile(LINE.format(NAME_LIMIT)), f"one line of 1 to {NAME_LIMIT} characters"),
|
||||
"tool": (re.compile(LINE.format(200)), "one line of 1 to 200 characters"),
|
||||
"id": (re.compile(r"[0-9a-f]{16}"), "an id the Agent Orchestrator returned"),
|
||||
}
|
||||
|
||||
|
||||
def expect(arguments: dict, names: tuple[str, ...]) -> None:
|
||||
problems = [f"missing argument {name}" for name in names if name not in arguments]
|
||||
problems += [f"unknown argument {key}" for key in sorted(map(str, arguments)) if key not in names]
|
||||
if problems:
|
||||
raise ToolError("; ".join(problems))
|
||||
|
||||
|
||||
def field(arguments: dict, name: str) -> str:
|
||||
pattern, hint = FIELDS[name]
|
||||
value = arguments[name]
|
||||
if not isinstance(value, str) or not pattern.fullmatch(value):
|
||||
raise ToolError(f"{name} must be {hint}")
|
||||
return value
|
||||
|
||||
|
||||
def bounded(arguments: dict, name: str, limit: int) -> str:
|
||||
value = arguments[name]
|
||||
if not isinstance(value, str) or len(value) > limit:
|
||||
raise ToolError(f"{name} must be text of at most {limit} characters")
|
||||
return value
|
||||
|
||||
|
||||
def message_text(arguments: dict, name: str) -> str:
|
||||
value = bounded(arguments, name, TEXT_LIMIT)
|
||||
if not value.strip():
|
||||
raise ToolError(f"{name} must not be empty")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def seconds(arguments: dict, name: str, cap: float) -> float:
|
||||
value = arguments[name]
|
||||
if isinstance(value, bool) or not isinstance(value, int | float) or not 0 <= value < math.inf:
|
||||
raise ToolError(f"{name} must be a finite number of seconds, 0 or more")
|
||||
return float(min(value, cap))
|
||||
|
||||
|
||||
def decode(body: bytes) -> dict:
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except (ValueError, RecursionError):
|
||||
raise ToolError("body must be a JSON object") from None
|
||||
if not isinstance(data, dict):
|
||||
raise ToolError("body must be a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def single(query: dict[str, list[str]], name: str) -> str | None:
|
||||
values = query.get(name)
|
||||
if values is None:
|
||||
return None
|
||||
if len(values) != 1:
|
||||
raise ToolError(f"{name} must be given once")
|
||||
return values[0]
|
||||
|
||||
|
||||
def instant(query: dict[str, list[str]], name: str) -> float | None:
|
||||
text = single(query, name)
|
||||
if text is None or text == "":
|
||||
return None
|
||||
try:
|
||||
value = float(text)
|
||||
except ValueError:
|
||||
raise ToolError(f"{name} must be seconds since the epoch") from None
|
||||
if not 0 <= value < 1e11:
|
||||
raise ToolError(f"{name} must be seconds since the epoch")
|
||||
return value
|
||||
|
||||
|
||||
def whole(query: dict[str, list[str]], name: str) -> int | None:
|
||||
text = single(query, name)
|
||||
if text is None or text == "":
|
||||
return None
|
||||
if not COUNT.fullmatch(text):
|
||||
raise ToolError(f"{name} must be a whole number")
|
||||
return int(text)
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from .council import HUMAN, agent, ordered, subagent
|
||||
from .names import Directory
|
||||
from .orbits import affinity
|
||||
from .records import shown
|
||||
from .register import ENDED, parties, status_of
|
||||
|
||||
CHAIR = "Maintainer"
|
||||
|
||||
|
||||
def parent_of(roles: dict[str, str], known: set[str], fallback: str) -> str:
|
||||
for letter in "AR":
|
||||
names = sorted(name for name, letters in roles.items() if letter in letters and name in known)
|
||||
if names:
|
||||
return names[0]
|
||||
return fallback
|
||||
|
||||
|
||||
def duties(name: str, task: str, row: dict, holders: dict[str, dict[str, dict]]) -> dict:
|
||||
entry = {"task": task, "title": shown(row.get("title")), "status": status_of(row), "worker": False, "letters": ""}
|
||||
return holders.setdefault(name, {}).setdefault(task, entry)
|
||||
|
||||
|
||||
def edges(radio: list[dict], grant_rows: list[dict], chair: str) -> list[dict]:
|
||||
counts: Counter[tuple[str, str, str]] = Counter()
|
||||
for row in (row for row in radio if row["source"]):
|
||||
for target in row["targets"] or ["*"]:
|
||||
counts[(row["source"], target, row["class"])] += 1
|
||||
for grant in grant_rows:
|
||||
counts[(chair, grant["target"], "grant")] += 1
|
||||
return [{"source": s, "target": t, "class": c, "count": n} for (s, t, c), n in sorted(counts.items())]
|
||||
|
||||
|
||||
def graph(radio: list[dict], grant_rows: list[dict], tasks: dict[str, dict], claims: list[dict], end: float, directory: Directory) -> dict:
|
||||
current = {task: row for task, row in sorted(tasks.items()) if status_of(row) not in ENDED}
|
||||
chair = directory.key(CHAIR, end)
|
||||
workers = {task: agent(row.get("worker")) for task, row in current.items()}
|
||||
keys = {row["source"] for row in radio} | {key for row in radio for key in row["targets"]}
|
||||
keys |= {claim["session"] for claim in claims if claim["agent"] and claim["session"] != "-"}
|
||||
keys |= {directory.key(name, end) for name in workers.values() if not subagent(name)}
|
||||
found = sorted(keys - {"", HUMAN, chair}, key=lambda key: (directory.label(key, end).lower(), key))
|
||||
known = {chair, *found}
|
||||
moons: dict[str, tuple[str, str]] = {}
|
||||
holders: dict[str, dict[str, dict]] = {}
|
||||
for task, row in current.items():
|
||||
roles: dict[str, str] = {}
|
||||
for name, letters in parties(row).items():
|
||||
key = directory.key(name, end)
|
||||
roles[key] = roles.get(key, "") + letters
|
||||
worker = workers[task]
|
||||
holder = directory.key(worker, end)
|
||||
if subagent(worker):
|
||||
parent = parent_of(roles, known, chair)
|
||||
holder = f"{worker} @ {parent}"
|
||||
moons[holder] = (parent, worker)
|
||||
if holder:
|
||||
duties(holder, task, row, holders)["worker"] = True
|
||||
for key, letters in roles.items():
|
||||
duties(key, task, row, holders)["letters"] += letters
|
||||
plan = [(HUMAN, "operator", None, HUMAN), (chair, "chair", None, directory.label(chair, end))]
|
||||
plan += [(key, "session", None, directory.label(key, end)) for key in found]
|
||||
plan += [(moon, "subagent", *moons[moon]) for moon in ordered(set(moons))]
|
||||
nodes = [
|
||||
{
|
||||
"id": key,
|
||||
"kind": node_kind,
|
||||
"parent": parent,
|
||||
"label": label,
|
||||
"names": directory.names(key) or [label],
|
||||
"timeline": directory.timeline(key) or [{"name": label, "since": end}],
|
||||
"tasks": list(holders.get(key, {}).values()),
|
||||
"claims": [claim for claim in claims if claim["session"] == key],
|
||||
}
|
||||
for key, node_kind, parent, label in plan
|
||||
]
|
||||
layout = affinity(nodes, radio, tasks, claims, end, lambda name: directory.key(name, end))
|
||||
return {"nodes": nodes, "edges": edges(radio, grant_rows, chair), "layout": layout}
|
||||
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .engines import STT_LIMIT
|
||||
from .exchange import PLAIN_CSP, Exchange
|
||||
|
||||
BODY_LIMIT = 1 << 20
|
||||
UPLOAD_LIMIT = 50 << 20
|
||||
PAGE_CSP = "sandbox allow-scripts"
|
||||
SETTINGS_ROUTE = "/api/settings"
|
||||
TTS_ROUTE = "/api/tts"
|
||||
WAV_TYPE = "audio/wav"
|
||||
STT_ROUTE = "/api/stt"
|
||||
ROUTE = re.compile(r"/reviews/([0-9a-f]{16})/(page|files|approve|reject)")
|
||||
ITEM = re.compile(r"/api/(asks|decisions)/([0-9a-f]{16})(?:/(allow|deny|choose))?")
|
||||
ENTRY = re.compile(r"/api/stream/([0-9]{1,12})")
|
||||
BOARD = re.compile(r"/api/board/([a-z-]{1,32})")
|
||||
UPLOADS = "/api/uploads"
|
||||
POSTS = ("/api/asks", UPLOADS, "/api/messages")
|
||||
STORED = {
|
||||
"page": (".html", "text/html; charset=utf-8", PAGE_CSP),
|
||||
"files": (".json", "application/json", PLAIN_CSP),
|
||||
}
|
||||
DECISIONS = {"approve": "approved", "reject": "rejected"}
|
||||
INBOX_ROUTE = re.compile(r"/api/inbox/(approve|mark)")
|
||||
UNDO_ROUTE = re.compile(r"/api/inbox/undo/([0-9a-f]{16})")
|
||||
RULE_ROUTE = re.compile(r"/api/rules/([0-9a-f]{16})/(enable|disable|delete)")
|
||||
RUNNER_ROUTE = re.compile(r"/api/runner/([0-9a-f]{16})/(claim|report)")
|
||||
PROOF_ROUTE = re.compile(r"/api/reviews/([0-9a-f]{16})")
|
||||
SIGN_ROUTE = "/api/sign"
|
||||
RULE_SIGN_ROUTE = "/api/rules/sign"
|
||||
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE)
|
||||
|
||||
|
||||
class Handler(Exchange):
|
||||
def speak(self, body: bytes) -> None:
|
||||
headers = {"Content-Security-Policy": PLAIN_CSP}
|
||||
self.attempt(lambda: self.server.hub.speech.synthesize(body), lambda wav: self.send(HTTPStatus.OK, WAV_TYPE, wav, headers))
|
||||
|
||||
def hear(self, body: bytes) -> None:
|
||||
self.answer(lambda: self.server.hub.speech.transcribe(body), HTTPStatus.OK)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = urlsplit(self.path).path
|
||||
hub = self.server.hub
|
||||
route = ROUTE.fullmatch(path)
|
||||
item = ITEM.fullmatch(path)
|
||||
entry = ENTRY.fullmatch(path)
|
||||
proof = PROOF_ROUTE.fullmatch(path)
|
||||
if self.host() is None:
|
||||
self.send_text(HTTPStatus.FORBIDDEN, "forbidden host")
|
||||
elif path == "/":
|
||||
self.index()
|
||||
elif proof:
|
||||
self.answer(lambda: hub.review_proof(proof.group(1)), HTTPStatus.OK)
|
||||
elif path == "/api/state":
|
||||
self.answer(lambda: hub.window(self.query()), HTTPStatus.OK)
|
||||
elif path == "/api/stream":
|
||||
self.answer(lambda: hub.stream(self.query()), HTTPStatus.OK)
|
||||
elif path == "/api/events":
|
||||
self.answer(lambda: hub.events(self.query()), HTTPStatus.OK)
|
||||
elif path == "/api/tree":
|
||||
self.answer(hub.repositories, HTTPStatus.OK)
|
||||
elif path == "/api/subagents":
|
||||
self.answer(lambda: hub.subagents(self.query()), HTTPStatus.OK)
|
||||
elif path == "/api/runner/jobs":
|
||||
if self.client():
|
||||
self.answer(hub.runner_jobs, HTTPStatus.OK)
|
||||
else:
|
||||
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Orchestrator header and no origin only")
|
||||
elif entry:
|
||||
self.answer(lambda: hub.entry(int(entry.group(1))), HTTPStatus.OK)
|
||||
elif item and item.group(1) == "asks" and item.group(3) is None:
|
||||
self.answer(lambda: hub.ask_wait(item.group(2), self.query()), HTTPStatus.OK)
|
||||
elif route and route.group(2) in STORED:
|
||||
self.stored(route.group(1), route.group(2))
|
||||
elif route or item or path == "/mcp" or path in POSTS or path in ACTION_POSTS or BOARD.fullmatch(path) or self.posted(path):
|
||||
self.not_allowed()
|
||||
else:
|
||||
self.send_text(HTTPStatus.NOT_FOUND, "not found")
|
||||
|
||||
def action(self, path: str) -> Callable[[bytes], None] | None:
|
||||
hub = self.server.hub
|
||||
route = ROUTE.fullmatch(path)
|
||||
item = ITEM.fullmatch(path)
|
||||
board = BOARD.fullmatch(path)
|
||||
if route and route.group(2) in DECISIONS:
|
||||
return lambda body: self.answer(lambda: hub.verdict(route.group(1), DECISIONS[route.group(2)], body), HTTPStatus.OK)
|
||||
if item and item.group(3):
|
||||
return lambda body: self.answer(lambda: hub.settle(item.group(1), item.group(2), item.group(3), body), HTTPStatus.OK)
|
||||
if path == UPLOADS:
|
||||
return lambda body: self.answer(lambda: hub.upload(self.query(), body), HTTPStatus.CREATED)
|
||||
if path == "/api/messages":
|
||||
return lambda body: self.answer(lambda: hub.message(body), HTTPStatus.CREATED)
|
||||
if board:
|
||||
return lambda body: self.answer(lambda: hub.board(board.group(1), body), HTTPStatus.OK)
|
||||
return self.inbox_action(path)
|
||||
|
||||
def inbox_action(self, path: str) -> Callable[[bytes], None] | None:
|
||||
hub = self.server.hub
|
||||
inbox = INBOX_ROUTE.fullmatch(path)
|
||||
undo = UNDO_ROUTE.fullmatch(path)
|
||||
rule = RULE_ROUTE.fullmatch(path)
|
||||
if inbox and inbox.group(1) == "approve":
|
||||
return lambda body: self.answer(lambda: hub.approve(body), HTTPStatus.ACCEPTED)
|
||||
if path == SIGN_ROUTE:
|
||||
return lambda body: self.answer(lambda: hub.sign(body), HTTPStatus.OK)
|
||||
if path == RULE_SIGN_ROUTE:
|
||||
return lambda body: self.answer(lambda: hub.sign_rule(body), HTTPStatus.OK)
|
||||
if inbox:
|
||||
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
|
||||
if undo:
|
||||
return lambda _body: self.answer(lambda: hub.undo(undo.group(1)), HTTPStatus.OK)
|
||||
if path == "/api/rules":
|
||||
return lambda body: self.answer(lambda: hub.add_rule(body), HTTPStatus.CREATED)
|
||||
if rule:
|
||||
return lambda _body: self.answer(lambda: hub.rule_action(rule.group(1), rule.group(2)), HTTPStatus.OK)
|
||||
if path == "/api/nl":
|
||||
return lambda body: self.answer(lambda: hub.nl(body), HTTPStatus.OK)
|
||||
if path == SETTINGS_ROUTE:
|
||||
return lambda body: self.answer(lambda: hub.save_settings(body), HTTPStatus.OK)
|
||||
if path == TTS_ROUTE:
|
||||
return self.speak
|
||||
if path == STT_ROUTE:
|
||||
return self.hear
|
||||
return None
|
||||
|
||||
def posted(self, path: str) -> bool:
|
||||
return any(pattern.fullmatch(path) for pattern in (INBOX_ROUTE, UNDO_ROUTE, RULE_ROUTE, RUNNER_ROUTE))
|
||||
|
||||
def runner(self, path: str, body: bytes) -> None:
|
||||
hub = self.server.hub
|
||||
route = RUNNER_ROUTE.fullmatch(path)
|
||||
if route.group(2) == "claim":
|
||||
self.answer(lambda: hub.claim(route.group(1)), HTTPStatus.OK)
|
||||
else:
|
||||
self.answer(lambda: hub.report(route.group(1), body), HTTPStatus.OK)
|
||||
|
||||
def receiver(self, path: str, host: str) -> Callable[[bytes], None] | None:
|
||||
"""Returns:
|
||||
What handles the body once every header guard let the request
|
||||
pass, or None after a guard answered it.
|
||||
"""
|
||||
if path == "/mcp":
|
||||
return self.mcp_receiver(host)
|
||||
if path == "/api/asks":
|
||||
if self.local(host):
|
||||
return lambda body: self.answer(lambda: self.server.hub.ask(body), HTTPStatus.CREATED)
|
||||
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: local POST with the X-Kvb-Orchestrator header and no foreign origin only")
|
||||
return None
|
||||
if RUNNER_ROUTE.fullmatch(path):
|
||||
if self.client():
|
||||
return lambda body: self.runner(path, body)
|
||||
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Orchestrator header and no origin only")
|
||||
return None
|
||||
action = self.action(path)
|
||||
if action is None:
|
||||
self.send_text(HTTPStatus.NOT_FOUND, "not found")
|
||||
elif not self.guarded(host):
|
||||
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: same-origin POST with the X-Kvb-Orchestrator header only")
|
||||
elif path == STT_ROUTE and self.headers.get_content_type() != WAV_TYPE:
|
||||
self.send_text(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, f"use {WAV_TYPE}")
|
||||
else:
|
||||
return action
|
||||
return None
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlsplit(self.path).path
|
||||
length = self.body_length({UPLOADS: UPLOAD_LIMIT, STT_ROUTE: STT_LIMIT}.get(path, BODY_LIMIT))
|
||||
if length is None:
|
||||
return
|
||||
host = self.host()
|
||||
if host is None:
|
||||
self.send_text(HTTPStatus.FORBIDDEN, "forbidden host")
|
||||
return
|
||||
receive = self.receiver(path, host)
|
||||
if receive is not None:
|
||||
receive(self.rfile.read(length))
|
||||
|
||||
def index(self) -> None:
|
||||
nonce = secrets.token_urlsafe(18)
|
||||
page, csp = self.server.hub.render(nonce)
|
||||
headers = {"Content-Security-Policy": csp, "X-Frame-Options": "DENY"}
|
||||
self.send(HTTPStatus.OK, "text/html; charset=utf-8", page.encode("utf-8"), headers)
|
||||
|
||||
def stored(self, review_id: str, name: str) -> None:
|
||||
suffix, content_type, csp = STORED[name]
|
||||
reviews = self.server.hub.reviews
|
||||
path = reviews.stored(review_id, suffix)
|
||||
if reviews.get(review_id) is None or not path.is_file():
|
||||
self.send_text(HTTPStatus.NOT_FOUND, "not found")
|
||||
return
|
||||
self.attempt(path.read_bytes, lambda data: self.send(HTTPStatus.OK, content_type, data, {"Content-Security-Policy": csp}))
|
||||
|
||||
def mcp_receiver(self, host: str) -> Callable[[bytes], None] | None:
|
||||
origins = self.headers.get_all("Origin")
|
||||
if origins is not None and origins != [f"http://{host}"]:
|
||||
self.send_text(HTTPStatus.FORBIDDEN, "foreign origin")
|
||||
elif self.headers.get_content_type() != "application/json":
|
||||
self.send_text(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "use application/json")
|
||||
else:
|
||||
return self.mcp
|
||||
return None
|
||||
|
||||
def mcp(self, body: bytes) -> None:
|
||||
status, reply = self.server.hub.rpc(body)
|
||||
if reply is not None:
|
||||
self.send_json(status, reply)
|
||||
return
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
|
||||
from .approving import Approving
|
||||
from .asking import Asking
|
||||
from .batches import Pending
|
||||
from .engines import Engines, Speech
|
||||
from .events import Log, Watch
|
||||
from .inbox import Inbox
|
||||
from .jobs import Jobs
|
||||
from .language import Language, Model
|
||||
from .ledger import Ledger, Marks, Reviews
|
||||
from .mcp import Mcp
|
||||
from .messaging import Messaging
|
||||
from .names import Names
|
||||
from .reviewing import Reviewing
|
||||
from .rules import Rules
|
||||
from .settings import Settings
|
||||
from .signed import signing
|
||||
from .skill_scripts import Runner
|
||||
from .sources import Sources
|
||||
from .timeline import Timeline
|
||||
from .transcripts import Transcripts
|
||||
from .tree import Tree
|
||||
from .view import View
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Places:
|
||||
repos: Path
|
||||
worktrees: Path
|
||||
timers: Path
|
||||
|
||||
|
||||
def environment(templates: Path) -> jinja2.Environment:
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(templates),
|
||||
autoescape=True,
|
||||
undefined=jinja2.StrictUndefined,
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
env.policies["json.dumps_kwargs"] = {"sort_keys": False}
|
||||
return env
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
|
||||
council: Path
|
||||
pages: Path
|
||||
public_port: int
|
||||
reviews: Reviews
|
||||
asks: Ledger
|
||||
decisions: Ledger
|
||||
messages: Ledger
|
||||
uploads: Path
|
||||
host_state: Path
|
||||
transcripts: Transcripts
|
||||
scripts: Runner
|
||||
templates: jinja2.Environment
|
||||
rules: Ledger
|
||||
jobs: Ledger
|
||||
marks: Marks
|
||||
log: Log
|
||||
batches: Pending
|
||||
names: Names
|
||||
watch: Watch
|
||||
tree: Tree
|
||||
model: Model
|
||||
timers: Path
|
||||
settings: Settings
|
||||
speech: Speech
|
||||
approvers: Path
|
||||
|
||||
def hosts(self) -> tuple[str, str]:
|
||||
return f"127.0.0.1:{self.public_port}", f"localhost:{self.public_port}"
|
||||
|
||||
def url(self) -> str:
|
||||
return f"{signing.origin_of(self.public_port)}/"
|
||||
|
||||
|
||||
def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_state: Path, transcripts: Path, scripts: Runner, templates: Path, places: Places, model: Model, engines: Engines, approvers: Path) -> Hub:
|
||||
uploads = state / "uploads"
|
||||
uploads.mkdir(exist_ok=True)
|
||||
return Hub(
|
||||
approvers=approvers,
|
||||
speech=Speech(engines, state / "tts"),
|
||||
rules=Ledger(state / "rules.json"),
|
||||
jobs=Ledger(state / "jobs.json"),
|
||||
marks=Marks(state / "marks.json"),
|
||||
log=Log(state / "log.jsonl"),
|
||||
batches=Pending(),
|
||||
names=Names(state / "names.json"),
|
||||
settings=Settings(state / "settings.json"),
|
||||
watch=Watch(),
|
||||
tree=Tree(repos=places.repos, worktrees=places.worktrees, home=state, lock=threading.Lock(), cached=None, scanned=-math.inf),
|
||||
model=model,
|
||||
timers=places.timers,
|
||||
council=council,
|
||||
pages=pages,
|
||||
public_port=public_port,
|
||||
reviews=Reviews(state),
|
||||
asks=Ledger(state / "asks.json"),
|
||||
decisions=Ledger(state / "decisions.json"),
|
||||
messages=Ledger(state / "messages.json"),
|
||||
uploads=uploads,
|
||||
host_state=host_state,
|
||||
transcripts=Transcripts(transcripts),
|
||||
scripts=scripts,
|
||||
templates=environment(templates),
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .approving import INBOX_KINDS
|
||||
from .errors import ToolError
|
||||
from .fields import decode, expect
|
||||
from .names import Directory
|
||||
from .records import digest, iso_epoch
|
||||
from .register import operator_rows, runnable
|
||||
from .signed import signing
|
||||
|
||||
SNOOZE_LIMIT = 8 * 86400
|
||||
|
||||
|
||||
class Inbox:
|
||||
def inbox(self, now: float, tasks: dict[str, dict], directory: Directory) -> list[dict]:
|
||||
base = {"agent": "", "detail": "", "green": False, "gate": "", "score": "", "warnings": [], "options": [], "recommended": "", "command": "", "directory": "", "sha256": "", "runnable": False}
|
||||
items = []
|
||||
for review in self.reviews.pending():
|
||||
created = iso_epoch(review["created"]) or now
|
||||
gate = review.get("gate", "")
|
||||
fields = {"title": review["title"], "detail": review["branch"], "green": gate == "pass", "gate": gate, "score": review.get("score", ""), "directory": review["worktree"]}
|
||||
items.append({**base, "kind": "review", "id": review["id"], "ts": created, **fields})
|
||||
for ask in (ask for ask in self.asks.pending() if ask["expires"] > now):
|
||||
warnings = signing.warnings(ask["command"])
|
||||
fields = {"title": f"{ask['tool']}: {ask['command'][:200]}", "agent": directory.now(ask["session"]), "green": not warnings, "warnings": warnings}
|
||||
items.append({**base, "kind": "ask", "id": ask["id"], "ts": ask["created_ts"], **fields, "command": ask["command"], "directory": ask["cwd"]})
|
||||
for decision in self.decisions.pending():
|
||||
recommended = decision.get("recommended", "")
|
||||
fields = {"title": decision["question"][:300], "agent": decision["agent"], "green": recommended != "", "options": decision["options"], "recommended": recommended}
|
||||
items.append({**base, "kind": "decision", "id": decision["id"], "ts": decision["created_ts"], **fields})
|
||||
held = self.open_jobs()
|
||||
for row in (row for row in operator_rows(tasks) if row["task"] not in held):
|
||||
ok = runnable(row)
|
||||
fields = {"title": row["command"], "detail": row["why"], "command": row["command"], "directory": row["directory"], "sha256": digest(row["command"]) if ok else "", "runnable": ok}
|
||||
items.append({**base, "kind": "operator", "id": row["task"], "ts": self.watch.first(row["task"], now), **fields})
|
||||
for item in items:
|
||||
mark = self.marks.get(f"{item['kind']}:{item['id']}")
|
||||
item.update(pinned=mark["pinned"], until=mark["until"] if mark["until"] > now else 0.0, held=self.batches.holds(item["kind"], item["id"]))
|
||||
return sorted(items, key=lambda item: (not item["pinned"], -item["ts"], item["id"]))
|
||||
|
||||
def mark(self, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("kind", "id", "until", "pinned"))
|
||||
kind_name, until, pinned = payload["kind"], payload["until"], payload["pinned"]
|
||||
if kind_name not in INBOX_KINDS or not isinstance(payload["id"], str) or not 0 < len(payload["id"]) <= 200:
|
||||
raise ToolError(f"kind must be one of {', '.join(INBOX_KINDS)} and id an item id")
|
||||
now = time.time()
|
||||
if isinstance(until, bool) or not isinstance(until, int | float) or not (until == 0 or now < until <= now + SNOOZE_LIMIT):
|
||||
raise ToolError("until must be 0 or a moment within the next 8 days")
|
||||
if not isinstance(pinned, bool):
|
||||
raise ToolError("pinned must be true or false")
|
||||
mark = {"until": float(until), "pinned": pinned}
|
||||
self.marks.put(f"{kind_name}:{payload['id']}", mark)
|
||||
return {"kind": kind_name, "id": payload["id"], **mark}
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .errors import Conflict, Missing, ToolError
|
||||
from .fields import bounded, decode, expect
|
||||
from .register import OPERATOR, REGISTER, operator_rows, read_register, runnable
|
||||
from .signed import operator_signed
|
||||
from .skill_scripts import OPERATOR_SCRIPT
|
||||
|
||||
JOB_OPEN = ("queued", "running")
|
||||
RUN_OUTPUT = 64 << 10
|
||||
|
||||
|
||||
def runner_job(job: dict) -> dict:
|
||||
return {**{key: job[key] for key in ("id", "task", "command", "directory", "sha256")}, "proof": job.get("proof")}
|
||||
|
||||
|
||||
class Jobs:
|
||||
def open_jobs(self) -> set[str]:
|
||||
return {job["task"] for job in self.jobs.listed() if job["status"] in JOB_OPEN}
|
||||
|
||||
def queue_job(self, item: dict, proof: dict | None) -> str:
|
||||
row = next((row for row in operator_rows(read_register(self.council / REGISTER)) if row["task"] == item["id"]), None)
|
||||
if row is None or not runnable(row) or operator_signed(row) != item["signed"]:
|
||||
return "verweigert: die Zeile hat sich seit der Freigabe geändert"
|
||||
if item["id"] in self.open_jobs():
|
||||
return "läuft schon"
|
||||
job = {"task": row["task"], "command": row["command"], "directory": row["directory"], "sha256": item["sha256"], "status": "queued", "proof": proof}
|
||||
self.jobs.add({**job, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})
|
||||
return "an den Runner übergeben"
|
||||
|
||||
def runner_jobs(self) -> dict:
|
||||
return {"jobs": [runner_job(job) for job in self.jobs.listed() if job["status"] == "queued"]}
|
||||
|
||||
def claim(self, job_id: str) -> dict:
|
||||
job, changed = self.jobs.transition(job_id, "queued", {"status": "running", "started": time.time()})
|
||||
if job is None:
|
||||
raise Missing(job_id)
|
||||
if not changed:
|
||||
raise Conflict({"id": job_id, "status": job["status"]})
|
||||
return runner_job(job)
|
||||
|
||||
def report(self, job_id: str, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("output", "code", "refused"))
|
||||
output, code, refused = bounded(payload, "output", RUN_OUTPUT), payload["code"], payload["refused"]
|
||||
if not isinstance(refused, bool) or (code is not None and (isinstance(code, bool) or not isinstance(code, int))):
|
||||
raise ToolError("code must be a whole number or null and refused true or false")
|
||||
record = self.jobs.get(job_id)
|
||||
if record is None:
|
||||
raise Missing(job_id)
|
||||
if record["status"] != "running":
|
||||
raise Conflict({"id": job_id, "status": record["status"]})
|
||||
if code is None and not refused:
|
||||
self.jobs.change(job_id, {"output": output})
|
||||
return {"id": job_id, "status": "running", "code": None}
|
||||
status = "refused" if refused else "done" if code == 0 else "failed"
|
||||
job, changed = self.jobs.transition(job_id, "running", {"status": status, "code": code, "output": output, "finished": time.time()})
|
||||
if not changed:
|
||||
raise Conflict({"id": job_id, "status": job["status"]})
|
||||
runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if status == "done" else []
|
||||
tail = "\n".join(output.splitlines()[-20:])
|
||||
self.log.add({"kind": "run", "title": f"{status}: {job['command'][:200]}", "text": tail, "ref": {"job": job_id, "task": job["task"], "code": code, "status": status}})
|
||||
return {"id": job_id, "status": status, "code": code, "runs": runs}
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .council import NEWS, ordered, read_claims
|
||||
from .errors import ToolError
|
||||
from .events import VIEWS
|
||||
from .fields import bounded, decode, expect, message_text
|
||||
from .records import read_jsonl
|
||||
from .register import REGISTER, priority, read_register
|
||||
|
||||
NL_LIMIT = 2000
|
||||
NL_ACTION_LIMIT = 20
|
||||
LLM_TIMEOUT = 60
|
||||
LLM_REPLY = 1 << 20
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
LLM_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect)
|
||||
SCREENS = ("stream", "inbox", "rules", "agents", "artifacts")
|
||||
IDS = {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "Ids from items."}
|
||||
NL_ACTIONS = [
|
||||
{"name": "approve", "description": "Approve inbox items; a decision takes its recommended option.", "parameters": {"ids": IDS}},
|
||||
{"name": "reject", "description": "Reject reviews or deny asks.", "parameters": {"ids": IDS}},
|
||||
{"name": "choose", "description": "Pick an option label of a decision.", "parameters": {"id": {"type": "string"}, "label": {"type": "string"}}},
|
||||
{"name": "snooze", "description": "Hide inbox items for some hours.", "parameters": {"ids": IDS, "hours": {"type": "number", "minimum": 1, "maximum": 192}}},
|
||||
{"name": "pin", "description": "Pin inbox items to the top.", "parameters": {"ids": IDS}},
|
||||
{"name": "view", "description": "Show a saved view of the stream.", "parameters": {"name": {"type": "string", "enum": list(VIEWS)}}},
|
||||
{"name": "open", "description": "Switch the screen.", "parameters": {"view": {"type": "string", "enum": list(SCREENS)}}},
|
||||
{"name": "message", "description": "Radio a direct message to an agent.", "parameters": {"to": {"type": "string"}, "text": {"type": "string"}}},
|
||||
{"name": "filter", "description": "Search the stream for a text.", "parameters": {"q": {"type": "string"}}},
|
||||
]
|
||||
|
||||
|
||||
def nl_messages(text: str, items: list[dict], agents: list[str]) -> list[dict]:
|
||||
schemas = [{"name": action["name"], "description": action["description"], "arguments": action["parameters"]} for action in NL_ACTIONS]
|
||||
system = (
|
||||
"You turn one command of the Agent Orchestrator operator into hub actions. Reply with exactly one JSON object "
|
||||
'{"actions": [{"name": "<action>", "arguments": {...}}]} and nothing else. Every action has one of these '
|
||||
f"names, and its arguments object holds exactly the listed properties: {json.dumps(schemas, ensure_ascii=False)}. "
|
||||
'Use only ids from items and names from agents. Reply {"actions": []} when nothing fits.'
|
||||
)
|
||||
user = json.dumps({"command": text, "items": items, "agents": agents}, ensure_ascii=False)
|
||||
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
||||
|
||||
|
||||
def nl_ids(arguments: dict, items: dict[str, dict]) -> list[dict]:
|
||||
ids = arguments["ids"]
|
||||
if not isinstance(ids, list) or not ids or not all(isinstance(ident, str) and ident in items for ident in ids):
|
||||
raise ToolError("ids must name inbox items")
|
||||
if len(set(ids)) != len(ids):
|
||||
raise ToolError("ids must be distinct")
|
||||
return [items[ident] for ident in ids]
|
||||
|
||||
|
||||
def nl_item(item: dict, verb: str, label: str) -> dict:
|
||||
return {"kind": item["kind"], "id": item["id"], "verb": verb, "label": label, "sha256": item["sha256"]}
|
||||
|
||||
|
||||
def nl_action(raw: object, items: dict[str, dict], agents: list[str]) -> dict:
|
||||
if not isinstance(raw, dict) or set(raw) != {"name", "arguments"} or not isinstance(raw["arguments"], dict):
|
||||
raise ToolError("an action must be an object with name and arguments")
|
||||
schema = next((action for action in NL_ACTIONS if action["name"] == raw["name"]), None)
|
||||
if schema is None:
|
||||
raise ToolError(f"unknown action {str(raw['name'])[:40]!r}")
|
||||
name, arguments = schema["name"], raw["arguments"]
|
||||
expect(arguments, tuple(schema["parameters"]))
|
||||
if name in ("approve", "reject"):
|
||||
picked = nl_ids(arguments, items)
|
||||
if name == "reject" and any(item["kind"] not in ("review", "ask") for item in picked):
|
||||
raise ToolError("reject takes reviews and asks only")
|
||||
if name == "approve" and any(item["kind"] == "decision" and not item["recommended"] for item in picked):
|
||||
raise ToolError("a decision without a recommended option needs choose")
|
||||
if name == "approve" and any(item["kind"] == "operator" and not item["runnable"] for item in picked):
|
||||
raise ToolError("that operator row holds no command to run")
|
||||
chosen = [nl_item(item, "approve" if name == "approve" else "reject", item["recommended"] if item["kind"] == "decision" else "") for item in picked]
|
||||
lines = [f"{item['kind']} {item['id']}: {item['command'] if item['kind'] == 'operator' else item['title']}" for item in picked]
|
||||
verb = "Freigeben" if name == "approve" else "Ablehnen"
|
||||
return {"name": name, "summary": f"{verb}:\n" + "\n".join(lines), "args": {"items": chosen}}
|
||||
if name == "choose":
|
||||
item = items.get(arguments["id"]) if isinstance(arguments["id"], str) else None
|
||||
if item is None or item["kind"] != "decision" or arguments["label"] not in [option["label"] for option in item["options"]]:
|
||||
raise ToolError("choose takes a decision id and one of its option labels")
|
||||
return {"name": name, "summary": f"Entscheiden: {item['title']} → {arguments['label']}", "args": {"items": [nl_item(item, "approve", arguments["label"])]}}
|
||||
if name in ("snooze", "pin"):
|
||||
picked = nl_ids(arguments, items)
|
||||
hours = arguments.get("hours")
|
||||
if name == "snooze" and (isinstance(hours, bool) or not isinstance(hours, int | float) or not 1 <= hours <= 192):
|
||||
raise ToolError("hours must be between 1 and 192")
|
||||
keys = [{"kind": item["kind"], "id": item["id"]} for item in picked]
|
||||
words = f"Zurückstellen ({hours} h)" if name == "snooze" else "Anheften"
|
||||
return {"name": name, "summary": f"{words}: " + ", ".join(item["title"][:80] for item in picked), "args": {"keys": keys, "hours": hours}}
|
||||
if name == "view":
|
||||
if arguments["name"] not in VIEWS:
|
||||
raise ToolError("unknown view")
|
||||
return {"name": name, "summary": f"Ansicht: {VIEWS[arguments['name']]['label']}", "args": {"name": arguments["name"]}}
|
||||
if name == "open":
|
||||
if arguments["view"] not in SCREENS:
|
||||
raise ToolError("unknown screen")
|
||||
return {"name": name, "summary": f"Öffnen: {arguments['view']}", "args": {"view": arguments["view"]}}
|
||||
if name == "message":
|
||||
text = message_text(arguments, "text")
|
||||
if arguments["to"] not in agents:
|
||||
raise ToolError("to must name a known agent")
|
||||
return {"name": name, "summary": f"Funk an {arguments['to']}: {text}", "args": {"to": arguments["to"], "text": text}}
|
||||
query = bounded(arguments, "q", 200)
|
||||
return {"name": name, "summary": f"Suchen: {query}", "args": {"q": query}}
|
||||
|
||||
|
||||
def nl_actions(reply: str, items: list[dict], agents: list[str]) -> dict:
|
||||
text = reply.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except (ValueError, RecursionError):
|
||||
raise ToolError("the model answered no JSON object") from None
|
||||
if not isinstance(data, dict) or not isinstance(data.get("actions"), list):
|
||||
raise ToolError("the model answered no actions list")
|
||||
known = {item["id"]: item for item in items}
|
||||
accepted, rejected = [], []
|
||||
for raw in data["actions"][:NL_ACTION_LIMIT]:
|
||||
try:
|
||||
accepted.append(nl_action(raw, known, agents))
|
||||
except ToolError as error:
|
||||
rejected.append({"action": json.dumps(raw, ensure_ascii=False)[:300], "reason": str(error)})
|
||||
return {"actions": accepted, "rejected": rejected}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Model:
|
||||
url: str
|
||||
model: str
|
||||
key: str
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.url and self.model)
|
||||
|
||||
def complete(self, messages: list[dict]) -> str:
|
||||
target = self.url.rstrip("/")
|
||||
target = target if target.endswith("/chat/completions") else f"{target}/chat/completions"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.key:
|
||||
headers["Authorization"] = f"Bearer {self.key}"
|
||||
body = json.dumps({"model": self.model, "messages": messages, "temperature": 0, "response_format": {"type": "json_object"}}).encode()
|
||||
request = urllib.request.Request(target, data=body, headers=headers, method="POST")
|
||||
try:
|
||||
with LLM_OPENER.open(request, timeout=LLM_TIMEOUT) as response:
|
||||
data = json.loads(response.read(LLM_REPLY))
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
except (OSError, ValueError, KeyError, IndexError, TypeError) as error:
|
||||
raise ToolError(f"the model endpoint failed: {error}") from None
|
||||
if not isinstance(content, str):
|
||||
raise ToolError("the model endpoint answered no text")
|
||||
return content
|
||||
|
||||
|
||||
def read_key(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
class Language:
|
||||
def nl(self, body: bytes) -> dict:
|
||||
if not self.model.enabled():
|
||||
raise ToolError("the natural-language command line is off: set KVB_ORCHESTRATOR_LLM_URL and KVB_ORCHESTRATOR_LLM_MODEL in orchestrator/.env")
|
||||
payload = decode(body)
|
||||
expect(payload, ("text",))
|
||||
text = message_text(payload, "text")
|
||||
if len(text) > NL_LIMIT:
|
||||
raise ToolError(f"text must be at most {NL_LIMIT} characters")
|
||||
now = time.time()
|
||||
claims = read_claims(self.council)
|
||||
tasks = read_register(self.council / REGISTER)
|
||||
directory = self.directory(claims, read_jsonl(self.council / NEWS), now)
|
||||
self.watch.see(tasks, now)
|
||||
items = self.inbox(now, tasks, directory)
|
||||
agents = ordered({claim["agent"] for claim in claims if claim["agent"]} | set(priority(tasks)))
|
||||
shown_items = [{"id": item["id"], "kind": item["kind"], "title": item["title"][:200]} for item in items]
|
||||
return nl_actions(self.model.complete(nl_messages(text, shown_items, agents)), items, agents)
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from .records import read_json, stamp, write_atomic
|
||||
|
||||
|
||||
class Ledger:
|
||||
def __init__(self, file: Path) -> None:
|
||||
self.file = file
|
||||
self.changed = threading.Condition()
|
||||
self.items: dict[str, dict] = {}
|
||||
if file.is_file():
|
||||
self.items = json.loads(file.read_text(encoding="utf-8"))
|
||||
|
||||
def fresh(self) -> str:
|
||||
with self.changed:
|
||||
item_id = secrets.token_hex(8)
|
||||
while item_id in self.items:
|
||||
item_id = secrets.token_hex(8)
|
||||
return item_id
|
||||
|
||||
def write(self, items: dict[str, dict]) -> None:
|
||||
write_atomic(self.file, json.dumps(items, indent=1, sort_keys=True).encode("utf-8"))
|
||||
self.items = items
|
||||
|
||||
def insert(self, item_id: str, fields: dict) -> dict:
|
||||
with self.changed:
|
||||
record = {**fields, "id": item_id, "created": stamp()}
|
||||
self.write({**self.items, item_id: record})
|
||||
return dict(record)
|
||||
|
||||
def add(self, fields: dict) -> dict:
|
||||
with self.changed:
|
||||
return self.insert(self.fresh(), fields)
|
||||
|
||||
def get(self, item_id: str) -> dict | None:
|
||||
with self.changed:
|
||||
record = self.items.get(item_id)
|
||||
return None if record is None else dict(record)
|
||||
|
||||
def listed(self) -> list[dict]:
|
||||
with self.changed:
|
||||
records = [dict(record) for record in self.items.values()]
|
||||
return sorted(records, key=lambda record: (record["created"], record["id"]))
|
||||
|
||||
def pending(self) -> list[dict]:
|
||||
return [record for record in self.listed() if record.get("status") == "pending"]
|
||||
|
||||
def transition(self, item_id: str, before: str, changes: dict) -> tuple[dict | None, bool]:
|
||||
with self.changed:
|
||||
record = self.items.get(item_id)
|
||||
if record is None:
|
||||
return None, False
|
||||
if record.get("status") != before:
|
||||
return dict(record), False
|
||||
updated = {**record, **changes}
|
||||
self.write({**self.items, item_id: updated})
|
||||
self.changed.notify_all()
|
||||
return dict(updated), True
|
||||
|
||||
def decide(self, item_id: str, changes: dict) -> tuple[dict | None, bool]:
|
||||
return self.transition(item_id, "pending", {**changes, "decided": stamp()})
|
||||
|
||||
def change(self, item_id: str, changes: dict) -> dict | None:
|
||||
with self.changed:
|
||||
record = self.items.get(item_id)
|
||||
if record is None:
|
||||
return None
|
||||
updated = {**record, **changes}
|
||||
self.write({**self.items, item_id: updated})
|
||||
return dict(updated)
|
||||
|
||||
def remove(self, item_id: str) -> bool:
|
||||
with self.changed:
|
||||
if item_id not in self.items:
|
||||
return False
|
||||
self.write({key: value for key, value in self.items.items() if key != item_id})
|
||||
return True
|
||||
|
||||
def wait(self, item_id: str, timeout: float) -> dict | None:
|
||||
with self.changed:
|
||||
if item_id not in self.items:
|
||||
return None
|
||||
self.changed.wait_for(lambda: self.items[item_id].get("status") != "pending", timeout)
|
||||
return dict(self.items[item_id])
|
||||
|
||||
|
||||
class Reviews(Ledger):
|
||||
def __init__(self, state: Path) -> None:
|
||||
super().__init__(state / "reviews.json")
|
||||
self.pages = state / "pages"
|
||||
self.pages.mkdir(exist_ok=True)
|
||||
|
||||
def stored(self, review_id: str, suffix: str) -> Path:
|
||||
return self.pages / f"{review_id}{suffix}"
|
||||
|
||||
def submit(self, fields: dict[str, str], page: bytes, files: list[dict]) -> dict:
|
||||
with self.changed:
|
||||
review_id = self.fresh()
|
||||
write_atomic(self.stored(review_id, ".html"), page)
|
||||
write_atomic(self.stored(review_id, ".json"), json.dumps({"files": files}).encode("utf-8"))
|
||||
return self.insert(review_id, {**fields, "files": len(files), "status": "pending", "decided": None})
|
||||
|
||||
|
||||
class Marks:
|
||||
def __init__(self, file: Path) -> None:
|
||||
self.file = file
|
||||
self.lock = threading.Lock()
|
||||
data = read_json(file)
|
||||
self.items: dict[str, dict] = data if isinstance(data, dict) else {}
|
||||
|
||||
def put(self, key: str, mark: dict) -> None:
|
||||
with self.lock:
|
||||
self.items = {**self.items, key: mark}
|
||||
write_atomic(self.file, json.dumps(self.items, sort_keys=True).encode("utf-8"))
|
||||
|
||||
def get(self, key: str) -> dict:
|
||||
with self.lock:
|
||||
return dict(self.items.get(key, {"until": 0.0, "pinned": False}))
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .records import iso_epoch, parse_line, read_json, shown
|
||||
|
||||
EDGE_READ = 256 << 10
|
||||
GONE = 86400
|
||||
|
||||
|
||||
def lifetime_stamp(ts: float | None) -> str:
|
||||
if ts is None:
|
||||
return ""
|
||||
return datetime.datetime.fromtimestamp(ts, datetime.UTC).strftime("%Y-%m-%dT%H:%MZ")
|
||||
|
||||
|
||||
def line_ts(raw: bytes) -> float | None:
|
||||
row = parse_line(raw.decode("utf-8", errors="replace"))
|
||||
return None if row is None else iso_epoch(row.get("timestamp"))
|
||||
|
||||
|
||||
def edge_times(path: Path) -> tuple[float | None, float | None, float | None]:
|
||||
try:
|
||||
info = path.stat()
|
||||
with path.open("rb") as handle:
|
||||
head = handle.read(EDGE_READ)
|
||||
handle.seek(max(info.st_size - EDGE_READ, 0))
|
||||
tail = handle.read(EDGE_READ)
|
||||
except OSError:
|
||||
return None, None, None
|
||||
first = next((ts for ts in map(line_ts, head.splitlines()) if ts is not None), None)
|
||||
last = next((ts for ts in map(line_ts, reversed(tail.splitlines())) if ts is not None), None)
|
||||
return first, last, info.st_mtime
|
||||
|
||||
|
||||
def lifetime(times: tuple[float | None, float | None, float | None], gone: bool) -> dict:
|
||||
"""Args:
|
||||
times: first entry, last entry and modification time of a transcript.
|
||||
gone: whether the agent is known to have ended.
|
||||
|
||||
Returns:
|
||||
born and died as YYYY-MM-DDTHH:MMZ in UTC, empty when unknown; died
|
||||
stays empty unless gone. since and until hold the same moments in
|
||||
epoch seconds, None when unknown or not gone.
|
||||
"""
|
||||
first, last, _ = times
|
||||
ended = last if gone else None
|
||||
return {"born": lifetime_stamp(first), "died": lifetime_stamp(ended), "since": first, "until": ended}
|
||||
|
||||
|
||||
def session_gone(beat: float | None, changed: float | None, now: float) -> bool:
|
||||
return (beat is None or now - beat > GONE) and changed is not None and now - changed > GONE
|
||||
|
||||
|
||||
def subagent_rows(root: Path, session: str) -> list[dict]:
|
||||
try:
|
||||
paths = sorted(root.glob(f"*/{session}/subagents/agent-*.meta.json"))
|
||||
except OSError:
|
||||
return []
|
||||
rows = []
|
||||
for path in paths:
|
||||
data = read_json(path)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
ident = path.name.removeprefix("agent-").removesuffix(".meta.json")
|
||||
depth = data.get("spawnDepth")
|
||||
try:
|
||||
started = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
finished = bool(data.get("finished"))
|
||||
rows.append({
|
||||
"id": ident, "type": shown(data.get("agentType")), "description": shown(data.get("description")),
|
||||
"depth": depth if isinstance(depth, int) and not isinstance(depth, bool) and depth > 0 else 1,
|
||||
"finished": finished, "tool_use": shown(data.get("toolUseId")), "started": started,
|
||||
**lifetime(edge_times(path.with_name(f"agent-{ident}.jsonl")), finished),
|
||||
})
|
||||
return sorted(rows, key=lambda row: (row["started"], row["id"]))
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
from . import NAME
|
||||
from .asking import OPTION_LIMIT
|
||||
from .errors import RpcError, ToolError
|
||||
from .fields import WAIT_CAP
|
||||
from .reviewing import GATES
|
||||
|
||||
PROTOCOLS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
||||
|
||||
|
||||
def text_property(description: str) -> dict[str, str]:
|
||||
return {"type": "string", "description": description}
|
||||
|
||||
|
||||
def tool(name: str, description: str, properties: dict[str, dict]) -> dict:
|
||||
schema = {"type": "object", "properties": properties, "required": list(properties), "additionalProperties": False}
|
||||
return {"name": name, "description": description, "inputSchema": schema}
|
||||
|
||||
|
||||
def rpc_error(request_id: object, code: int, message: str) -> dict:
|
||||
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
class Mcp:
|
||||
def tools(self) -> list[dict]:
|
||||
review_id = text_property("Review id that review_request returned.")
|
||||
wait = {"type": "number", "minimum": 0, "description": f"Seconds to wait, capped at {WAIT_CAP}."}
|
||||
option = {
|
||||
"type": "object",
|
||||
"properties": {"label": text_property("Button text, one line."), "description": text_property("What choosing it means; may be empty.")},
|
||||
"required": ["label", "description"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
return [
|
||||
tool(
|
||||
"review_request",
|
||||
"Ask the operator to approve a staged commit in the Agent Orchestrator. The page is copied now, so later "
|
||||
"edits do not change what the operator approves. Returns the review id and the hub URL.",
|
||||
{
|
||||
"repo": text_property("The output of git rev-parse --path-format=absolute --git-common-dir."),
|
||||
"worktree": text_property("The output of git rev-parse --show-toplevel."),
|
||||
"branch": text_property("The output of git symbolic-ref --short HEAD."),
|
||||
"tree": text_property("The output of git write-tree for the staged index."),
|
||||
"title": text_property("The commit subject."),
|
||||
"page": text_property(f"Absolute path of the pkgmgr review page, an .html file under {self.pages}."),
|
||||
"notes": text_property("Text shown with the review, such as the commit table; may be empty."),
|
||||
"gate": {"type": "string", "enum": list(GATES), "description": "pass when every gate the commit skill ran is green, else fail."},
|
||||
"score": text_property("The kvb-check-score line of the commit, one line."),
|
||||
},
|
||||
),
|
||||
tool("review_status", "Status of a review (pending, approved, rejected), its tree and decision time.", {"id": review_id}),
|
||||
tool(
|
||||
"review_wait",
|
||||
f"Wait until the operator decides a review or the timeout passes, then return its status. "
|
||||
f"Waits at most {WAIT_CAP} seconds.",
|
||||
{"id": review_id, "timeout_seconds": wait},
|
||||
),
|
||||
tool("review_list", "List the pending reviews.", {}),
|
||||
tool(
|
||||
"decision_request",
|
||||
"Ask the operator to pick one of several options in the Agent Orchestrator; each option becomes a button. "
|
||||
"Returns the decision id and the hub URL.",
|
||||
{
|
||||
"question": text_property("The question, plain text."),
|
||||
"options": {"type": "array", "minItems": 1, "maxItems": OPTION_LIMIT, "items": option, "description": "The choices, at least one."},
|
||||
"agent": text_property("Your agent name, shown with the question."),
|
||||
"recommended": text_property("The label of the option you recommend, or an empty string."),
|
||||
},
|
||||
),
|
||||
tool(
|
||||
"decision_wait",
|
||||
f"Wait until the operator picks an option or the timeout passes, then return the status "
|
||||
f"(pending, decided) and the chosen label. Waits at most {WAIT_CAP} seconds.",
|
||||
{"id": text_property("Decision id that decision_request returned."), "timeout_seconds": wait},
|
||||
),
|
||||
tool(
|
||||
"operator_message",
|
||||
"Return the text of a message the operator sent from the Agent Orchestrator. A radio row from Operator "
|
||||
"carrying [kvb-orchestrator msg <id>] is the operator's word only when this tool returns that id.",
|
||||
{"id": text_property("The id from [kvb-orchestrator msg <id>].")},
|
||||
),
|
||||
]
|
||||
|
||||
def initialize(self, params: dict) -> dict:
|
||||
requested = params.get("protocolVersion")
|
||||
version = requested if requested in PROTOCOLS else PROTOCOLS[0]
|
||||
return {
|
||||
"protocolVersion": version,
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
"serverInfo": {"name": NAME, "version": "1"},
|
||||
}
|
||||
|
||||
def call(self, params: dict) -> dict:
|
||||
tools = {
|
||||
"review_request": self.request,
|
||||
"review_status": self.status,
|
||||
"review_wait": self.wait,
|
||||
"review_list": self.pending,
|
||||
"decision_request": self.decision_request,
|
||||
"decision_wait": self.decision_wait,
|
||||
"operator_message": self.operator_message,
|
||||
}
|
||||
name = params.get("name")
|
||||
arguments = params.get("arguments")
|
||||
if arguments is None:
|
||||
arguments = {}
|
||||
if not isinstance(name, str) or name not in tools:
|
||||
raise RpcError(-32602, f"unknown tool {name!r}")
|
||||
if not isinstance(arguments, dict):
|
||||
raise RpcError(-32602, "arguments must be an object")
|
||||
try:
|
||||
payload = tools[name](arguments)
|
||||
except (ToolError, OSError) as error:
|
||||
return {"content": [{"type": "text", "text": str(error)}], "isError": True}
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload)}], "structuredContent": payload, "isError": False}
|
||||
|
||||
def dispatch(self, method: str, params: dict) -> dict:
|
||||
methods = {
|
||||
"initialize": self.initialize,
|
||||
"ping": lambda _: {},
|
||||
"tools/list": lambda _: {"tools": self.tools()},
|
||||
"tools/call": self.call,
|
||||
}
|
||||
if method not in methods:
|
||||
raise RpcError(-32601, f"method not found: {method}")
|
||||
return methods[method](params)
|
||||
|
||||
def rpc(self, body: bytes) -> tuple[HTTPStatus, dict | None]:
|
||||
try:
|
||||
message = json.loads(body)
|
||||
except (ValueError, RecursionError):
|
||||
return HTTPStatus.BAD_REQUEST, rpc_error(None, -32700, "parse error")
|
||||
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
|
||||
return HTTPStatus.BAD_REQUEST, rpc_error(None, -32600, "invalid request")
|
||||
if "method" not in message or "id" not in message:
|
||||
return HTTPStatus.ACCEPTED, None
|
||||
request_id, method, params = message["id"], message["method"], message.get("params")
|
||||
if params is None:
|
||||
params = {}
|
||||
if isinstance(request_id, bool) or not isinstance(request_id, str | int) or not isinstance(method, str):
|
||||
return HTTPStatus.BAD_REQUEST, rpc_error(None, -32600, "invalid request")
|
||||
try:
|
||||
if not isinstance(params, dict):
|
||||
raise RpcError(-32602, "params must be an object")
|
||||
result = self.dispatch(method, params)
|
||||
except RpcError as error:
|
||||
return HTTPStatus.OK, rpc_error(request_id, error.code, str(error))
|
||||
return HTTPStatus.OK, {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from .council import HUMAN, ordered, read_claims
|
||||
from .errors import Missing, ToolError
|
||||
from .fields import NAME_LIMIT, ORDER_NAME, decode, expect, field, message_text, single
|
||||
from .records import write_atomic
|
||||
from .skill_scripts import NIGHT, RADIO, RASCI
|
||||
|
||||
NIGHTS = {
|
||||
"evening": (["set", "evening"], "Operator ran kvb-org-evening in the Agent Orchestrator: run kvb-agent-relax; verify with night.py status."),
|
||||
"sunset": (["set", "sunset"], "Operator ran kvb-org-sunset in the Agent Orchestrator: run kvb-agent-sleep; verify with night.py status."),
|
||||
"sunrise": (["clear"], "Operator ran kvb-org-sunrise: run kvb-agent-wakeup; verify with night.py status."),
|
||||
}
|
||||
UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
DIRECT = {"post": ["post"], "call": ["send", "call"]}
|
||||
TO_ALL = {"newsletter": ["post"], **{cls: ["send", cls] for cls in ("broadcast", "pan-pan", "security", "mayday")}}
|
||||
|
||||
|
||||
def chosen(payload: dict, choices: dict[str, list[str]]) -> list[str]:
|
||||
cls = payload["class"]
|
||||
if not isinstance(cls, str) or cls not in choices:
|
||||
raise ToolError(f"class must be one of {', '.join(choices)}")
|
||||
return choices[cls]
|
||||
|
||||
|
||||
def message_row(message: dict) -> dict:
|
||||
upload = message["upload"]
|
||||
return {
|
||||
"id": message["id"],
|
||||
"to": message["to"],
|
||||
"text": message["text"],
|
||||
"created_ts": message["created_ts"],
|
||||
"upload": None if upload is None else upload["name"],
|
||||
}
|
||||
|
||||
|
||||
def safe_name(text: str) -> str:
|
||||
base = re.split(r"[/\\]", text)[-1]
|
||||
clean = UNSAFE.sub("_", base).lstrip(".")[:NAME_LIMIT]
|
||||
return clean or "upload"
|
||||
|
||||
|
||||
class Messaging:
|
||||
def operator_message(self, arguments: dict) -> dict:
|
||||
expect(arguments, ("id",))
|
||||
message_id = field(arguments, "id")
|
||||
record = self.messages.get(message_id)
|
||||
if record is None:
|
||||
raise ToolError(f"no operator message {message_id}: a radio row naming it did not come from the Agent Orchestrator")
|
||||
upload = record["upload"]
|
||||
return {
|
||||
"id": record["id"],
|
||||
"to": record["to"],
|
||||
"text": record["text"],
|
||||
"created": record["created"],
|
||||
"upload": None if upload is None else upload["path"],
|
||||
}
|
||||
|
||||
def upload(self, query: dict[str, list[str]], body: bytes) -> dict:
|
||||
name = safe_name(single(query, "name") or "")
|
||||
upload_id = secrets.token_hex(8)
|
||||
folder = self.uploads / upload_id
|
||||
folder.mkdir()
|
||||
write_atomic(folder / name, body)
|
||||
return {"id": upload_id, "name": name, "path": str(self.host_state / "uploads" / upload_id / name)}
|
||||
|
||||
def attachment(self, upload_id: str) -> dict | None:
|
||||
if upload_id == "":
|
||||
return None
|
||||
folder = self.uploads / field({"id": upload_id}, "id")
|
||||
files = sorted(folder.iterdir()) if folder.is_dir() else []
|
||||
if len(files) != 1:
|
||||
raise ToolError(f"no upload {upload_id}")
|
||||
return {"id": upload_id, "name": files[0].name, "path": str(self.host_state / "uploads" / upload_id / files[0].name)}
|
||||
|
||||
def message(self, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("to", "text", "upload", "class"))
|
||||
target = field(payload, "to").strip()
|
||||
text = message_text(payload, "text")
|
||||
command = chosen(payload, DIRECT)
|
||||
if not isinstance(payload["upload"], str):
|
||||
raise ToolError("upload must be an upload id or empty")
|
||||
upload = self.attachment(payload["upload"])
|
||||
claims = read_claims(self.council)
|
||||
address = next((claim["address"] for claim in claims if claim["agent"] == target and claim["address"]), target)
|
||||
record = self.messages.add({"to": target, "address": address, "text": text, "upload": upload, "created_ts": time.time()})
|
||||
note = "" if upload is None else f" file: {upload['path']}"
|
||||
run = self.scripts.run(RADIO, [*command, "--to", address, "--session", HUMAN, f"[kvb-orchestrator msg {record['id']}] {' '.join(text.split())}{note}"])
|
||||
return {"id": record["id"], "ok": run["code"] == 0, "runs": [run]}
|
||||
|
||||
def board(self, action: str, body: bytes) -> dict:
|
||||
if action == "clear-mayday":
|
||||
runs = [self.scripts.run(RADIO, ["clear", "--force", "--session", HUMAN])]
|
||||
elif action == "broadcast":
|
||||
payload = decode(body)
|
||||
expect(payload, ("text", "class"))
|
||||
text = message_text(payload, "text")
|
||||
command = chosen(payload, TO_ALL)
|
||||
record = self.messages.add({"to": "*", "address": "*", "text": text, "upload": None, "created_ts": time.time()})
|
||||
runs = [self.scripts.run(RADIO, [*command, "--session", HUMAN, f"[kvb-orchestrator msg {record['id']}] {' '.join(text.split())}"])]
|
||||
elif action == "order":
|
||||
payload = decode(body)
|
||||
expect(payload, ("agents",))
|
||||
names = payload["agents"]
|
||||
if not isinstance(names, list) or not names or not all(isinstance(n, str) and ORDER_NAME.fullmatch(n) for n in names):
|
||||
raise ToolError("agents must be a non-empty list of agent names without commas")
|
||||
if len(set(names)) != len(names):
|
||||
raise ToolError("agents must be distinct")
|
||||
runs = [self.scripts.run(RASCI, ["order", ",".join(names)])]
|
||||
elif action in NIGHTS:
|
||||
command, text = NIGHTS[action]
|
||||
runs = [self.scripts.run(NIGHT, [*command, "--session", HUMAN])]
|
||||
peers = ordered({claim["address"] for claim in read_claims(self.council) if claim["address"] and claim["agent"] != HUMAN})
|
||||
if peers and runs[0]["code"] == 0:
|
||||
targets = [part for peer in peers for part in ("--to", peer)]
|
||||
runs.append(self.scripts.run(RADIO, [*DIRECT["call"], *targets, "--session", HUMAN, text]))
|
||||
else:
|
||||
raise Missing(action)
|
||||
return {"ok": all(run["code"] == 0 for run in runs), "runs": runs}
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from .council import HUMAN, agent
|
||||
from .events import event
|
||||
from .records import read_json, write_atomic
|
||||
from .transcripts import STREAM_AGE
|
||||
|
||||
PSEUDO = "name:"
|
||||
TITLE_SCAN = 10.0
|
||||
SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
||||
|
||||
|
||||
class Directory:
|
||||
def __init__(self, entries: list[dict]) -> None:
|
||||
self.by_session: dict[str, list[dict]] = {}
|
||||
self.by_name: dict[str, list[str]] = {}
|
||||
for entry in entries:
|
||||
self.by_session.setdefault(entry["session"], []).append(entry)
|
||||
holders = self.by_name.setdefault(entry["name"], [])
|
||||
if entry["session"] in holders:
|
||||
holders.remove(entry["session"])
|
||||
holders.append(entry["session"])
|
||||
|
||||
def at(self, session: str, ts: float) -> str:
|
||||
own = self.by_session.get(session)
|
||||
if not own:
|
||||
return ""
|
||||
valid = [entry for entry in own if entry["first_seen"] <= ts]
|
||||
return (valid[-1] if valid else own[0])["name"]
|
||||
|
||||
def now(self, session: str) -> str:
|
||||
own = self.by_session.get(session)
|
||||
return own[-1]["name"] if own else ""
|
||||
|
||||
def find(self, name: str, ts: float) -> str:
|
||||
holders = self.by_name.get(name, [])
|
||||
real = [session for session in holders if not session.startswith(PSEUDO)]
|
||||
valid = [session for session in real if self.at(session, ts) == name]
|
||||
chosen = valid or real or holders
|
||||
return chosen[-1] if chosen else ""
|
||||
|
||||
def key(self, name: str, ts: float) -> str:
|
||||
if not name or name == HUMAN:
|
||||
return name
|
||||
return self.find(name, ts) or f"{PSEUDO}{name}"
|
||||
|
||||
def label(self, key: str, ts: float) -> str:
|
||||
return self.at(key, ts) or key.removeprefix(PSEUDO)
|
||||
|
||||
def names(self, key: str) -> list[str]:
|
||||
return list(dict.fromkeys(entry["name"] for entry in self.by_session.get(key, [])))
|
||||
|
||||
def timeline(self, key: str) -> list[dict]:
|
||||
return [{"name": entry["name"], "since": entry["first_seen"]} for entry in self.by_session.get(key, [])]
|
||||
|
||||
def aliases(self, name: str) -> set[str]:
|
||||
return set(self.by_name.get(name, []))
|
||||
|
||||
def renames(self, begin: float, end: float) -> list[dict]:
|
||||
found = []
|
||||
for session, own in self.by_session.items():
|
||||
for before, after in itertools.pairwise(own):
|
||||
if before["name"] != after["name"] and begin <= after["first_seen"] <= end:
|
||||
found.append(event({
|
||||
"id": f"rename:{session}:{after['first_seen']}", "ts": after["first_seen"], "kind": "rename", "agent": after["name"],
|
||||
"now": self.now(session), "session": session, "title": f"{before['name']} heißt jetzt {after['name']}",
|
||||
"text": f"Quelle: {after['source']}", "ref": {"before": before["name"], "after": after["name"], "source": after["source"]},
|
||||
}))
|
||||
return found
|
||||
|
||||
|
||||
class Names:
|
||||
def __init__(self, file: Path) -> None:
|
||||
self.file = file
|
||||
self.lock = threading.Lock()
|
||||
self.titled = -math.inf
|
||||
data = read_json(file)
|
||||
data = data if isinstance(data, dict) else {}
|
||||
entries, observed = data.get("entries"), data.get("observed")
|
||||
keys = {"session", "name", "first_seen", "source"}
|
||||
self.entries = [entry for entry in entries if isinstance(entry, dict) and set(entry) == keys] if isinstance(entries, list) else []
|
||||
self.observed: dict[str, str] = observed if isinstance(observed, dict) else {}
|
||||
|
||||
def observe(self, sightings: list[tuple[str, str, float | None, str]], now: float) -> None:
|
||||
with self.lock:
|
||||
changed = False
|
||||
for session, name, seen, source in sightings:
|
||||
key = f"{session}\x00{source}"
|
||||
if not session or not name or self.observed.get(key) == name:
|
||||
continue
|
||||
self.observed[key] = name
|
||||
changed = True
|
||||
history = [entry for entry in self.entries if entry["session"] == session]
|
||||
if history and history[-1]["name"] == name:
|
||||
continue
|
||||
floor = history[-1]["first_seen"] if history else -math.inf
|
||||
first = seen if seen is not None and seen > floor else max(now, floor)
|
||||
self.entries.append({"session": session, "name": name, "first_seen": first, "source": source})
|
||||
if changed:
|
||||
write_atomic(self.file, json.dumps({"entries": self.entries, "observed": self.observed}, sort_keys=True).encode("utf-8"))
|
||||
|
||||
def listed(self) -> list[dict]:
|
||||
with self.lock:
|
||||
return [dict(entry) for entry in self.entries]
|
||||
|
||||
def due(self, now: float) -> bool:
|
||||
with self.lock:
|
||||
if now - self.titled < TITLE_SCAN:
|
||||
return False
|
||||
self.titled = now
|
||||
return True
|
||||
|
||||
|
||||
def title_sightings(root: Path, claimed: set[str], now: float) -> list[tuple[str, str, float | None, str]]:
|
||||
try:
|
||||
paths = sorted(root.glob("*/*/custom-title.json"))
|
||||
except OSError:
|
||||
return []
|
||||
found = []
|
||||
for path in paths:
|
||||
session = path.parent.name
|
||||
try:
|
||||
changed = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if not SESSION_ID.fullmatch(session) or (session not in claimed and changed < now - STREAM_AGE):
|
||||
continue
|
||||
data = read_json(path)
|
||||
name = agent(data.get("customTitle") if isinstance(data, dict) else None)
|
||||
if name:
|
||||
found.append((session, name, changed, "title"))
|
||||
return found
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
import functools
|
||||
import itertools
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
|
||||
from .council import agent
|
||||
from .names import Directory
|
||||
from .register import ENDED, parties, status_of
|
||||
|
||||
HALF_LIFE = 1800.0
|
||||
INNER = 150.0
|
||||
OUTER = 300.0
|
||||
TRACK_STEPS = 12
|
||||
|
||||
|
||||
def pair_weight(weights: dict[tuple[str, str], float], first: str, second: str) -> float:
|
||||
return weights.get((min(first, second), max(first, second)), 0.0)
|
||||
|
||||
|
||||
def chain(names: list[str], weights: dict[tuple[str, str], float], activity: dict[str, float]) -> list[str]:
|
||||
left = sorted(names, key=lambda name: (name.lower(), name))
|
||||
if not left:
|
||||
return []
|
||||
current = max(left, key=lambda name: activity[name])
|
||||
order = [current]
|
||||
left.remove(current)
|
||||
while left:
|
||||
last = current
|
||||
current = max(left, key=lambda name: (pair_weight(weights, last, name), activity[name]))
|
||||
order.append(current)
|
||||
left.remove(current)
|
||||
return order
|
||||
|
||||
|
||||
def affinity(nodes: list[dict], radio: list[dict], tasks: dict[str, dict], claims: list[dict], end: float, key_of: Callable[[str], str]) -> dict:
|
||||
"""Args:
|
||||
nodes: graph nodes keyed by session; chair and session nodes take part.
|
||||
radio: rows with ts, source and targets as node keys.
|
||||
tasks: register rows; workers and role holders are names.
|
||||
claims: claims; their session is a node key.
|
||||
end: the moment the decay counts back from.
|
||||
key_of: maps a name to its node key.
|
||||
|
||||
Returns:
|
||||
orbit: {key: {angle, radius}}, closer and inner for agents that talk
|
||||
or work together recently; order: the keys around the circle.
|
||||
"""
|
||||
names = [node["id"] for node in nodes if node["kind"] in ("chair", "session")]
|
||||
activity = dict.fromkeys(names, 0.0)
|
||||
weights: dict[tuple[str, str], float] = {}
|
||||
|
||||
def link(first: str, second: str, amount: float) -> None:
|
||||
if first != second and first in activity and second in activity:
|
||||
pair = (min(first, second), max(first, second))
|
||||
weights[pair] = weights.get(pair, 0.0) + amount
|
||||
activity[first] += amount
|
||||
activity[second] += amount
|
||||
|
||||
for row in radio:
|
||||
fade = 0.5 ** (max(end - row["ts"], 0.0) / HALF_LIFE)
|
||||
if row["source"] in activity and not row["targets"]:
|
||||
activity[row["source"]] += fade
|
||||
for target in row["targets"]:
|
||||
link(row["source"], target, fade)
|
||||
places: dict[str, set[str]] = {}
|
||||
for task, row in sorted(tasks.items()):
|
||||
if status_of(row) in ENDED:
|
||||
continue
|
||||
members = {key_of(agent(row.get("worker"))), *map(key_of, parties(row))} | {claim["session"] for claim in claims if claim["office"] == task}
|
||||
members.discard("")
|
||||
for first, second in itertools.combinations(sorted(members), 2):
|
||||
link(first, second, 1.0)
|
||||
for place in (row.get("workdir"), row.get("branch")):
|
||||
if isinstance(place, str) and place.strip():
|
||||
places.setdefault(place.strip(), set()).update(members)
|
||||
for members in places.values():
|
||||
for first, second in itertools.combinations(sorted(members), 2):
|
||||
link(first, second, 1.0)
|
||||
for node in nodes:
|
||||
if node["kind"] == "subagent" and node["parent"] in activity:
|
||||
activity[node["parent"]] += 1.0
|
||||
order = chain(names, weights, activity)
|
||||
gaps = [1.0 / (1.0 + pair_weight(weights, first, second)) for first, second in zip(order, order[1:] + order[:1], strict=True)]
|
||||
total = sum(gaps) or 1.0
|
||||
top = max(activity.values(), default=0.0)
|
||||
angle = -math.pi / 2
|
||||
orbit = {}
|
||||
for name, gap in zip(order, gaps, strict=True):
|
||||
share = activity[name] / top if top > 0 else 0.0
|
||||
orbit[name] = {"angle": round(angle, 4), "radius": round(OUTER - (OUTER - INNER) * share, 2)}
|
||||
angle += 2 * math.pi * gap / total
|
||||
return {"orbit": orbit, "order": order}
|
||||
|
||||
|
||||
def track(nodes: list[dict], radio: list[dict], tasks: dict[str, dict], claims: list[dict], span: tuple[float, float], directory: Directory, steps: int) -> list[dict]:
|
||||
"""Args:
|
||||
nodes: graph nodes as affinity takes them.
|
||||
radio: rows with ts, source and targets as node keys, sorted by ts.
|
||||
tasks: register rows.
|
||||
claims: claims.
|
||||
span: the first and the last moment to sample.
|
||||
directory: resolves a name to its node key at a moment.
|
||||
steps: the intervals between the samples; 0 samples nothing.
|
||||
|
||||
Returns:
|
||||
steps + 1 samples {ts, orbit}, evenly spaced over span, each orbit the
|
||||
affinity layout of the radio heard up to its moment.
|
||||
"""
|
||||
if steps <= 0:
|
||||
return []
|
||||
start, end = span
|
||||
stamps = [row["ts"] for row in radio]
|
||||
samples = []
|
||||
for moment in [start + (end - start) * index / steps for index in range(steps)] + [end]:
|
||||
heard = radio[: bisect.bisect_right(stamps, moment)]
|
||||
layout = affinity(nodes, heard, tasks, claims, moment, functools.partial(directory.key, ts=moment))
|
||||
samples.append({"ts": moment, "orbit": layout["orbit"]})
|
||||
return samples
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .fields import LINE
|
||||
|
||||
SPEECH_LIMIT = 1000
|
||||
SPOKEN_TEXT = 500
|
||||
SPOKEN_NAME = 60
|
||||
SPOKEN_GROUP = 120
|
||||
ALARM_WORDS = {"pan-pan": "PAN-PAN", "security": "SECURITE"}
|
||||
ADDRESSED = ("post", "call", "receipt")
|
||||
CALLS = re.compile(r"^(?:(?:MAYDAY|PAN[ -]?PAN|SECURIT[EÉY])\b[\s:,.;!-]*)+", re.IGNORECASE)
|
||||
HUB_MARK = re.compile(r"\[kvb-orchestrator msg [0-9a-f]{16}\]")
|
||||
HUB_FILE = re.compile(r"\s+file: /\S*$")
|
||||
SPOKEN_LINE = re.compile(LINE.format(SPEECH_LIMIT))
|
||||
|
||||
|
||||
def clip(text: str, limit: int) -> str:
|
||||
words = " ".join(text.split())
|
||||
if len(words) <= limit:
|
||||
return words
|
||||
return words[:limit].rsplit(" ", 1)[0] or words[:limit]
|
||||
|
||||
|
||||
def spoken_text(cls: str, text: str) -> str:
|
||||
body = HUB_MARK.sub("", text)
|
||||
if body != text:
|
||||
body = HUB_FILE.sub("", body)
|
||||
if cls in ("mayday", *ALARM_WORDS):
|
||||
body = CALLS.sub("", body.strip())
|
||||
body = clip(body, SPOKEN_TEXT)
|
||||
return "" if body == "-" else body
|
||||
|
||||
|
||||
def phrase(cls: str, sender: str, called: list[str], text: str) -> str:
|
||||
"""Args:
|
||||
cls: the radio class of the row, one of council.CLASSES or receipt.
|
||||
sender: the sender's name valid at the row's time.
|
||||
called: the names of the called stations valid at the row's time.
|
||||
text: the radio text.
|
||||
|
||||
Returns:
|
||||
The row in maritime radio phraseology (SMCP style), one line of at
|
||||
most SPEECH_LIMIT characters.
|
||||
"""
|
||||
station = clip(sender, SPOKEN_NAME) or "unknown station"
|
||||
if cls == "mayday":
|
||||
head, end = f"MAYDAY MAYDAY MAYDAY, this is {station} {station} {station}", "over"
|
||||
elif cls in ALARM_WORDS:
|
||||
word = ALARM_WORDS[cls]
|
||||
head, end = f"{word} {word} {word}, all stations, this is {station}", "over"
|
||||
elif cls in ADDRESSED and called:
|
||||
group = clip(" and ".join(clip(name, SPOKEN_NAME) for name in called), SPOKEN_GROUP)
|
||||
head, end = f"{group}, {group}, this is {station}", "over"
|
||||
else:
|
||||
head, end = f"all stations, this is {station}", "out"
|
||||
return ", ".join(part for part in (head, spoken_text(cls, text), end) if part)
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def stamp() -> str:
|
||||
return datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def write_atomic(path: Path, data: bytes) -> None:
|
||||
with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}-", delete=False) as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(handle.name, path)
|
||||
|
||||
|
||||
def read_json(path: Path) -> object:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, RecursionError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_line(line: str) -> dict | None:
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except (ValueError, RecursionError):
|
||||
return None
|
||||
return row if isinstance(row, dict) else None
|
||||
|
||||
|
||||
def read_jsonl(path: Path) -> list[dict]:
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
return [row for row in map(parse_line, lines) if row is not None]
|
||||
|
||||
|
||||
def epoch(value: object) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, int | float) or not 0 <= value < 1e11:
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def iso_epoch(value: object) -> float | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=datetime.UTC)
|
||||
return parsed.timestamp()
|
||||
|
||||
|
||||
def shown(value: object) -> str:
|
||||
if value is None or value == "":
|
||||
return "-"
|
||||
return str(value)
|
||||
|
||||
|
||||
def digest(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .council import agent
|
||||
from .fields import ORDER_NAME
|
||||
from .records import read_json, shown
|
||||
|
||||
REGISTER = "rasci.json"
|
||||
OPERATOR = "operator/"
|
||||
ENDED = ("done", "stopped")
|
||||
|
||||
|
||||
def read_register(path: Path) -> dict[str, dict]:
|
||||
data = read_json(path)
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {task: row for task, row in data.items() if isinstance(row, dict)}
|
||||
|
||||
|
||||
def status_of(row: dict) -> str:
|
||||
if "status" not in row:
|
||||
return "queued"
|
||||
return str(row["status"])
|
||||
|
||||
|
||||
def holders(row: dict, letter: str) -> str:
|
||||
roles = row.get("roles")
|
||||
if not isinstance(roles, dict):
|
||||
return "-"
|
||||
names = sorted((str(p) for p, v in roles.items() if isinstance(v, str) and letter in v), key=str.lower)
|
||||
return shown(", ".join(names))
|
||||
|
||||
|
||||
def parties(row: dict) -> dict[str, str]:
|
||||
roles = row.get("roles")
|
||||
if not isinstance(roles, dict):
|
||||
return {}
|
||||
found: dict[str, str] = {}
|
||||
for party, letters in roles.items():
|
||||
name = agent(party)
|
||||
if name and isinstance(letters, str):
|
||||
found[name] = found.get(name, "") + letters
|
||||
return found
|
||||
|
||||
|
||||
def is_open(task: str, row: dict, operator: bool) -> bool:
|
||||
return task.startswith(OPERATOR) == operator and status_of(row) not in ENDED
|
||||
|
||||
|
||||
def operator_rows(tasks: dict[str, dict]) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"task": task,
|
||||
"command": shown(row.get("title")),
|
||||
"directory": shown(row.get("workdir")),
|
||||
"why": shown(row.get("description")),
|
||||
"check": shown(row.get("check")),
|
||||
"status": status_of(row),
|
||||
}
|
||||
for task, row in sorted(tasks.items())
|
||||
if is_open(task, row, True)
|
||||
]
|
||||
|
||||
|
||||
def runnable(row: dict) -> bool:
|
||||
return row["command"] != "-" and row["directory"].startswith("/") and row["check"] != "decision"
|
||||
|
||||
|
||||
def pinned(item: tuple[str, dict]) -> tuple[bool, int, str]:
|
||||
task, row = item
|
||||
prio = row.get("prio")
|
||||
if isinstance(prio, int) and not isinstance(prio, bool):
|
||||
return False, prio, task
|
||||
return True, 0, task
|
||||
|
||||
|
||||
def register_row(task: str, row: dict) -> dict:
|
||||
results = row.get("key_results")
|
||||
if not isinstance(results, list):
|
||||
results = []
|
||||
return {
|
||||
"task": task,
|
||||
"title": shown(row.get("title")),
|
||||
"status": status_of(row),
|
||||
"worker": shown(row.get("worker")),
|
||||
"responsible": holders(row, "R"),
|
||||
"accountable": holders(row, "A"),
|
||||
"objective": shown(row.get("objective")),
|
||||
"branch": shown(row.get("branch")),
|
||||
"workdir": shown(row.get("workdir")),
|
||||
"met": sum(1 for kr in results if isinstance(kr, dict) and kr.get("met") is True),
|
||||
"total": len(results),
|
||||
}
|
||||
|
||||
|
||||
def register_rows(tasks: dict[str, dict]) -> list[dict]:
|
||||
return [register_row(task, row) for task, row in sorted(tasks.items(), key=pinned) if is_open(task, row, False)]
|
||||
|
||||
|
||||
def priority(tasks: dict[str, dict]) -> list[str]:
|
||||
names: list[str] = []
|
||||
for _, row in sorted(tasks.items(), key=pinned):
|
||||
worker = row.get("worker")
|
||||
if status_of(row) not in ENDED and isinstance(worker, str) and ORDER_NAME.fullmatch(worker) and worker not in names:
|
||||
names.append(worker)
|
||||
return names
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
|
||||
DIGITS = re.compile(r"[0-9]+")
|
||||
TEXTS = ("path", "old", "add", "del", "tag", "placeholder")
|
||||
|
||||
|
||||
class ReviewPage(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.files: list[dict] = []
|
||||
self.section = ""
|
||||
self.details: list[str] = []
|
||||
self.spans: list[str] = []
|
||||
self.file: dict | None = None
|
||||
self.texts: dict[str, list[str]] = {}
|
||||
self.row = ""
|
||||
self.cells: list[str] | None = None
|
||||
self.cell: list[str] | None = None
|
||||
self.detail = False
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
values = dict(attrs)
|
||||
classes = (values.get("class") or "").split()
|
||||
first = classes[0] if classes else ""
|
||||
if tag == "section":
|
||||
self.section = values.get("id") or ""
|
||||
elif tag == "details":
|
||||
if first == "file" and "commit" not in self.details:
|
||||
self.file = {"section": self.section, "status": "", "tags": [], "lines": []}
|
||||
self.texts = {name: [] for name in TEXTS}
|
||||
self.spans = []
|
||||
self.details.append(first)
|
||||
elif self.file is None:
|
||||
return
|
||||
elif tag == "span":
|
||||
self.spans.append(first)
|
||||
if first == "badge":
|
||||
self.file["status"] = values.get("title") or ""
|
||||
elif tag == "p" and first == "placeholder":
|
||||
self.detail = True
|
||||
elif tag == "tr":
|
||||
self.row, self.cells = first, []
|
||||
elif tag == "td" and self.cells is not None:
|
||||
self.cell = []
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self.file is None:
|
||||
return
|
||||
if self.cell is not None:
|
||||
self.cell.append(data)
|
||||
elif self.detail:
|
||||
self.texts["placeholder"].append(data)
|
||||
elif self.spans and self.spans[-1] in self.texts:
|
||||
self.texts[self.spans[-1]].append(data)
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == "section":
|
||||
self.section = ""
|
||||
elif tag == "details" and self.details:
|
||||
if self.details.pop() == "file" and self.file is not None:
|
||||
self.files.append(self.finish(self.file))
|
||||
self.file = None
|
||||
elif self.file is None:
|
||||
return
|
||||
elif tag == "span" and self.spans:
|
||||
if self.spans.pop() == "tag":
|
||||
self.file["tags"].append("".join(self.texts["tag"]).strip())
|
||||
self.texts["tag"] = []
|
||||
elif tag == "p":
|
||||
self.detail = False
|
||||
elif tag == "td" and self.cell is not None and self.cells is not None:
|
||||
self.cells.append("".join(self.cell))
|
||||
self.cell = None
|
||||
elif tag == "tr" and self.cells is not None:
|
||||
self.file["lines"].append(diff_line(self.row, self.cells))
|
||||
self.cells = None
|
||||
|
||||
def finish(self, file: dict) -> dict:
|
||||
text = {name: "".join(parts).strip() for name, parts in self.texts.items()}
|
||||
counts = {name: DIGITS.search(text[name]) for name in ("add", "del")}
|
||||
numbers = {name: int(match.group()) if match else 0 for name, match in counts.items()}
|
||||
return {**file, "path": text["path"], "old": text["old"], "added": numbers["add"], "removed": numbers["del"], "detail": text["placeholder"]}
|
||||
|
||||
|
||||
def diff_line(kind_name: str, cells: list[str]) -> dict:
|
||||
if len(cells) >= 4:
|
||||
return {"kind": kind_name, "old": cells[0].strip(), "new": cells[1].strip(), "text": cells[3]}
|
||||
return {"kind": kind_name, "old": "", "new": "", "text": cells[-1].strip() if cells else ""}
|
||||
|
||||
|
||||
def review_files(page: bytes) -> list[dict]:
|
||||
parser = ReviewPage()
|
||||
parser.feed(page.decode("utf-8", errors="replace"))
|
||||
parser.close()
|
||||
return parser.files
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import Conflict, Missing, ToolError
|
||||
from .fields import WAIT_CAP, bounded, decode, expect, field, seconds
|
||||
from .review_page import review_files
|
||||
from .signed import review_signed
|
||||
|
||||
PAGE_LIMIT = 32 << 20
|
||||
NOTES_LIMIT = 100_000
|
||||
SCORE_LIMIT = 500
|
||||
GATES = ("pass", "fail")
|
||||
REQUEST = ("repo", "worktree", "branch", "tree", "title", "page", "notes", "gate", "score")
|
||||
LISTED = ("id", "created", "worktree", "branch", "tree", "title")
|
||||
STATE = ("id", "status", "tree", "decided")
|
||||
|
||||
|
||||
def found(review: dict | None, review_id: str) -> dict:
|
||||
if review is None:
|
||||
raise ToolError(f"no review {review_id}")
|
||||
return review
|
||||
|
||||
|
||||
def state_of(review: dict) -> dict:
|
||||
return {key: review[key] for key in STATE}
|
||||
|
||||
|
||||
def review_row(review: dict) -> dict:
|
||||
created = datetime.datetime.fromisoformat(review["created"]).timestamp()
|
||||
return {**{key: review[key] for key in LISTED}, "notes": review["notes"], "created_ts": created, "files": review.get("files")}
|
||||
|
||||
|
||||
class Reviewing:
|
||||
def read_page(self, page: str) -> bytes:
|
||||
path = Path(page)
|
||||
resolved = path.resolve()
|
||||
inside = ".." not in path.parts and path.is_relative_to(self.pages) and resolved.is_relative_to(self.pages.resolve())
|
||||
if not inside or path.suffix != ".html" or resolved.suffix != ".html":
|
||||
raise ToolError(f"page must be an .html file under {self.pages}")
|
||||
try:
|
||||
descriptor = os.open(resolved, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
except OSError as error:
|
||||
raise ToolError(f"page {page}: {error.strerror}") from error
|
||||
with os.fdopen(descriptor, "rb") as handle:
|
||||
if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
|
||||
raise ToolError(f"page {page} is not a regular file")
|
||||
data = handle.read(PAGE_LIMIT + 1)
|
||||
if len(data) > PAGE_LIMIT:
|
||||
raise ToolError(f"page {page} exceeds {PAGE_LIMIT} bytes")
|
||||
return data
|
||||
|
||||
def request(self, arguments: dict) -> dict:
|
||||
expect(arguments, REQUEST)
|
||||
fields = {name: field(arguments, name) for name in ("repo", "worktree", "branch", "tree", "title")}
|
||||
notes = arguments["notes"]
|
||||
if not isinstance(notes, str) or len(notes) > NOTES_LIMIT:
|
||||
raise ToolError(f"notes must be text of at most {NOTES_LIMIT} characters")
|
||||
if arguments["gate"] not in GATES:
|
||||
raise ToolError("gate must be pass or fail")
|
||||
score = " ".join(bounded(arguments, "score", SCORE_LIMIT).split())
|
||||
page = self.read_page(field(arguments, "page"))
|
||||
extra = {"notes": notes, "gate": arguments["gate"], "score": score}
|
||||
review = self.reviews.submit({**fields, "title": fields["title"].strip(), **extra}, page, review_files(page))
|
||||
self.automate("review", review, {"item": review_signed(review), "gate": review["gate"]})
|
||||
return {"id": review["id"], "status": review["status"], "url": self.url()}
|
||||
|
||||
def review_proof(self, review_id: str) -> dict:
|
||||
review = self.reviews.get(review_id)
|
||||
if review is None:
|
||||
raise Missing(review_id)
|
||||
return {**state_of(review), "proof": review.get("proof")}
|
||||
|
||||
def verdict(self, review_id: str, status: str, body: bytes) -> dict:
|
||||
review = self.reviews.get(review_id)
|
||||
if review is None:
|
||||
raise Missing(review_id)
|
||||
proof = None
|
||||
if status == "approved":
|
||||
payload = decode(body)
|
||||
expect(payload, ("proof",))
|
||||
proof = self.covered(payload["proof"], [review_signed(review)], time.time())
|
||||
decided, changed = self.reviews.decide(review_id, {"status": status, "proof": proof})
|
||||
if not changed:
|
||||
raise Conflict(state_of(decided))
|
||||
return state_of(decided)
|
||||
|
||||
def status(self, arguments: dict) -> dict:
|
||||
expect(arguments, ("id",))
|
||||
review_id = field(arguments, "id")
|
||||
return state_of(found(self.reviews.get(review_id), review_id))
|
||||
|
||||
def wait(self, arguments: dict) -> dict:
|
||||
expect(arguments, ("id", "timeout_seconds"))
|
||||
review_id = field(arguments, "id")
|
||||
timeout = seconds(arguments, "timeout_seconds", WAIT_CAP)
|
||||
return state_of(found(self.reviews.wait(review_id, timeout), review_id))
|
||||
|
||||
def pending(self, arguments: dict) -> dict:
|
||||
expect(arguments, ())
|
||||
return {"reviews": [{key: review[key] for key in LISTED} for review in self.reviews.pending()]}
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import time
|
||||
|
||||
from .errors import Conflict, Missing, ToolError
|
||||
from .fields import COMMAND_LIMIT, PATH_LIMIT, bounded, decode, expect
|
||||
from .signed import signing
|
||||
|
||||
RULE_KINDS = ("ask", "review", "decision")
|
||||
|
||||
|
||||
def rule_matches(rule: dict, kind_name: str, subject: dict, now: float) -> bool:
|
||||
if not rule["enabled"] or rule["expires"] <= now or rule["kind"] != kind_name:
|
||||
return False
|
||||
if kind_name == "decision":
|
||||
return subject["recommended"] != "" and fnmatch.fnmatchcase(subject["agent"], rule["pattern"])
|
||||
proof = rule.get("proof")
|
||||
return proof is not None and signing.rule_allows(proof["payload"], subject["item"], subject["gate"], now)
|
||||
|
||||
|
||||
def rule_fields(raw: dict, now: float) -> dict:
|
||||
kind_name, expires = raw["kind"], raw["expires"]
|
||||
if kind_name not in RULE_KINDS:
|
||||
raise ToolError(f"kind must be one of {', '.join(RULE_KINDS)}")
|
||||
tool_name = bounded(raw, "tool", 200).strip()
|
||||
if (kind_name == "ask") != bool(tool_name):
|
||||
raise ToolError("an ask rule names a tool; review and decision rules leave tool empty")
|
||||
pattern, scope = bounded(raw, "pattern", COMMAND_LIMIT), bounded(raw, "scope", PATH_LIMIT)
|
||||
if not pattern.strip() or not scope.strip() or "\n" in pattern or "\n" in scope:
|
||||
raise ToolError("pattern and scope must be one non-empty line each")
|
||||
if isinstance(expires, bool) or not isinstance(expires, int | float) or not now + 1 < expires <= now + signing.RULE_LIFETIME:
|
||||
raise ToolError("expires must be a moment within the next 366 days")
|
||||
return {"kind": kind_name, "tool": tool_name, "pattern": pattern, "scope": scope, "expires": int(expires)}
|
||||
|
||||
|
||||
class Rules:
|
||||
def sign_rule(self, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("kind", "tool", "pattern", "scope", "expires"))
|
||||
fields = rule_fields(payload, time.time())
|
||||
rule = signing.rule_payload(self.rules.fresh(), [fields["kind"]], fields["tool"], fields["pattern"], fields["scope"], fields["expires"])
|
||||
credentials = [key["id"] for key in self.keys()]
|
||||
return {"payload": rule, "challenge": signing.challenge(rule), "sign": fields["kind"] != "decision", "credentials": credentials}
|
||||
|
||||
def add_rule(self, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("rule", "assertion", "enabled"))
|
||||
rule, assertion = payload["rule"], payload["assertion"]
|
||||
now = time.time()
|
||||
try:
|
||||
signing.checked_rule(rule, now)
|
||||
except signing.Refused as error:
|
||||
raise ToolError(f"rule: {error}") from None
|
||||
if len(rule["kinds"]) != 1:
|
||||
raise ToolError("a rule names exactly one kind")
|
||||
fields = rule_fields({**{name: rule[name] for name in ("tool", "pattern", "scope", "expires")}, "kind": rule["kinds"][0]}, now)
|
||||
if fields["tool"] != rule["tool"]:
|
||||
raise ToolError("tool must not carry surrounding blanks")
|
||||
signed = fields["kind"] != "decision"
|
||||
if not (isinstance(assertion, dict) if signed else assertion is None):
|
||||
raise ToolError("an ask or review rule carries the passkey assertion; a decision rule carries null")
|
||||
if not isinstance(payload["enabled"], bool):
|
||||
raise ToolError("enabled must be true or false")
|
||||
if self.rules.get(rule["id"]) is not None:
|
||||
raise Conflict({"id": rule["id"], "status": "exists"})
|
||||
proof = {"payload": rule, "assertion": assertion} if signed else None
|
||||
return self.rules.insert(rule["id"], {**fields, "expires": float(fields["expires"]), "enabled": payload["enabled"], "created_ts": now, "proof": proof})
|
||||
|
||||
def rule_action(self, rule_id: str, verb: str) -> dict:
|
||||
if verb == "delete":
|
||||
if not self.rules.remove(rule_id):
|
||||
raise Missing(rule_id)
|
||||
return {"id": rule_id, "deleted": True}
|
||||
record = self.rules.change(rule_id, {"enabled": verb == "enable"})
|
||||
if record is None:
|
||||
raise Missing(rule_id)
|
||||
return record
|
||||
@@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .council import CLASSES
|
||||
from .errors import ToolError
|
||||
from .fields import FIELDS, NAME_LIMIT, expect
|
||||
from .records import read_json, write_atomic
|
||||
|
||||
THEMES = ("sky", "light", "dark", "custom")
|
||||
TONES = ("mayday", "review", "direct", "radio")
|
||||
COLOR = re.compile(r"#[0-9a-f]{6}")
|
||||
CLOCK = re.compile(r"(?:[01][0-9]|2[0-3]):[0-5][0-9]")
|
||||
MIDNIGHT = "24:00"
|
||||
STREAMS_LIMIT = 20
|
||||
STREAM_URL = re.compile(r"https?://[A-Za-z0-9._~:/?#\[\]@!$&()*+,;=%-]{1,2040}")
|
||||
HOSTNAME = re.compile(r"(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*")
|
||||
DEFAULT_PORTS = {"http": 80, "https": 443}
|
||||
SETTINGS = {
|
||||
"latitude": 0.0,
|
||||
"longitude": 0.0,
|
||||
"theme": "sky",
|
||||
"colors": {"top": "#0b1020", "bottom": "#2b3a67", "accent": "#8fadff"},
|
||||
"music": {"on": False, "volume": 0.4},
|
||||
"sounds": {
|
||||
"mayday": {"on": True, "volume": 0.9},
|
||||
"review": {"on": True, "volume": 0.6},
|
||||
"direct": {"on": True, "volume": 0.5},
|
||||
"radio": {"on": False, "volume": 0.25},
|
||||
},
|
||||
"notify": True,
|
||||
"streams": [],
|
||||
"speech": {"on": False, "classes": ["mayday", "pan-pan", "security"], "muted": []},
|
||||
"space": {
|
||||
"graph": {"axis": "none", "yaw": 30.0, "pitch": 20.0, "zoom": 1.0, "pan_x": 0.0, "pan_y": 0.0},
|
||||
"sky": {"azimuth": 180.0, "altitude": 0.0, "fov": 180.0},
|
||||
},
|
||||
}
|
||||
SPACE_AXES = ("none", "time", "spawn")
|
||||
SPACE_LIMITS = {
|
||||
"graph": {"yaw": (-180.0, 180.0), "pitch": (-90.0, 90.0), "zoom": (0.25, 4.0), "pan_x": (-2000.0, 2000.0), "pan_y": (-2000.0, 2000.0)},
|
||||
"sky": {"azimuth": (0.0, 360.0), "altitude": (-30.0, 85.0), "fov": (20.0, 240.0)},
|
||||
}
|
||||
MUTED_LIMIT = 200
|
||||
LEGACY_CLASSES = {"direct": ("post", "call")}
|
||||
|
||||
|
||||
def section(value: object, name: str, keys: tuple[str, ...]) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
raise ToolError(f"{name} must be an object")
|
||||
try:
|
||||
expect(value, keys)
|
||||
except ToolError as error:
|
||||
raise ToolError(f"{name}: {error}") from None
|
||||
return value
|
||||
|
||||
|
||||
def number_in(value: object, name: str, low: float, high: float) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, int | float) or not low <= value <= high:
|
||||
raise ToolError(f"{name} must be a number from {low} to {high}")
|
||||
return float(value)
|
||||
|
||||
|
||||
def flag(value: object, name: str) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise ToolError(f"{name} must be true or false")
|
||||
return value
|
||||
|
||||
|
||||
def color(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not COLOR.fullmatch(value):
|
||||
raise ToolError(f"{name} must be a colour like #1a2b3c")
|
||||
return value
|
||||
|
||||
|
||||
def stream_origin(url: object) -> str:
|
||||
if not isinstance(url, str) or not STREAM_URL.fullmatch(url):
|
||||
raise ToolError("a stream url must be an http or https URL without spaces or quotes")
|
||||
parts = urlsplit(url)
|
||||
try:
|
||||
port = parts.port
|
||||
except ValueError:
|
||||
raise ToolError("a stream url needs a valid port") from None
|
||||
host = parts.hostname or ""
|
||||
if parts.username is not None or parts.password is not None or not HOSTNAME.fullmatch(host):
|
||||
raise ToolError("a stream url needs a plain host name and no user info")
|
||||
return f"{parts.scheme}://{host}" if port in (None, DEFAULT_PORTS[parts.scheme]) else f"{parts.scheme}://{host}:{port}"
|
||||
|
||||
|
||||
def clock_minutes(value: object, name: str, end: bool) -> int:
|
||||
if end and value == MIDNIGHT:
|
||||
return 24 * 60
|
||||
if not isinstance(value, str) or not CLOCK.fullmatch(value):
|
||||
raise ToolError(f"{name} must be a time like 07:30")
|
||||
hours, minutes = value.split(":")
|
||||
return int(hours) * 60 + int(minutes)
|
||||
|
||||
|
||||
def stream_entry(raw: object, index: int) -> dict:
|
||||
name = f"streams[{index}]"
|
||||
entry = section(raw, name, ("name", "url", "from", "to", "days", "on"))
|
||||
label, days = entry["name"], entry["days"]
|
||||
if not isinstance(label, str) or not FIELDS["label"][0].fullmatch(label):
|
||||
raise ToolError(f"{name}.name must be one line of 1 to {NAME_LIMIT} characters")
|
||||
stream_origin(entry["url"])
|
||||
if clock_minutes(entry["from"], f"{name}.from", False) == clock_minutes(entry["to"], f"{name}.to", True):
|
||||
raise ToolError(f"{name} must end at another time than it starts")
|
||||
if not isinstance(days, list) or not days or len(set(map(str, days))) != len(days) or any(isinstance(day, bool) or not isinstance(day, int) or not 0 <= day <= 6 for day in days):
|
||||
raise ToolError(f"{name}.days must list distinct weekdays from 0 (Monday) to 6 (Sunday)")
|
||||
return {"name": label, "url": entry["url"], "from": entry["from"], "to": entry["to"], "days": sorted(days), "on": flag(entry["on"], f"{name}.on")}
|
||||
|
||||
|
||||
def tone_setting(raw: object, kind_name: str) -> dict:
|
||||
entry = section(raw, f"sounds.{kind_name}", ("on", "volume"))
|
||||
return {"on": flag(entry["on"], f"sounds.{kind_name}.on"), "volume": number_in(entry["volume"], f"sounds.{kind_name}.volume", 0.0, 1.0)}
|
||||
|
||||
|
||||
def distinct_texts(value: object, valid: Callable[[str], bool], limit: int) -> bool:
|
||||
if not isinstance(value, list) or len(value) > limit or not all(isinstance(item, str) and valid(item) for item in value):
|
||||
return False
|
||||
return len(set(value)) == len(value)
|
||||
|
||||
|
||||
def speech_setting(raw: object) -> dict:
|
||||
entry = section(raw, "speech", ("on", "classes", "muted"))
|
||||
classes, muted = entry["classes"], entry["muted"]
|
||||
if not distinct_texts(classes, lambda name: name in CLASSES, len(CLASSES)):
|
||||
raise ToolError(f"speech.classes must list distinct classes of {', '.join(CLASSES)}")
|
||||
if "mayday" not in classes:
|
||||
raise ToolError("speech.classes must include mayday: a MAYDAY is never quieter than anything else")
|
||||
if not distinct_texts(muted, FIELDS["agent"][0].fullmatch, MUTED_LIMIT):
|
||||
raise ToolError(f"speech.muted must list at most {MUTED_LIMIT} distinct agent keys, each one line of 1 to 200 characters")
|
||||
return {"on": flag(entry["on"], "speech.on"), "classes": [name for name in CLASSES if name in classes], "muted": sorted(muted)}
|
||||
|
||||
|
||||
def space_setting(raw: object) -> dict:
|
||||
entry = section(raw, "space", tuple(SETTINGS["space"]))
|
||||
graph = section(entry["graph"], "space.graph", tuple(SETTINGS["space"]["graph"]))
|
||||
sky = section(entry["sky"], "space.sky", tuple(SETTINGS["space"]["sky"]))
|
||||
if not isinstance(graph["axis"], str) or graph["axis"] not in SPACE_AXES:
|
||||
raise ToolError(f"space.graph.axis must be one of {', '.join(SPACE_AXES)}")
|
||||
views = {"graph": graph, "sky": sky}
|
||||
numbers = {view: {key: number_in(views[view][key], f"space.{view}.{key}", low, high) for key, (low, high) in limits.items()} for view, limits in SPACE_LIMITS.items()}
|
||||
return {"graph": {"axis": graph["axis"], **numbers["graph"]}, "sky": numbers["sky"]}
|
||||
|
||||
|
||||
def valid_settings(raw: object) -> dict:
|
||||
data = section(raw, "settings", tuple(SETTINGS))
|
||||
colors = section(data["colors"], "colors", tuple(SETTINGS["colors"]))
|
||||
music = section(data["music"], "music", ("on", "volume"))
|
||||
sounds = section(data["sounds"], "sounds", TONES)
|
||||
if not isinstance(data["theme"], str) or data["theme"] not in THEMES:
|
||||
raise ToolError(f"theme must be one of {', '.join(THEMES)}")
|
||||
if not isinstance(data["streams"], list) or len(data["streams"]) > STREAMS_LIMIT:
|
||||
raise ToolError(f"streams must be a list of at most {STREAMS_LIMIT} entries")
|
||||
return {
|
||||
"latitude": number_in(data["latitude"], "latitude", -90.0, 90.0),
|
||||
"longitude": number_in(data["longitude"], "longitude", -180.0, 180.0),
|
||||
"theme": data["theme"],
|
||||
"colors": {key: color(colors[key], f"colors.{key}") for key in SETTINGS["colors"]},
|
||||
"music": {"on": flag(music["on"], "music.on"), "volume": number_in(music["volume"], "music.volume", 0.0, 1.0)},
|
||||
"sounds": {kind_name: tone_setting(sounds[kind_name], kind_name) for kind_name in TONES},
|
||||
"notify": flag(data["notify"], "notify"),
|
||||
"streams": [stream_entry(entry, index) for index, entry in enumerate(data["streams"])],
|
||||
"speech": speech_setting(data["speech"]),
|
||||
"space": space_setting(data["space"]),
|
||||
}
|
||||
|
||||
|
||||
def migrated(stored: object) -> object:
|
||||
speech = stored.get("speech") if isinstance(stored, dict) else None
|
||||
classes = speech.get("classes") if isinstance(speech, dict) else None
|
||||
if not isinstance(classes, list) or not any(name in LEGACY_CLASSES for name in classes):
|
||||
return stored
|
||||
renamed = [new for name in classes for new in LEGACY_CLASSES.get(name, (name,))]
|
||||
return {**stored, "speech": {**speech, "classes": list(dict.fromkeys(renamed))}}
|
||||
|
||||
|
||||
def media_origins(settings: dict) -> list[str]:
|
||||
return sorted({stream_origin(stream["url"]) for stream in settings["streams"]})
|
||||
|
||||
|
||||
class Settings:
|
||||
def __init__(self, file: Path) -> None:
|
||||
self.file = file
|
||||
self.lock = threading.Lock()
|
||||
stored = migrated(read_json(file))
|
||||
try:
|
||||
self.current = valid_settings({**SETTINGS, **stored} if isinstance(stored, dict) else stored)
|
||||
except ToolError:
|
||||
self.current = valid_settings(SETTINGS)
|
||||
|
||||
def get(self) -> dict:
|
||||
with self.lock:
|
||||
return copy.deepcopy(self.current)
|
||||
|
||||
def put(self, raw: object) -> dict:
|
||||
settings = valid_settings(raw)
|
||||
with self.lock:
|
||||
write_atomic(self.file, json.dumps(settings, indent=1, sort_keys=True).encode("utf-8"))
|
||||
self.current = settings
|
||||
return copy.deepcopy(settings)
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
SIGNING_SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_signing", Path(__file__).resolve().parents[1] / "client" / "signing.py")
|
||||
signing = importlib.util.module_from_spec(SIGNING_SPEC)
|
||||
SIGNING_SPEC.loader.exec_module(signing)
|
||||
|
||||
|
||||
def ask_signed(ask: dict) -> dict:
|
||||
return signing.ask_item(ask["id"], ask["tool"], ask["command"], ask["cwd"], ask["session"])
|
||||
|
||||
|
||||
def review_signed(review: dict) -> dict:
|
||||
return signing.review_item(review["id"], review.get("repo", ""), review["worktree"], review["branch"], review["tree"])
|
||||
|
||||
|
||||
def operator_signed(row: dict) -> dict:
|
||||
return signing.operator_item(row["task"], row["command"], row["directory"])
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
SCRIPT_TIMEOUT = 60
|
||||
OUTPUT_LIMIT = 4000
|
||||
SAFE_PATH = "/usr/local/bin:/usr/bin:/bin"
|
||||
RADIO = "kvb-org-radio/scripts/radio.py"
|
||||
RASCI = "kvb-org-rasci/scripts/rasci.py"
|
||||
NIGHT = "kvb-org-sunset/scripts/night.py"
|
||||
OPERATOR_SCRIPT = "kvb-org-operator/scripts/operator.py"
|
||||
|
||||
|
||||
class Runner(Protocol):
|
||||
def run(self, script: str, args: list[str]) -> dict: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scripts:
|
||||
skills: Path
|
||||
council: Path
|
||||
home: Path
|
||||
|
||||
def run(self, script: str, args: list[str]) -> dict:
|
||||
command = " ".join([Path(script).name, *args])
|
||||
env = {"PATH": SAFE_PATH, "HOME": str(self.home), "AGENTS_COUNCIL_ROOT": str(self.council), "PYTHONDONTWRITEBYTECODE": "1"}
|
||||
try:
|
||||
done = subprocess.run(
|
||||
[sys.executable, str(self.skills / script), *args],
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=SCRIPT_TIMEOUT,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
return {"command": command, "code": -1, "stdout": "", "stderr": str(error)}
|
||||
return {"command": command, "code": done.returncode, "stdout": done.stdout[-OUTPUT_LIMIT:], "stderr": done.stderr[-OUTPUT_LIMIT:]}
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .council import HUMAN, agent, read_claims
|
||||
from .crons import cron_state, queue_rows, timer_rows
|
||||
from .errors import ToolError
|
||||
from .fields import single
|
||||
from .lifetimes import edge_times, lifetime, session_gone, subagent_rows
|
||||
from .names import PSEUDO, SESSION_ID, Directory, title_sightings
|
||||
from .records import epoch, iso_epoch
|
||||
from .register import REGISTER, read_register
|
||||
from .tree import assign
|
||||
|
||||
|
||||
class Sources:
|
||||
def subagents(self, query: dict[str, list[str]]) -> dict:
|
||||
session = single(query, "session") or ""
|
||||
if not SESSION_ID.fullmatch(session):
|
||||
raise ToolError("session must be a session id")
|
||||
now = time.time()
|
||||
beats = [claim["beat"] for claim in read_claims(self.council) if claim["session"] == session and claim["beat"] is not None]
|
||||
try:
|
||||
path = next(iter(sorted(self.transcripts.root.glob(f"*/{session}.jsonl"))), None)
|
||||
except OSError:
|
||||
path = None
|
||||
times = edge_times(path) if path is not None else (None, None, None)
|
||||
life = lifetime(times, session_gone(max(beats, default=None), times[2], now))
|
||||
return {"session": session, **life, "subagents": subagent_rows(self.transcripts.root, session)}
|
||||
|
||||
def directory(self, claims: list[dict], news: list[dict], now: float) -> Directory:
|
||||
claimed = {claim["session"] for claim in claims}
|
||||
sightings = title_sightings(self.transcripts.root, claimed, now) if self.names.due(now) else []
|
||||
sightings += [(claim["session"], claim["agent"], iso_epoch(claim["since"]), "claim") for claim in claims if claim["agent"] and claim["session"] != "-"]
|
||||
self.names.observe(sorted(sightings, key=lambda sighting: (sighting[2] is None, sighting[2] or 0.0)), now)
|
||||
known = {entry["name"] for entry in self.names.listed()}
|
||||
radio = []
|
||||
for row in news:
|
||||
name, ts = agent(row.get("from")), epoch(row.get("ts"))
|
||||
if name and name != HUMAN and name not in known and ts is not None:
|
||||
radio.append((f"{PSEUDO}{name}", name, ts, "radio"))
|
||||
known.add(name)
|
||||
self.names.observe(radio, now)
|
||||
return Directory(self.names.listed())
|
||||
|
||||
def repositories(self) -> dict:
|
||||
tasks = read_register(self.council / REGISTER)
|
||||
return assign(self.tree.get(), tasks, read_claims(self.council), self.reviews.listed())
|
||||
|
||||
def schedules(self, claims: list[dict], now: float) -> list[dict]:
|
||||
beats = {claim["session"]: claim["beat"] for claim in claims if claim["beat"] is not None}
|
||||
rows = [cron_state(cron, beats, now) for cron in self.transcripts.schedules()]
|
||||
return rows + timer_rows(self.timers) + queue_rows(self.council)
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .asking import ask_state
|
||||
from .council import MAYDAY_ACTIVE, NEWS, radio_rows, read_claims, sessions
|
||||
from .crons import cron_event
|
||||
from .errors import Missing, ToolError
|
||||
from .events import event, event_filters, wanted
|
||||
from .fields import instant, single, whole
|
||||
from .names import Directory
|
||||
from .records import iso_epoch, read_jsonl
|
||||
from .register import REGISTER, operator_rows, read_register
|
||||
from .signed import signing
|
||||
from .transcripts import STREAM_CUT, STREAM_KINDS, matches
|
||||
|
||||
STREAM_LIMIT = 2000
|
||||
EVENT_LIMIT = 3000
|
||||
|
||||
|
||||
class Timeline:
|
||||
def stream(self, query: dict[str, list[str]]) -> dict:
|
||||
self.transcripts.refresh()
|
||||
names = sessions(read_claims(self.council))
|
||||
wanted = single(query, "kind")
|
||||
kinds = None if not wanted else set(wanted.split(","))
|
||||
if kinds is not None and not kinds <= set(STREAM_KINDS):
|
||||
raise ToolError(f"kind must be a comma list of {', '.join(STREAM_KINDS)}")
|
||||
filters = {
|
||||
"agent": single(query, "agent") or "",
|
||||
"session": single(query, "session") or "",
|
||||
"kinds": kinds,
|
||||
"q": (single(query, "q") or "").lower(),
|
||||
"begin": instant(query, "from"),
|
||||
"end": instant(query, "to"),
|
||||
}
|
||||
limit = min(whole(query, "limit") or STREAM_LIMIT, STREAM_LIMIT)
|
||||
picked = [
|
||||
{**entry, "agent": names.get(entry["session"], ""), "text": entry["text"][:STREAM_CUT], "truncated": len(entry["text"]) > STREAM_CUT}
|
||||
for entry in self.transcripts.since(whole(query, "after") or 0)
|
||||
if matches(entry, names.get(entry["session"], ""), filters)
|
||||
]
|
||||
picked.sort(key=lambda entry: (entry["ts"], entry["id"]))
|
||||
return {"entries": picked[-limit:], "last": self.transcripts.last(), "sessions": names}
|
||||
|
||||
def entry(self, entry_id: int) -> dict:
|
||||
record = self.transcripts.entry(entry_id)
|
||||
if record is None:
|
||||
raise Missing(str(entry_id))
|
||||
return {"id": record["id"], "text": record["text"]}
|
||||
|
||||
def timeline(self, begin: float, end: float, now: float, tasks: dict[str, dict], claims: list[dict], radio: list[dict], directory: Directory) -> list[dict]:
|
||||
rows = []
|
||||
for index, row in enumerate(radio_rows(radio, begin, end)):
|
||||
alarm = row["class"] in ("mayday", "pan-pan") and row["ts"] >= end - MAYDAY_ACTIVE
|
||||
title = f"{row['from'] or '?'} → {', '.join(row['to']) or 'alle'}"
|
||||
source = directory.key(row["from"], row["ts"])
|
||||
who = {"agent": directory.at(source, row["ts"]) or row["from"], "now": directory.now(source), "session": source}
|
||||
ref = {"to": row["to"], "class": row["class"], "targets": [directory.key(name, row["ts"]) for name in row["to"]]}
|
||||
rows.append(event({"id": f"radio:{row['ts']}:{index}", "ts": row["ts"], "kind": "radio", "part": row["class"], **who, "title": title, "text": row["text"], "waiting": alarm, "ref": ref}))
|
||||
for review in self.reviews.listed():
|
||||
created = iso_epoch(review["created"]) or now
|
||||
if created <= end and (review["status"] == "pending" or created >= begin):
|
||||
ref = {key: review.get(key, "") for key in ("id", "status", "gate", "score", "branch", "worktree", "tree", "files")}
|
||||
rows.append(event({"id": f"review:{review['id']}", "ts": created, "kind": "review", "title": review["title"], "text": review["notes"][:STREAM_CUT], "waiting": review["status"] == "pending", "ref": ref}))
|
||||
for ask in self.asks.listed():
|
||||
state = ask_state(ask, now)
|
||||
if ask["created_ts"] <= end and (state["status"] == "pending" or ask["created_ts"] >= begin):
|
||||
ref = {"id": ask["id"], "status": state["status"], "cwd": ask["cwd"], "tool": ask["tool"], "warnings": signing.warnings(ask["command"])}
|
||||
who = {"agent": directory.at(ask["session"], ask["created_ts"]), "now": directory.now(ask["session"]), "session": ask["session"]}
|
||||
rows.append(event({"id": f"ask:{ask['id']}", "ts": ask["created_ts"], "kind": "ask", **who, "title": ask["tool"], "text": ask["command"], "waiting": state["status"] == "pending", "ref": ref}))
|
||||
for decision in self.decisions.listed():
|
||||
if decision["created_ts"] <= end and (decision["status"] == "pending" or decision["created_ts"] >= begin):
|
||||
ref = {key: decision.get(key, "") for key in ("id", "status", "choice", "recommended", "options")}
|
||||
text = " · ".join(option["label"] for option in decision["options"])
|
||||
source = directory.key(decision["agent"], decision["created_ts"])
|
||||
who = {"agent": directory.at(source, decision["created_ts"]) or decision["agent"], "now": directory.now(source), "session": source}
|
||||
rows.append(event({"id": f"decision:{decision['id']}", "ts": decision["created_ts"], "kind": "decision", **who, "title": decision["question"][:300], "text": text, "waiting": decision["status"] == "pending", "ref": ref}))
|
||||
held = self.open_jobs()
|
||||
for row in operator_rows(tasks):
|
||||
first = self.watch.first(row["task"], now)
|
||||
if first <= end:
|
||||
ref = {key: row[key] for key in ("task", "directory", "check", "status")}
|
||||
rows.append(event({"id": f"operator:{row['task']}", "ts": first, "kind": "operator", "title": row["command"], "text": row["why"], "waiting": row["task"] not in held, "ref": ref}))
|
||||
rows += [change for change in self.watch.listed() if begin <= change["ts"] <= end]
|
||||
rows += [cron_event(cron, directory) for cron in self.schedules(claims, now) if cron["created"] <= end and (cron["active"] or cron["created"] >= begin)]
|
||||
rows += [row for row in self.log.listed() if begin <= row["ts"] <= end]
|
||||
rows += directory.renames(begin, end)
|
||||
return sorted(rows, key=lambda row: (row["ts"], row["id"]))
|
||||
|
||||
def events(self, query: dict[str, list[str]]) -> dict:
|
||||
now = time.time()
|
||||
begin, end = self.span(query, now)
|
||||
filters = event_filters(query)
|
||||
limit = min(whole(query, "limit") or EVENT_LIMIT, EVENT_LIMIT)
|
||||
after = whole(query, "after") or 0
|
||||
self.transcripts.refresh()
|
||||
claims = read_claims(self.council)
|
||||
news = read_jsonl(self.council / NEWS)
|
||||
directory = self.directory(claims, news, now)
|
||||
filters["aliases"] = directory.aliases(filters["agent"]) if filters["agent"] else set()
|
||||
tasks = read_register(self.council / REGISTER)
|
||||
self.watch.see(tasks, now)
|
||||
rows = [row for row in self.timeline(begin, end, now, tasks, claims, news, directory) if wanted(row, filters)]
|
||||
live = instant(query, "to") is None
|
||||
entries = []
|
||||
for entry in self.transcripts.since(after) if "transcript" in filters["kinds"] else []:
|
||||
shaped = event({
|
||||
"id": f"transcript:{entry['id']}", "ts": entry["ts"], "kind": "transcript", "part": entry["kind"], "agent": directory.at(entry["session"], entry["ts"]),
|
||||
"now": directory.now(entry["session"]), "session": entry["session"], "sub": entry["sub"], "title": entry["kind"], "text": entry["text"][:STREAM_CUT],
|
||||
"ref": {"entry": entry["id"], "truncated": len(entry["text"]) > STREAM_CUT},
|
||||
})
|
||||
if entry["ts"] >= begin and (live or entry["ts"] <= end) and wanted(shaped, filters):
|
||||
entries.append(shaped)
|
||||
return {"events": rows[-limit:], "entries": entries[-limit:], "last": self.transcripts.last(), "sessions": sessions(claims)}
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
from .records import epoch, iso_epoch, parse_line, shown
|
||||
|
||||
STREAM_KEEP = 20_000
|
||||
STREAM_TEXT = 20_000
|
||||
STREAM_CUT = 2000
|
||||
STREAM_FIRST = 512 << 10
|
||||
STREAM_AGE = 7 * 86400
|
||||
STREAM_SCAN = 1.0
|
||||
STREAM_KINDS = ("user", "assistant", "tool_call", "tool_result", "system")
|
||||
SCHEDULED = re.compile(r"Scheduled (?:recurring job|one-shot task) ([0-9a-f]{6,32})")
|
||||
CRON_LIFETIME = 7 * 86400
|
||||
LOG_KEEP = 5000
|
||||
|
||||
|
||||
def block_text(content: object) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if not isinstance(content, list):
|
||||
return ""
|
||||
parts = []
|
||||
for block in content:
|
||||
if isinstance(block, dict) and isinstance(block.get("text"), str):
|
||||
parts.append(block["text"])
|
||||
elif isinstance(block, dict) and block.get("type") == "image":
|
||||
parts.append("[image]")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def transcript_parts(row: dict) -> list[tuple[str, str]]:
|
||||
role = row.get("type")
|
||||
if role == "system":
|
||||
text = block_text(row.get("content"))
|
||||
return [("system", text)] if text else []
|
||||
message = row.get("message")
|
||||
if role not in ("user", "assistant") or not isinstance(message, dict):
|
||||
return []
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return [("system" if row.get("isMeta") else str(role), content)] if content else []
|
||||
found = []
|
||||
for block in content if isinstance(content, list) else []:
|
||||
part = block.get("type") if isinstance(block, dict) else None
|
||||
if part == "text":
|
||||
found.append((str(role), block_text([block])))
|
||||
elif part == "tool_use":
|
||||
found.append(("tool_call", f"{shown(block.get('name'))} {json.dumps(block.get('input'), ensure_ascii=False)}"))
|
||||
elif part == "tool_result":
|
||||
found.append(("tool_result", block_text(block.get("content"))))
|
||||
return [(name, text) for name, text in found if text]
|
||||
|
||||
|
||||
def matches(entry: dict, name: str, filters: dict) -> bool:
|
||||
begin, end = filters["begin"], filters["end"]
|
||||
return all(
|
||||
(
|
||||
not filters["agent"] or name == filters["agent"],
|
||||
not filters["session"] or entry["session"] == filters["session"],
|
||||
filters["kinds"] is None or entry["kind"] in filters["kinds"],
|
||||
not filters["q"] or filters["q"] in entry["text"].lower(),
|
||||
begin is None or entry["ts"] >= begin,
|
||||
end is None or entry["ts"] <= end,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def text_of(value: object) -> str:
|
||||
return value[:STREAM_CUT] if isinstance(value, str) else ""
|
||||
|
||||
|
||||
class Transcripts:
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = root
|
||||
self.lock = threading.Lock()
|
||||
self.offsets: dict[Path, int] = {}
|
||||
self.entries: deque[dict] = deque(maxlen=STREAM_KEEP)
|
||||
self.crons: dict[str, dict] = {}
|
||||
self.count = 0
|
||||
self.scanned = -math.inf
|
||||
|
||||
def refresh(self) -> None:
|
||||
with self.lock:
|
||||
if time.monotonic() - self.scanned < STREAM_SCAN:
|
||||
return
|
||||
self.scanned = time.monotonic()
|
||||
try:
|
||||
paths = sorted(self.root.rglob("*.jsonl"))
|
||||
except OSError:
|
||||
paths = []
|
||||
for path in paths:
|
||||
self.read(path)
|
||||
|
||||
def start(self, path: Path, info: os.stat_result) -> int:
|
||||
offset = self.offsets.get(path)
|
||||
if offset is None:
|
||||
recent = info.st_mtime >= time.time() - STREAM_AGE
|
||||
return max(info.st_size - STREAM_FIRST, 0) if recent else info.st_size
|
||||
return 0 if info.st_size < offset else offset
|
||||
|
||||
def read(self, path: Path) -> None:
|
||||
try:
|
||||
info = path.stat()
|
||||
begin = self.start(path, info)
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(begin)
|
||||
data = handle.read(max(info.st_size - begin, 0))
|
||||
except OSError:
|
||||
return
|
||||
end = data.rfind(b"\n") + 1
|
||||
self.offsets[path] = begin + end
|
||||
for raw in data[:end].splitlines():
|
||||
row = parse_line(raw.decode("utf-8", errors="replace"))
|
||||
if row is not None:
|
||||
self.add(path, row)
|
||||
|
||||
def add(self, path: Path, row: dict) -> None:
|
||||
ts = iso_epoch(row.get("timestamp"))
|
||||
if ts is None:
|
||||
return
|
||||
session = row.get("sessionId") if isinstance(row.get("sessionId"), str) else path.stem
|
||||
sub = row.get("agentId") if isinstance(row.get("agentId"), str) else ""
|
||||
message = row.get("message")
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
for block in content if isinstance(content, list) else []:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_use":
|
||||
self.schedule(block, ts, session, sub)
|
||||
elif isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
self.scheduled(block)
|
||||
for name, text in transcript_parts(row):
|
||||
self.count += 1
|
||||
self.entries.append({"id": self.count, "ts": ts, "session": session, "sub": sub, "kind": name, "text": text[:STREAM_TEXT]})
|
||||
|
||||
def end(self, kind_name: str, session: str, sub: str, ts: float) -> None:
|
||||
for cron in self.crons.values():
|
||||
if cron["type"] == kind_name and cron["session"] == session and cron["sub"] == sub and cron["deleted"] is None:
|
||||
cron["deleted"] = ts
|
||||
|
||||
def schedule(self, block: dict, ts: float, session: str, sub: str) -> None:
|
||||
name, data, key = block.get("name"), block.get("input"), block.get("id")
|
||||
if not isinstance(data, dict) or not isinstance(key, str):
|
||||
return
|
||||
base = {"key": key, "session": session, "sub": sub, "created": ts, "deleted": None, "until": None, "job": "", "expression": "", "prompt": "", "reason": ""}
|
||||
delay = data.get("delaySeconds")
|
||||
if name == "CronCreate":
|
||||
self.crons[key] = {**base, "type": "cron", "expression": text_of(data.get("cron")), "prompt": text_of(data.get("prompt")), "until": ts + CRON_LIFETIME}
|
||||
elif name == "CronDelete":
|
||||
for cron in self.crons.values():
|
||||
if cron["type"] == "cron" and cron["job"] and cron["job"] == data.get("id") and cron["deleted"] is None:
|
||||
cron["deleted"] = ts
|
||||
elif name == "ScheduleWakeup":
|
||||
self.end("wakeup", session, sub, ts)
|
||||
if data.get("stop") is not True and epoch(delay) is not None:
|
||||
wakeup = {"type": "wakeup", "expression": f"in {int(delay)} s", "prompt": text_of(data.get("prompt")), "reason": text_of(data.get("reason")), "until": ts + float(delay)}
|
||||
self.crons[key] = {**base, **wakeup}
|
||||
elif name == "Bash" and data.get("run_in_background") is True:
|
||||
self.crons[key] = {**base, "type": "background", "expression": text_of(data.get("description"))[:200] or "Bash", "prompt": text_of(data.get("command"))}
|
||||
while len(self.crons) > LOG_KEEP:
|
||||
del self.crons[min(self.crons, key=lambda name: self.crons[name]["created"])]
|
||||
|
||||
def scheduled(self, block: dict) -> None:
|
||||
cron = self.crons.get(block.get("tool_use_id"))
|
||||
found = SCHEDULED.search(block_text(block.get("content")))
|
||||
if cron is not None and cron["type"] == "cron" and found:
|
||||
cron["job"] = found.group(1)
|
||||
|
||||
def schedules(self) -> list[dict]:
|
||||
with self.lock:
|
||||
return [dict(cron) for cron in self.crons.values()]
|
||||
|
||||
def since(self, after: int) -> list[dict]:
|
||||
with self.lock:
|
||||
return [entry for entry in self.entries if entry["id"] > after]
|
||||
|
||||
def entry(self, entry_id: int) -> dict | None:
|
||||
with self.lock:
|
||||
return next((dict(entry) for entry in self.entries if entry["id"] == entry_id), None)
|
||||
|
||||
def first(self) -> float | None:
|
||||
with self.lock:
|
||||
return min((entry["ts"] for entry in self.entries), default=None)
|
||||
|
||||
def last(self) -> int:
|
||||
with self.lock:
|
||||
return self.count
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .council import agent
|
||||
from .register import ENDED, status_of
|
||||
from .skill_scripts import SAFE_PATH
|
||||
|
||||
TREE_TTL = 60.0
|
||||
TREE_DEPTH = 6
|
||||
GIT_TIMEOUT = 10
|
||||
|
||||
|
||||
def git(cwd: Path, args: list[str], home: Path) -> str | None:
|
||||
env = {"PATH": SAFE_PATH, "HOME": str(home), "GIT_CONFIG_NOSYSTEM": "1", "GIT_NO_LAZY_FETCH": "1", "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0", "LC_ALL": "C"}
|
||||
try:
|
||||
done = subprocess.run(
|
||||
["git", "-c", "safe.directory=*", "-c", "core.fsmonitor=false", "-C", str(cwd), *args],
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=GIT_TIMEOUT,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
return done.stdout if done.returncode == 0 else None
|
||||
|
||||
|
||||
def parse_worktrees(text: str) -> list[dict]:
|
||||
found = []
|
||||
for block in text.strip().split("\n\n"):
|
||||
lines = dict(line.partition(" ")[::2] for line in block.splitlines() if line)
|
||||
if "worktree" not in lines or "bare" in lines:
|
||||
continue
|
||||
found.append({
|
||||
"path": lines["worktree"], "head": lines.get("HEAD", ""), "branch": lines.get("branch", "").removeprefix("refs/heads/"),
|
||||
"prunable": "prunable" in lines,
|
||||
})
|
||||
return found
|
||||
|
||||
|
||||
def tracking(text: str) -> tuple[int, int, bool]:
|
||||
counts = {name: int(number) for name, number in re.findall(r"(ahead|behind) ([0-9]+)", text)}
|
||||
return counts.get("ahead", 0), counts.get("behind", 0), text.strip() == "gone"
|
||||
|
||||
|
||||
def parse_refs(text: str) -> list[dict]:
|
||||
found = []
|
||||
for line in text.splitlines():
|
||||
parts = line.split("\x00")
|
||||
if len(parts) != 4:
|
||||
continue
|
||||
ahead, behind, gone = tracking(parts[3])
|
||||
found.append({"name": parts[0], "head": parts[1], "upstream": parts[2], "ahead": ahead, "behind": behind, "gone": gone})
|
||||
return found
|
||||
|
||||
|
||||
def linked(root: Path) -> list[Path]:
|
||||
found = []
|
||||
for top, dirs, names in os.walk(root):
|
||||
here = Path(top)
|
||||
if ".git" in names or ".git" in dirs:
|
||||
found.append(here)
|
||||
dirs[:] = []
|
||||
elif len(here.relative_to(root).parts) >= TREE_DEPTH:
|
||||
dirs[:] = []
|
||||
else:
|
||||
dirs.sort()
|
||||
return found
|
||||
|
||||
|
||||
def dirty(path: Path, home: Path) -> bool | None:
|
||||
# agents write these repos, and status runs every filter.*.clean command the config names
|
||||
if git(path, ["config", "--get-regexp", r"^filter\."], home) is not None:
|
||||
return None
|
||||
status = git(path, ["status", "--porcelain=v1", "--untracked-files=normal", "--ignore-submodules=all"], home)
|
||||
return None if status is None else bool(status.strip())
|
||||
|
||||
|
||||
def repo_entry(path: Path, label: str, home: Path) -> dict:
|
||||
trees = parse_worktrees(git(path, ["worktree", "list", "--porcelain"], home) or "")
|
||||
refs = git(path, ["for-each-ref", "--format=%(refname:short)%00%(objectname)%00%(upstream:short)%00%(upstream:track,nobracket)", "refs/heads"], home)
|
||||
branches = parse_refs(refs or "")
|
||||
for tree in trees:
|
||||
tree["dirty"] = None if tree["prunable"] else dirty(Path(tree["path"]), home)
|
||||
known = {branch["name"] for branch in branches}
|
||||
branches += [{"name": tree["branch"], "head": tree["head"], "upstream": "", "ahead": 0, "behind": 0, "gone": False} for tree in trees if tree["branch"] and tree["branch"] not in known]
|
||||
for branch in branches:
|
||||
branch["worktrees"] = [tree for tree in trees if tree["branch"] == branch["name"]]
|
||||
return {"path": str(path), "name": label, "branches": branches, "detached": [tree for tree in trees if not tree["branch"]]}
|
||||
|
||||
|
||||
def scan_tree(repos: Path, worktrees: Path, home: Path) -> dict:
|
||||
root = repos.resolve()
|
||||
try:
|
||||
mains = sorted(path.resolve() for path in root.glob("*/*/*") if (path / ".git").is_dir())
|
||||
except OSError:
|
||||
mains = []
|
||||
entries = {str(path): repo_entry(path, str(path.relative_to(root)), home) for path in mains if path.is_relative_to(root)}
|
||||
seen = {tree["path"] for entry in entries.values() for branch in entry["branches"] for tree in branch["worktrees"]}
|
||||
seen |= {tree["path"] for entry in entries.values() for tree in entry["detached"]}
|
||||
for path in linked(worktrees.resolve()):
|
||||
if str(path) in seen:
|
||||
continue
|
||||
common = git(path, ["rev-parse", "--path-format=absolute", "--git-common-dir"], home)
|
||||
main = Path(common.strip()).parent if common else None
|
||||
if main is not None and str(main) not in entries and (main / ".git").is_dir():
|
||||
entries[str(main)] = repo_entry(main, str(main), home)
|
||||
return {"repos": sorted(entries.values(), key=lambda entry: entry["name"]), "scanned": time.time()}
|
||||
|
||||
|
||||
def inside(path: str, folder: str) -> bool:
|
||||
return path == folder or path.startswith(f"{folder.rstrip('/')}/")
|
||||
|
||||
|
||||
def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list[dict]) -> dict:
|
||||
current = {task: row for task, row in tasks.items() if status_of(row) not in ENDED}
|
||||
holders: dict[str, set[str]] = {}
|
||||
for task, row in current.items():
|
||||
names = {agent(row.get("worker"))} | {claim["agent"] for claim in claims if claim["office"] == task}
|
||||
holders[task] = {name for name in names if name}
|
||||
latest: dict[str, dict] = {}
|
||||
for review in reviews:
|
||||
latest[review["worktree"]] = {"status": review["status"], "gate": review.get("gate", ""), "title": review["title"]}
|
||||
repos = []
|
||||
for entry in scan["repos"]:
|
||||
branches = []
|
||||
for branch in entry["branches"]:
|
||||
trees = []
|
||||
for tree in branch["worktrees"]:
|
||||
workers = set()
|
||||
for task, row in current.items():
|
||||
workdir, name = row.get("workdir"), row.get("branch")
|
||||
by_dir = isinstance(workdir, str) and workdir.startswith("/") and inside(workdir, tree["path"])
|
||||
by_branch = not isinstance(workdir, str) and name == branch["name"]
|
||||
if by_dir or by_branch:
|
||||
workers |= holders[task]
|
||||
trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"])})
|
||||
branches.append({**branch, "worktrees": trees})
|
||||
repos.append({**entry, "branches": branches})
|
||||
return {"repos": repos, "scanned": scan["scanned"]}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tree:
|
||||
repos: Path
|
||||
worktrees: Path
|
||||
home: Path
|
||||
lock: threading.Lock
|
||||
cached: dict | None
|
||||
scanned: float
|
||||
|
||||
def get(self) -> dict:
|
||||
with self.lock:
|
||||
if self.cached is None or time.monotonic() - self.scanned >= TREE_TTL:
|
||||
self.cached = scan_tree(self.repos, self.worktrees, self.home)
|
||||
self.scanned = time.monotonic()
|
||||
return self.cached
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .asking import ask_row, decision_row
|
||||
from .council import (
|
||||
MAYDAY_ACTIVE,
|
||||
NEWS,
|
||||
grants,
|
||||
night_mode,
|
||||
radio_rows,
|
||||
read_claims,
|
||||
sessions,
|
||||
silence_of,
|
||||
)
|
||||
from .errors import ToolError
|
||||
from .events import KINDS, VIEWS
|
||||
from .fields import decode, instant, single
|
||||
from .graph import graph
|
||||
from .jobs import JOB_OPEN
|
||||
from .messaging import message_row
|
||||
from .orbits import TRACK_STEPS, track
|
||||
from .phrasing import phrase
|
||||
from .records import epoch, iso_epoch, read_jsonl
|
||||
from .register import REGISTER, operator_rows, priority, read_register, register_rows
|
||||
from .reviewing import review_row
|
||||
from .settings import media_origins
|
||||
from .skill_scripts import OUTPUT_LIMIT
|
||||
|
||||
RADIO_WINDOW = 86400
|
||||
JOBS_SHOWN = 20
|
||||
ACTIVE = 900
|
||||
INDEX_CSP = (
|
||||
"default-src 'none'; script-src 'nonce-{0}'; style-src 'nonce-{0}'; connect-src 'self'; media-src {1}; "
|
||||
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
||||
)
|
||||
|
||||
|
||||
def badges(inbox: list[dict], claims: list[dict], jobs: list[dict], now: float) -> dict[str, int]:
|
||||
return {
|
||||
"inbox": sum(1 for item in inbox if not item["until"]),
|
||||
"agents": len({claim["agent"] for claim in claims if claim["beat"] is not None and claim["beat"] >= now - ACTIVE}),
|
||||
"artifacts": sum(1 for job in jobs if job["status"] in JOB_OPEN),
|
||||
}
|
||||
|
||||
|
||||
class View:
|
||||
def span(self, query: dict[str, list[str]], now: float) -> tuple[float, float]:
|
||||
end = instant(query, "to")
|
||||
end = now if end is None else min(end, now)
|
||||
begin = instant(query, "from")
|
||||
begin = end - RADIO_WINDOW if begin is None else begin
|
||||
if begin > end:
|
||||
raise ToolError("from must not lie after to")
|
||||
return begin, end
|
||||
|
||||
def window(self, query: dict[str, list[str]]) -> dict:
|
||||
now = time.time()
|
||||
begin, end = self.span(query, now)
|
||||
sampled = single(query, "track")
|
||||
if sampled not in (None, "1"):
|
||||
raise ToolError("track must be 1 or left out")
|
||||
return self.state(begin, end, now, TRACK_STEPS if sampled else 0)
|
||||
|
||||
def first(self, news: list[dict], history: list[dict], claims: list[dict], now: float) -> float:
|
||||
moments = [epoch(row.get("ts")) for row in news] + [epoch(row.get("start")) for row in history]
|
||||
moments += [iso_epoch(claim["since"]) for claim in claims] + [self.transcripts.first()]
|
||||
moments += [iso_epoch(record["created"]) for ledger in (self.reviews, self.asks, self.decisions, self.messages) for record in ledger.listed()]
|
||||
return min([now, *(moment for moment in moments if moment is not None)])
|
||||
|
||||
def state(self, begin: float, end: float, now: float, steps: int) -> dict:
|
||||
tasks = read_register(self.council / REGISTER)
|
||||
news = read_jsonl(self.council / NEWS)
|
||||
history = read_jsonl(self.council / "queue-history.jsonl")
|
||||
claims = read_claims(self.council)
|
||||
names = sessions(claims)
|
||||
directory = self.directory(claims, news, now)
|
||||
radio = []
|
||||
for row in radio_rows(news, begin, end):
|
||||
source = directory.key(row["from"], row["ts"])
|
||||
targets = [directory.key(name, row["ts"]) for name in row["to"]]
|
||||
called = [directory.at(key, row["ts"]) or name for key, name in zip(targets, row["to"], strict=True)]
|
||||
spoken = {"speech": phrase(row["class"], directory.at(source, row["ts"]) or row["from"], called, row["text"]), "voice": self.speech.voice_of(source)}
|
||||
radio.append({**row, "source": source, "targets": targets, "now": directory.now(source), **spoken})
|
||||
grant_rows = [{**grant, "target": directory.key(grant["to"], grant["ts"])} for grant in grants(history, (begin, end), claims, tasks)]
|
||||
self.watch.see(tasks, now)
|
||||
jobs = [{**{key: value for key, value in job.items() if key != "proof"}, "output": job["output"][-OUTPUT_LIMIT:]} for job in self.jobs.listed()[-JOBS_SHOWN:]]
|
||||
drawn = graph(radio, grant_rows, tasks, claims, end, directory)
|
||||
first = self.first(news, history, claims, now)
|
||||
drawn["track"] = track(drawn["nodes"], radio, tasks, claims, (min(max(begin, first), end), end), directory, steps)
|
||||
people = {node["id"]: {"name": node["label"], "now": directory.now(node["id"]) or node["label"], "names": node["names"]} for node in drawn["nodes"] if node["kind"] != "subagent"}
|
||||
settings = self.settings.get()
|
||||
inbox = self.inbox(now, tasks, directory)
|
||||
return {
|
||||
"settings": settings,
|
||||
"media": media_origins(settings),
|
||||
"speech": self.speech.describe(),
|
||||
"people": people,
|
||||
"inbox": inbox,
|
||||
"badges": badges(inbox, claims, self.jobs.listed(), now),
|
||||
"pending": self.batches.listed(),
|
||||
"rules": [{**{key: value for key, value in rule.items() if key != "proof"}, "signed": rule.get("proof") is not None} for rule in self.rules.listed()],
|
||||
"approvers": self.keys(),
|
||||
"jobs": jobs,
|
||||
"views": VIEWS,
|
||||
"kinds": dict(KINDS),
|
||||
"llm": self.model.enabled(),
|
||||
"now": now,
|
||||
"since": begin,
|
||||
"until": end,
|
||||
"first": first,
|
||||
"alarm_since": end - MAYDAY_ACTIVE,
|
||||
"reviews": [row for row in map(review_row, self.reviews.pending()) if row["created_ts"] <= end],
|
||||
"asks": [ask_row(ask, names) for ask in self.asks.pending() if ask["expires"] > now and ask["created_ts"] <= end],
|
||||
"decisions": [decision_row(decision) for decision in self.decisions.pending() if decision["created_ts"] <= end],
|
||||
"messages": [message_row(message) for message in self.messages.listed() if begin <= message["created_ts"] <= end],
|
||||
"operator": operator_rows(tasks),
|
||||
"register": register_rows(tasks),
|
||||
"priority": priority(tasks),
|
||||
"radio": radio,
|
||||
"claims": claims,
|
||||
"grants": grant_rows,
|
||||
"night": night_mode(self.council),
|
||||
"silence": silence_of(self.council, now),
|
||||
"graph": drawn,
|
||||
}
|
||||
|
||||
def save_settings(self, body: bytes) -> dict:
|
||||
return self.settings.put(decode(body))
|
||||
|
||||
def render(self, nonce: str) -> tuple[str, str]:
|
||||
now = time.time()
|
||||
state = self.state(now - RADIO_WINDOW, now, now, 0)
|
||||
page = self.templates.get_template("index.html.j2").render(nonce=nonce, state=state)
|
||||
return page, INDEX_CSP.format(nonce, " ".join(["'self'", *state["media"]]))
|
||||
Reference in new issue
Block a user