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>
116 lines
7.5 KiB
Python
116 lines
7.5 KiB
Python
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)}
|