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,5 @@
|
||||
KVB_ORCHESTRATOR_PORT=8737
|
||||
KVB_ORCHESTRATOR_LLM_URL=
|
||||
KVB_ORCHESTRATOR_LLM_MODEL=
|
||||
KVB_ORCHESTRATOR_LLM_KEY_FILE=
|
||||
KVB_ORCHESTRATOR_OPERATOR_VOICE=en_US-libritts-high:0
|
||||
@@ -0,0 +1,42 @@
|
||||
FROM python:3.12-slim AS whisper
|
||||
|
||||
RUN apt-get update && apt-get install --yes --no-install-recommends build-essential cmake git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN git clone --depth 1 --branch v1.9.5 https://github.com/ggml-org/whisper.cpp /src \
|
||||
&& test "$(cd /src && git rev-parse HEAD)" = d1be6fde11ac6e0407606b4e42fe72d34add8037 \
|
||||
&& cmake -S /src -B /build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DGGML_OPENMP=OFF -DWHISPER_BUILD_TESTS=OFF \
|
||||
&& cmake --build /build --config Release --target whisper-cli --parallel \
|
||||
&& install -D --mode=0755 /build/bin/whisper-cli /out/whisper-cli
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update && apt-get install --yes --no-install-recommends git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir --root-user-action=ignore --requirement /app/requirements.txt
|
||||
|
||||
COPY requirements-piper.txt /opt/piper/requirements.txt
|
||||
RUN python3 -m venv --without-pip /opt/piper \
|
||||
&& pip --python /opt/piper/bin/python install --no-cache-dir --root-user-action=ignore --require-hashes --only-binary=:all: --requirement /opt/piper/requirements.txt
|
||||
|
||||
ADD --checksum=sha256:9127a559e11603f10b366d1a20ac7426826081dbc521de4c2420c57728d73f0f --chmod=0644 \
|
||||
https://huggingface.co/rhasspy/piper-voices/resolve/e7229f7d33a10e145e109b886d661984201ac3c4/en/en_US/libritts/high/en_US-libritts-high.onnx \
|
||||
/opt/voices/en_US-libritts-high.onnx
|
||||
ADD --checksum=sha256:2efdc6d7f954588b8180132cbd9b8001933fdd00932c92bc92fd0d2028a9eb3d --chmod=0644 \
|
||||
https://huggingface.co/rhasspy/piper-voices/resolve/e7229f7d33a10e145e109b886d661984201ac3c4/en/en_US/libritts/high/en_US-libritts-high.onnx.json \
|
||||
/opt/voices/en_US-libritts-high.onnx.json
|
||||
ADD --checksum=sha256:c577b9a86e7e048a0b7eada054f4dd79a56bbfa911fbdacf900ac5b567cbb7d9 --chmod=0644 \
|
||||
https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-base-q8_0.bin \
|
||||
/opt/whisper/ggml-base-q8_0.bin
|
||||
COPY --from=whisper /out/whisper-cli /opt/whisper/whisper-cli
|
||||
# ADD --chmod=0644 also gives the directories it creates mode 0644; without the search bit the non-root hub finds no voice and no whisper.cpp
|
||||
RUN chmod 0755 /opt/voices /opt/whisper
|
||||
|
||||
COPY server.py /app/server.py
|
||||
COPY kvb_orchestrator /app/kvb_orchestrator
|
||||
COPY client/signing.py /app/client/signing.py
|
||||
COPY templates /app/templates
|
||||
|
||||
ENTRYPOINT ["python3", "/app/server.py"]
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Args:
|
||||
pin --port <port> --approvers <file> --credential <blob>: pins the passkey
|
||||
that the Agent Orchestrator settings panel registered (the blob it shows) in
|
||||
<file>, written with mode 0600. Run it on the host, outside every
|
||||
sandbox, e.g. --approvers ~/.config/kvb-orchestrator/approvers.json.
|
||||
verify --port <port> --approvers <file> --replay <file> --review <id>
|
||||
--gate <pass|fail>: run in the worktree right before the commit;
|
||||
fetches the review's signature from the Agent Orchestrator on 127.0.0.1:<port>
|
||||
and checks it against the repository, worktree, branch and the tree
|
||||
git write-tree prints, as this process sees them.
|
||||
|
||||
Returns:
|
||||
pin: exit code 0 and the pinned credential on stdout; 1 and the reason
|
||||
on stderr when the blob is no valid registration.
|
||||
verify: exit code 0 when a pinned key signed this exact commit; 1 and
|
||||
the reason on stderr otherwise. Commit only on 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from hub_api import GUARD, SLACK, base_url, load
|
||||
from passkey import attested, b64decode
|
||||
from signing import ALGORITHMS, ID, Refused, origin_of, review_item
|
||||
from verifier import Trust, check, pin
|
||||
|
||||
GIT_TIMEOUT = 30
|
||||
|
||||
|
||||
def git(cwd: Path, args: list[str]) -> str:
|
||||
try:
|
||||
done = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=GIT_TIMEOUT, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise Refused(f"git {args[0]} did not run: {error}") from error
|
||||
if done.returncode != 0:
|
||||
raise Refused(f"git {' '.join(args)} failed: {done.stderr.strip()}")
|
||||
return done.stdout.strip()
|
||||
|
||||
|
||||
def staged(cwd: Path, review_id: str) -> dict:
|
||||
repo = git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"])
|
||||
worktree = git(cwd, ["rev-parse", "--show-toplevel"])
|
||||
branch = git(cwd, ["symbolic-ref", "--short", "HEAD"])
|
||||
return review_item(review_id, repo, worktree, branch, git(cwd, ["write-tree"]))
|
||||
|
||||
|
||||
def fetch(port: int, path: str) -> dict:
|
||||
request = urllib.request.Request(f"{base_url(port)}{path}", headers=GUARD)
|
||||
try:
|
||||
data = load(request, SLACK)
|
||||
except (OSError, ValueError) as error:
|
||||
raise Refused(f"the Agent Orchestrator did not answer: {error}") from error
|
||||
if not isinstance(data, dict):
|
||||
raise Refused("the Agent Orchestrator answered no object")
|
||||
return data
|
||||
|
||||
|
||||
def verify_commit(args: argparse.Namespace) -> str:
|
||||
review = fetch(args.port, f"/api/reviews/{args.review}")
|
||||
if review.get("status") != "approved":
|
||||
raise Refused(f"the review is {review.get('status')}, not approved")
|
||||
item = staged(Path.cwd(), args.review)
|
||||
check(review.get("proof"), item, args.gate, Trust(approvers=args.approvers, replay=args.replay, origin=origin_of(args.port)), time.time())
|
||||
return f"verified: the operator signed review {args.review}, tree {item['tree']} on {item['branch']} in {item['worktree']}"
|
||||
|
||||
|
||||
def pin_credential(args: argparse.Namespace) -> str:
|
||||
try:
|
||||
credential = json.loads(b64decode(args.credential, "credential"))
|
||||
except (ValueError, RecursionError) as error:
|
||||
raise Refused("the credential blob is no JSON") from error
|
||||
entry = attested(credential, origin_of(args.port))
|
||||
entries = pin(args.approvers, entry, time.time())
|
||||
return f"pinned {entry['id']} ({ALGORITHMS[entry['alg']]}) in {args.approvers}; {len(entries)} key(s) pinned"
|
||||
|
||||
|
||||
def review_id(text: str) -> str:
|
||||
if not ID.fullmatch(text):
|
||||
raise argparse.ArgumentTypeError("a review id is 16 hex digits")
|
||||
return text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="approval")
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
pinning = commands.add_parser("pin")
|
||||
verifying = commands.add_parser("verify")
|
||||
for sub in (pinning, verifying):
|
||||
sub.add_argument("--port", type=int, required=True)
|
||||
sub.add_argument("--approvers", type=Path, required=True)
|
||||
pinning.add_argument("--credential", required=True)
|
||||
verifying.add_argument("--replay", type=Path, required=True)
|
||||
verifying.add_argument("--review", type=review_id, required=True)
|
||||
verifying.add_argument("--gate", choices=("pass", "fail"), required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
print(pin_credential(args) if args.command == "pin" else verify_commit(args))
|
||||
except (Refused, OSError) as error:
|
||||
print(f"refused: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
SLACK = 15.0
|
||||
GUARD = {"X-Kvb-Orchestrator": "1"}
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect)
|
||||
|
||||
|
||||
def base_url(port: int) -> str:
|
||||
return f"http://127.0.0.1:{port}"
|
||||
|
||||
|
||||
def load(request: urllib.request.Request, timeout: float) -> object:
|
||||
with OPENER.open(request, timeout=timeout) as response:
|
||||
return json.loads(response.read())
|
||||
|
||||
|
||||
def call(request: urllib.request.Request, timeout: float) -> dict:
|
||||
data = load(request, timeout)
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError("the hub answered no object")
|
||||
return data
|
||||
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from signing import RP_ID, Refused, b64encode, challenge
|
||||
|
||||
try:
|
||||
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa
|
||||
except ImportError:
|
||||
serialization = None
|
||||
|
||||
FLAG_UP = 0x01
|
||||
FLAG_AT = 0x40
|
||||
EC_PREFIX = bytes.fromhex("3059301306072a8648ce3d020106082a8648ce3d030107034200")
|
||||
P256_PRIME = 2**256 - 2**224 + 2**192 + 2**96 - 1
|
||||
P256_B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
|
||||
ED_PREFIX = bytes.fromhex("302a300506032b6570032100")
|
||||
RSA_IDENTIFIER = bytes.fromhex("300d06092a864886f70d0101010500")
|
||||
B64URL = re.compile(r"[A-Za-z0-9_-]+")
|
||||
ASSERTION_KEYS = ("id", "clientDataJSON", "authenticatorData", "signature")
|
||||
CBOR_DEPTH = 16
|
||||
TIMEOUT = 30
|
||||
|
||||
|
||||
def b64decode(text: object, name: str) -> bytes:
|
||||
if not isinstance(text, str) or not B64URL.fullmatch(text):
|
||||
raise Refused(f"{name} is no base64url text")
|
||||
try:
|
||||
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
||||
except ValueError as error:
|
||||
raise Refused(f"{name} is no base64url text") from error
|
||||
|
||||
|
||||
def cbor(data: bytes, offset: int, depth: int) -> tuple[object, int]:
|
||||
if depth > CBOR_DEPTH or offset >= len(data):
|
||||
raise Refused("the CBOR data is truncated or nests too deep")
|
||||
major, info = data[offset] >> 5, data[offset] & 0x1F
|
||||
offset += 1
|
||||
value = info
|
||||
if 24 <= info <= 27:
|
||||
size = 1 << (info - 24)
|
||||
if offset + size > len(data):
|
||||
raise Refused("the CBOR data is truncated")
|
||||
value = int.from_bytes(data[offset : offset + size], "big")
|
||||
offset += size
|
||||
elif info > 27:
|
||||
raise Refused("the CBOR data uses an indefinite or reserved length")
|
||||
if major == 0:
|
||||
return value, offset
|
||||
if major == 1:
|
||||
return -1 - value, offset
|
||||
if major in (2, 3):
|
||||
chunk = data[offset : offset + value]
|
||||
if len(chunk) != value:
|
||||
raise Refused("the CBOR data is truncated")
|
||||
if major == 2:
|
||||
return chunk, offset + value
|
||||
try:
|
||||
return chunk.decode("utf-8"), offset + value
|
||||
except UnicodeDecodeError as error:
|
||||
raise Refused("the CBOR text is no UTF-8") from error
|
||||
if major == 4:
|
||||
items = []
|
||||
for _ in range(value):
|
||||
item, offset = cbor(data, offset, depth + 1)
|
||||
items.append(item)
|
||||
return items, offset
|
||||
if major == 5:
|
||||
entries: dict = {}
|
||||
for _ in range(value):
|
||||
key, offset = cbor(data, offset, depth + 1)
|
||||
if isinstance(key, bool) or not isinstance(key, int | str) or key in entries:
|
||||
raise Refused("the CBOR map holds a bad or repeated key")
|
||||
entries[key], offset = cbor(data, offset, depth + 1)
|
||||
return entries, offset
|
||||
if major == 7 and info in (20, 21, 22):
|
||||
return {20: False, 21: True, 22: None}[info], offset
|
||||
raise Refused("the CBOR data holds an unsupported item")
|
||||
|
||||
|
||||
def der(tag: int, body: bytes) -> bytes:
|
||||
size = len(body)
|
||||
if size < 0x80:
|
||||
return bytes([tag, size]) + body
|
||||
octets = size.to_bytes((size.bit_length() + 7) // 8, "big")
|
||||
return bytes([tag, 0x80 | len(octets)]) + octets + body
|
||||
|
||||
|
||||
def der_integer(value: bytes) -> bytes:
|
||||
digits = value.lstrip(b"\x00") or b"\x00"
|
||||
return der(0x02, b"\x00" + digits if digits[0] & 0x80 else digits)
|
||||
|
||||
|
||||
def octets(value: object, size: int) -> bool:
|
||||
return isinstance(value, bytes) and len(value) == size
|
||||
|
||||
|
||||
def p256_point(x: object, y: object) -> bytes | None:
|
||||
# KeePassXC 2.7.12 drops leading zero bytes of x and y (Botan BigInt as hex), against RFC 9053; pad them back to 32.
|
||||
if not all(isinstance(value, bytes) and 1 <= len(value) <= 32 for value in (x, y)):
|
||||
return None
|
||||
left, right = int.from_bytes(x, "big"), int.from_bytes(y, "big")
|
||||
if left >= P256_PRIME or right >= P256_PRIME or (right * right - left**3 + 3 * left - P256_B) % P256_PRIME:
|
||||
return None
|
||||
return b"\x04" + left.to_bytes(32, "big") + right.to_bytes(32, "big")
|
||||
|
||||
|
||||
def cose_key(key: object) -> tuple[int, bytes]:
|
||||
if not isinstance(key, dict):
|
||||
raise Refused("the credential public key is no COSE map")
|
||||
kty, alg = key.get(1), key.get(3)
|
||||
if kty == 2 and alg == -7 and key.get(-1) == 1 and (point := p256_point(key.get(-2), key.get(-3))):
|
||||
return alg, EC_PREFIX + point
|
||||
if kty == 1 and alg == -8 and key.get(-1) == 6 and octets(key.get(-2), 32):
|
||||
return alg, ED_PREFIX + key[-2]
|
||||
if kty == 3 and alg == -257 and isinstance(key.get(-1), bytes) and isinstance(key.get(-2), bytes) and key[-1] and key[-2]:
|
||||
public = der(0x30, der_integer(key[-1]) + der_integer(key[-2]))
|
||||
return alg, der(0x30, RSA_IDENTIFIER + der(0x03, b"\x00" + public))
|
||||
raise Refused("the key is none of ES256 on P-256, EdDSA on Ed25519 or RS256")
|
||||
|
||||
|
||||
def client_data(raw: bytes, kind: str, origin: str) -> dict:
|
||||
try:
|
||||
client = json.loads(raw)
|
||||
except (ValueError, RecursionError) as error:
|
||||
raise Refused("clientDataJSON is no JSON") from error
|
||||
if not isinstance(client, dict) or client.get("type") != kind:
|
||||
raise Refused(f"clientDataJSON is no {kind}")
|
||||
if client.get("origin") != origin or client.get("crossOrigin") is True:
|
||||
raise Refused(f"the origin is not {origin}")
|
||||
return client
|
||||
|
||||
|
||||
def rp_flags(data: bytes, size: int) -> int:
|
||||
if len(data) < size or data[:32] != hashlib.sha256(RP_ID.encode("ascii")).digest():
|
||||
raise Refused(f"the rpIdHash is not the one of {RP_ID}")
|
||||
if not data[32] & FLAG_UP:
|
||||
raise Refused("the user presence flag is missing")
|
||||
return data[32]
|
||||
|
||||
|
||||
def attested(credential: object, origin: str) -> dict:
|
||||
"""Args:
|
||||
credential: {"id", "attestationObject", "clientDataJSON"} as the
|
||||
settings panel encodes the navigator.credentials.create answer.
|
||||
origin: the only origin the registration may come from.
|
||||
|
||||
Returns:
|
||||
The entry to pin: {"id", "alg", "spki"}, the key as base64url DER.
|
||||
"""
|
||||
if not isinstance(credential, dict) or set(credential) != {"id", "attestationObject", "clientDataJSON"}:
|
||||
raise Refused("the credential needs exactly id, attestationObject and clientDataJSON")
|
||||
raw_id = b64decode(credential["id"], "id")
|
||||
client_data(b64decode(credential["clientDataJSON"], "clientDataJSON"), "webauthn.create", origin)
|
||||
blob = b64decode(credential["attestationObject"], "attestationObject")
|
||||
attestation, end = cbor(blob, 0, 0)
|
||||
if end != len(blob) or not isinstance(attestation, dict) or not isinstance(attestation.get("authData"), bytes):
|
||||
raise Refused("the attestationObject holds no authData")
|
||||
data = attestation["authData"]
|
||||
if not rp_flags(data, 55) & FLAG_AT:
|
||||
raise Refused("the registration carries no attested credential")
|
||||
size = int.from_bytes(data[53:55], "big")
|
||||
if data[55 : 55 + size] != raw_id:
|
||||
raise Refused("the attested credential id differs from the id")
|
||||
key, _ = cbor(data, 55 + size, 0)
|
||||
alg, spki = cose_key(key)
|
||||
return {"id": b64encode(raw_id), "alg": alg, "spki": b64encode(spki)}
|
||||
|
||||
|
||||
def by_library(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool:
|
||||
try:
|
||||
key = serialization.load_der_public_key(spki)
|
||||
if alg == -7 and isinstance(key, ec.EllipticCurvePublicKey) and isinstance(key.curve, ec.SECP256R1):
|
||||
key.verify(signature, data, ec.ECDSA(hashes.SHA256()))
|
||||
elif alg == -8 and isinstance(key, ed25519.Ed25519PublicKey):
|
||||
key.verify(signature, data)
|
||||
elif alg == -257 and isinstance(key, rsa.RSAPublicKey):
|
||||
key.verify(signature, data, padding.PKCS1v15(), hashes.SHA256())
|
||||
else:
|
||||
return False
|
||||
except (InvalidSignature, UnsupportedAlgorithm, ValueError, TypeError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def key_type(alg: int, spki: bytes) -> bool:
|
||||
if alg == -7:
|
||||
return len(spki) == len(EC_PREFIX) + 65 and spki.startswith(EC_PREFIX)
|
||||
if alg == -8:
|
||||
return len(spki) == len(ED_PREFIX) + 32 and spki.startswith(ED_PREFIX)
|
||||
return alg == -257 and RSA_IDENTIFIER in spki[:24]
|
||||
|
||||
|
||||
def by_openssl(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool:
|
||||
if not key_type(alg, spki):
|
||||
return False
|
||||
with tempfile.TemporaryDirectory(prefix="kvb-orchestrator-verify-") as folder:
|
||||
key, message, sealed = (Path(folder) / name for name in ("key.pem", "data.bin", "signature.bin"))
|
||||
key.write_text(f"-----BEGIN PUBLIC KEY-----\n{base64.encodebytes(spki).decode('ascii')}-----END PUBLIC KEY-----\n", encoding="ascii")
|
||||
message.write_bytes(data)
|
||||
sealed.write_bytes(signature)
|
||||
if alg == -8:
|
||||
argv = ["openssl", "pkeyutl", "-verify", "-pubin", "-inkey", str(key), "-rawin", "-in", str(message), "-sigfile", str(sealed)]
|
||||
else:
|
||||
argv = ["openssl", "dgst", "-sha256", "-verify", str(key), "-signature", str(sealed), str(message)]
|
||||
try:
|
||||
done = subprocess.run(argv, capture_output=True, timeout=TIMEOUT, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return done.returncode == 0
|
||||
|
||||
|
||||
def verify_signature(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool:
|
||||
if serialization is None:
|
||||
return by_openssl(alg, spki, data, signature)
|
||||
return by_library(alg, spki, data, signature)
|
||||
|
||||
|
||||
def verify_assertion(assertion: object, payload: dict, credentials: list[dict], origin: str) -> int:
|
||||
"""Args:
|
||||
assertion: {"id", "clientDataJSON", "authenticatorData", "signature"}
|
||||
from navigator.credentials.get, base64url.
|
||||
payload: the payload whose challenge the operator signed.
|
||||
credentials: the pinned keys.
|
||||
origin: the only origin the signature may come from.
|
||||
|
||||
Returns:
|
||||
The authenticator's signature counter.
|
||||
"""
|
||||
if not isinstance(assertion, dict) or not all(isinstance(assertion.get(name), str) for name in ASSERTION_KEYS):
|
||||
raise Refused("the assertion is incomplete")
|
||||
credential = next((entry for entry in credentials if entry["id"] == assertion["id"]), None)
|
||||
if credential is None:
|
||||
raise Refused("the signing key is not pinned")
|
||||
raw = b64decode(assertion["clientDataJSON"], "clientDataJSON")
|
||||
if client_data(raw, "webauthn.get", origin).get("challenge") != challenge(payload):
|
||||
raise Refused("the challenge is not the one of the payload")
|
||||
data = b64decode(assertion["authenticatorData"], "authenticatorData")
|
||||
rp_flags(data, 37)
|
||||
signed = data + hashlib.sha256(raw).digest()
|
||||
if not verify_signature(credential["alg"], b64decode(credential["spki"], "spki"), signed, b64decode(assertion["signature"], "signature")):
|
||||
raise Refused("the signature does not verify with the pinned key")
|
||||
return int.from_bytes(data[33:37], "big")
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Args:
|
||||
--port: port of the Agent Orchestrator on 127.0.0.1; the signature's origin is
|
||||
http://localhost:<port>.
|
||||
--timeout: seconds to wait for the operator's decision; keep it below the
|
||||
hook's timeout in settings.json.
|
||||
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
|
||||
--replay: this hook's replay store, outside the hub's reach, e.g.
|
||||
~/.local/state/kvb-orchestrator-approvals/hook.json.
|
||||
stdin: the Claude Code PermissionRequest hook input.
|
||||
|
||||
Returns:
|
||||
stdout: the PermissionRequest decision JSON: allow only when a pinned
|
||||
passkey signed exactly this tool, command, cwd, session and ask id;
|
||||
deny when the operator denied in the Agent Orchestrator. Nothing on timeout,
|
||||
error, a missing or invalid signature, a value longer than the hub
|
||||
takes or a hub that is down, so Claude Code shows its own dialog.
|
||||
The exit code is 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import hub_api
|
||||
import signing
|
||||
import verifier
|
||||
|
||||
POLL = 25.0
|
||||
NAME_LIMIT = 200
|
||||
COMMAND_LIMIT = 4000
|
||||
PATH_LIMIT = 4096
|
||||
LIMITS = {"tool": NAME_LIMIT, "command": COMMAND_LIMIT, "cwd": PATH_LIMIT, "session": NAME_LIMIT, "agent": NAME_LIMIT}
|
||||
DENIED = "The operator denied this in the Agent Orchestrator."
|
||||
|
||||
|
||||
def summary(tool_input: object) -> str:
|
||||
if isinstance(tool_input, dict):
|
||||
for key in ("command", "file_path", "url", "pattern", "path"):
|
||||
if isinstance(tool_input.get(key), str):
|
||||
return tool_input[key]
|
||||
return json.dumps(tool_input, ensure_ascii=False)
|
||||
|
||||
|
||||
def facts(hook: dict) -> dict:
|
||||
return {
|
||||
"tool": str(hook.get("tool_name") or "?"),
|
||||
"command": summary(hook.get("tool_input")),
|
||||
"cwd": str(hook.get("cwd") or ""),
|
||||
"session": str(hook.get("session_id") or ""),
|
||||
"agent": str(hook.get("agent_type") or ""),
|
||||
}
|
||||
|
||||
|
||||
def create(base: str, ask: dict, timeout: float) -> str:
|
||||
payload = {**ask, "timeout": timeout}
|
||||
headers = {"Content-Type": "application/json", **hub_api.GUARD}
|
||||
request = urllib.request.Request(f"{base}/api/asks", data=json.dumps(payload).encode(), headers=headers, method="POST")
|
||||
ask_id = hub_api.call(request, hub_api.SLACK)["id"]
|
||||
if not isinstance(ask_id, str) or not signing.ID.fullmatch(ask_id):
|
||||
raise ValueError("the hub answered no ask id")
|
||||
return ask_id
|
||||
|
||||
|
||||
def status(base: str, ask_id: str, wait: float) -> dict:
|
||||
answer = hub_api.call(urllib.request.Request(f"{base}/api/asks/{ask_id}?wait={wait:.1f}"), wait + hub_api.SLACK)
|
||||
if not isinstance(answer.get("status"), str):
|
||||
raise TypeError("the hub answered no status")
|
||||
return answer
|
||||
|
||||
|
||||
def decision(answer: dict, item: dict, trust: verifier.Trust) -> dict | None:
|
||||
if answer["status"] == "denied":
|
||||
return {"behavior": "deny", "message": DENIED}
|
||||
if answer["status"] != "allowed":
|
||||
return None
|
||||
try:
|
||||
verifier.check(answer.get("proof"), item, "", trust, time.time())
|
||||
except (signing.Refused, OSError) as error:
|
||||
print(f"kvb-orchestrator: not signed, Claude Code asks instead: {error}", file=sys.stderr)
|
||||
return None
|
||||
return {"behavior": "allow"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="permission_hook")
|
||||
parser.add_argument("--port", type=int, required=True)
|
||||
parser.add_argument("--timeout", type=float, required=True)
|
||||
parser.add_argument("--approvers", type=Path, required=True)
|
||||
parser.add_argument("--replay", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
base = hub_api.base_url(args.port)
|
||||
trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port))
|
||||
deadline = time.monotonic() + args.timeout
|
||||
try:
|
||||
hook = json.load(sys.stdin)
|
||||
ask = facts(hook)
|
||||
too_long = [name for name, limit in LIMITS.items() if len(ask[name]) > limit]
|
||||
if too_long:
|
||||
print(f"kvb-orchestrator: {', '.join(too_long)} too long to sign in full, Claude Code asks instead", file=sys.stderr)
|
||||
return 0
|
||||
ask_id = create(base, ask, args.timeout)
|
||||
answer = {"status": "pending"}
|
||||
while answer["status"] == "pending" and (left := deadline - time.monotonic()) > 0:
|
||||
answer = status(base, ask_id, min(left, POLL))
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||||
return 0
|
||||
chosen = decision(answer, signing.ask_item(ask_id, ask["tool"], ask["command"], ask["cwd"], ask["session"]), trust)
|
||||
if chosen is not None:
|
||||
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": chosen}}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Args:
|
||||
--port: port of the Agent Orchestrator on 127.0.0.1; the signature's origin is
|
||||
http://localhost:<port>.
|
||||
--interval: seconds between two polls of the hub.
|
||||
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
|
||||
--replay: the runner's replay store, outside the hub's reach, e.g.
|
||||
~/.local/state/kvb-orchestrator-approvals/runner.json.
|
||||
--once: handle the queued jobs once and exit.
|
||||
|
||||
Returns:
|
||||
Runs as the operator, outside any sandbox, every operator command a
|
||||
pinned passkey signed in the Agent Orchestrator: exactly the signed text, with bash
|
||||
in the signed directory, each signature once and before its expiry.
|
||||
Refuses every other job. Posts the last 200 output lines back while it
|
||||
runs and the exit code at its end. The exit code is 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import hub_api
|
||||
import signing
|
||||
import verifier
|
||||
|
||||
LINES = 200
|
||||
BEAT = 2.0
|
||||
REFUSED = "refused: no valid operator signature for this command"
|
||||
|
||||
|
||||
def call(base: str, path: str, payload: dict | None) -> dict:
|
||||
headers = dict(hub_api.GUARD)
|
||||
data = None
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
data = json.dumps(payload).encode()
|
||||
request = urllib.request.Request(f"{base}{path}", data=data, headers=headers, method="GET" if payload is None else "POST")
|
||||
return hub_api.call(request, hub_api.SLACK)
|
||||
|
||||
|
||||
def collect(stream: object, tail: collections.deque, lock: threading.Lock) -> None:
|
||||
# The reader closes the pipe at EOF: a close in execute() would block on the buffer lock this read holds and SIGPIPE a command still running.
|
||||
with stream:
|
||||
for line in stream:
|
||||
with lock:
|
||||
tail.append(line)
|
||||
|
||||
|
||||
def refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
|
||||
"""Args:
|
||||
job: the claimed job with task, command, directory and proof.
|
||||
trust: the pinned keys, the runner's replay store and the origin.
|
||||
now: the current time in epoch seconds.
|
||||
|
||||
Returns:
|
||||
Why the job must not run, or None when a pinned key signed it.
|
||||
"""
|
||||
try:
|
||||
verifier.check(job.get("proof"), signing.operator_item(job["task"], job["command"], job["directory"]), "", trust, now)
|
||||
except (signing.Refused, OSError) as error:
|
||||
return f"{REFUSED}: {error}"
|
||||
return None
|
||||
|
||||
|
||||
def execute(job: dict, report: Callable[[dict], None]) -> int:
|
||||
"""Args:
|
||||
job: the claimed and verified job with command and directory.
|
||||
report: called with {"output", "code", "refused"}; code None while
|
||||
the command runs.
|
||||
|
||||
Returns:
|
||||
The exit code.
|
||||
"""
|
||||
tail: collections.deque = collections.deque(maxlen=LINES)
|
||||
lock = threading.Lock()
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["bash", "-c", job["command"]],
|
||||
cwd=job["directory"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
errors="replace",
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as error:
|
||||
report({"output": f"could not start: {error}", "code": 127, "refused": False})
|
||||
return 127
|
||||
reader = threading.Thread(target=collect, args=(process.stdout, tail, lock), daemon=True)
|
||||
reader.start()
|
||||
while True:
|
||||
try:
|
||||
code = process.wait(BEAT)
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
with lock:
|
||||
output = "".join(tail)
|
||||
report({"output": output, "code": None, "refused": False})
|
||||
reader.join()
|
||||
report({"output": "".join(tail), "code": code, "refused": False})
|
||||
return code
|
||||
|
||||
|
||||
def sender(base: str, job_id: str) -> Callable[[dict], None]:
|
||||
def send(result: dict) -> None:
|
||||
try:
|
||||
call(base, f"/api/runner/{job_id}/report", result)
|
||||
except (OSError, ValueError, TypeError):
|
||||
return
|
||||
|
||||
return send
|
||||
|
||||
|
||||
def cycle(base: str, trust: verifier.Trust) -> None:
|
||||
for job in call(base, "/api/runner/jobs", None)["jobs"]:
|
||||
try:
|
||||
claimed = call(base, f"/api/runner/{job['id']}/claim", {})
|
||||
except urllib.error.HTTPError:
|
||||
continue
|
||||
send = sender(base, claimed["id"])
|
||||
reason = refusal(claimed, trust, time.time())
|
||||
if reason is None:
|
||||
execute(claimed, send)
|
||||
else:
|
||||
send({"output": reason, "code": None, "refused": True})
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog="runner")
|
||||
parser.add_argument("--port", type=int, required=True)
|
||||
parser.add_argument("--interval", type=float, required=True)
|
||||
parser.add_argument("--approvers", type=Path, required=True)
|
||||
parser.add_argument("--replay", type=Path, required=True)
|
||||
parser.add_argument("--once", action="store_true")
|
||||
args = parser.parse_args()
|
||||
base = hub_api.base_url(args.port)
|
||||
trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port))
|
||||
while True:
|
||||
try:
|
||||
cycle(base, trust)
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
pass
|
||||
if args.once:
|
||||
return 0
|
||||
time.sleep(args.interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
RP_ID = "localhost"
|
||||
VERSION = 1
|
||||
LIFETIME = 900
|
||||
RULE_LIFETIME = 366 * 86400
|
||||
ALGORITHMS = {-7: "ES256", -8: "EdDSA", -257: "RS256"}
|
||||
HASH = re.compile(r"[0-9a-f]{64}")
|
||||
NONCE = re.compile(r"[0-9a-f]{32}")
|
||||
ID = re.compile(r"[0-9a-f]{16}")
|
||||
CHAINED = re.compile(r"[;&|`$<>()\n\r]")
|
||||
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
|
||||
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
|
||||
WARNINGS = (
|
||||
("löscht rekursiv", re.compile(r"\brm\s+(?:-\S+\s+)*-\S*[rR]")),
|
||||
("erzwingt", re.compile(r"--force\b|\bpush\b.*\s-f\b")),
|
||||
("pusht", re.compile(r"\bgit\b.*\bpush\b")),
|
||||
("root", re.compile(r"\bsudo\b|\bdoas\b")),
|
||||
("Pipe in eine Shell", re.compile(r"\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b")),
|
||||
("verwirft Änderungen", re.compile(r"\bgit\b.*\b(?:reset\s+--hard|clean\s+-\S*f)")),
|
||||
("Docker löscht", re.compile(r"\bdocker\s+(?:system\s+prune|volume\s+(?:rm|prune)|rmi?\b)")),
|
||||
)
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def origin_of(port: int) -> str:
|
||||
return f"http://{RP_ID}:{port}"
|
||||
|
||||
|
||||
def b64encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False).encode("ascii")
|
||||
|
||||
|
||||
def challenge(payload: dict) -> str:
|
||||
return b64encode(hashlib.sha256(canonical(payload)).digest())
|
||||
|
||||
|
||||
def item_hash(item: dict) -> str:
|
||||
return hashlib.sha256(canonical(item)).hexdigest()
|
||||
|
||||
|
||||
def ask_item(ask_id: str, tool: str, command: str, cwd: str, session: str) -> dict:
|
||||
return {"kind": "ask", "id": ask_id, "tool": tool, "command": command, "cwd": cwd, "session": session}
|
||||
|
||||
|
||||
def review_item(review_id: str, repo: str, worktree: str, branch: str, tree: str) -> dict:
|
||||
return {"kind": "review", "id": review_id, "repo": repo, "worktree": worktree, "branch": branch, "tree": tree}
|
||||
|
||||
|
||||
def operator_item(task: str, command: str, directory: str) -> dict:
|
||||
return {"kind": "operator", "id": task, "command": command, "directory": directory}
|
||||
|
||||
|
||||
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
|
||||
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
|
||||
|
||||
|
||||
def rule_payload(rule_id: str, kinds: list[str], tool: str, pattern: str, scope: str, expires: int) -> dict:
|
||||
return {"v": VERSION, "kind": "rule", "id": rule_id, "kinds": kinds, "tool": tool, "pattern": pattern, "scope": scope, "expires": expires}
|
||||
|
||||
|
||||
def warnings(command: str) -> list[str]:
|
||||
return [label for label, pattern in WARNINGS if pattern.search(command)]
|
||||
|
||||
|
||||
def expiry(value: object, now: float, cap: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise Refused("the expiry is no whole number of epoch seconds")
|
||||
if value <= now:
|
||||
raise Refused("the signature has expired")
|
||||
if value > now + cap:
|
||||
raise Refused("the expiry lies too far ahead")
|
||||
return value
|
||||
|
||||
|
||||
def checked_approval(payload: object, now: float) -> dict:
|
||||
if not isinstance(payload, dict) or set(payload) != APPROVAL_KEYS or payload["v"] != VERSION or payload["kind"] != "approve":
|
||||
raise Refused("the payload is no approval")
|
||||
items = payload["items"]
|
||||
if not isinstance(items, list) or not items or not all(isinstance(entry, str) and HASH.fullmatch(entry) for entry in items):
|
||||
raise Refused("the approval lists no item hashes")
|
||||
if not isinstance(payload["nonce"], str) or not NONCE.fullmatch(payload["nonce"]):
|
||||
raise Refused("the approval holds no nonce")
|
||||
expiry(payload["expires"], now, LIFETIME)
|
||||
return payload
|
||||
|
||||
|
||||
def checked_rule(payload: object, now: float) -> dict:
|
||||
if not isinstance(payload, dict) or set(payload) != RULE_KEYS or payload["v"] != VERSION or payload["kind"] != "rule":
|
||||
raise Refused("the payload is no rule")
|
||||
if not isinstance(payload["id"], str) or not ID.fullmatch(payload["id"]):
|
||||
raise Refused("the rule holds no id")
|
||||
kinds = payload["kinds"]
|
||||
if not isinstance(kinds, list) or not kinds or not all(isinstance(kind, str) for kind in kinds):
|
||||
raise Refused("the rule names no kinds")
|
||||
if not isinstance(payload["tool"], str) or not all(isinstance(payload[name], str) and payload[name] for name in ("pattern", "scope")):
|
||||
raise Refused("the rule needs a tool text, a pattern and a scope")
|
||||
expiry(payload["expires"], now, RULE_LIFETIME)
|
||||
return payload
|
||||
|
||||
|
||||
def rule_allows(rule: dict, item: dict, gate: str, now: float) -> bool:
|
||||
if rule["expires"] <= now or item["kind"] not in rule["kinds"]:
|
||||
return False
|
||||
if item["kind"] == "ask":
|
||||
named = fnmatch.fnmatchcase(item["tool"], rule["tool"]) and fnmatch.fnmatchcase(item["cwd"], rule["scope"])
|
||||
plain = not CHAINED.search(item["command"]) and not warnings(item["command"])
|
||||
return named and plain and fnmatch.fnmatchcase(item["command"], rule["pattern"])
|
||||
if item["kind"] == "review":
|
||||
return gate == "pass" and fnmatch.fnmatchcase(item["branch"], rule["pattern"]) and fnmatch.fnmatchcase(item["worktree"], rule["scope"])
|
||||
return False
|
||||
|
||||
|
||||
def load_pinned(path: Path) -> list[dict]:
|
||||
try:
|
||||
mode = path.stat().st_mode
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except (OSError, ValueError, RecursionError) as error:
|
||||
raise Refused(f"the pinned keys in {path} are unreadable") from error
|
||||
if mode & 0o022:
|
||||
raise Refused(f"{path} is writable by others")
|
||||
entries = data.get("credentials") if isinstance(data, dict) else None
|
||||
shaped = isinstance(entries, list) and all(isinstance(entry, dict) and isinstance(entry.get("id"), str) and entry.get("alg") in ALGORITHMS and isinstance(entry.get("spki"), str) for entry in entries)
|
||||
if not shaped:
|
||||
raise Refused(f"the pinned keys in {path} are malformed")
|
||||
return entries
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from passkey import verify_assertion
|
||||
from signing import (
|
||||
Refused,
|
||||
checked_approval,
|
||||
checked_rule,
|
||||
item_hash,
|
||||
load_pinned,
|
||||
rule_allows,
|
||||
)
|
||||
|
||||
|
||||
class Trust(NamedTuple):
|
||||
approvers: Path
|
||||
replay: Path
|
||||
origin: str
|
||||
|
||||
|
||||
def pinned(path: Path) -> list[dict]:
|
||||
entries = load_pinned(path)
|
||||
if not entries:
|
||||
raise Refused("no key is pinned")
|
||||
return entries
|
||||
|
||||
|
||||
def write_private(path: Path, data: dict) -> None:
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
descriptor, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}-")
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=1, sort_keys=True)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(name, path)
|
||||
except OSError:
|
||||
Path(name).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def pin(path: Path, entry: dict, now: float) -> list[dict]:
|
||||
stamp = datetime.datetime.fromtimestamp(now, datetime.UTC).isoformat(timespec="seconds")
|
||||
entries = [old for old in load_pinned(path) if old["id"] != entry["id"]] + [{**entry, "pinned": stamp}]
|
||||
write_private(path, {"credentials": entries})
|
||||
return entries
|
||||
|
||||
|
||||
def read_store(store: Path) -> dict:
|
||||
try:
|
||||
data = json.loads(store.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return {"used": {}, "counts": {}}
|
||||
except (OSError, ValueError, RecursionError) as error:
|
||||
raise Refused(f"the replay store {store} is unreadable") from error
|
||||
counts = data.get("counts") if isinstance(data, dict) else None
|
||||
shaped = isinstance(counts, dict) and all(isinstance(last, dict) and isinstance(last.get("count"), int) and isinstance(last.get("nonce"), str) for last in counts.values())
|
||||
if not shaped or not isinstance(data.get("used"), dict):
|
||||
raise Refused(f"the replay store {store} is malformed")
|
||||
return data
|
||||
|
||||
|
||||
def consume(store: Path, key: str, expires: int, credential: str, count: int, nonce: str, now: float) -> None:
|
||||
"""Args:
|
||||
store: the executor's own replay store, outside the hub's reach.
|
||||
key: the one-time id, nonce and item hash.
|
||||
expires: until when the id must stay known.
|
||||
credential: the id of the key that signed.
|
||||
count: its signature counter; 0 when the authenticator keeps none.
|
||||
nonce: the payload nonce; one bulk signature carries one counter.
|
||||
now: the current time in epoch seconds.
|
||||
"""
|
||||
store.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
descriptor = os.open(store.with_name(f"{store.name}.lock"), os.O_RDWR | os.O_CREAT, 0o600)
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
state = read_store(store)
|
||||
used = {name: until for name, until in state["used"].items() if isinstance(until, int | float) and until > now}
|
||||
if key in used:
|
||||
raise Refused("this signature was already used for this item")
|
||||
last = state["counts"].get(credential)
|
||||
if last is not None and not (count > last["count"] or (count == last["count"] and (count == 0 or nonce == last["nonce"]))):
|
||||
raise Refused("the signature counter did not grow: a cloned authenticator or a replay")
|
||||
write_private(store, {"used": {**used, key: expires}, "counts": {**state["counts"], credential: {"count": count, "nonce": nonce}}})
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def check(proof: object, item: dict, gate: str, trust: Trust, now: float) -> None:
|
||||
"""Args:
|
||||
proof: {"payload", "assertion"} the Agent Orchestrator relayed for the item.
|
||||
item: the executor's own item, built from what it is about to do.
|
||||
gate: pass or fail as the executor itself saw it; a rule approves a
|
||||
review on pass only.
|
||||
trust: the pinned keys, the executor's replay store and the origin.
|
||||
now: the current time in epoch seconds.
|
||||
|
||||
Raises:
|
||||
Refused with the reason, unless a pinned key signed an unexpired
|
||||
approval that lists the item and is used here for the first time, or
|
||||
an unexpired rule whose pattern the item matches.
|
||||
"""
|
||||
if not isinstance(proof, dict) or set(proof) != {"payload", "assertion"}:
|
||||
raise Refused("no signature")
|
||||
payload = proof["payload"]
|
||||
if isinstance(payload, dict) and payload.get("kind") == "rule":
|
||||
checked_rule(payload, now)
|
||||
verify_assertion(proof["assertion"], payload, pinned(trust.approvers), trust.origin)
|
||||
if not rule_allows(payload, item, gate, now):
|
||||
raise Refused("the signed rule does not match this item")
|
||||
return
|
||||
checked_approval(payload, now)
|
||||
count = verify_assertion(proof["assertion"], payload, pinned(trust.approvers), trust.origin)
|
||||
digest = item_hash(item)
|
||||
if digest not in payload["items"]:
|
||||
raise Refused("the signature does not list this item")
|
||||
consume(trust.replay, f"{payload['nonce']}:{digest}", payload["expires"], proof["assertion"]["id"], count, payload["nonce"], now)
|
||||
@@ -0,0 +1,132 @@
|
||||
name: kvb-orchestrator
|
||||
|
||||
services:
|
||||
hub:
|
||||
build: .
|
||||
image: kvb-orchestrator:local
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
user: "${KVB_ORCHESTRATOR_USER:?written into .env by scripts/install.sh}"
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
mem_limit: 2g
|
||||
pids_limit: 256
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import socket; socket.create_connection(('127.0.0.1', 8080), 2).close()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
ports:
|
||||
- "127.0.0.1:${KVB_ORCHESTRATOR_PORT:?set in .env}:8080"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
command:
|
||||
- --bind
|
||||
- 0.0.0.0
|
||||
- --listen-port
|
||||
- "8080"
|
||||
- --public-port
|
||||
- "${KVB_ORCHESTRATOR_PORT:?set in .env}"
|
||||
- --council
|
||||
- /tmp/agents-council
|
||||
- --pages
|
||||
- /tmp/kvb-git-diff
|
||||
- --state
|
||||
- /state
|
||||
- --host-state
|
||||
- "${KVB_ORCHESTRATOR_STATE:?written into .env by scripts/install.sh}"
|
||||
- --skills
|
||||
- /skills
|
||||
- --transcripts
|
||||
- /projects
|
||||
- --templates
|
||||
- /app/templates
|
||||
- --repos
|
||||
- "${KVB_ORCHESTRATOR_REPOS:?written into .env by scripts/install.sh}"
|
||||
- --worktrees
|
||||
- "${KVB_ORCHESTRATOR_WORKTREES:?written into .env by scripts/install.sh}"
|
||||
- --timers
|
||||
- /timers
|
||||
- --llm-url
|
||||
- "${KVB_ORCHESTRATOR_LLM_URL}"
|
||||
- --llm-model
|
||||
- "${KVB_ORCHESTRATOR_LLM_MODEL}"
|
||||
- --llm-key-file
|
||||
- /run/kvb-orchestrator/llm.key
|
||||
- --piper
|
||||
- /opt/piper/bin/piper
|
||||
- --voices
|
||||
- /opt/voices
|
||||
- --operator-voice
|
||||
- "${KVB_ORCHESTRATOR_OPERATOR_VOICE:?set in .env}"
|
||||
- --whisper
|
||||
- /opt/whisper/whisper-cli
|
||||
- --whisper-model
|
||||
- /opt/whisper/ggml-base-q8_0.bin
|
||||
- --scratch
|
||||
- /scratch
|
||||
- --approvers
|
||||
- /approvers/approvers.json
|
||||
tmpfs:
|
||||
- /scratch:size=64m,mode=1777,noexec,nosuid,nodev
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /tmp
|
||||
target: /tmp
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: "${KVB_ORCHESTRATOR_COUNCIL:?written into .env by scripts/install.sh}"
|
||||
target: /tmp/agents-council
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: "${KVB_ORCHESTRATOR_SKILLS:?written into .env by scripts/install.sh}"
|
||||
target: /skills
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: "${KVB_ORCHESTRATOR_PROJECTS:?written into .env by scripts/install.sh}"
|
||||
target: /projects
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: "${KVB_ORCHESTRATOR_STATE:?written into .env by scripts/install.sh}"
|
||||
target: /state
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: "${KVB_ORCHESTRATOR_REPOS:?written into .env by scripts/install.sh}"
|
||||
target: "${KVB_ORCHESTRATOR_REPOS:?written into .env by scripts/install.sh}"
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: "${KVB_ORCHESTRATOR_WORKTREES:?written into .env by scripts/install.sh}"
|
||||
target: "${KVB_ORCHESTRATOR_WORKTREES:?written into .env by scripts/install.sh}"
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: "${KVB_ORCHESTRATOR_TIMERS:?written into .env by scripts/install.sh}"
|
||||
target: /timers
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: "${KVB_ORCHESTRATOR_APPROVERS:?written into .env by scripts/install.sh}"
|
||||
target: /approvers
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
# /dev/null is an empty key file, which server.py --llm-key-file reads as no key
|
||||
source: "${KVB_ORCHESTRATOR_LLM_KEY_FILE:-/dev/null}"
|
||||
target: /run/kvb-orchestrator/llm.key
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
@@ -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"]]))
|
||||
@@ -0,0 +1,19 @@
|
||||
piper-tts==1.8.0 \
|
||||
--hash=sha256:25b4d3f31ff70c8fa7151908e00aaa5650cbdf16bca8fcf21299f3941b89a7d3 \
|
||||
--hash=sha256:3f60c1917de6d8e8033f395878ad3f88f6dfee88a8b05f98971a275f76a38484
|
||||
onnxruntime==1.31.1 \
|
||||
--hash=sha256:1f3dea47f2f0e5117c2404cea7d21fd253ae7836a3bf167a501cdfbb2600d536 \
|
||||
--hash=sha256:0b9a492176886992e0e875f06bc0367e333f5fff11711a2d642e18b02f3009cc
|
||||
pathvalidate==3.3.1 \
|
||||
--hash=sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f
|
||||
numpy==2.5.4 \
|
||||
--hash=sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a \
|
||||
--hash=sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2
|
||||
protobuf==7.36.2 \
|
||||
--hash=sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2 \
|
||||
--hash=sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e \
|
||||
--hash=sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e
|
||||
flatbuffers==25.12.19 \
|
||||
--hash=sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4
|
||||
packaging==26.3 \
|
||||
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
|
||||
@@ -0,0 +1 @@
|
||||
jinja2==3.1.6
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Args:
|
||||
--bind: address the server listens on.
|
||||
--listen-port: port the server listens on.
|
||||
--public-port: port browsers and agents reach the hub on; the Host and
|
||||
Origin headers must name it.
|
||||
--council: directory holding rasci.json, news.jsonl, queue-history.jsonl,
|
||||
night.json, radio-silence.json and the claims under cluster/*/ and
|
||||
work/*/; a missing file leaves its part of the state empty. The board
|
||||
actions change it through the skill scripts.
|
||||
--pages: directory every review page must lie in.
|
||||
--state: writable directory for reviews.json, asks.json, decisions.json,
|
||||
messages.json, rules.json, jobs.json, marks.json, settings.json,
|
||||
log.jsonl, the page copies and uploads/<id>/<name>.
|
||||
--host-state: the state directory's path on the host, named in the radio
|
||||
rows that carry an upload.
|
||||
--skills: installed skills directory holding the kvb-org-radio,
|
||||
kvb-org-rasci and kvb-org-sunset scripts.
|
||||
--transcripts: directory the Claude Code transcripts (*.jsonl) lie under.
|
||||
--templates: directory holding index.html.j2 and the *.js modules it
|
||||
includes.
|
||||
--repos: directory holding the repositories as <host>/<owner>/<repo>,
|
||||
mounted read-only at its host path.
|
||||
--worktrees: directory holding linked worktrees, mounted read-only at its
|
||||
host path.
|
||||
--timers: directory holding the systemd user units (*.timer).
|
||||
--llm-url: base URL of an OpenAI-compatible chat endpoint; empty turns the
|
||||
natural-language command line off.
|
||||
--llm-model: model name sent to that endpoint; empty turns it off.
|
||||
--llm-key-file: file holding the endpoint's bearer key; empty file or
|
||||
unreadable means no key.
|
||||
--piper: the Piper executable, run as a separate process; missing turns
|
||||
speech output off.
|
||||
--voices: directory holding the Piper voices as <model>.onnx with
|
||||
<model>.onnx.json; every speaker of a model is a voice <model>:<id>.
|
||||
--operator-voice: the voice of the operator and the hub; it must be one of
|
||||
the installed voices when any is installed.
|
||||
--whisper: the whisper.cpp command line executable; missing turns speech
|
||||
input off.
|
||||
--whisper-model: the ggml model whisper.cpp loads.
|
||||
--scratch: writable directory the engines use as HOME and TMPDIR.
|
||||
--approvers: the pinned passkeys, mounted read-only; the hub only lists
|
||||
their ids and algorithms for the page and never verifies or pins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from kvb_orchestrator import NAME
|
||||
from kvb_orchestrator.engines import Engines
|
||||
from kvb_orchestrator.errors import ToolError
|
||||
from kvb_orchestrator.exchange import HubServer
|
||||
from kvb_orchestrator.handler import Handler
|
||||
from kvb_orchestrator.hub import Places, open_hub
|
||||
from kvb_orchestrator.language import Model, read_key
|
||||
from kvb_orchestrator.skill_scripts import Scripts
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(prog=NAME)
|
||||
parser.add_argument("--bind", required=True)
|
||||
for name in ("--listen-port", "--public-port"):
|
||||
parser.add_argument(name, type=int, required=True)
|
||||
paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--repos", "--worktrees", "--timers", "--llm-key-file", "--approvers")
|
||||
for name in (*paths, "--piper", "--voices", "--whisper", "--whisper-model", "--scratch"):
|
||||
parser.add_argument(name, type=Path, required=True)
|
||||
for name in ("--llm-url", "--llm-model", "--operator-voice"):
|
||||
parser.add_argument(name, required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
hub = open_hub(
|
||||
council=args.council,
|
||||
pages=args.pages,
|
||||
public_port=args.public_port,
|
||||
state=args.state,
|
||||
host_state=args.host_state,
|
||||
transcripts=args.transcripts,
|
||||
scripts=Scripts(skills=args.skills, council=args.council, home=args.state),
|
||||
templates=args.templates,
|
||||
places=Places(repos=args.repos, worktrees=args.worktrees, timers=args.timers),
|
||||
model=Model(url=args.llm_url.strip(), model=args.llm_model.strip(), key=read_key(args.llm_key_file)),
|
||||
engines=Engines(piper=args.piper, voices=args.voices, operator_voice=args.operator_voice, whisper=args.whisper, whisper_model=args.whisper_model, scratch=args.scratch),
|
||||
approvers=args.approvers,
|
||||
)
|
||||
except ToolError as error:
|
||||
parser.error(str(error))
|
||||
server = HubServer((args.bind, args.listen_port), Handler)
|
||||
server.hub = hub
|
||||
print(f"{NAME}: listening on {args.bind}:{args.listen_port}, public port {args.public_port}", flush=True)
|
||||
server.serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Agent Orchestrator runner: runs the operator commands a pinned passkey signed in the Agent Orchestrator
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=%h/.local/share/kvb-orchestrator/.env
|
||||
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/runner.py --port ${KVB_ORCHESTRATOR_PORT} --interval 2 --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/runner.json
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,216 @@
|
||||
const Ambience = (() => {
|
||||
"use strict";
|
||||
const RAD = Math.PI / 180;
|
||||
const DAY_MS = 86400000;
|
||||
const J2000 = 2451545;
|
||||
const AU = 149597870.7;
|
||||
const EARTH = 6378.14;
|
||||
const GLASS = 0.8;
|
||||
const CARD = 0.92;
|
||||
const LIGHT_FROM = 4;
|
||||
const SKY = [
|
||||
[-90, "#03050b", "#070a14"],
|
||||
[-18, "#04060e", "#0a0f1f"],
|
||||
[-12, "#080d1f", "#18203d"],
|
||||
[-6, "#111c3d", "#3b3763"],
|
||||
[-3, "#1c2b5a", "#8a5b6c"],
|
||||
[0, "#2b4377", "#de8a5e"],
|
||||
[3, "#44679f", "#efab6d"],
|
||||
[6, "#5884bf", "#f1c690"],
|
||||
[12, "#5d92d0", "#c8dbee"],
|
||||
[30, "#4e89d5", "#b4d2f1"],
|
||||
[90, "#3f7fd8", "#a9cbef"],
|
||||
];
|
||||
const STAGES = [[-18, "night"], [-12, "astronomical"], [-6, "nautical"], [0, "civil"], [6, "golden"], [Infinity, "day"]];
|
||||
const THEMES = {
|
||||
light: {
|
||||
bg: "#eef2f7", panel: "#ffffff", sunken: "#f2f4f8", hover: "#eaeef5", text: "#121821", muted: "#434e5f", faint: "#6b7586",
|
||||
line: "#dde3eb", "line-strong": "#c7cfda", accent: "#2f55d4", "accent-soft": "#e6ecfd", "on-accent": "#ffffff",
|
||||
ok: "#16804a", "ok-soft": "#e3f3ea", bad: "#c2352a", "bad-soft": "#fcebea", warn: "#8a5200", "warn-soft": "#fff1d9",
|
||||
info: "#2a5bd0", "info-soft": "#e7eefc", violet: "#6f3fc4", "violet-soft": "#f0e9fb", teal: "#0b7f78", "teal-soft": "#def2f0",
|
||||
"neutral-soft": "#eceff4", direct: "#3a6ff7", broadcast: "#12a39a", alarm: "#e0453a", security: "#8a4bd8", grant: "#2f9e58",
|
||||
"add-bg": "#e7f6ec", "add-text": "#14632f", "del-bg": "#fdeeed", "del-text": "#9b2219", "hunk-bg": "#edf1fd",
|
||||
shadow: "0 1px 2px rgb(15 23 42 / 6%), 0 4px 14px rgb(15 23 42 / 6%)", "shadow-lift": "0 12px 36px rgb(15 23 42 / 16%)",
|
||||
},
|
||||
dark: {
|
||||
bg: "#0b0f15", panel: "#121821", sunken: "#0e131a", hover: "#1a212b", text: "#e7ebf2", muted: "#a3adbb", faint: "#6f7988",
|
||||
line: "#252d38", "line-strong": "#343e4b", accent: "#8fadff", "accent-soft": "#1b2440", "on-accent": "#0b1020",
|
||||
ok: "#4cc47e", "ok-soft": "#12291c", bad: "#ff7b72", "bad-soft": "#341614", warn: "#f2b84b", "warn-soft": "#33260f",
|
||||
info: "#7da5ff", "info-soft": "#18233f", violet: "#b994ff", "violet-soft": "#251a3b", teal: "#43c9bf", "teal-soft": "#0f2a28",
|
||||
"neutral-soft": "#1d242e", direct: "#6f9bff", broadcast: "#3cc7bd", alarm: "#ff6b61", security: "#b58cff", grant: "#4cc97a",
|
||||
"add-bg": "#0f2a1a", "add-text": "#8fe0aa", "del-bg": "#33151a", "del-text": "#ffaaa3", "hunk-bg": "#18213a",
|
||||
shadow: "0 1px 2px rgb(0 0 0 / 40%), 0 6px 18px rgb(0 0 0 / 28%)", "shadow-lift": "0 16px 44px rgb(0 0 0 / 55%)",
|
||||
},
|
||||
};
|
||||
const sin = (deg) => Math.sin(deg * RAD);
|
||||
const cos = (deg) => Math.cos(deg * RAD);
|
||||
const tan = (deg) => Math.tan(deg * RAD);
|
||||
const clamp = (value, low, high) => Math.min(Math.max(value, low), high);
|
||||
const asin = (value) => Math.asin(clamp(value, -1, 1)) / RAD;
|
||||
const acos = (value) => Math.acos(clamp(value, -1, 1)) / RAD;
|
||||
const atan2 = (y, x) => Math.atan2(y, x) / RAD;
|
||||
const wrap = (deg) => ((deg % 360) + 360) % 360;
|
||||
|
||||
function julian(ms) {
|
||||
return ms / DAY_MS + 2440587.5;
|
||||
}
|
||||
|
||||
function centuries(ms) {
|
||||
return (julian(ms) - J2000) / 36525;
|
||||
}
|
||||
|
||||
function sidereal(ms) {
|
||||
const days = julian(ms) - J2000;
|
||||
const t = days / 36525;
|
||||
return wrap(280.46061837 + 360.98564736629 * days + 0.000387933 * t * t - (t * t * t) / 38710000);
|
||||
}
|
||||
|
||||
function obliquity(t) {
|
||||
const omega = 125.04 - 1934.136 * t;
|
||||
return 23 + (26 + (21.448 - t * (46.815 + t * (0.00059 - t * 0.001813))) / 60) / 60 + 0.00256 * cos(omega);
|
||||
}
|
||||
|
||||
function equatorial(lambda, beta, eps) {
|
||||
return {
|
||||
ra: wrap(atan2(sin(lambda) * cos(eps) - tan(beta) * sin(eps), cos(lambda))),
|
||||
dec: asin(sin(beta) * cos(eps) + cos(beta) * sin(eps) * sin(lambda)),
|
||||
};
|
||||
}
|
||||
|
||||
function horizontal(ha, dec, lat) {
|
||||
return {
|
||||
alt: asin(sin(lat) * sin(dec) + cos(lat) * cos(dec) * cos(ha)),
|
||||
az: wrap(atan2(sin(ha), cos(ha) * sin(lat) - tan(dec) * cos(lat)) + 180),
|
||||
};
|
||||
}
|
||||
|
||||
function sun(ms, lat, lon) {
|
||||
const t = centuries(ms);
|
||||
const l0 = wrap(280.46646 + t * (36000.76983 + t * 0.0003032));
|
||||
const m = 357.52911 + t * (35999.05029 - 0.0001537 * t);
|
||||
const e = 0.016708634 - t * (0.000042037 + 0.0000001267 * t);
|
||||
const center = sin(m) * (1.914602 - t * (0.004817 + 0.000014 * t)) + sin(2 * m) * (0.019993 - 0.000101 * t) + sin(3 * m) * 0.000289;
|
||||
const lambda = wrap(l0 + center - 0.00569 - 0.00478 * sin(125.04 - 1934.136 * t));
|
||||
const eps = obliquity(t);
|
||||
const place = equatorial(lambda, 0, eps);
|
||||
const y = tan(eps / 2) ** 2;
|
||||
const eot = (4 * (y * sin(2 * l0) - 2 * e * sin(m) + 4 * e * y * sin(m) * cos(2 * l0) - 0.5 * y * y * sin(4 * l0) - 1.25 * e * e * sin(2 * m))) / RAD;
|
||||
const distance = (1.000001018 * (1 - e * e)) / (1 + e * cos(m + center));
|
||||
const minutes = (((ms % DAY_MS) + DAY_MS) % DAY_MS) / 60000;
|
||||
const ha = (minutes + eot + 4 * lon) / 4 - 180;
|
||||
return {...place, lambda, eot, distance, ha, ...horizontal(ha, place.dec, lat)};
|
||||
}
|
||||
|
||||
function moon(ms, lat, lon) {
|
||||
const t = centuries(ms);
|
||||
const anomaly = 134.9 + 477198.85 * t;
|
||||
const lambda = wrap(218.32 + 481267.881 * t + 6.29 * sin(anomaly) - 1.27 * sin(259.2 - 413335.38 * t) + 0.66 * sin(235.7 + 890534.23 * t)
|
||||
+ 0.21 * sin(269.9 + 954397.7 * t) - 0.19 * sin(357.5 + 35999.05 * t) - 0.11 * sin(186.6 + 966404.05 * t));
|
||||
const beta = 5.13 * sin(93.3 + 483202.03 * t) + 0.28 * sin(228.2 + 960400.87 * t) - 0.28 * sin(318.3 + 6003.18 * t) - 0.17 * sin(217.6 - 407332.2 * t);
|
||||
const parallax = 0.9508 + 0.0518 * cos(anomaly) + 0.0095 * cos(259.2 - 413335.38 * t) + 0.0078 * cos(235.7 + 890534.23 * t) + 0.0028 * cos(269.9 + 954397.7 * t);
|
||||
const place = equatorial(lambda, beta, obliquity(t));
|
||||
const distance = EARTH / sin(parallax);
|
||||
const ha = sidereal(ms) + lon - place.ra;
|
||||
const seen = horizontal(ha, place.dec, lat);
|
||||
const light = sun(ms, lat, lon);
|
||||
const dra = light.ra - place.ra;
|
||||
const elongation = acos(sin(light.dec) * sin(place.dec) + cos(light.dec) * cos(place.dec) * cos(dra));
|
||||
const phase = atan2(light.distance * AU * sin(elongation), distance - light.distance * AU * cos(elongation));
|
||||
const limb = wrap(atan2(cos(light.dec) * sin(dra), sin(light.dec) * cos(place.dec) - cos(light.dec) * sin(place.dec) * cos(dra)));
|
||||
const parallactic = atan2(sin(ha), tan(lat) * cos(place.dec) - sin(place.dec) * cos(ha));
|
||||
return {
|
||||
...place, lambda, beta, distance, phase, limb,
|
||||
alt: seen.alt - parallax * cos(seen.alt),
|
||||
az: seen.az,
|
||||
lit: (1 + cos(phase)) / 2,
|
||||
tilt: wrap(limb - parallactic),
|
||||
waxing: wrap(lambda - light.lambda) < 180,
|
||||
};
|
||||
}
|
||||
|
||||
function hex(text) {
|
||||
return [1, 3, 5].map((index) => parseInt(text.slice(index, index + 2), 16));
|
||||
}
|
||||
|
||||
function mix(from, to, share) {
|
||||
return from.map((value, index) => value + (to[index] - value) * share);
|
||||
}
|
||||
|
||||
function css(rgb) {
|
||||
return `rgb(${rgb.map((value) => Math.round(value)).join(" ")})`;
|
||||
}
|
||||
|
||||
function luminance(rgb) {
|
||||
const [r, g, b] = rgb.map((value) => {
|
||||
const channel = value / 255;
|
||||
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
function contrast(first, second) {
|
||||
const [high, low] = [luminance(first), luminance(second)].sort((a, b) => b - a);
|
||||
return (high + 0.05) / (low + 0.05);
|
||||
}
|
||||
|
||||
function skyAt(alt) {
|
||||
const level = clamp(alt, SKY[0][0], SKY[SKY.length - 1][0]);
|
||||
const index = Math.max(SKY.findIndex(([edge]) => edge >= level), 1);
|
||||
const [low, lowTop, lowBottom] = SKY[index - 1];
|
||||
const [high, highTop, highBottom] = SKY[index];
|
||||
const share = (level - low) / (high - low);
|
||||
return {top: mix(hex(lowTop), hex(highTop), share), bottom: mix(hex(lowBottom), hex(highBottom), share)};
|
||||
}
|
||||
|
||||
function stage(alt) {
|
||||
return STAGES.find(([edge]) => alt < edge)[1];
|
||||
}
|
||||
|
||||
function starlight(alt) {
|
||||
return clamp((-alt - 6) / 10, 0, 1);
|
||||
}
|
||||
|
||||
function modeAt(alt) {
|
||||
return alt >= LIGHT_FROM ? "light" : "dark";
|
||||
}
|
||||
|
||||
function modeOf(top, bottom) {
|
||||
return luminance(mix(hex(top), hex(bottom), 0.5)) > 0.2 ? "light" : "dark";
|
||||
}
|
||||
|
||||
function stars(count, seed) {
|
||||
let state = seed >>> 0;
|
||||
const next = () => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
return state / 4294967296;
|
||||
};
|
||||
return Array.from({length: count}, () => ({ra: next() * 360, dec: asin(next() * 2 - 1), glow: next() ** 2}));
|
||||
}
|
||||
|
||||
function minutesOf(text) {
|
||||
const [hours, minutes] = text.split(":").map(Number);
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
function slotOf(date) {
|
||||
return {day: (date.getDay() + 6) % 7, minute: date.getHours() * 60 + date.getMinutes()};
|
||||
}
|
||||
|
||||
function covers(stream, slot) {
|
||||
const start = minutesOf(stream.from);
|
||||
const end = minutesOf(stream.to);
|
||||
if (start < end) {
|
||||
return stream.days.includes(slot.day) && slot.minute >= start && slot.minute < end;
|
||||
}
|
||||
return (stream.days.includes(slot.day) && slot.minute >= start) || (stream.days.includes((slot.day + 6) % 7) && slot.minute < end);
|
||||
}
|
||||
|
||||
function activeStream(streams, slot) {
|
||||
return streams.find((stream) => stream.on && covers(stream, slot)) || null;
|
||||
}
|
||||
|
||||
return {GLASS, CARD, SKY, THEMES, julian, sidereal, horizontal, sun, moon, hex, mix, css, luminance, contrast, skyAt, stage, starlight, modeAt, modeOf, stars, slotOf, activeStream};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = Ambience;
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>{% if state.badges.inbox %}({{ state.badges.inbox }}) {% endif %}Agent Orchestrator</title>
|
||||
<style nonce="{{ nonce }}">
|
||||
:root {
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px; --sp-6: 32px; --sp-7: 48px;
|
||||
--fs-2xs: 11px; --fs-xs: 11.5px; --fs-sm: 12.5px; --fs-md: 14px; --fs-lg: 16px; --fs-xl: 18px; --fs-2xl: 22px; --fs-3xl: 34px;
|
||||
--lh: 1.55; --lh-tight: 1.25;
|
||||
--radius-sm: 8px; --radius: 14px; --radius-lg: 20px;
|
||||
--veil: 100%; --card: 100%;
|
||||
--blur: saturate(150%) blur(22px);
|
||||
--ease: cubic-bezier(.2, .7, .2, 1);
|
||||
--head: 104px;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--glass: color-mix(in srgb, var(--bg) var(--veil), transparent);
|
||||
--glass-panel: color-mix(in srgb, var(--panel) var(--veil), transparent);
|
||||
--glass-card: color-mix(in srgb, var(--panel) var(--card), transparent);
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; background: #05070d; }
|
||||
body { min-height: 100vh; margin: 0; background: transparent; color: var(--text); font: var(--fs-md)/var(--lh) var(--sans); -webkit-font-smoothing: antialiased; transition: color .8s var(--ease); }
|
||||
.sky { position: fixed; inset: 0; z-index: -1; display: block; width: 100vw; height: 100vh; pointer-events: none; }
|
||||
button, input, select { font: inherit; color: inherit; }
|
||||
code, pre { font-family: var(--mono); font-size: var(--fs-sm); }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 22%, transparent); }
|
||||
.top, .navbar, main, .drawer, .sheet-box, .palette-box, .toast, .sky-panel, .menu-box { -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur); transition: background-color .8s var(--ease), border-color .8s var(--ease); }
|
||||
::view-transition-old(root), ::view-transition-new(root) { animation-duration: .22s; animation-timing-function: var(--ease); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
.head { position: sticky; top: 0; z-index: 6; }
|
||||
.top { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-3) var(--sp-5); background: var(--glass-panel); border-bottom: 1px solid var(--line); }
|
||||
.brand { display: flex; flex: 1; align-items: center; gap: var(--sp-3); min-width: 0; }
|
||||
.brand-text { display: grid; line-height: var(--lh-tight); }
|
||||
.sky-note { color: var(--muted); font-size: var(--fs-xs); white-space: nowrap; }
|
||||
.logo { width: 24px; height: 24px; border-radius: var(--radius-sm); background: linear-gradient(135deg, var(--accent), var(--violet)); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 18%), var(--shadow); }
|
||||
h1 { margin: 0; font-size: var(--fs-lg); font-weight: 680; letter-spacing: -.01em; }
|
||||
.navbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); padding: var(--sp-2) var(--sp-5); background: var(--glass-panel); border-bottom: 1px solid var(--line); }
|
||||
.tabs { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-3); }
|
||||
.tab-group { display: inline-flex; align-items: center; gap: 2px; }
|
||||
.subtabs { display: inline-flex; gap: 2px; padding-left: var(--sp-1); border-left: 1px solid var(--line); }
|
||||
.subtabs button { font-size: var(--fs-sm); }
|
||||
.tabs button { display: inline-flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border: 0; border-radius: 10px; background: transparent; color: var(--muted); font-weight: 560; cursor: pointer; transition: background .15s, color .15s; }
|
||||
.icon-btn { min-width: 34px; justify-content: center; font-size: var(--fs-lg); line-height: 1; }
|
||||
.btn[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
|
||||
.tabs button:hover { background: var(--hover); color: var(--text); }
|
||||
.tabs button[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
|
||||
.badge { min-width: 22px; padding: 0 7px; border-radius: 999px; background: var(--neutral-soft); color: var(--muted); font-size: var(--fs-xs); font-weight: 650; line-height: 20px; text-align: center; font-variant-numeric: tabular-nums; }
|
||||
.badge[hidden] { display: none; }
|
||||
.badge.urgent { background: var(--accent); color: var(--on-accent); }
|
||||
.badge.alarm { background: var(--alarm); color: #fff; }
|
||||
.tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; }
|
||||
.banner { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); background: var(--alarm); color: #fff; font-weight: 650; }
|
||||
.banner[hidden] { display: none; }
|
||||
.banner .text { flex: 1; margin: 0; }
|
||||
.banner .btn { border-color: rgb(255 255 255 / 70%); background: transparent; color: #fff; }
|
||||
.menu { position: relative; }
|
||||
.menu > summary { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); font-weight: 580; cursor: pointer; list-style: none; }
|
||||
.menu > summary::-webkit-details-marker { display: none; }
|
||||
.menu-box { position: absolute; top: calc(100% + 6px); z-index: 25; display: grid; gap: var(--sp-3); width: max-content; max-width: min(640px, calc(100vw - 32px)); padding: var(--sp-3) var(--sp-4); background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); }
|
||||
.menu-box.end { right: 0; }
|
||||
.menu-box textarea { min-width: min(460px, calc(100vw - 64px)); }
|
||||
.menu-box h3 { margin: 0; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); }
|
||||
.classes { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); }
|
||||
.updated { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: var(--fs-sm); white-space: nowrap; }
|
||||
.updated::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
|
||||
.updated.offline::before { background: var(--bad); box-shadow: 0 0 0 3px var(--bad-soft); }
|
||||
body.leitstand { --fs-xs: 13px; --fs-sm: 14px; --fs-md: 16px; --fs-lg: 18px; --fs-xl: 21px; --fs-2xl: 26px; --fs-3xl: 44px; }
|
||||
.shell { padding: var(--sp-4) var(--sp-4) var(--sp-6); }
|
||||
main { min-width: 0; min-height: calc(100vh - var(--head) - var(--sp-6) - var(--sp-4)); padding: var(--sp-5) var(--sp-5) 80px; background: var(--glass); border: 1px solid color-mix(in srgb, var(--line) 70%, transparent); border-radius: var(--radius-lg); box-shadow: var(--shadow); }
|
||||
@media (max-width: 640px) {
|
||||
.top, .navbar, .banner { gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); }
|
||||
.shell { padding: var(--sp-2) var(--sp-2) var(--sp-6); }
|
||||
main { padding: var(--sp-3) var(--sp-3) 88px; border-radius: var(--radius); }
|
||||
.menu-box { position: fixed; top: auto; right: var(--sp-2); left: var(--sp-2); width: auto; }
|
||||
.replay { flex-basis: 100%; min-width: 0; }
|
||||
.sheet { padding: 0; }
|
||||
.sheet-box { min-height: 100%; border-radius: 0; }
|
||||
.settings .grid { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.stack { display: grid; gap: 26px; }
|
||||
.group-title { display: flex; align-items: center; gap: 10px; margin: 0; font-size: var(--fs-sm); font-weight: 650; color: var(--muted); letter-spacing: .02em; text-transform: uppercase; }
|
||||
.count { min-width: 20px; padding: 0 7px; border-radius: 999px; background: var(--neutral-soft); color: var(--muted); font-size: var(--fs-xs); line-height: 19px; text-align: center; text-transform: none; }
|
||||
.card { min-width: 0; padding: var(--sp-4) var(--sp-4); background: var(--glass-card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); transition: background-color .8s var(--ease), border-color .8s var(--ease); }
|
||||
.card.alarm { border-color: color-mix(in srgb, var(--alarm) 45%, var(--line)); background: linear-gradient(0deg, var(--panel), color-mix(in srgb, var(--bad-soft) 70%, var(--panel))); }
|
||||
.card.flush { padding: 0; overflow: hidden; }
|
||||
.card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
||||
.muted { color: var(--muted); font-size: var(--fs-sm); }
|
||||
h2.title { margin: 0 0 2px; font-size: var(--fs-xl); font-weight: 650; letter-spacing: -.01em; overflow-wrap: anywhere; }
|
||||
h3 { margin: 0; font-size: var(--fs-lg); font-weight: 620; overflow-wrap: anywhere; }
|
||||
.who { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.text { margin: 8px 0 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 6px 22px; margin: 8px 0 0; font-size: var(--fs-sm); }
|
||||
.meta div { display: flex; gap: 6px; min-width: 0; }
|
||||
.meta dt { color: var(--muted); }
|
||||
.meta dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
|
||||
.command { display: flex; align-items: center; gap: 10px; margin-top: 4px; padding: 7px 10px; background: var(--sunken); border: 1px solid var(--line); border-radius: 10px; }
|
||||
.command code { flex: 1; min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
pre.notes { max-height: 260px; margin: 12px 0 0; padding: 10px 12px; overflow: auto; background: var(--sunken); border: 1px solid var(--line); border-radius: 10px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 14px; }
|
||||
.spacer { flex: 1; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); color: var(--text); font-weight: 580; text-decoration: none; cursor: pointer; transition: background .15s, border-color .15s, transform .05s; }
|
||||
.btn:hover { background: var(--hover); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.btn.small { padding: 4px 10px; font-size: var(--fs-sm); }
|
||||
.btn.ghost { border-color: transparent; background: transparent; color: var(--muted); }
|
||||
.btn.ghost:hover { background: var(--hover); color: var(--text); }
|
||||
.btn.primary { background: var(--ok); border-color: var(--ok); color: #fff; }
|
||||
.btn.primary:hover { background: color-mix(in srgb, var(--ok) 88%, #000); }
|
||||
.btn.danger { border-color: color-mix(in srgb, var(--bad) 55%, var(--line)); color: var(--bad); }
|
||||
.btn.danger:hover { background: var(--bad-soft); }
|
||||
.btn.soft { background: var(--accent-soft); border-color: transparent; color: var(--accent); }
|
||||
.pill { display: inline-flex; align-items: center; gap: 5px; padding: 1px 9px; border-radius: 999px; font-size: var(--fs-xs); font-weight: 650; line-height: 20px; white-space: nowrap; background: var(--neutral-soft); color: var(--muted); }
|
||||
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: .85; }
|
||||
.pill[data-tone="ok"] { background: var(--ok-soft); color: var(--ok); }
|
||||
.pill[data-tone="bad"] { background: var(--bad-soft); color: var(--bad); }
|
||||
.pill[data-tone="warn"] { background: var(--warn-soft); color: var(--warn); }
|
||||
.pill[data-tone="info"] { background: var(--info-soft); color: var(--info); }
|
||||
.pill[data-tone="violet"] { background: var(--violet-soft); color: var(--violet); }
|
||||
.pill[data-tone="teal"] { background: var(--teal-soft); color: var(--teal); }
|
||||
.empty { display: grid; justify-items: center; gap: var(--sp-2); padding: var(--sp-7) var(--sp-5); background: var(--glass-card); border: 1px dashed var(--line-strong); border-radius: var(--radius); text-align: center; }
|
||||
.empty h2 { margin: 6px 0 0; font-size: var(--fs-lg); font-weight: 640; }
|
||||
.empty p { margin: 0; color: var(--muted); }
|
||||
.empty-mark { width: 40px; height: 40px; border-radius: 50%; background: radial-gradient(circle, var(--ok) 0 5px, var(--ok-soft) 6px); }
|
||||
.chips { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 10px; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; max-width: 360px; padding: 5px 12px; overflow: hidden; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); color: var(--muted); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||||
.chip:hover { color: var(--text); border-color: var(--line-strong); }
|
||||
.chip[aria-pressed="true"] { background: var(--accent-soft); border-color: transparent; color: var(--accent); }
|
||||
.chip.preset { font-weight: 650; }
|
||||
.chip .x { padding: 0 2px; border: 0; background: none; color: inherit; cursor: pointer; }
|
||||
.split { display: grid; grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); gap: 14px; align-items: start; }
|
||||
@media (max-width: 860px) { .split, .graph-body { grid-template-columns: minmax(0, 1fr); } }
|
||||
.tree { position: sticky; top: 0; max-height: 70vh; padding: 10px 6px; overflow: auto; }
|
||||
.tree-title { margin: 6px 10px 4px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.tree-list { margin: 0; padding: 0 0 0 10px; list-style: none; }
|
||||
.tree > div > .tree-section > .tree-list { padding-left: 0; }
|
||||
.tree summary { padding: 3px 8px; border-radius: 7px; color: var(--muted); font-size: var(--fs-sm); cursor: pointer; }
|
||||
.tree summary:hover { background: var(--hover); }
|
||||
.dir { font-family: var(--mono); font-size: var(--fs-sm); }
|
||||
.file-entry { display: flex; align-items: center; gap: 8px; width: 100%; padding: 4px 8px; border: 0; border-radius: 7px; background: transparent; text-align: left; cursor: pointer; }
|
||||
.file-entry:hover { background: var(--hover); }
|
||||
.file-entry[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
|
||||
.file-entry .name { flex: 1; min-width: 0; overflow: hidden; font-family: var(--mono); font-size: var(--fs-sm); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.letter { display: inline-grid; place-items: center; flex: none; width: 18px; height: 18px; border-radius: 5px; background: var(--neutral-soft); color: var(--muted); font-family: var(--mono); font-size: var(--fs-2xs); font-weight: 700; }
|
||||
.letter[data-tone="ok"] { background: var(--ok-soft); color: var(--ok); }
|
||||
.letter[data-tone="bad"] { background: var(--bad-soft); color: var(--bad); }
|
||||
.letter[data-tone="info"] { background: var(--info-soft); color: var(--info); }
|
||||
.letter[data-tone="violet"] { background: var(--violet-soft); color: var(--violet); }
|
||||
.letter[data-tone="warn"] { background: var(--warn-soft); color: var(--warn); }
|
||||
.stat { display: inline-flex; gap: 6px; font-family: var(--mono); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
|
||||
.stat .add { color: var(--ok); }
|
||||
.stat .del { color: var(--bad); }
|
||||
.diff-pane { padding: 0; overflow: hidden; }
|
||||
.diff-head { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--sunken); }
|
||||
.diff-head .path { overflow-wrap: anywhere; font-weight: 600; }
|
||||
.pad { margin: 0; padding: 18px 16px; }
|
||||
.diff-scroll { overflow: auto; max-height: 70vh; }
|
||||
table.diff { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: var(--fs-sm); line-height: 1.5; }
|
||||
table.diff td { padding: 0 10px; vertical-align: top; }
|
||||
table.diff td.num { width: 1%; min-width: 44px; color: var(--faint); text-align: right; user-select: none; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
table.diff td.sign { width: 1%; padding: 0 4px; color: var(--faint); user-select: none; }
|
||||
table.diff td.code { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
table.diff tr.add { background: var(--add-bg); }
|
||||
table.diff tr.add td.code, table.diff tr.add td.sign { color: var(--add-text); }
|
||||
table.diff tr.del { background: var(--del-bg); }
|
||||
table.diff tr.del td.code, table.diff tr.del td.sign { color: var(--del-text); }
|
||||
table.diff tr.hunk td { padding-top: 4px; padding-bottom: 4px; background: var(--hunk-bg); color: var(--muted); }
|
||||
table.diff tr.more td { padding: 8px 16px; color: var(--muted); font-style: italic; }
|
||||
.feed { margin: 0; padding: 0; list-style: none; }
|
||||
.footnote { margin: 0; padding: 12px 18px; color: var(--muted); font-size: var(--fs-sm); }
|
||||
.entry { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: 12px; padding: 10px 18px; border-bottom: 1px solid var(--line); }
|
||||
@media (max-width: 640px) {
|
||||
.entry { grid-template-columns: minmax(0, 1fr); padding: 10px 12px; }
|
||||
.msg-meta { display: flex; flex-wrap: wrap; align-items: center; }
|
||||
.tabs button { padding: 6px 8px; }
|
||||
}
|
||||
.entry:last-child { border-bottom: 0; }
|
||||
.entry:hover { background: var(--hover); }
|
||||
.entry.waiting { box-shadow: inset 3px 0 0 var(--warn); }
|
||||
.entry.alarm { background: color-mix(in srgb, var(--bad-soft) 75%, var(--panel)); }
|
||||
.entry pre { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.entry.k-transcript pre, .entry.k-cron pre, .entry.k-run pre { color: var(--muted); }
|
||||
.entry .line-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||
.msg-meta { display: grid; justify-items: start; align-content: start; gap: 4px; color: var(--muted); font-size: var(--fs-sm); }
|
||||
.route { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; }
|
||||
.route .headline { font-weight: 600; overflow-wrap: anywhere; }
|
||||
.filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-bottom: 10px; }
|
||||
.filters select, .filters input, .field { padding: 6px 10px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); }
|
||||
.filters input[type="search"] { flex: 1; min-width: 200px; }
|
||||
.graph { display: grid; gap: 14px; }
|
||||
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 18px; padding: 10px 14px; }
|
||||
.segmented { display: inline-flex; padding: 3px; gap: 2px; border-radius: 10px; background: var(--sunken); border: 1px solid var(--line); }
|
||||
.segmented button { padding: 4px 11px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); font-size: var(--fs-sm); font-weight: 580; cursor: pointer; }
|
||||
.segmented button[aria-selected="true"], .segmented button[aria-pressed="true"] { background: var(--panel); color: var(--text); box-shadow: var(--shadow); }
|
||||
.replay { display: flex; flex: 1; align-items: center; gap: 10px; min-width: 280px; }
|
||||
.replay input[type="range"] { flex: 1; accent-color: var(--accent); }
|
||||
.window-label { color: var(--muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.graph-body { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 14px; align-items: start; }
|
||||
.stage-card { padding: 8px; }
|
||||
.stage svg { display: block; width: 100%; height: auto; max-height: calc(100vh - var(--head) - 120px); }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 6px 16px; padding: 6px 10px 4px; color: var(--muted); font-size: var(--fs-sm); }
|
||||
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.swatch { display: inline-block; width: 18px; height: 4px; border-radius: 2px; background: var(--c); }
|
||||
.panel { position: sticky; top: calc(var(--head) + 12px); max-height: calc(100vh - var(--head) - 24px); overflow: auto; }
|
||||
.panel-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||||
.panel-title { margin: 0; font-size: var(--fs-xl); font-weight: 650; overflow-wrap: anywhere; }
|
||||
.panel h3, .drawer-body h3 { display: flex; align-items: center; gap: 8px; margin: 18px 0 8px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.mini { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
||||
.mini li { display: grid; gap: 3px; padding: 9px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); font-size: var(--fs-sm); overflow-wrap: anywhere; }
|
||||
.mini .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.roles { color: var(--accent); font-size: var(--fs-sm); font-weight: 600; }
|
||||
.t-direct { --c: var(--direct); }
|
||||
.t-broadcast { --c: var(--broadcast); }
|
||||
.t-alarm { --c: var(--alarm); }
|
||||
.t-security { --c: var(--security); }
|
||||
.t-grant { --c: var(--grant); }
|
||||
.t-spawn { --c: var(--violet); }
|
||||
svg.chart { font-family: inherit; }
|
||||
svg .ring { fill: none; stroke: var(--line-strong); stroke-dasharray: 2 7; }
|
||||
svg .edge { fill: none; stroke: var(--c); stroke-linecap: round; opacity: .55; transition: opacity .15s; }
|
||||
svg .edge:hover { opacity: 1; }
|
||||
svg .tip { fill: var(--c); }
|
||||
svg .halo { fill: none; stroke: var(--c); opacity: .35; }
|
||||
svg .node { cursor: pointer; }
|
||||
svg .node circle { fill: var(--panel); stroke: var(--line-strong); stroke-width: 1.5; transition: stroke .15s; }
|
||||
svg .node:hover circle, svg .node:focus-visible circle { stroke: var(--accent); }
|
||||
svg .node:focus-visible { outline: none; }
|
||||
svg .node.k-operator circle { fill: var(--accent); stroke: color-mix(in srgb, var(--accent) 60%, var(--panel)); stroke-width: 6; }
|
||||
svg .node.k-chair circle { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; }
|
||||
svg .node.k-subagent circle { fill: var(--violet-soft); stroke: var(--violet); }
|
||||
svg .node.claimed circle { stroke: var(--ok); stroke-width: 2.2; }
|
||||
svg .node.selected circle { stroke: var(--accent); stroke-width: 3.5; }
|
||||
svg .label { fill: var(--text); font-size: var(--fs-sm); font-weight: 560; paint-order: stroke; stroke: var(--panel); stroke-width: 4px; stroke-linejoin: round; }
|
||||
svg .initial { fill: var(--muted); font-size: var(--fs-sm); font-weight: 700; pointer-events: none; }
|
||||
svg .k-operator .initial { fill: var(--on-accent); font-size: var(--fs-xl); }
|
||||
svg .k-chair .initial { fill: var(--accent); font-size: var(--fs-lg); }
|
||||
svg .group-arc { fill: none; stroke: var(--line-strong); stroke-width: 7; stroke-linecap: round; }
|
||||
svg .group-arc.k-operator, svg .group-arc.k-chair { stroke: var(--accent); }
|
||||
svg .tick { stroke: var(--c); stroke-linecap: round; opacity: .7; }
|
||||
svg .lane { fill: transparent; }
|
||||
svg .lane.odd { fill: var(--sunken); }
|
||||
svg .lane-label { fill: var(--text); font-size: var(--fs-sm); font-weight: 560; cursor: pointer; }
|
||||
svg .grid { stroke: var(--line); }
|
||||
svg .axis { fill: var(--muted); font-size: var(--fs-2xs); }
|
||||
svg .now { stroke: var(--accent); stroke-dasharray: 3 4; }
|
||||
svg .mark { stroke: var(--c); fill: var(--c); opacity: .75; }
|
||||
svg .mark line, svg .mark path { fill: none; stroke-width: 1.6; stroke-linecap: round; }
|
||||
svg .mark:hover { opacity: 1; }
|
||||
svg .mark:hover line, svg .mark:hover path { stroke-width: 2.6; }
|
||||
svg .pulse { fill: var(--c); filter: drop-shadow(0 0 4px var(--c)); }
|
||||
svg .pulse-ring { fill: none; stroke: var(--c); stroke-width: 2.5; }
|
||||
input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); }
|
||||
.custom { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-lg); line-height: 1; }
|
||||
.avatar.big { width: 52px; height: 52px; font-size: var(--fs-3xl); }
|
||||
.linkish { padding: 0; border: 0; background: none; color: inherit; font-weight: 650; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
|
||||
.linkish:hover { color: var(--accent); text-decoration: underline; }
|
||||
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 20; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(480px, 100vw); background: var(--glass-card); border-left: 1px solid var(--line); box-shadow: var(--shadow-lift); }
|
||||
.drawer[hidden] { display: none; }
|
||||
.drawer-head { display: flex; align-items: center; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
|
||||
.drawer-body { overflow: auto; padding: 4px 18px 12px; }
|
||||
.drawer-form { display: grid; gap: 8px; padding: 12px 18px 18px; border-top: 1px solid var(--line); }
|
||||
textarea { width: 100%; min-height: 84px; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); color: var(--text); font: inherit; resize: vertical; }
|
||||
.bubble { display: grid; gap: 3px; margin: 0 0 8px; padding: 8px 11px; border-radius: 12px; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-sm); overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.bubble.mine { margin-left: 34px; background: var(--accent-soft); border-color: transparent; }
|
||||
.bubble.theirs { margin-right: 34px; }
|
||||
.toasts { position: fixed; right: 18px; bottom: 18px; z-index: 40; display: grid; gap: 10px; width: min(440px, calc(100vw - 36px)); }
|
||||
.toast { padding: var(--sp-3) var(--sp-4); background: var(--glass-card); border: 1px solid var(--line-strong); border-left: 4px solid var(--ok); border-radius: 12px; box-shadow: var(--shadow-lift); cursor: pointer; }
|
||||
.toast.bad { border-left-color: var(--bad); }
|
||||
.toast.wait { border-left-color: var(--warn); cursor: default; }
|
||||
.toast pre { max-height: 180px; margin: 6px 0 0; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.undo-row { display: flex; align-items: center; gap: 10px; margin-top: 6px; }
|
||||
.palette, .sheet { position: fixed; inset: 0; z-index: 50; display: grid; justify-items: center; align-items: start; padding: 10vh var(--sp-4) var(--sp-4); overflow: auto; background: rgb(0 0 0 / 32%); animation: fade .18s var(--ease); }
|
||||
.sheet { z-index: 30; padding-top: 5vh; }
|
||||
.palette[hidden], .sheet[hidden] { display: none; }
|
||||
.palette-box, .sheet-box { animation: rise .22s var(--ease); }
|
||||
@keyframes fade { from { opacity: 0; } }
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(8px) scale(.99); } }
|
||||
.palette-box { width: min(620px, calc(100vw - 32px)); overflow: hidden; background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); }
|
||||
.palette-box input { width: 100%; padding: 14px 16px; border: 0; background: transparent; font-size: var(--fs-lg); outline: none; }
|
||||
.palette-row { display: flex; align-items: center; gap: 6px; padding-right: 10px; border-bottom: 1px solid var(--line); }
|
||||
.palette-row input { flex: 1; min-width: 0; }
|
||||
.mic[aria-pressed="true"] { background: var(--bad-soft); color: var(--bad); }
|
||||
.palette-list { max-height: 55vh; margin: 0; padding: 6px; overflow: auto; list-style: none; }
|
||||
.palette-list li { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; cursor: pointer; }
|
||||
.palette-list li[aria-selected="true"] { background: var(--accent-soft); color: var(--accent); }
|
||||
.palette-list .muted { margin-left: auto; }
|
||||
.palette-plan { display: grid; gap: 8px; padding: 12px 16px 16px; }
|
||||
.palette-plan pre { margin: 0; padding: 8px 10px; background: var(--sunken); border-radius: 8px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.sheet-box { width: min(760px, 100%); background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); }
|
||||
.sheet-box.wide { width: min(1400px, 100%); }
|
||||
.sheet-head { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--line); }
|
||||
.sheet-body { display: grid; gap: 14px; padding: 16px 18px 20px; }
|
||||
.options { display: grid; gap: 6px; margin-top: 8px; }
|
||||
.option { display: grid; gap: 2px; padding: 8px 11px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); text-align: left; cursor: pointer; }
|
||||
.option:hover { background: var(--accent-soft); border-color: var(--accent); }
|
||||
.option.recommended { border-color: var(--ok); box-shadow: inset 3px 0 0 var(--ok); }
|
||||
.option span { color: var(--muted); font-size: var(--fs-sm); }
|
||||
.inbox-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
.inbox-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.inbox-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
||||
.item { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-2) var(--sp-3); padding: var(--sp-3); border: 1px solid var(--line); border-radius: 12px; background: var(--glass-card); }
|
||||
.item.cursor { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.item.green { box-shadow: inset 3px 0 0 var(--ok); }
|
||||
.item.red { box-shadow: inset 3px 0 0 var(--bad); }
|
||||
.item.held { opacity: .6; }
|
||||
.item input[type="checkbox"] { margin-top: 4px; accent-color: var(--accent); }
|
||||
.head-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.item-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.check { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: var(--fs-sm); cursor: pointer; }
|
||||
.order-list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
|
||||
.order-list li { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); cursor: grab; }
|
||||
.order-list li.dragging { opacity: .5; }
|
||||
.order-list .rank { min-width: 22px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.board { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 14px; align-items: start; }
|
||||
.repos { display: grid; gap: 10px; }
|
||||
.repo { padding: 0; overflow: hidden; }
|
||||
.repo > summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 11px 16px; font-weight: 620; cursor: pointer; }
|
||||
.branch-row { display: grid; gap: 6px; padding: 9px 16px 9px 30px; border-top: 1px solid var(--line); }
|
||||
.branch-name { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.wt { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 7px 10px; border-radius: 8px; background: var(--sunken); font-size: var(--fs-sm); }
|
||||
.wt code { overflow-wrap: anywhere; }
|
||||
.rule-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.rule-form { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; align-items: end; }
|
||||
.rule-form label, .sound-row label { display: grid; gap: 4px; color: var(--muted); font-size: var(--fs-sm); }
|
||||
.sound-row { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(120px, 220px) auto; align-items: center; gap: var(--sp-3); padding: var(--sp-2) 0; border-bottom: 1px solid var(--line); }
|
||||
.settings { display: grid; gap: var(--sp-5); }
|
||||
.settings [hidden] { display: none; }
|
||||
.settings section { display: grid; gap: var(--sp-3); }
|
||||
.settings h3 { margin: 0; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.settings .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: var(--sp-3); align-items: end; }
|
||||
.settings label { display: grid; gap: var(--sp-1); color: var(--muted); font-size: var(--fs-sm); }
|
||||
.settings label.check { display: inline-flex; }
|
||||
.settings input[type="color"] { width: 100%; height: 36px; padding: 2px; border: 1px solid var(--line-strong); border-radius: var(--radius-sm); background: var(--panel); }
|
||||
.settings input[type="range"] { accent-color: var(--accent); }
|
||||
.stream-row { display: grid; gap: var(--sp-2); padding: var(--sp-3); border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--sunken); }
|
||||
.days { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); }
|
||||
.sheet-foot { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); padding-top: var(--sp-3); border-top: 1px solid var(--line); }
|
||||
.keys { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; }
|
||||
.keys dt { font-family: var(--mono); font-weight: 650; }
|
||||
.keys dd { margin: 0; color: var(--muted); }
|
||||
.space-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
||||
.stage canvas.space { display: block; width: 100%; height: clamp(360px, 68vh, 820px); border-radius: var(--radius-sm); cursor: grab; touch-action: none; }
|
||||
.stage canvas.space:active { cursor: grabbing; }
|
||||
.space-note { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 8px 10px 0; color: var(--muted); font-size: var(--fs-sm); }
|
||||
.space-list { padding: 6px 10px 2px; }
|
||||
.space-list summary { margin: 4px 0; color: var(--muted); font-size: var(--fs-sm); cursor: pointer; }
|
||||
body.sky-look .shell { visibility: hidden; }
|
||||
body.sky-look .sky { z-index: 5; pointer-events: auto; cursor: grab; touch-action: none; }
|
||||
body.sky-look .sky:active { cursor: grabbing; }
|
||||
.sky-panel { position: fixed; bottom: var(--sp-5); left: 50%; z-index: 8; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px; max-width: calc(100vw - 24px); padding: 8px 12px; transform: translateX(-50%); background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); }
|
||||
.sky-panel[hidden] { display: none; }
|
||||
.compass { min-width: 220px; font-weight: 620; font-variant-numeric: tabular-nums; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas class="sky" id="sky" aria-hidden="true"></canvas>
|
||||
<div class="head" id="head">
|
||||
<header class="top">
|
||||
<div class="brand"><span class="logo" aria-hidden="true"></span><div class="brand-text"><h1>Agent Orchestrator</h1><span class="sky-note" id="sky-note"></span></div></div>
|
||||
<div class="tools">
|
||||
<button type="button" class="btn small ghost" id="palette-open" title="Befehle (Strg+K)" aria-label="Befehle">⌘K</button>
|
||||
<button type="button" class="btn small ghost icon-btn" id="settings-open" title="Einstellungen und Ambiente" aria-label="Einstellungen">⚙</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="navbar">
|
||||
<nav class="tabs" id="tabs" aria-label="Navigation"></nav>
|
||||
<span class="spacer"></span>
|
||||
<details class="menu" id="time"></details>
|
||||
<span class="updated" id="updated" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="banner" id="mayday" role="alert" hidden></div>
|
||||
</div>
|
||||
<div class="shell">
|
||||
<main id="main"></main>
|
||||
</div>
|
||||
<aside class="drawer" id="drawer" aria-label="Agent" hidden></aside>
|
||||
<div class="sheet" id="sheet" hidden></div>
|
||||
<div class="toasts" id="toasts" aria-live="polite"></div>
|
||||
<div class="palette" id="palette" hidden></div>
|
||||
<div class="sky-panel" id="sky-panel" role="group" aria-label="Blick in den Himmel" hidden></div>
|
||||
<script nonce="{{ nonce }}" id="state" type="application/json">{{ state | tojson }}</script>
|
||||
<script nonce="{{ nonce }}">
|
||||
{% include "ambience.js" %}
|
||||
</script>
|
||||
<script nonce="{{ nonce }}">
|
||||
{% include "voice.js" %}
|
||||
|
||||
{% include "nav.js" %}
|
||||
</script>
|
||||
<script nonce="{{ nonce }}">
|
||||
{# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #}
|
||||
{% include "space-view.js" %}
|
||||
|
||||
{% include "space-scene.js" %}
|
||||
|
||||
{% include "space-paint.js" %}
|
||||
|
||||
{% include "space-pick.js" %}
|
||||
|
||||
{% include "space.js" %}
|
||||
</script>
|
||||
<script nonce="{{ nonce }}">
|
||||
(() => {
|
||||
"use strict";
|
||||
{% include "page-constants.js" %}
|
||||
|
||||
{% include "page-state.js" %}
|
||||
|
||||
{% include "page-dom.js" %}
|
||||
|
||||
{% include "page-api.js" %}
|
||||
|
||||
{% include "page-passkey.js" %}
|
||||
|
||||
{% include "page-board.js" %}
|
||||
|
||||
{% include "page-screens.js" %}
|
||||
|
||||
{% include "page-inbox.js" %}
|
||||
|
||||
{% include "page-review.js" %}
|
||||
|
||||
{% include "page-filters.js" %}
|
||||
|
||||
{% include "page-stream.js" %}
|
||||
|
||||
{% include "page-graph-layout.js" %}
|
||||
|
||||
{% include "page-graph-charts.js" %}
|
||||
|
||||
{% include "page-space-steer.js" %}
|
||||
|
||||
{% include "page-space-scene.js" %}
|
||||
|
||||
{% include "page-tree.js" %}
|
||||
|
||||
{% include "page-time.js" %}
|
||||
|
||||
{% include "page-pulses.js" %}
|
||||
|
||||
{% include "page-music.js" %}
|
||||
|
||||
{% include "page-sky.js" %}
|
||||
|
||||
{% include "page-radio.js" %}
|
||||
|
||||
{% include "page-settings.js" %}
|
||||
|
||||
{% include "page-speech.js" %}
|
||||
|
||||
{% include "page-alerts.js" %}
|
||||
|
||||
{% include "page-rules.js" %}
|
||||
|
||||
{% include "page-keyboard.js" %}
|
||||
|
||||
{% include "page-agent.js" %}
|
||||
|
||||
{% include "page-palette.js" %}
|
||||
|
||||
{% include "page-load.js" %}
|
||||
|
||||
{% include "page-boot.js" %}
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
const Nav = (() => {
|
||||
"use strict";
|
||||
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
|
||||
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"]]};
|
||||
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
|
||||
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
|
||||
const known = (view) => VIEWS.some(([id]) => id === view);
|
||||
|
||||
function tabOf(screen) {
|
||||
return Object.keys(SUBS).find((tab) => SUBS[tab].some(([id]) => id === screen)) || screen;
|
||||
}
|
||||
|
||||
function hashOf(screen, view) {
|
||||
return tabOf(screen) === screen ? (view ? `#${screen}/${view}` : `#${screen}`) : `#${tabOf(screen)}/${screen}`;
|
||||
}
|
||||
|
||||
function route(hash) {
|
||||
const [head, sub] = decodeURIComponent(String(hash).replace(/^#/, "")).split("/");
|
||||
if (Object.hasOwn(OLD, head)) {
|
||||
const [screen, fallback] = OLD[head];
|
||||
const view = known(sub) ? sub : fallback;
|
||||
return {screen, view, redirect: hashOf(screen, view)};
|
||||
}
|
||||
if (Object.hasOwn(SUBS, head) && SUBS[head].some(([id]) => id === sub)) {
|
||||
return {screen: sub, view: null, redirect: null};
|
||||
}
|
||||
if (TABS.some(([id]) => id === head) && head !== "stream") {
|
||||
return {screen: head, view: known(sub) ? sub : null, redirect: null};
|
||||
}
|
||||
return {screen: "stream", view: null, redirect: null};
|
||||
}
|
||||
|
||||
return {TABS, SUBS, VIEWS, route, hashOf, tabOf};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = Nav;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
function history(key) {
|
||||
const known = (people()[key] || {names: [nameOf(key)]}).names;
|
||||
const radio = state.radio.filter((row) => row.source === key || row.targets.includes(key)).map((row) => ({ts: row.ts, mine: row.from === HUMAN, who: row.from, text: row.text}));
|
||||
const sent = state.messages.filter((message) => known.includes(message.to) || message.to === "*").map((message) => ({ts: message.created_ts, mine: true, who: HUMAN, text: message.upload ? `${message.text}\n📎 ${message.upload}` : message.text, id: message.id}));
|
||||
const hubIds = new Set(sent.map((entry) => entry.id));
|
||||
const relayed = radio.filter((entry) => !(entry.who === HUMAN && [...hubIds].some((id) => entry.text.includes(`[kvb-orchestrator msg ${id}]`))));
|
||||
return [...relayed, ...sent].sort((a, b) => a.ts - b.ts).slice(-80);
|
||||
}
|
||||
|
||||
function drawDrawer() {
|
||||
if (!drawerDom) {
|
||||
return;
|
||||
}
|
||||
const node = state.graph.nodes.find((entry) => entry.id === ui.agent);
|
||||
const tasks = node ? node.tasks : [];
|
||||
const claims = node ? node.claims : [];
|
||||
drawerDom.info.replaceChildren(
|
||||
h("h3", {}, "Aufgaben", counter(tasks.length)),
|
||||
tasks.length ? h("ul", {class: "mini"}, tasks.map((task) => h("li", {},
|
||||
h("div", {class: "row"}, h("code", {}, task.task), status(task.status)),
|
||||
h("span", {}, dash(task.title)),
|
||||
h("span", {class: "roles"}, [task.worker ? "Worker" : "", task.letters ? `Rollen ${task.letters}` : ""].filter(Boolean).join(" · "))))) : h("p", {class: "muted"}, "Keine offenen Aufgaben."),
|
||||
h("h3", {}, "Claims", counter(claims.length)),
|
||||
claims.length ? h("ul", {class: "mini"}, claims.map((claim) => h("li", {},
|
||||
h("div", {class: "row"}, h("code", {}, claim.office), claim.beat === null ? null : h("span", {class: "muted"}, "Puls ", when(claim.beat))),
|
||||
h("span", {}, dash(claim.work)),
|
||||
h("span", {class: "muted"}, `${dash(claim.runtime)} · seit ${dash(claim.since)}`)))) : h("p", {class: "muted"}, "Keine Claims."));
|
||||
const rows = history(ui.agent);
|
||||
drawerDom.history.replaceChildren(rows.length
|
||||
? h("div", {}, rows.map((row) => h("div", {class: `bubble ${row.mine ? "mine" : "theirs"}`}, h("span", {class: "muted"}, `${row.who} · `, when(row.ts)), row.text)))
|
||||
: h("p", {class: "muted"}, "Kein Verlauf im gewählten Zeitraum."));
|
||||
}
|
||||
|
||||
async function loadSubagents(key, holder, life) {
|
||||
if (!SESSION_ID.test(key)) {
|
||||
holder.replaceChildren(h("p", {class: "muted"}, "Keine Session bekannt."));
|
||||
return;
|
||||
}
|
||||
let data = null;
|
||||
try {
|
||||
const response = await fetch(`/api/subagents?session=${encodeURIComponent(key)}`, {cache: "no-store"});
|
||||
data = response.ok ? await response.json() : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!data) {
|
||||
holder.replaceChildren(h("p", {class: "muted"}, "Subagenten nicht lesbar."));
|
||||
return;
|
||||
}
|
||||
life.textContent = stamp(data);
|
||||
const rows = data.subagents.map((row) => ({...row, session: key})).sort((a, b) => a.started - b.started);
|
||||
holder.replaceChildren(rows.length ? h("ul", {class: "mini"}, rows.map((row) => {
|
||||
const item = h("li", {},
|
||||
h("div", {class: "row"}, h("button", {type: "button", class: "linkish", title: "Transkript im Stream zeigen", on: {click: () => focusSub(row.session, row.id)}}, `${row.type}: ${row.description}`), row.finished ? pill("fertig", "neutral") : pill("läuft", "ok")),
|
||||
h("span", {class: "muted"}, `Tiefe ${row.depth} · `, when(row.started), stamp(row) ? ` · ${stamp(row)}` : ""));
|
||||
item.style.marginLeft = `${(row.depth - 1) * 18}px`;
|
||||
return item;
|
||||
})) : h("p", {class: "muted"}, "Keine Subagenten."));
|
||||
}
|
||||
|
||||
async function deliver(text, file, cls) {
|
||||
let upload = "";
|
||||
if (file) {
|
||||
if (file.size > UPLOAD_LIMIT) {
|
||||
toast("Datei zu groß", [`${file.name}: höchstens 50 MiB`], false);
|
||||
return false;
|
||||
}
|
||||
const stored = await send(`/api/uploads?name=${encodeURIComponent(file.name)}`, file, "application/octet-stream");
|
||||
if (!stored.ok || !stored.data) {
|
||||
toast("Upload fehlgeschlagen", [`${stored.status} ${stored.text}`.trim()], false);
|
||||
return false;
|
||||
}
|
||||
upload = stored.data.id;
|
||||
}
|
||||
return message(nameOf(ui.agent), text, upload, cls);
|
||||
}
|
||||
|
||||
async function message(to, text, upload, cls) {
|
||||
const label = TO_ONE.find(([id]) => id === cls)[1];
|
||||
const result = await sendJson("/api/messages", {to, text, upload, class: cls});
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label} an ${to} fehlgeschlagen`, [`${result.status} ${result.text}`.trim()], false);
|
||||
return false;
|
||||
}
|
||||
toast(`${label} an ${to}: ${result.data.ok ? "gesendet" : "gespeichert, Senden fehlgeschlagen"}`, [`kvb-orchestrator msg ${result.data.id}`, ...runLines(result.data)], result.data.ok);
|
||||
await load();
|
||||
return true;
|
||||
}
|
||||
|
||||
function openAgent(who) {
|
||||
const drawer = document.getElementById("drawer");
|
||||
const key = agentKey(who);
|
||||
const name = nameOf(key);
|
||||
ui.agent = key;
|
||||
const text = h("textarea", {"aria-label": `Nachricht an ${name}`, placeholder: `Nachricht an ${name} …`});
|
||||
const file = h("input", {type: "file", class: "field", "aria-label": "Datei anhängen"});
|
||||
const button = h("button", {type: "submit", class: "btn primary"}, "Senden");
|
||||
const classes = choice("to-one", TO_ONE, "post");
|
||||
const form = h("form", {class: "drawer-form"}, text, file, classes,
|
||||
h("p", {class: "muted"}, "Post kommt am Ende des Zuges an, Call sofort mit Lesebestätigung; prüfbar mit operator_message."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), micButton(text), button));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!text.value.trim()) {
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
if (await deliver(text.value, file.files[0] || null, picked(classes))) {
|
||||
text.value = "";
|
||||
file.value = "";
|
||||
}
|
||||
button.disabled = false;
|
||||
});
|
||||
const info = h("div", {});
|
||||
const subs = h("div", {}, h("p", {class: "muted"}, "Lade Subagenten …"));
|
||||
const log = h("div", {"aria-live": "polite"});
|
||||
const life = h("span", {class: "muted"});
|
||||
const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name);
|
||||
drawer.replaceChildren(
|
||||
h("div", {class: "drawer-head"}, face(name, true), life, h("div", {}, h("h2", {class: "panel-title"}, name), h("span", {class: "muted"}, former.length ? `Agent · früher ${former.join(", ")}` : "Agent")), h("span", {class: "spacer"}),
|
||||
h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: closeAgent}}, "×")),
|
||||
h("div", {class: "drawer-body"}, info, h("h3", {}, "Subagenten"), subs, h("h3", {}, "Verlauf"), log),
|
||||
form);
|
||||
drawer.hidden = false;
|
||||
drawerDom = {info, history: log};
|
||||
drawDrawer();
|
||||
loadSubagents(key, subs, life);
|
||||
text.focus();
|
||||
}
|
||||
|
||||
function closeAgent() {
|
||||
document.getElementById("drawer").hidden = true;
|
||||
drawerDom = null;
|
||||
ui.agent = null;
|
||||
}
|
||||
|
||||
function agentNames() {
|
||||
const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.priority]);
|
||||
return [...names].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
function play(kind, volume) {
|
||||
const sound = context();
|
||||
if (!sound) {
|
||||
return;
|
||||
}
|
||||
const level = Math.max(volume * 0.5, 0.0002);
|
||||
duck(Math.max(...TONES[kind].map(([, at, length]) => at + length)));
|
||||
for (const [frequency, at, length] of TONES[kind]) {
|
||||
const oscillator = sound.createOscillator();
|
||||
const gain = sound.createGain();
|
||||
const start = sound.currentTime + at;
|
||||
oscillator.type = kind === "mayday" ? "square" : "sine";
|
||||
oscillator.frequency.value = frequency;
|
||||
gain.gain.setValueAtTime(0.0001, start);
|
||||
gain.gain.exponentialRampToValueAtTime(level, start + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + length);
|
||||
oscillator.connect(gain).connect(sound.destination);
|
||||
oscillator.start(start);
|
||||
oscillator.stop(start + length + 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
function notifyLabel() {
|
||||
const button = document.getElementById("notify");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
const permission = "Notification" in window ? Notification.permission : "unsupported";
|
||||
const labels = {granted: "Benachrichtigungen an", denied: "Benachrichtigungen blockiert", default: "Benachrichtigungen aktivieren", unsupported: "Benachrichtigungen fehlen im Browser"};
|
||||
button.textContent = labels[permission] || labels.default;
|
||||
button.disabled = permission !== "default";
|
||||
}
|
||||
|
||||
async function askNotify() {
|
||||
if ("Notification" in window) {
|
||||
await Notification.requestPermission();
|
||||
}
|
||||
notifyLabel();
|
||||
}
|
||||
|
||||
function announce() {
|
||||
const alarms = new Map(alarmRows().map((row) => [`${row.ts}|${row.from}`, row]));
|
||||
const radio = new Set(state.radio.map((row) => `${row.ts}|${row.from}|${row.text.slice(0, 40)}`));
|
||||
if (known.inbox !== null && live()) {
|
||||
const fresh = state.inbox.filter((item) => !known.inbox.has(keyOf(item)));
|
||||
const newAlarms = [...alarms].filter(([key]) => !known.alarms.has(key)).map(([, row]) => row);
|
||||
const newRadio = state.radio.filter((row) => !known.radio.has(`${row.ts}|${row.from}|${row.text.slice(0, 40)}`));
|
||||
const direct = newRadio.filter((row) => row.to.includes(HUMAN));
|
||||
const present = {mayday: newAlarms.length > 0, review: fresh.some((item) => item.kind !== "operator"), direct: direct.length > 0, radio: newRadio.length > 0};
|
||||
const kind = SOUND_KINDS.map(([id]) => id).find((id) => present[id] && settings.sounds[id].on);
|
||||
if (kind) {
|
||||
play(kind, loudness(kind));
|
||||
}
|
||||
const lead = kind ? Math.max(...TONES[kind].map(([, at, length]) => at + length)) : 0;
|
||||
window.setTimeout(() => speakRows(newRadio), lead * 1000);
|
||||
if (settings.notify && "Notification" in window && Notification.permission === "granted") {
|
||||
for (const row of newAlarms) {
|
||||
new Notification(`${row.class === "mayday" ? "Mayday" : "Pan-Pan"} von ${dash(row.from)}`, {body: row.text.slice(0, 200), tag: `alarm-${row.ts}`, requireInteraction: row.class === "mayday"});
|
||||
}
|
||||
for (const item of fresh) {
|
||||
new Notification(`${ITEM_LABELS[item.kind]} wartet`, {body: item.title.slice(0, 200), tag: `item-${keyOf(item)}`, requireInteraction: item.kind === "ask"});
|
||||
}
|
||||
for (const row of direct) {
|
||||
new Notification(`Funk von ${dash(row.from)}`, {body: row.text.slice(0, 200), tag: `direct-${row.ts}`});
|
||||
}
|
||||
}
|
||||
}
|
||||
known.inbox = new Set(state.inbox.map(keyOf));
|
||||
known.alarms = new Set(alarms.keys());
|
||||
known.radio = radio;
|
||||
for (const batch of state.pending) {
|
||||
if (!known.batches.has(batch.id)) {
|
||||
known.batches.add(batch.id);
|
||||
const source = batch.source.startsWith("rule:") ? "Regel" : "Freigabe";
|
||||
undoToast(batch, `${source}: ${batch.items.map((item) => short(item.title)).join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
async function send(path, body, type) {
|
||||
try {
|
||||
const response = await fetch(path, {method: "POST", headers: {"X-Kvb-Orchestrator": "1", "Content-Type": type}, body, cache: "no-store"});
|
||||
const text = await response.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
return {ok: response.ok, status: response.status, text, data};
|
||||
} catch (error) {
|
||||
return {ok: false, status: 0, text: String(error), data: null};
|
||||
}
|
||||
}
|
||||
const sendJson = (path, payload) => send(path, JSON.stringify(payload), "application/json");
|
||||
const fail = (result) => [`${result.status} ${result.text}`.trim()];
|
||||
@@ -0,0 +1,128 @@
|
||||
async function act(action, label, payload) {
|
||||
ui.busy = true;
|
||||
const result = await sendJson(`/api/board/${action}`, payload);
|
||||
ui.busy = false;
|
||||
if (result.data && result.data.runs) {
|
||||
toast(`${label}: ${result.data.ok ? "erledigt" : "fehlgeschlagen"}`, runLines(result.data), result.data.ok);
|
||||
} else {
|
||||
toast(`${label}: fehlgeschlagen`, [`${result.status} ${result.text}`.trim()], false);
|
||||
}
|
||||
await load();
|
||||
return result.ok && result.data && result.data.ok;
|
||||
}
|
||||
|
||||
function confirmAct(action) {
|
||||
const [, label, question] = ACTIONS.find(([id]) => id === action);
|
||||
if (window.confirm(question)) {
|
||||
act(action, label, {});
|
||||
}
|
||||
}
|
||||
|
||||
function choice(name, options, picked) {
|
||||
return h("div", {class: "classes", role: "radiogroup", "aria-label": "Klasse"}, options.map(([id, label]) => h("label", {class: "check"},
|
||||
h("input", {type: "radio", name, value: id, checked: id === picked}), label)));
|
||||
}
|
||||
|
||||
const picked = (group) => group.querySelector("input:checked").value;
|
||||
|
||||
function toAll() {
|
||||
const text = h("textarea", {"aria-label": "Funk an alle", placeholder: "Nachricht an alle Agenten …", on: {input: (event) => {
|
||||
ui.draft = event.target.value;
|
||||
}}});
|
||||
text.value = ui.draft;
|
||||
const classes = choice("to-all", TO_ALL, "newsletter");
|
||||
const send = async () => {
|
||||
const cls = picked(classes);
|
||||
const label = TO_ALL.find(([id]) => id === cls)[1];
|
||||
if (!ui.draft.trim() || (cls === "mayday" && !window.confirm("Mayday an alle senden? Alle Agenten pausieren schreibende Schritte, bis du ihn aufhebst."))) {
|
||||
return;
|
||||
}
|
||||
if (await act("broadcast", `Funk an alle (${label})`, {text: ui.draft, class: cls})) {
|
||||
ui.draft = "";
|
||||
text.value = "";
|
||||
}
|
||||
};
|
||||
return h("details", {class: "menu", id: "to-all"}, h("summary", {}, "Funk an alle ▾"), h("div", {class: "menu-box"},
|
||||
text, classes, h("p", {class: "muted"}, "Newsletter kommt als Post am Ende des Zuges; Broadcast, Pan-Pan, Security und Mayday sofort."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Senden", "btn soft", send))));
|
||||
}
|
||||
|
||||
function openToAll() {
|
||||
go("agents", null);
|
||||
window.setTimeout(() => {
|
||||
const menu = document.getElementById("to-all");
|
||||
if (menu) {
|
||||
menu.open = true;
|
||||
menu.querySelector("textarea").focus();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function agentsToolbar() {
|
||||
const clear = btn("Mayday aufheben", "btn small danger", () => confirmAct("clear-mayday"));
|
||||
const mode = h("span", {});
|
||||
const night = h("details", {class: "menu"}, h("summary", {}, "Tag/Nacht ▾"), h("div", {class: "menu-box"}, mode,
|
||||
h("p", {class: "muted"}, "Abend: Agenten beenden ihre Aufgabe. Sonnenuntergang: sie schlafen. Sonnenaufgang: alle wachen auf."),
|
||||
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id, label]) => btn(label, "btn small", () => confirmAct(id))))));
|
||||
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, btn("Priorität", "btn small", prioritySheet), night), clear, mode};
|
||||
}
|
||||
|
||||
function drawToolbar() {
|
||||
if (!graphDom || !graphDom.toolbar) {
|
||||
return;
|
||||
}
|
||||
const [mode, tone] = NIGHT[state.night] || [state.night, "neutral"];
|
||||
graphDom.toolbar.clear.disabled = !state.silence;
|
||||
graphDom.toolbar.mode.replaceChildren(pill(mode, tone));
|
||||
}
|
||||
|
||||
function priorityContent() {
|
||||
if (ui.order === null || ui.order.some((name) => !state.priority.includes(name)) || ui.order.length !== state.priority.length) {
|
||||
ui.order = [...state.priority];
|
||||
}
|
||||
const list = h("ol", {class: "order-list"});
|
||||
let dragged = null;
|
||||
ui.order.forEach((name, index) => {
|
||||
const item = h("li", {draggable: "true", "data-name": name}, h("span", {class: "rank"}, `${index + 1}.`), face(name, false), person(name));
|
||||
item.addEventListener("dragstart", (event) => {
|
||||
dragged = name;
|
||||
item.classList.add("dragging");
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", name);
|
||||
});
|
||||
item.addEventListener("dragend", () => item.classList.remove("dragging"));
|
||||
item.addEventListener("dragover", (event) => event.preventDefault());
|
||||
item.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
if (dragged === null || dragged === name) {
|
||||
return;
|
||||
}
|
||||
const next = ui.order.filter((entry) => entry !== dragged);
|
||||
next.splice(next.indexOf(name) + (ui.order.indexOf(dragged) < ui.order.indexOf(name) ? 1 : 0), 0, dragged);
|
||||
ui.order = next;
|
||||
prioritySheet();
|
||||
});
|
||||
list.append(item);
|
||||
});
|
||||
const changed = JSON.stringify(ui.order) !== JSON.stringify(state.priority);
|
||||
return [
|
||||
ui.order.length ? list : h("p", {class: "muted"}, "Kein Agent mit offener Aufgabe."),
|
||||
h("div", {class: "actions"},
|
||||
h("span", {class: "muted"}, "Ziehen zum Umsortieren"),
|
||||
h("span", {class: "spacer"}),
|
||||
h("button", {type: "button", class: "btn ghost", disabled: !changed, on: {click: () => {
|
||||
ui.order = [...state.priority];
|
||||
prioritySheet();
|
||||
}}}, "Zurücksetzen"),
|
||||
h("button", {type: "button", class: "btn primary", disabled: !changed, on: {click: async () => {
|
||||
if (await act("order", "Priorität", {agents: ui.order})) {
|
||||
ui.order = null;
|
||||
prioritySheet();
|
||||
}
|
||||
}}}, "Reihenfolge speichern")),
|
||||
];
|
||||
}
|
||||
|
||||
function prioritySheet() {
|
||||
openSheet("Priorität der Agenten", priorityContent(), false);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
document.getElementById("palette").addEventListener("click", (event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closePalette();
|
||||
}
|
||||
});
|
||||
document.getElementById("sheet").addEventListener("click", (event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeSheet();
|
||||
}
|
||||
});
|
||||
document.getElementById("palette-open").addEventListener("click", openPalette);
|
||||
document.getElementById("settings-open").addEventListener("click", settingsSheet);
|
||||
document.addEventListener("click", (event) => closeMenus(event.target instanceof Element ? event.target.closest("details.menu") : null));
|
||||
document.addEventListener("fullscreenchange", () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.body.classList.remove("leitstand");
|
||||
}
|
||||
window.setTimeout(measure, 50);
|
||||
});
|
||||
document.addEventListener("pointerdown", context, {once: true});
|
||||
document.addEventListener("keydown", context, {once: true});
|
||||
document.addEventListener("pointerdown", unlock);
|
||||
document.addEventListener("keydown", keyboard);
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
window.addEventListener("hashchange", apply);
|
||||
window.addEventListener("resize", measure);
|
||||
window.addEventListener("resize", redrawSky);
|
||||
window.addEventListener("resize", requestSpace);
|
||||
skyPanel();
|
||||
radio.element = h("audio", {preload: "none"});
|
||||
radio.element.addEventListener("error", () => {
|
||||
if (radio.element.getAttribute("src")) {
|
||||
radio.playing = false;
|
||||
radio.url = "";
|
||||
tuneMusic(FADE / 1000);
|
||||
}
|
||||
});
|
||||
drawSky();
|
||||
musicButton();
|
||||
syncMusic();
|
||||
syncRadio();
|
||||
timeBar();
|
||||
notifyLabel();
|
||||
announce();
|
||||
measure();
|
||||
apply();
|
||||
drawTime();
|
||||
motion.asOf = ui.asOf;
|
||||
relayout(0);
|
||||
window.setInterval(refresh, 10000);
|
||||
window.setInterval(follow, 3000);
|
||||
window.setInterval(() => relayout(MOVE), RELAYOUT);
|
||||
window.setInterval(() => live() && drawSky(), 30000);
|
||||
window.setInterval(syncRadio, 20000);
|
||||
window.setInterval(() => {
|
||||
if (tree.dom && !document.hidden) {
|
||||
fetchTree();
|
||||
}
|
||||
}, 60000);
|
||||
@@ -0,0 +1,69 @@
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const DAY = 86400;
|
||||
const CHAIR = "Maintainer";
|
||||
const HUMAN = "Operator";
|
||||
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "alle"], [-1, "eigener"]];
|
||||
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
|
||||
const PARTS = [["user", "Nutzer"], ["assistant", "Assistent"], ["tool_call", "Werkzeugaufruf"], ["tool_result", "Werkzeugergebnis"], ["system", "System"]];
|
||||
const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
|
||||
const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"};
|
||||
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Entscheidung", operator: "Operator"};
|
||||
const NIGHT = {day: ["Tag", "ok"], evening: ["Abend", "warn"], sunset: ["Nacht", "violet"]};
|
||||
const ACTIONS = [["clear-mayday", "Mayday aufheben", "Die Funkstille des aktiven Maydays aufheben?"], ["evening", "Abend", "Alle Agenten in den Abend schicken (kvb-agent-relax)?"], ["sunset", "Sonnenuntergang", "Alle Agenten schlafen legen (kvb-agent-sleep)?"], ["sunrise", "Sonnenaufgang", "Alle Agenten wecken (kvb-agent-wakeup)?"]];
|
||||
const UPLOAD_LIMIT = 50 * 1024 * 1024;
|
||||
const STREAM_KEEP = 5000;
|
||||
const CLASSES = {post: "Post", call: "Call", newsletter: "Newsletter", broadcast: "Broadcast", mayday: "Mayday", "pan-pan": "Pan-Pan", security: "Security", receipt: "Gelesen", grant: "Freigabe"};
|
||||
const PILLS = {post: "info", call: "info", newsletter: "teal", broadcast: "teal", mayday: "bad", "pan-pan": "warn", security: "violet", receipt: "neutral", grant: "ok"};
|
||||
const LEGEND = [["direct", "Post / Call"], ["broadcast", "Newsletter / Broadcast"], ["alarm", "Mayday / Pan-Pan"], ["security", "Security"], ["grant", "Queue-Freigabe"]];
|
||||
const TO_ALL = [["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["pan-pan", "Pan-Pan"], ["security", "Security"], ["mayday", "Mayday"]];
|
||||
const TO_ONE = [["post", "Post"], ["call", "Call"]];
|
||||
const STATUS = {
|
||||
running: ["läuft", "ok"], queued: ["wartet", "info"], blocked: ["blockiert", "bad"], stale: ["veraltet", "warn"], pending: ["offen", "warn"], done: ["erledigt", "neutral"], stopped: ["gestoppt", "neutral"],
|
||||
approved: ["freigegeben", "ok"], rejected: ["abgelehnt", "bad"], allowed: ["erlaubt", "ok"], denied: ["abgelehnt", "bad"], expired: ["abgelaufen", "neutral"], decided: ["entschieden", "ok"], failed: ["fehlgeschlagen", "bad"], refused: ["verweigert", "bad"],
|
||||
};
|
||||
const SECTIONS = {staged: "Gestaged", unstaged: "Nicht gestaged", untracked: "Unversioniert"};
|
||||
const FILE_STATUS = {added: ["A", "ok"], deleted: ["D", "bad"], modified: ["M", "info"], renamed: ["R", "violet"], copied: ["C", "violet"], untracked: ["U", "warn"]};
|
||||
const SIGNS = {add: "+", del: "-", ctx: " ", meta: " "};
|
||||
const NODE_KINDS = {operator: "Operator", chair: "Chair", session: "Session", subagent: "Subagent"};
|
||||
const RADIUS = {operator: 30, chair: 22, session: 15, subagent: 6};
|
||||
const RINGS = [150, 300];
|
||||
const FEED_LIMIT = 300;
|
||||
const MOVE = 2000;
|
||||
const RELAYOUT = 60000;
|
||||
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const STORE = {views: "kvb-orchestrator.views"};
|
||||
const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, Asks, Entscheidungen"], ["direct", "Post und Calls an dich"], ["radio", "Aller Funk"]];
|
||||
const SPEECH_CLASSES = [["post", "Post"], ["call", "Call"], ["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["mayday", "MAYDAY"], ["pan-pan", "PAN-PAN"], ["security", "SECURITE"]];
|
||||
const SPEECH_QUEUE = 20;
|
||||
const SPEECH_QUIET = 60000;
|
||||
const SPEECH_FRESH = 300;
|
||||
const THEME_CHOICES = [["sky", "Himmel (folgt dem Licht draußen)"], ["light", "Immer hell"], ["dark", "Immer dunkel"], ["custom", "Eigene Farben"]];
|
||||
const COLOR_FIELDS = [["top", "Himmel oben"], ["bottom", "Himmel am Horizont"], ["accent", "Akzent"]];
|
||||
const STAGE_LABELS = {night: "Nacht", astronomical: "Astronomische Dämmerung", nautical: "Nautische Dämmerung", civil: "Bürgerliche Dämmerung", golden: "Goldene Stunde", day: "Tag"};
|
||||
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
const COMPASS = [["N", "Norden"], ["NO", "Nordosten"], ["O", "Osten"], ["SO", "Südosten"], ["S", "Süden"], ["SW", "Südwesten"], ["W", "Westen"], ["NW", "Nordwesten"]];
|
||||
const AXES = [["time", "Zeit", "Tiefe = Zeit im gewählten Zeitraum: jeder Agent eine Weltlinie"], ["spawn", "Spawn-Tiefe", "Tiefe = Spawn-Tiefe: Subagenten hängen als Baum unter ihrer Session"]];
|
||||
const SUBS_TTL = 60000;
|
||||
const TURN = 10;
|
||||
const NUDGE = 40;
|
||||
const ZOOM = 1.2;
|
||||
const GLIDE = 320;
|
||||
const PLANE = 450;
|
||||
const SAVE_DELAY = 700;
|
||||
const TRACK_RETRY = 5000;
|
||||
const CALM = [1, 9 / 8, 5 / 4, 3 / 2, 5 / 3, 2];
|
||||
const TENSE = [1, 16 / 15, 6 / 5, 45 / 32, 3 / 2, 8 / 5];
|
||||
const BASE = 110;
|
||||
const FADE = 3000;
|
||||
const DUCK = 0.2;
|
||||
const TONES = {
|
||||
mayday: [[880, 0, 0.18], [660, 0.22, 0.18], [880, 0.44, 0.18], [660, 0.66, 0.18], [880, 0.88, 0.32]],
|
||||
review: [[660, 0, 0.14], [990, 0.16, 0.24]],
|
||||
direct: [[740, 0, 0.2]],
|
||||
radio: [[520, 0, 0.07]],
|
||||
};
|
||||
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Agents · Artifacts"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "3D-Graph und Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Umschalt+Pfeile", "3D-Graph: verschieben (auch Umschalt+Ziehen)"], ["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
|
||||
const calm = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"});
|
||||
const absolute = new Intl.DateTimeFormat("de-DE", {dateStyle: "medium", timeStyle: "short"});
|
||||
const clock = new Intl.DateTimeFormat("de-DE", {hour: "2-digit", minute: "2-digit"});
|
||||
@@ -0,0 +1,132 @@
|
||||
function build(node, attrs, children) {
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (key === "on") {
|
||||
for (const [name, handler] of Object.entries(value)) {
|
||||
node.addEventListener(name, handler);
|
||||
}
|
||||
} else if (value !== null && value !== undefined && value !== false) {
|
||||
node.setAttribute(key, value === true ? "" : String(value));
|
||||
}
|
||||
}
|
||||
for (const child of children.flat(Infinity)) {
|
||||
if (child !== null && child !== undefined && child !== false) {
|
||||
node.append(child instanceof Node ? child : String(child));
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
const h = (tag, attrs, ...children) => build(document.createElement(tag), attrs, children);
|
||||
const s = (tag, attrs, ...children) => build(document.createElementNS(NS, tag), attrs, children);
|
||||
const dash = (value) => (value === "" || value === "-" || value === null || value === undefined ? "–" : value);
|
||||
const targetsOf = (row) => (row.to.length ? row.to.join(", ") : "alle");
|
||||
const fixed = (value) => value.toFixed(1);
|
||||
const short = (text) => (text.length > 80 ? `${text.slice(0, 80)}…` : text);
|
||||
const btn = (label, cls, run) => h("button", {type: "button", class: cls, on: {click: run}}, label);
|
||||
const nowSeconds = () => Date.now() / 1000;
|
||||
|
||||
function ago(ts) {
|
||||
const diff = ts - nowSeconds();
|
||||
const size = Math.abs(diff);
|
||||
if (size < 45) {
|
||||
return "gerade eben";
|
||||
}
|
||||
if (size < 3600) {
|
||||
return relative.format(Math.round(diff / 60), "minute");
|
||||
}
|
||||
if (size < DAY) {
|
||||
return relative.format(Math.round(diff / 3600), "hour");
|
||||
}
|
||||
return relative.format(Math.round(diff / DAY), "day");
|
||||
}
|
||||
const stampOf = (ts) => new Date(ts * 1000);
|
||||
const when = (ts) => h("time", {"data-ts": ts, datetime: stampOf(ts).toISOString(), title: absolute.format(stampOf(ts))}, ago(ts));
|
||||
const pill = (text, tone) => h("span", {class: "pill", "data-tone": tone}, text);
|
||||
const status = (value) => pill((STATUS[value] || [value])[0], (STATUS[value] || [value, "neutral"])[1]);
|
||||
const klass = (value) => pill(CLASSES[value] || value, PILLS[value] || "neutral");
|
||||
const counter = (value) => h("span", {class: "count"}, String(value));
|
||||
const meta = (pairs) => h("dl", {class: "meta"}, pairs.map(([label, value, title]) => h("div", {}, h("dt", {}, label), h("dd", {}, h("code", {title: title || null}, dash(value))))));
|
||||
const emptyState = (title, text) => h("div", {class: "empty"}, h("div", {class: "empty-mark", "aria-hidden": "true"}), h("h2", {}, title), h("p", {}, text));
|
||||
const live = () => ui.preset !== -1 && ui.asOf === null;
|
||||
const alarmRows = () => state.radio.filter((row) => (row.class === "mayday" || row.class === "pan-pan") && row.ts >= state.alarm_since).reverse();
|
||||
const keyOf = (item) => `${item.kind}:${item.id}`;
|
||||
const visibleInbox = () => state.inbox.filter((item) => ui.snoozed || !item.until);
|
||||
const waitingCount = () => state.inbox.filter((item) => !item.until).length;
|
||||
const isGreen = (item) => item.green && !item.held && !item.until && item.kind !== "operator";
|
||||
const selectable = (item) => !item.held && !(item.kind === "review" && item.gate !== "pass") && !(item.kind === "operator" && !item.runnable) && !(item.kind === "decision" && !item.recommended);
|
||||
const entryOf = (item, verb, label) => ({kind: item.kind, id: item.id, verb, label, sha256: item.sha256 || ""});
|
||||
|
||||
function tick() {
|
||||
for (const node of document.querySelectorAll("time[data-ts]")) {
|
||||
node.textContent = ago(Number(node.dataset.ts));
|
||||
}
|
||||
const updated = document.getElementById("updated");
|
||||
updated.classList.toggle("offline", ui.offline);
|
||||
updated.textContent = `${ui.offline ? "Keine Verbindung · " : ""}${live() ? "Live" : "Stand"} ${absolute.format(stampOf(live() ? state.now : state.until))}`;
|
||||
}
|
||||
|
||||
function avatar(name) {
|
||||
let hash = 2166136261;
|
||||
for (const char of String(name)) {
|
||||
hash = Math.imul(hash ^ char.codePointAt(0), 16777619) >>> 0;
|
||||
}
|
||||
return EMOJI[hash % EMOJI.length];
|
||||
}
|
||||
const face = (name, big) => h("span", {class: big ? "avatar big" : "avatar", "aria-hidden": "true"}, avatar(name));
|
||||
const person = (name) => (name && name !== "-" && name !== "alle" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `${name} öffnen`, on: {click: () => openAgent(name)}}, name) : dash(name));
|
||||
const people = () => state.people || {};
|
||||
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
|
||||
const nameOf = (key) => (people()[key] || {now: String(key).replace(/^name:/, "")}).now;
|
||||
const agentKey = (who) => (people()[who] ? who : (Object.entries(people()).find(([, entry]) => entry.now === who || entry.names.includes(who)) || [who])[0]);
|
||||
const chairKey = () => (state.graph.nodes.find((node) => node.kind === "chair") || {id: CHAIR}).id;
|
||||
const stamp = (life) => [life.born ? `🐣 ${life.born}` : "", life.died ? `🪦 ${life.died}` : ""].filter(Boolean).join(" · ");
|
||||
|
||||
function toast(title, lines, good) {
|
||||
const box = h("div", {class: `toast${good ? "" : " bad"}`, role: "status", title: "Schließen"}, h("strong", {}, title), lines.length ? h("pre", {}, lines.join("\n")) : null);
|
||||
box.addEventListener("click", () => box.remove());
|
||||
document.getElementById("toasts").append(box);
|
||||
window.setTimeout(() => box.remove(), good ? 9000 : 20000);
|
||||
}
|
||||
|
||||
function runLines(data) {
|
||||
return (data.runs || []).flatMap((run) => [`$ ${run.command} → ${run.code}`, run.stdout.trim(), run.stderr.trim()].filter(Boolean));
|
||||
}
|
||||
|
||||
function copyButton(text) {
|
||||
const button = h("button", {type: "button", class: "btn small ghost", title: "In die Zwischenablage kopieren"}, "Kopieren");
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
button.textContent = "Kopiert";
|
||||
} catch {
|
||||
button.textContent = "Nicht möglich";
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
button.textContent = "Kopieren";
|
||||
}, 1600);
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function closeMenus(keep) {
|
||||
for (const menu of document.querySelectorAll("details.menu[open]")) {
|
||||
if (menu !== keep) {
|
||||
menu.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSheet(title, body, wide) {
|
||||
const sheet = document.getElementById("sheet");
|
||||
sheet.replaceChildren(h("div", {class: `sheet-box${wide ? " wide" : ""}`, role: "dialog", "aria-modal": "true", "aria-label": title},
|
||||
h("div", {class: "sheet-head"}, h("h2", {class: "panel-title"}, title), h("span", {class: "spacer"}), h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: closeSheet}}, "×")),
|
||||
h("div", {class: "sheet-body"}, body)));
|
||||
sheet.hidden = false;
|
||||
}
|
||||
|
||||
function closeSheet() {
|
||||
const sheet = document.getElementById("sheet");
|
||||
sheet.hidden = true;
|
||||
sheet.replaceChildren();
|
||||
reviewDom = null;
|
||||
ui.review = null;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
const activeKinds = () => (filter.view ? new Set(state.views[filter.view].kinds) : filter.kinds);
|
||||
const activeWaiting = () => (filter.view ? state.views[filter.view].waiting : filter.waiting);
|
||||
|
||||
function filterQuery() {
|
||||
const params = new URLSearchParams();
|
||||
if (filter.view) {
|
||||
params.set("view", filter.view);
|
||||
} else {
|
||||
params.set("kinds", [...filter.kinds].join(","));
|
||||
if (filter.waiting) {
|
||||
params.set("waiting", "1");
|
||||
}
|
||||
}
|
||||
for (const name of ["q", "agent", "session", "sub"]) {
|
||||
if (filter[name]) {
|
||||
params.set(name, filter[name]);
|
||||
}
|
||||
}
|
||||
if (filter.parts.size !== PARTS.length) {
|
||||
params.set("parts", [...filter.parts].join(","));
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function windowKey() {
|
||||
if (ui.preset === -1) {
|
||||
return `custom:${ui.from}:${ui.to}`;
|
||||
}
|
||||
return `${ui.preset}:${ui.asOf === null ? "live" : ui.asOf}`;
|
||||
}
|
||||
|
||||
function customize() {
|
||||
if (filter.view) {
|
||||
filter.kinds = new Set(state.views[filter.view].kinds);
|
||||
filter.waiting = state.views[filter.view].waiting;
|
||||
filter.view = "";
|
||||
}
|
||||
filter.saved = "";
|
||||
}
|
||||
|
||||
function refilter() {
|
||||
drawFilters();
|
||||
drawStream();
|
||||
fetchEvents();
|
||||
}
|
||||
|
||||
function useView(id) {
|
||||
filter.view = id;
|
||||
filter.saved = "";
|
||||
filter.session = "";
|
||||
filter.sub = "";
|
||||
filter.agent = "";
|
||||
filter.q = "";
|
||||
if (ui.screen === "stream" && stream.dom) {
|
||||
stream.dom.search.value = "";
|
||||
refilter();
|
||||
} else {
|
||||
go("stream", null);
|
||||
}
|
||||
}
|
||||
|
||||
function useSaved(view) {
|
||||
filter.view = "";
|
||||
filter.saved = view.name;
|
||||
filter.kinds = new Set(view.kinds);
|
||||
filter.waiting = view.waiting;
|
||||
filter.q = view.q;
|
||||
filter.agent = view.agent;
|
||||
filter.session = "";
|
||||
filter.sub = "";
|
||||
if (ui.screen === "stream" && stream.dom) {
|
||||
stream.dom.search.value = filter.q;
|
||||
refilter();
|
||||
} else {
|
||||
go("stream", null);
|
||||
}
|
||||
}
|
||||
|
||||
function saveView() {
|
||||
const name = (window.prompt("Name der Ansicht", filter.saved || "") || "").trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const entry = {name: name.slice(0, 60), kinds: [...activeKinds()], waiting: activeWaiting(), q: filter.q, agent: filter.agent};
|
||||
userViews = [...userViews.filter((view) => view.name !== entry.name), entry];
|
||||
if (!keep(STORE.views, userViews)) {
|
||||
toast("Ansicht nur bis zum Neuladen gespeichert", ["Der Browser erlaubt kein localStorage."], false);
|
||||
}
|
||||
useSaved(entry);
|
||||
}
|
||||
|
||||
function dropView(name) {
|
||||
userViews = userViews.filter((view) => view.name !== name);
|
||||
keep(STORE.views, userViews);
|
||||
if (filter.saved === name) {
|
||||
filter.saved = "";
|
||||
}
|
||||
drawFilters();
|
||||
}
|
||||
|
||||
function drawFilters() {
|
||||
if (!stream.dom) {
|
||||
return;
|
||||
}
|
||||
const kinds = activeKinds();
|
||||
stream.dom.categories.replaceChildren(...Object.entries(state.views).map(([id, view]) => h("button", {type: "button", class: "chip preset", "aria-pressed": String(filter.view === id), on: {click: () => useView(id)}}, view.label)));
|
||||
const mine = userViews.map((view) => h("span", {class: "chip", "aria-pressed": String(!filter.view && filter.saved === view.name), role: "button", tabindex: 0, on: {
|
||||
click: () => useSaved(view),
|
||||
keydown: (event) => {
|
||||
if (event.key === "Enter") {
|
||||
useSaved(view);
|
||||
}
|
||||
},
|
||||
}}, view.name, h("button", {type: "button", class: "x", "aria-label": `${view.name} löschen`, on: {click: (event) => {
|
||||
event.stopPropagation();
|
||||
dropView(view.name);
|
||||
}}}, "×")));
|
||||
stream.dom.saved.replaceChildren(...mine, btn("+ Ansicht speichern", "btn small ghost", saveView));
|
||||
const kindChips = KINDS.map(([id, label]) => h("button", {type: "button", class: "chip", "aria-pressed": String(kinds.has(id)), on: {click: () => {
|
||||
customize();
|
||||
if (filter.kinds.has(id) && filter.kinds.size > 1) {
|
||||
filter.kinds.delete(id);
|
||||
} else {
|
||||
filter.kinds.add(id);
|
||||
}
|
||||
refilter();
|
||||
}}}, label));
|
||||
const waiting = h("button", {type: "button", class: "chip", "aria-pressed": String(activeWaiting()), on: {click: () => {
|
||||
customize();
|
||||
filter.waiting = !filter.waiting;
|
||||
refilter();
|
||||
}}}, "nur Wartendes");
|
||||
stream.dom.kinds.replaceChildren(waiting, ...kindChips);
|
||||
const parts = kinds.has("transcript") ? PARTS.map(([id, label]) => h("button", {type: "button", class: "chip", "aria-pressed": String(filter.parts.has(id)), on: {click: () => {
|
||||
if (filter.parts.has(id) && filter.parts.size > 1) {
|
||||
filter.parts.delete(id);
|
||||
} else {
|
||||
filter.parts.add(id);
|
||||
}
|
||||
refilter();
|
||||
}}}, label)) : [h("span", {class: "muted"}, "Erst mit der Art Transkripte.")];
|
||||
stream.dom.parts.replaceChildren(...parts);
|
||||
const focus = filter.sub || filter.session || filter.agent ? h("span", {class: "chip", "aria-pressed": "true"}, filter.sub ? `Subagent ${filter.sub.slice(0, 12)}` : filter.session ? `Session ${filter.session.slice(0, 8)}` : `Agent ${filter.agent}`,
|
||||
h("button", {type: "button", class: "x", "aria-label": "Filter entfernen", on: {click: () => {
|
||||
filter.sub = "";
|
||||
filter.session = "";
|
||||
filter.agent = "";
|
||||
refilter();
|
||||
}}}, "×")) : null;
|
||||
stream.dom.focus.replaceChildren(...(focus ? [focus] : []));
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
function orbit() {
|
||||
const nodes = state.graph.nodes;
|
||||
positions = layout(nodes);
|
||||
const svg = chartFrame(400, "Orbit: Operator in der Mitte; wer zusammen arbeitet oder funkt, rückt zusammen, Stille driften nach außen");
|
||||
svg.append(...RINGS.map((r) => s("circle", {class: "ring", r})));
|
||||
const edgeLayer = s("g", {});
|
||||
const halos = new Map();
|
||||
for (const link of aggregate()) {
|
||||
const from = positions.get(link.source);
|
||||
const shade = tone(link.cls);
|
||||
const words = `${link.count}× ${CLASSES[link.cls] || link.cls}`;
|
||||
if (!from) {
|
||||
continue;
|
||||
}
|
||||
if (link.target === "*") {
|
||||
const index = halos.get(link.source) || 0;
|
||||
halos.set(link.source, index + 1);
|
||||
edgeLayer.append(s("circle", {class: `halo t-${shade}`, cx: fixed(from.x), cy: fixed(from.y), r: radiusOf(link.source) + 7 + index * 7, "stroke-width": width(link.count)},
|
||||
s("title", {}, `${link.source} an alle: ${words}`)));
|
||||
continue;
|
||||
}
|
||||
const to = positions.get(link.target);
|
||||
if (!to || to === from) {
|
||||
continue;
|
||||
}
|
||||
const control = Space.bend(from, to);
|
||||
const start = toward(from, control, radiusOf(link.source) + 2);
|
||||
const end = toward(to, control, radiusOf(link.target) + 4);
|
||||
edgeLayer.append(s("path", {class: `edge t-${shade}`, d: `M${fixed(start.x)},${fixed(start.y)} Q${fixed(control.x)},${fixed(control.y)} ${fixed(end.x)},${fixed(end.y)}`, "stroke-width": fixed(width(link.count)), "marker-end": `url(#tip-${shade})`},
|
||||
s("title", {}, `${link.source} → ${link.target}: ${words}`)));
|
||||
}
|
||||
const nodeLayer = s("g", {}, nodes.map((node) => nodeMark(node, positions.get(node.id), node.kind === "operator" || node.kind === "subagent" ? null : RADIUS[node.kind] + 9)));
|
||||
const pulseLayer = s("g", {});
|
||||
svg.append(edgeLayer, nodeLayer, pulseLayer);
|
||||
graphDom.pulses = pulseLayer;
|
||||
return svg;
|
||||
}
|
||||
|
||||
function chord() {
|
||||
const nodes = state.graph.nodes.filter((node) => node.kind !== "subagent");
|
||||
const fresh = targets();
|
||||
const radius = 250;
|
||||
const step = (2 * Math.PI) / Math.max(nodes.length, 1);
|
||||
const spots = new Map(nodes.map((node) => {
|
||||
const angle = spotOf(node.id, fresh).chord;
|
||||
return [node.id, {x: radius * Math.cos(angle), y: radius * Math.sin(angle), angle}];
|
||||
}));
|
||||
const svg = chartFrame(450, "Chord: Nachrichten zwischen Agenten, Reihenfolge nach Nähe");
|
||||
const arcs = s("g", {});
|
||||
const point = (angle, r) => `${fixed(r * Math.cos(angle))},${fixed(r * Math.sin(angle))}`;
|
||||
for (const node of nodes) {
|
||||
const at = spots.get(node.id);
|
||||
const half = Math.min(step * 0.36, 0.5);
|
||||
arcs.append(s("path", {class: `group-arc k-${node.kind}`, d: `M${point(at.angle - half, radius + 14)} A${radius + 14},${radius + 14} 0 0 1 ${point(at.angle + half, radius + 14)}`}));
|
||||
}
|
||||
const pairs = new Map();
|
||||
const fans = new Map();
|
||||
for (const link of aggregate()) {
|
||||
const shade = tone(link.cls);
|
||||
if (!spots.has(link.source)) {
|
||||
continue;
|
||||
}
|
||||
if (link.target === "*") {
|
||||
const key = `${link.source}\u0000${shade}`;
|
||||
fans.set(key, {source: link.source, shade, count: (fans.has(key) ? fans.get(key).count : 0) + link.count});
|
||||
continue;
|
||||
}
|
||||
if (!spots.has(link.target) || link.target === link.source) {
|
||||
continue;
|
||||
}
|
||||
const [a, b] = [link.source, link.target].sort();
|
||||
const key = `${a}\u0000${b}\u0000${shade}`;
|
||||
pairs.set(key, {a, b, shade, count: (pairs.has(key) ? pairs.get(key).count : 0) + link.count});
|
||||
}
|
||||
const lanes = LEGEND.map(([name]) => name);
|
||||
for (const pair of pairs.values()) {
|
||||
const from = spots.get(pair.a);
|
||||
const to = spots.get(pair.b);
|
||||
const pull = 0.06 + 0.12 * lanes.indexOf(pair.shade);
|
||||
const control = {x: ((from.x + to.x) / 2) * pull, y: ((from.y + to.y) / 2) * pull};
|
||||
const start = toward(from, control, RADIUS.session + 2);
|
||||
const end = toward(to, control, RADIUS.session + 2);
|
||||
arcs.append(s("path", {class: `edge t-${pair.shade}`, d: `M${fixed(start.x)},${fixed(start.y)} Q${fixed(control.x)},${fixed(control.y)} ${fixed(end.x)},${fixed(end.y)}`, "stroke-width": fixed(width(pair.count))},
|
||||
s("title", {}, `${pair.a} ↔ ${pair.b}: ${pair.count}× ${LEGEND.find(([name]) => name === pair.shade)[1]}`)));
|
||||
}
|
||||
const offsets = new Map();
|
||||
for (const fan of fans.values()) {
|
||||
const at = spots.get(fan.source);
|
||||
const index = offsets.get(fan.source) || 0;
|
||||
offsets.set(fan.source, index + 1);
|
||||
const angle = at.angle + (index - 1) * 0.05;
|
||||
const length = Math.min(8 + 6 * Math.log2(1 + fan.count), 40);
|
||||
arcs.append(s("line", {class: `tick t-${fan.shade}`, x1: fixed((radius + 22) * Math.cos(angle)), y1: fixed((radius + 22) * Math.sin(angle)), x2: fixed((radius + 22 + length) * Math.cos(angle)), y2: fixed((radius + 22 + length) * Math.sin(angle)), "stroke-width": 4},
|
||||
s("title", {}, `${fan.source} an alle: ${fan.count}×`)));
|
||||
}
|
||||
svg.append(arcs, s("g", {}, nodes.map((node) => nodeMark(node, spots.get(node.id), 74))));
|
||||
return svg;
|
||||
}
|
||||
|
||||
function swimlane() {
|
||||
const rank = new Map([HUMAN, ...state.graph.layout.order].map((id, index) => [id, index]));
|
||||
const lanes = state.graph.nodes.filter((node) => node.kind !== "subagent").sort((a, b) => (rank.has(a.id) ? rank.get(a.id) : 1e6) - (rank.has(b.id) ? rank.get(b.id) : 1e6));
|
||||
const begin = state.since;
|
||||
const end = Math.max(state.until, state.since + 1);
|
||||
const left = 160;
|
||||
const right = 24;
|
||||
const top = 40;
|
||||
const lane = 36;
|
||||
const full = 1120;
|
||||
const height = top + lanes.length * lane + 12;
|
||||
const x = (ts) => left + ((ts - begin) / (end - begin)) * (full - left - right);
|
||||
const y = new Map(lanes.map((node, index) => [node.id, top + index * lane + lane / 2]));
|
||||
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": "Swimlane: Nachrichten über die Zeit"}, defs());
|
||||
lanes.forEach((node, index) => {
|
||||
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane}));
|
||||
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "text-anchor": "start", on: {click: () => select(node.id)}}, node.label));
|
||||
});
|
||||
for (let index = 0; index <= 6; index += 1) {
|
||||
const ts = begin + ((end - begin) * index) / 6;
|
||||
svg.append(s("line", {class: "grid", x1: fixed(x(ts)), x2: fixed(x(ts)), y1: top - 6, y2: height - 8}));
|
||||
svg.append(s("text", {class: "axis", x: fixed(x(ts)), y: top - 14, "text-anchor": index === 0 ? "start" : index === 6 ? "end" : "middle"}, (end - begin > DAY ? absolute : clock).format(stampOf(ts))));
|
||||
}
|
||||
if (live()) {
|
||||
svg.append(s("line", {class: "now", x1: fixed(x(end)), x2: fixed(x(end)), y1: top - 6, y2: height - 8}));
|
||||
}
|
||||
const marks = s("g", {});
|
||||
const arrows = (cls, ts, from, to, words) => {
|
||||
const x0 = fixed(x(ts));
|
||||
const y0 = y.get(from);
|
||||
if (y0 === undefined) {
|
||||
return;
|
||||
}
|
||||
const shade = tone(cls);
|
||||
const hint = s("title", {}, `${clock.format(stampOf(ts))} · ${dash(labelOf(from))} → ${to.length ? to.map(labelOf).join(", ") : "alle"}\n${words}`);
|
||||
if (!to.length) {
|
||||
const rays = [[-6, -13], [0, -15], [6, -13], [-6, 13], [0, 15], [6, 13]].map(([dx, dy]) => `M${x0},${y0} l${dx},${dy}`).join(" ");
|
||||
marks.append(s("g", {class: `mark t-${shade}`}, hint, s("path", {d: rays}), s("circle", {cx: x0, cy: y0, r: 3.5})));
|
||||
return;
|
||||
}
|
||||
const bundle = s("g", {class: `mark t-${shade}`}, hint, s("circle", {cx: x0, cy: y0, r: 3}));
|
||||
for (const target of to) {
|
||||
const y1 = y.get(target);
|
||||
if (y1 !== undefined && y1 !== y0) {
|
||||
bundle.append(s("line", {x1: x0, y1: y0, x2: x0, y2: y1 > y0 ? y1 - 6 : y1 + 6, "marker-end": `url(#tip-${shade})`}));
|
||||
}
|
||||
}
|
||||
marks.append(bundle);
|
||||
};
|
||||
for (const row of state.radio) {
|
||||
arrows(row.class, row.ts, row.source, row.targets, row.text.length > 120 ? `${row.text.slice(0, 120)}…` : row.text);
|
||||
}
|
||||
for (const grant of state.grants) {
|
||||
arrows("grant", grant.ts, chairKey(), [grant.target], `Queue-Freigabe ${grant.kind} (${grant.id})`);
|
||||
}
|
||||
svg.append(marks);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function legend() {
|
||||
const axis = graph3d() ? depth() : "none";
|
||||
const words = {
|
||||
none: "Linienstärke = Anzahl · Nähe = Zusammenarbeit der letzten Zeit",
|
||||
time: "Weltlinie = Agent, Lage = Nähe der Zusammenarbeit zu ihrer Zeit · Bogen = ein Ereignis · Ebene = Stand der Wiedergabe",
|
||||
spawn: "Ebenen = Spawn-Tiefe · Bogen = Nachrichten im Zeitraum, Zahl im Stream",
|
||||
};
|
||||
return h("div", {class: "legend"}, LEGEND.map(([name, label]) => h("span", {}, h("i", {class: `swatch t-${name}`}), label)),
|
||||
axis === "none" ? null : h("span", {}, h("i", {class: "swatch t-spawn"}), "Spawn"),
|
||||
h("span", {class: "muted"}, words[axis]));
|
||||
}
|
||||
|
||||
function side() {
|
||||
const node = state.graph.nodes.find((entry) => entry.id === ui.node);
|
||||
if (!node) {
|
||||
const where = graph3d() ? "eine Weltlinie oder darunter auf ihren Namen" : "einen Knoten";
|
||||
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, `Klicke auf ${where}: sein Panel zeigt Aufgaben, Claims, Subagenten und Verlauf.`)];
|
||||
}
|
||||
const messages = state.radio.filter((row) => row.source === node.id || row.targets.includes(node.id)).slice(-10).reverse();
|
||||
return [
|
||||
h("div", {class: "panel-head"},
|
||||
h("div", {}, h("h2", {class: "panel-title"}, node.label), h("span", {class: "muted"}, `${NODE_KINDS[node.kind]}${node.parent ? ` von ${labelOf(node.parent)}` : ""}`)),
|
||||
h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: () => select(node.id)}}, "×")),
|
||||
h("h3", {}, "Letzte Nachrichten", counter(messages.length)),
|
||||
messages.length ? h("ul", {class: "mini"}, messages.map((row) => h("li", {},
|
||||
h("div", {class: "row"}, klass(row.class), when(row.ts)),
|
||||
h("span", {class: "muted"}, `${dash(row.from)} → ${targetsOf(row)}`),
|
||||
h("span", {}, row.text)))) : h("p", {class: "muted"}, "Keine Nachrichten im gewählten Zeitraum."),
|
||||
];
|
||||
}
|
||||
|
||||
function viewSwitch(screen) {
|
||||
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.VIEWS.map(([id, name]) => h("button", {type: "button", "aria-pressed": String(ui.views[screen] === id), on: {click: () => go(screen, id)}}, name)));
|
||||
}
|
||||
|
||||
const later = (what) => emptyState(`${what} folgt`, "Diese Ansicht ist geplant und kommt mit dem nächsten Ausbau.");
|
||||
|
||||
function agentsScreen() {
|
||||
const view = ui.views.agents;
|
||||
const stageBox = h("div", {class: "stage"});
|
||||
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
||||
const toolbar = agentsToolbar();
|
||||
graphDom = {stage: stageBox, aside, pulses: null, space: null, toolbar};
|
||||
const body = view === "tree" ? later("Der Spawn-Baum") : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legend()), aside);
|
||||
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? spaceTools() : null), body);
|
||||
}
|
||||
|
||||
function artifactsScreen() {
|
||||
const view = ui.views.artifacts;
|
||||
return h("div", {class: "graph"}, h("div", {class: "card controls"}, viewSwitch("artifacts")), view === "tree" ? treeScreen() : later(`Artifacts ${Nav.VIEWS.find(([id]) => id === view)[1]}`));
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
function aggregate() {
|
||||
const links = new Map();
|
||||
const add = (source, target, cls) => {
|
||||
const key = `${source}\u0000${target}\u0000${cls}`;
|
||||
const link = links.get(key) || {source, target, cls, count: 0};
|
||||
link.count += 1;
|
||||
links.set(key, link);
|
||||
};
|
||||
for (const row of state.radio) {
|
||||
for (const target of row.source ? (row.targets.length ? row.targets : ["*"]) : []) {
|
||||
add(row.source, target, row.class);
|
||||
}
|
||||
}
|
||||
for (const grant of state.grants) {
|
||||
add(chairKey(), grant.target, "grant");
|
||||
}
|
||||
const order = (link) => (tone(link.cls) === "alarm" ? 1e6 : 0) + link.count;
|
||||
return [...links.values()].sort((a, b) => order(a) - order(b));
|
||||
}
|
||||
|
||||
const width = (count) => Math.min(1.25 + Math.log2(1 + count) * 1.6, 9);
|
||||
const tone = (cls) => Space.TONES[cls] || "direct";
|
||||
|
||||
function defs() {
|
||||
return s("defs", {}, LEGEND.map(([name]) => s("marker", {id: `tip-${name}`, viewBox: "0 0 10 10", refX: 8, refY: 5, markerWidth: 9, markerHeight: 9, markerUnits: "userSpaceOnUse", orient: "auto-start-reverse", class: `t-${name}`},
|
||||
s("path", {class: "tip", d: "M0,0 L10,5 L0,10 z"}))));
|
||||
}
|
||||
|
||||
function toward(point, aim, gap) {
|
||||
const dx = aim.x - point.x;
|
||||
const dy = aim.y - point.y;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
return {x: point.x + (dx / length) * gap, y: point.y + (dy / length) * gap};
|
||||
}
|
||||
|
||||
function radiusOf(id) {
|
||||
const node = state.graph.nodes.find((entry) => entry.id === id);
|
||||
return node ? RADIUS[node.kind] : RADIUS.session;
|
||||
}
|
||||
|
||||
function targets() {
|
||||
const plan = state.graph.layout;
|
||||
const order = [HUMAN, ...plan.order];
|
||||
return new Map(order.map((id, index) => {
|
||||
const spot = plan.orbit[id] || {angle: -Math.PI / 2, radius: 0};
|
||||
return [id, {angle: spot.angle, radius: spot.radius, chord: -Math.PI / 2 + (2 * Math.PI * index) / order.length}];
|
||||
}));
|
||||
}
|
||||
|
||||
function step(now) {
|
||||
const progress = motion.duration ? Math.min((now - motion.start) / motion.duration, 1) : 1;
|
||||
const eased = Space.ease(progress);
|
||||
motion.current = new Map([...motion.target].map(([id, to]) => {
|
||||
const from = motion.from.get(id) || to;
|
||||
return [id, {angle: Space.turn(from.angle, to.angle, eased), radius: from.radius + (to.radius - from.radius) * eased, chord: Space.turn(from.chord, to.chord, eased)}];
|
||||
}));
|
||||
if (ui.views.agents !== "swimlane") {
|
||||
stage();
|
||||
}
|
||||
motion.frame = progress < 1 ? window.requestAnimationFrame(step) : 0;
|
||||
}
|
||||
|
||||
function relayout(duration) {
|
||||
const next = targets();
|
||||
motion.from = new Map([...next.keys()].map((id) => [id, motion.current.get(id) || next.get(id)]));
|
||||
motion.target = next;
|
||||
motion.start = performance.now();
|
||||
motion.duration = calm.matches ? 0 : duration;
|
||||
if (!motion.frame) {
|
||||
motion.frame = window.requestAnimationFrame(step);
|
||||
}
|
||||
}
|
||||
|
||||
function spotOf(id, fresh) {
|
||||
return motion.current.get(id) || fresh.get(id) || {angle: -Math.PI / 2, radius: RINGS[1], chord: -Math.PI / 2};
|
||||
}
|
||||
|
||||
function layout(nodes) {
|
||||
const fresh = targets();
|
||||
const spots = new Map();
|
||||
for (const node of nodes.filter((entry) => entry.kind !== "subagent")) {
|
||||
const at = spotOf(node.id, fresh);
|
||||
const radius = node.kind === "operator" ? 0 : at.radius;
|
||||
spots.set(node.id, {x: radius * Math.cos(at.angle), y: radius * Math.sin(at.angle), angle: at.angle});
|
||||
}
|
||||
const moons = new Map();
|
||||
for (const node of nodes.filter((entry) => entry.kind === "subagent")) {
|
||||
moons.set(node.parent, [...(moons.get(node.parent) || []), node]);
|
||||
}
|
||||
for (const [parent, list] of moons) {
|
||||
const base = spots.get(parent) || spots.get(chairKey()) || {x: 0, y: 0, angle: -Math.PI / 2};
|
||||
list.forEach((node, index) => spots.set(node.id, Space.moon(base, index, list.length)));
|
||||
}
|
||||
return spots;
|
||||
}
|
||||
|
||||
function caption(node, at, r, gap) {
|
||||
if (node.kind === "subagent") {
|
||||
return null;
|
||||
}
|
||||
if (gap === null) {
|
||||
return s("text", {class: "label", y: r + 17, "text-anchor": "middle"}, node.label);
|
||||
}
|
||||
const cos = Math.cos(at.angle);
|
||||
const sin = Math.sin(at.angle);
|
||||
const anchor = cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle";
|
||||
return s("text", {class: "label", x: fixed(cos * gap), y: fixed(sin * (gap + 3)), dy: "0.35em", "text-anchor": anchor}, node.label);
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
ui.node = ui.node === id ? null : id;
|
||||
stage();
|
||||
drawSide();
|
||||
const node = state.graph.nodes.find((entry) => entry.id === id);
|
||||
if (ui.node !== null && node && node.kind !== "operator") {
|
||||
openAgent(node.kind === "subagent" ? node.parent : id);
|
||||
}
|
||||
}
|
||||
|
||||
function nodeMark(node, at, gap) {
|
||||
const r = RADIUS[node.kind];
|
||||
const classes = ["node", `k-${node.kind}`, ui.node === node.id ? "selected" : "", node.claims.length ? "claimed" : ""].filter(Boolean).join(" ");
|
||||
const tasks = node.tasks.length ? ` · ${node.tasks.length} Aufgaben` : "";
|
||||
return s("g", {
|
||||
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${NODE_KINDS[node.kind]}`,
|
||||
on: {
|
||||
click: () => select(node.id),
|
||||
keydown: (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
select(node.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` von ${labelOf(node.parent)}` : ""}${tasks}`),
|
||||
s("circle", {r}),
|
||||
node.kind === "subagent" ? null : s("text", {class: "initial", "text-anchor": "middle", dy: "0.35em"}, node.label.slice(0, 1).toUpperCase()),
|
||||
caption(node, at, r, gap));
|
||||
}
|
||||
|
||||
function chartFrame(size, label) {
|
||||
return s("svg", {viewBox: `${-size} ${-size} ${2 * size} ${2 * size}`, class: "chart", role: "img", "aria-label": label}, defs());
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
async function submit(entries, label) {
|
||||
ui.busy = true;
|
||||
const signed = await proofFor(entries, label);
|
||||
if (signed === null) {
|
||||
ui.busy = false;
|
||||
await load();
|
||||
return false;
|
||||
}
|
||||
const result = await sendJson("/api/inbox/approve", {items: entries, proof: signed.proof});
|
||||
ui.busy = false;
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label}: nicht möglich`, fail(result), false);
|
||||
await load();
|
||||
return false;
|
||||
}
|
||||
known.batches.add(result.data.id);
|
||||
ui.selected.clear();
|
||||
undoToast(result.data, label);
|
||||
await load();
|
||||
return true;
|
||||
}
|
||||
|
||||
function undoToast(batch, label) {
|
||||
const left = h("span", {class: "muted"});
|
||||
const button = h("button", {type: "button", class: "btn small"}, "Rückgängig");
|
||||
const box = h("div", {class: "toast wait", role: "status"}, h("strong", {}, label), h("div", {class: "undo-row"}, left, h("span", {class: "spacer"}), button));
|
||||
let timer = 0;
|
||||
const count = () => {
|
||||
const rest = Math.ceil(batch.due - nowSeconds());
|
||||
if (rest <= 0) {
|
||||
window.clearInterval(timer);
|
||||
box.remove();
|
||||
load();
|
||||
return;
|
||||
}
|
||||
left.textContent = `wird in ${rest} s wirksam`;
|
||||
};
|
||||
timer = window.setInterval(count, 250);
|
||||
button.addEventListener("click", async () => {
|
||||
window.clearInterval(timer);
|
||||
button.disabled = true;
|
||||
const result = await send(`/api/inbox/undo/${encodeURIComponent(batch.id)}`, "", "application/json");
|
||||
box.remove();
|
||||
toast(result.ok ? `Rückgängig: ${label}` : "Zu spät: schon wirksam", result.ok ? [] : [`${result.status} ${result.text}`.trim()], result.ok);
|
||||
await load();
|
||||
});
|
||||
document.getElementById("toasts").append(box);
|
||||
count();
|
||||
}
|
||||
|
||||
function approveOne(item) {
|
||||
if (item.kind === "decision") {
|
||||
if (!item.recommended) {
|
||||
openItem(item);
|
||||
return null;
|
||||
}
|
||||
return submit([entryOf(item, "approve", item.recommended)], `Entscheidung: ${item.recommended}`);
|
||||
}
|
||||
return submit([entryOf(item, "approve", "")], `${item.kind === "operator" ? "Ausführen" : "Freigabe"}: ${short(item.title)}`);
|
||||
}
|
||||
|
||||
const rejectOne = (item) => submit([entryOf(item, "reject", "")], `Ablehnung: ${short(item.title)}`);
|
||||
const chooseOne = (item, label) => submit([entryOf(item, "approve", label)], `Entscheidung: ${label}`);
|
||||
|
||||
function bulk(items, label) {
|
||||
const usable = items.filter(selectable);
|
||||
if (!usable.length) {
|
||||
toast(`${label}: nichts freizugeben`, [], false);
|
||||
return;
|
||||
}
|
||||
const rows = usable.map((item) => h("li", {},
|
||||
h("div", {class: "row"}, pill(ITEM_LABELS[item.kind], KIND_TONES[item.kind]), item.kind === "decision" ? h("span", {class: "muted"}, `→ ${item.recommended}`) : null),
|
||||
h("strong", {}, item.title),
|
||||
item.kind === "operator" ? h("div", {class: "command"}, h("code", {}, item.command)) : null,
|
||||
item.kind === "operator" ? h("span", {class: "muted"}, `in ${item.directory}`) : null,
|
||||
item.kind === "ask" ? h("div", {class: "command"}, h("code", {}, item.command)) : null));
|
||||
const confirm = h("button", {type: "button", class: "btn primary"}, `${usable.length} freigeben`);
|
||||
confirm.addEventListener("click", () => {
|
||||
closeSheet();
|
||||
submit(usable.map((item) => entryOf(item, "approve", item.kind === "decision" ? item.recommended : "")), `${label}: ${usable.length} Einträge`);
|
||||
});
|
||||
openSheet(label, [h("p", {class: "muted"}, "Jeder Eintrag und jeder volle Befehl, der freigegeben wird. Nach dem Klick signiert der Passkey die ganze Auswahl einmal; danach bleiben 3 s zum Rückgängigmachen."), h("ul", {class: "mini"}, rows), h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm)], false);
|
||||
confirm.focus();
|
||||
}
|
||||
|
||||
async function mark(item, until, pinned) {
|
||||
const result = await sendJson("/api/inbox/mark", {kind: item.kind, id: item.id, until, pinned});
|
||||
if (!result.ok) {
|
||||
toast("Merken fehlgeschlagen", [`${result.status} ${result.text}`.trim()], false);
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
function tomorrow() {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + 1);
|
||||
date.setHours(8, 0, 0, 0);
|
||||
return date.getTime() / 1000;
|
||||
}
|
||||
|
||||
function toggle(item) {
|
||||
if (!item || !selectable(item)) {
|
||||
return;
|
||||
}
|
||||
const key = keyOf(item);
|
||||
if (ui.selected.has(key)) {
|
||||
ui.selected.delete(key);
|
||||
} else {
|
||||
ui.selected.add(key);
|
||||
}
|
||||
drawInbox();
|
||||
}
|
||||
|
||||
function actionsFor(item) {
|
||||
if (item.held) {
|
||||
return [h("span", {class: "muted"}, "wird wirksam …")];
|
||||
}
|
||||
const buttons = [];
|
||||
if (item.kind === "review" || item.kind === "ask") {
|
||||
buttons.push(btn("Freigeben", "btn small primary", () => approveOne(item)), btn("Ablehnen", "btn small danger", () => rejectOne(item)));
|
||||
} else if (item.kind === "decision" && item.recommended) {
|
||||
buttons.push(btn(`Empfehlung: ${item.recommended}`, "btn small primary", () => approveOne(item)));
|
||||
} else if (item.kind === "operator" && item.runnable) {
|
||||
buttons.push(btn("Ausführen", "btn small primary", () => approveOne(item)));
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
function inboxItem(item, index) {
|
||||
const red = item.kind === "review" && item.gate !== "pass";
|
||||
const classes = ["item", isGreen(item) ? "green" : "", red ? "red" : "", item.held ? "held" : "", index === ui.cursor ? "cursor" : ""].filter(Boolean).join(" ");
|
||||
const box = h("input", {type: "checkbox", "aria-label": `${item.title} auswählen`, checked: ui.selected.has(keyOf(item)), disabled: !selectable(item), on: {change: () => toggle(item)}});
|
||||
return h("li", {class: classes, "data-index": index},
|
||||
box,
|
||||
h("div", {},
|
||||
h("div", {class: "head-row"},
|
||||
pill(ITEM_LABELS[item.kind], KIND_TONES[item.kind]),
|
||||
item.kind === "review" ? pill(item.gate === "pass" ? "Gate grün" : "Gate rot", item.gate === "pass" ? "ok" : "bad") : null,
|
||||
item.warnings.map((warning) => pill(warning, "bad")),
|
||||
item.pinned ? pill("angeheftet", "violet") : null,
|
||||
item.until ? pill(`bis ${absolute.format(stampOf(item.until))}`, "neutral") : null,
|
||||
h("span", {class: "spacer"}), when(item.ts)),
|
||||
h("button", {type: "button", class: "linkish", title: "Öffnen", on: {click: () => openItem(item)}}, item.title),
|
||||
item.agent ? h("div", {class: "muted"}, "von ", person(item.agent)) : null,
|
||||
item.kind === "review" ? h("div", {class: "muted"}, item.detail, item.score ? ` · ${item.score}` : "") : null,
|
||||
item.kind === "operator" && item.title !== item.command ? h("div", {class: "command"}, h("code", {}, item.command)) : null,
|
||||
item.kind === "operator" ? h("div", {class: "muted"}, `in ${dash(item.directory)}`, item.detail !== "-" ? ` · ${item.detail}` : "") : null,
|
||||
item.kind === "ask" ? h("div", {class: "muted"}, `in ${dash(item.directory)}`) : null,
|
||||
item.kind === "decision" ? h("div", {class: "options"}, item.options.map((option) => h("button", {
|
||||
type: "button", class: `option${option.label === item.recommended ? " recommended" : ""}`, disabled: item.held, on: {click: () => chooseOne(item, option.label)},
|
||||
}, h("strong", {}, option.label), option.description ? h("span", {}, option.description) : null))) : null),
|
||||
h("div", {class: "item-actions"},
|
||||
actionsFor(item),
|
||||
item.kind === "operator" ? copyButton(item.command) : null,
|
||||
h("span", {class: "spacer"}),
|
||||
btn("1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned)),
|
||||
btn("morgen", "btn small ghost", () => mark(item, tomorrow(), item.pinned)),
|
||||
item.until ? btn("wecken", "btn small ghost", () => mark(item, 0, item.pinned)) : null,
|
||||
btn(item.pinned ? "lösen" : "anheften", "btn small ghost", () => mark(item, item.until, !item.pinned))));
|
||||
}
|
||||
|
||||
function inboxScreen() {
|
||||
inboxDom = h("div", {class: "inbox"});
|
||||
return inboxDom;
|
||||
}
|
||||
|
||||
function drawInbox() {
|
||||
if (!inboxDom) {
|
||||
return;
|
||||
}
|
||||
const items = visibleInbox();
|
||||
ui.cursor = Math.min(ui.cursor, Math.max(items.length - 1, 0));
|
||||
const present = new Set(items.map(keyOf));
|
||||
for (const key of [...ui.selected]) {
|
||||
if (!present.has(key)) {
|
||||
ui.selected.delete(key);
|
||||
}
|
||||
}
|
||||
const sig = JSON.stringify([items, [...ui.selected], ui.cursor, ui.snoozed, ui.busy, Math.floor(nowSeconds() / 60)]);
|
||||
if (sig === inboxSig) {
|
||||
return;
|
||||
}
|
||||
inboxSig = sig;
|
||||
const green = items.filter(isGreen);
|
||||
const chosen = items.filter((item) => ui.selected.has(keyOf(item)));
|
||||
const hidden = state.inbox.length - waitingCount();
|
||||
const snoozed = h("input", {type: "checkbox", checked: ui.snoozed, on: {change: (event) => {
|
||||
ui.snoozed = event.target.checked;
|
||||
drawInbox();
|
||||
}}});
|
||||
const choose = h("button", {type: "button", class: "btn small soft", disabled: !chosen.length || ui.busy, on: {click: () => bulk(chosen, "Auswahl freigeben")}}, `Auswahl freigeben (${chosen.length})`);
|
||||
const all = h("button", {type: "button", class: "btn small primary", disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Alle grünen freigeben")}}, `Alle grünen freigeben (${green.length})`);
|
||||
inboxDom.replaceChildren(
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Inbox", counter(waitingCount())), h("span", {class: "spacer"}),
|
||||
hidden ? h("label", {class: "check"}, snoozed, `${hidden} zurückgestellt`) : null),
|
||||
h("div", {class: "inbox-actions"}, choose, all),
|
||||
onLocalhost() ? null : h("p", {class: "muted"}, "Freigaben signiert der Passkey nur unter ", localLink()),
|
||||
items.length ? h("ol", {class: "inbox-list"}, items.map(inboxItem)) : emptyState("Alles erledigt", "Gerade wartet nichts auf dich."));
|
||||
const current = inboxDom.querySelector(`.item[data-index="${ui.cursor}"]`);
|
||||
if (current && document.activeElement === document.body) {
|
||||
current.scrollIntoView({block: "nearest"});
|
||||
}
|
||||
}
|
||||
|
||||
function openItem(item) {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
if (item.kind === "review") {
|
||||
reviewSheet(item.id);
|
||||
return;
|
||||
}
|
||||
const body = [
|
||||
h("div", {class: "who"}, pill(ITEM_LABELS[item.kind], KIND_TONES[item.kind]), item.agent ? face(item.agent, false) : null, item.agent ? person(item.agent) : null, when(item.ts)),
|
||||
h("p", {class: "text"}, item.title),
|
||||
item.command ? h("div", {class: "command"}, h("code", {}, item.command), copyButton(item.command)) : null,
|
||||
meta([["Verzeichnis", item.directory], ["Warnungen", item.warnings.join(", ")], ["Id", item.id]]),
|
||||
item.kind === "decision" ? h("div", {class: "options"}, item.options.map((option) => h("button", {
|
||||
type: "button", class: `option${option.label === item.recommended ? " recommended" : ""}`, disabled: item.held, on: {click: () => {
|
||||
closeSheet();
|
||||
chooseOne(item, option.label);
|
||||
}},
|
||||
}, h("strong", {}, option.label), option.description ? h("span", {}, option.description) : null))) : null,
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), actionsFor(item).map((node) => {
|
||||
node.addEventListener("click", closeSheet);
|
||||
return node;
|
||||
})),
|
||||
];
|
||||
openSheet(ITEM_LABELS[item.kind], body, false);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
function helpSheet() {
|
||||
openSheet("Tastenkürzel", [h("dl", {class: "keys"}, KEYS.map(([key, text]) => [h("dt", {}, key), h("dd", {}, text)])),
|
||||
h("p", {class: "muted"}, "Leitstand: Vollbild mit großer Schrift für den zweiten Bildschirm, unter ⚙ Ambiente. j, k, x, a und Enter wirken in der Inbox.")], false);
|
||||
}
|
||||
|
||||
function overlayOpen() {
|
||||
return !document.getElementById("palette").hidden || !document.getElementById("sheet").hidden;
|
||||
}
|
||||
|
||||
function moveCursor(delta) {
|
||||
const items = visibleInbox();
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
ui.cursor = Math.min(Math.max(ui.cursor + delta, 0), items.length - 1);
|
||||
drawInbox();
|
||||
const current = inboxDom && inboxDom.querySelector(`.item[data-index="${ui.cursor}"]`);
|
||||
if (current) {
|
||||
current.scrollIntoView({block: "nearest"});
|
||||
}
|
||||
}
|
||||
|
||||
function keyboard(event) {
|
||||
const target = event.target;
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
if (document.getElementById("palette").hidden) {
|
||||
openPalette();
|
||||
} else {
|
||||
closePalette();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
if (!document.getElementById("palette").hidden) {
|
||||
closePalette();
|
||||
} else if (!document.getElementById("sheet").hidden) {
|
||||
closeSheet();
|
||||
} else if (space.look) {
|
||||
skyLook(false);
|
||||
} else if (ui.agent !== null) {
|
||||
closeAgent();
|
||||
} else {
|
||||
closeMenus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.isComposing || overlayOpen()) {
|
||||
return;
|
||||
}
|
||||
if (target instanceof HTMLElement && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))) {
|
||||
return;
|
||||
}
|
||||
const inInbox = ui.screen === "inbox";
|
||||
const current = inInbox ? visibleInbox()[ui.cursor] : null;
|
||||
const index = Number(event.key) - 1;
|
||||
if (/^[1-3]$/.test(event.key)) {
|
||||
event.preventDefault();
|
||||
go(Nav.TABS[index][0], null);
|
||||
} else if (inInbox && (event.key === "j" || event.key === "k")) {
|
||||
event.preventDefault();
|
||||
moveCursor(event.key === "j" ? 1 : -1);
|
||||
} else if (inInbox && event.key === "x") {
|
||||
event.preventDefault();
|
||||
toggle(current);
|
||||
} else if (inInbox && event.key === "a") {
|
||||
event.preventDefault();
|
||||
if (current && !current.held) {
|
||||
approveOne(current);
|
||||
}
|
||||
} else if (event.key === "Enter" && current && target === document.body) {
|
||||
event.preventDefault();
|
||||
openItem(current);
|
||||
} else if (event.key === "/") {
|
||||
event.preventDefault();
|
||||
if (ui.screen !== "stream") {
|
||||
go("stream", null);
|
||||
}
|
||||
window.setTimeout(() => stream.dom && stream.dom.search.focus(), 0);
|
||||
} else if (event.key === "?") {
|
||||
event.preventDefault();
|
||||
helpSheet();
|
||||
} else if (event.key === "h") {
|
||||
event.preventDefault();
|
||||
skyLook(!space.look);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
async function load() {
|
||||
try {
|
||||
const response = await fetch(`/api/state?${windowQuery()}${wantsTrack() ? "&track=1" : ""}`, {cache: "no-store"});
|
||||
if (!response.ok) {
|
||||
throw new Error(String(response.status));
|
||||
}
|
||||
const next = await response.json();
|
||||
const previous = state.now;
|
||||
state = next;
|
||||
ui.offline = false;
|
||||
if (JSON.stringify(state.settings) !== settingsSig) {
|
||||
adoptSettings(state.settings);
|
||||
}
|
||||
tuneMusic(FADE / 1000);
|
||||
drawSky();
|
||||
announce();
|
||||
render(false);
|
||||
drawTime();
|
||||
drawDrawer();
|
||||
drawStream();
|
||||
if (motion.current.size === 0 || ui.asOf !== motion.asOf) {
|
||||
const first = motion.current.size === 0;
|
||||
motion.asOf = ui.asOf;
|
||||
relayout(first ? 0 : ui.playing ? 450 : MOVE);
|
||||
}
|
||||
pulses(state.radio.filter((row) => row.ts > previous));
|
||||
} catch {
|
||||
ui.offline = true;
|
||||
tick();
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!ui.busy && !ui.playing && !document.hidden && String(window.getSelection()).length === 0) {
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
function follow() {
|
||||
if (ui.screen === "stream" && live() && !document.hidden) {
|
||||
fetchEvents();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
function context() {
|
||||
if (!audio) {
|
||||
try {
|
||||
audio = new window.AudioContext();
|
||||
} catch {
|
||||
audio = null;
|
||||
}
|
||||
}
|
||||
if (audio && audio.state === "suspended") {
|
||||
audio.resume().catch(() => null);
|
||||
}
|
||||
return audio;
|
||||
}
|
||||
|
||||
function glide(param, target, seconds) {
|
||||
const now = audio.currentTime;
|
||||
param.cancelScheduledValues(now);
|
||||
param.setValueAtTime(param.value, now);
|
||||
param.linearRampToValueAtTime(target, now + seconds);
|
||||
}
|
||||
|
||||
function impulse(sound) {
|
||||
const length = sound.sampleRate * 4;
|
||||
const buffer = sound.createBuffer(2, length, sound.sampleRate);
|
||||
for (let channel = 0; channel < 2; channel += 1) {
|
||||
const data = buffer.getChannelData(channel);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
data[index] = (Math.random() * 2 - 1) * (1 - index / length) ** 3;
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function musicLevel() {
|
||||
return settings.music.on && !radio.playing ? settings.music.volume * 0.3 * (music.ducked ? DUCK : 1) : 0;
|
||||
}
|
||||
|
||||
function note() {
|
||||
if (!music.gain) {
|
||||
return;
|
||||
}
|
||||
if (audio.state === "running") {
|
||||
const now = audio.currentTime;
|
||||
const scale = Math.random() < (music.tension ? 0.3 + 0.5 * music.tension : 0) ? TENSE : CALM;
|
||||
const frequency = BASE * 2 * scale[Math.floor(Math.random() * scale.length)] * (Math.random() < 0.3 ? 2 : 1);
|
||||
const level = audio.createGain();
|
||||
level.gain.setValueAtTime(0, now);
|
||||
level.gain.linearRampToValueAtTime(0.07, now + 2.5);
|
||||
level.gain.linearRampToValueAtTime(0, now + 8);
|
||||
level.connect(music.filter);
|
||||
for (const cents of [0, 5]) {
|
||||
const oscillator = audio.createOscillator();
|
||||
oscillator.type = "triangle";
|
||||
oscillator.frequency.value = frequency;
|
||||
oscillator.detune.value = cents;
|
||||
oscillator.connect(level);
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + 8.5);
|
||||
}
|
||||
}
|
||||
const slow = 4500 + Math.random() * 4000;
|
||||
const fast = 2400 + Math.random() * 2600;
|
||||
music.timer = window.setTimeout(note, slow + (fast - slow) * music.tension);
|
||||
}
|
||||
|
||||
function startMusic() {
|
||||
const sound = context();
|
||||
if (!sound || music.gain) {
|
||||
return;
|
||||
}
|
||||
const gain = sound.createGain();
|
||||
gain.gain.value = 0;
|
||||
gain.connect(sound.destination);
|
||||
const reverb = sound.createConvolver();
|
||||
reverb.buffer = impulse(sound);
|
||||
reverb.connect(gain);
|
||||
const filter = sound.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.value = 700;
|
||||
filter.Q.value = 0.4;
|
||||
filter.connect(reverb);
|
||||
const dry = sound.createGain();
|
||||
dry.gain.value = 0.3;
|
||||
filter.connect(dry).connect(gain);
|
||||
music.drone = [BASE, BASE * 1.5].map((frequency) => {
|
||||
const oscillator = sound.createOscillator();
|
||||
const level = sound.createGain();
|
||||
const lfo = sound.createOscillator();
|
||||
const depth = sound.createGain();
|
||||
oscillator.frequency.value = frequency;
|
||||
level.gain.value = 0.04;
|
||||
lfo.frequency.value = 0.05 + Math.random() * 0.05;
|
||||
depth.gain.value = 0.02;
|
||||
lfo.connect(depth).connect(level.gain);
|
||||
oscillator.connect(level).connect(filter);
|
||||
oscillator.start();
|
||||
lfo.start();
|
||||
return {oscillator, lfo};
|
||||
});
|
||||
Object.assign(music, {gain, filter});
|
||||
note();
|
||||
}
|
||||
|
||||
function stopMusic() {
|
||||
if (!music.gain) {
|
||||
return;
|
||||
}
|
||||
const {gain, drone} = music;
|
||||
window.clearTimeout(music.timer);
|
||||
glide(gain.gain, 0, 2);
|
||||
music.gain = null;
|
||||
window.setTimeout(() => {
|
||||
for (const {oscillator, lfo} of drone) {
|
||||
oscillator.stop();
|
||||
lfo.stop();
|
||||
}
|
||||
gain.disconnect();
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function tuneMusic(seconds) {
|
||||
music.tension = Math.min(waitingCount() / 4, 1);
|
||||
if (music.gain) {
|
||||
glide(music.filter.frequency, 700 + 900 * music.tension, 4);
|
||||
glide(music.drone[1].oscillator.detune, 8 * music.tension, 6);
|
||||
glide(music.gain.gain, musicLevel(), seconds);
|
||||
}
|
||||
}
|
||||
|
||||
function syncMusic() {
|
||||
if (settings.music.on) {
|
||||
startMusic();
|
||||
} else {
|
||||
stopMusic();
|
||||
}
|
||||
tuneMusic(FADE / 1000);
|
||||
}
|
||||
|
||||
function musicButton() {
|
||||
const button = document.getElementById("music-toggle");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
button.setAttribute("aria-pressed", String(settings.music.on));
|
||||
button.textContent = settings.music.on ? "♪ Musik an" : "♪ Musik";
|
||||
}
|
||||
|
||||
function toggleMusic() {
|
||||
context();
|
||||
saveSettings({...settings, music: {...settings.music, on: !settings.music.on}});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
function paletteItems() {
|
||||
return [
|
||||
...Nav.TABS.flatMap(([id, label], index) => [{label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)}, ...(Nav.SUBS[id] || []).map(([sub, name]) => ({label: `Ansicht: ${label} › ${name}`, hint: Nav.hashOf(sub, null), run: () => go(sub, null)}))]),
|
||||
...["agents", "artifacts"].flatMap((screen) => Nav.VIEWS.map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
|
||||
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Kategorie", run: () => useView(id)})),
|
||||
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "eigene Ansicht", run: () => useSaved(view)})),
|
||||
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})),
|
||||
{label: "Funk an alle …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
|
||||
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Tag/Nacht", run: () => confirmAct(id)})),
|
||||
{label: "Priorität …", hint: "Reihenfolge der Agenten", run: prioritySheet},
|
||||
{label: "Regeln …", hint: "automatische Freigaben", run: () => go("rules", null)},
|
||||
{label: "Einstellungen …", hint: "Ambiente, Ort, Farben, Musik, Töne, Funk vorlesen, Radio", run: settingsSheet},
|
||||
{label: settings.music.on ? "Musik aus" : "Musik an", hint: "Ambiente", run: toggleMusic},
|
||||
{label: space.look ? "Himmel verlassen" : "Himmel ansehen", hint: "h", run: () => skyLook(!space.look)},
|
||||
{label: "Leitstand umschalten", hint: "Ambiente", run: toggleLeitstand},
|
||||
{label: "Benachrichtigungen aktivieren", hint: "Ambiente", run: askNotify},
|
||||
{label: "Tastenkürzel", hint: "?", run: helpSheet},
|
||||
];
|
||||
}
|
||||
|
||||
function drawPalette() {
|
||||
if (palette.plan) {
|
||||
return;
|
||||
}
|
||||
const raw = palette.input.value.trim();
|
||||
const q = raw.toLowerCase();
|
||||
const items = paletteItems().filter((item) => !q || item.label.toLowerCase().includes(q) || item.hint.toLowerCase().includes(q));
|
||||
if (raw) {
|
||||
items.push(state.llm
|
||||
? {label: `KI-Befehl: „${raw}“`, hint: "Tab", keep: true, run: () => askModel(raw)}
|
||||
: {label: "KI-Befehl ist aus", hint: "KVB_ORCHESTRATOR_LLM_URL und KVB_ORCHESTRATOR_LLM_MODEL in orchestrator/.env setzen", keep: true, run: () => toast("KI-Befehl ist aus", ["Setze KVB_ORCHESTRATOR_LLM_URL, KVB_ORCHESTRATOR_LLM_MODEL und KVB_ORCHESTRATOR_LLM_KEY_FILE in orchestrator/.env und installiere neu."], false)});
|
||||
}
|
||||
palette.items = items;
|
||||
palette.index = Math.min(palette.index, Math.max(items.length - 1, 0));
|
||||
palette.list.replaceChildren(...items.map((item, index) => h("li", {role: "option", "aria-selected": String(index === palette.index), on: {
|
||||
click: () => runPalette(index),
|
||||
mousemove: () => {
|
||||
if (palette.index !== index) {
|
||||
palette.index = index;
|
||||
drawPalette();
|
||||
}
|
||||
},
|
||||
}}, h("span", {}, item.label), h("span", {class: "muted"}, item.hint))));
|
||||
const current = palette.list.children[palette.index];
|
||||
if (current) {
|
||||
current.scrollIntoView({block: "nearest"});
|
||||
}
|
||||
}
|
||||
|
||||
function runPalette(index) {
|
||||
const item = palette.items[index];
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
if (!item.keep) {
|
||||
closePalette();
|
||||
}
|
||||
item.run();
|
||||
}
|
||||
|
||||
async function askModel(text) {
|
||||
palette.plan = {actions: [], rejected: [], busy: true};
|
||||
palette.list.replaceChildren(h("li", {}, h("span", {class: "muted"}, "Frage das Modell … (nur Befehl, Ids und Titel gehen hinaus)")));
|
||||
const result = await sendJson("/api/nl", {text});
|
||||
if (!palette.plan) {
|
||||
return;
|
||||
}
|
||||
if (!result.ok || !result.data) {
|
||||
palette.plan = null;
|
||||
palette.list.replaceChildren(h("li", {}, h("span", {}, `KI-Befehl fehlgeschlagen: ${result.status} ${result.text}`.trim())));
|
||||
return;
|
||||
}
|
||||
palette.plan = result.data;
|
||||
const actions = result.data.actions.map((action) => h("pre", {}, action.summary));
|
||||
const rejected = result.data.rejected.map((entry) => h("p", {class: "muted"}, `verworfen: ${entry.reason} · ${entry.action}`));
|
||||
palette.list.replaceChildren(h("li", {class: "palette-plan"},
|
||||
actions.length ? actions : h("p", {class: "muted"}, "Das Modell schlug keine gültige Aktion vor."),
|
||||
rejected,
|
||||
h("p", {class: "muted"}, actions.length ? "Enter führt genau diese Aktionen aus · Esc bricht ab" : "Esc schließt")));
|
||||
}
|
||||
|
||||
async function executePlan(plan) {
|
||||
for (const action of plan.actions) {
|
||||
const args = action.args;
|
||||
if (action.name === "approve" || action.name === "reject" || action.name === "choose") {
|
||||
await submit(args.items, action.summary.split("\n")[0]);
|
||||
} else if (action.name === "snooze" || action.name === "pin") {
|
||||
for (const key of args.keys) {
|
||||
const item = state.inbox.find((entry) => entry.kind === key.kind && entry.id === key.id);
|
||||
if (item) {
|
||||
await mark(item, action.name === "snooze" ? nowSeconds() + args.hours * 3600 : item.until, action.name === "pin" ? true : item.pinned);
|
||||
}
|
||||
}
|
||||
} else if (action.name === "view") {
|
||||
useView(args.name);
|
||||
} else if (action.name === "open") {
|
||||
go(args.view, null);
|
||||
} else if (action.name === "message") {
|
||||
await message(args.to, args.text, "", "post");
|
||||
} else if (action.name === "filter") {
|
||||
filter.q = args.q;
|
||||
if (ui.screen === "stream" && stream.dom) {
|
||||
stream.dom.search.value = args.q;
|
||||
refilter();
|
||||
} else {
|
||||
go("stream", null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openPalette() {
|
||||
const box = document.getElementById("palette");
|
||||
palette.input = h("input", {type: "text", placeholder: "Ansicht, Agent, Aktion – oder ein Befehl in Worten …", "aria-label": "Befehl", role: "combobox", "aria-expanded": "true"});
|
||||
palette.list = h("ul", {class: "palette-list", role: "listbox"});
|
||||
palette.index = 0;
|
||||
palette.plan = null;
|
||||
palette.input.addEventListener("input", () => {
|
||||
palette.index = 0;
|
||||
palette.plan = null;
|
||||
drawPalette();
|
||||
});
|
||||
palette.input.addEventListener("keydown", (event) => {
|
||||
if (palette.plan && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const plan = palette.plan;
|
||||
if (!plan.busy && plan.actions.length) {
|
||||
closePalette();
|
||||
executePlan(plan);
|
||||
}
|
||||
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const size = Math.max(palette.items.length, 1);
|
||||
palette.index = (palette.index + (event.key === "ArrowDown" ? 1 : size - 1)) % size;
|
||||
drawPalette();
|
||||
} else if (event.key === "Tab" && palette.input.value.trim() && !palette.plan) {
|
||||
event.preventDefault();
|
||||
palette.index = palette.items.length - 1;
|
||||
runPalette(palette.index);
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
runPalette(palette.index);
|
||||
}
|
||||
});
|
||||
box.replaceChildren(h("div", {class: "palette-box", role: "dialog", "aria-label": "Befehle"}, h("div", {class: "palette-row"}, palette.input, micButton(palette.input)), palette.list));
|
||||
box.hidden = false;
|
||||
drawPalette();
|
||||
palette.input.focus();
|
||||
}
|
||||
|
||||
function closePalette() {
|
||||
document.getElementById("palette").hidden = true;
|
||||
palette.plan = null;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
const RP_ID = "localhost";
|
||||
const ALGORITHMS = [-7, -8, -257];
|
||||
const onLocalhost = () => window.location.hostname === RP_ID;
|
||||
const localUrl = () => `http://${RP_ID}:${window.location.port}${window.location.pathname}${window.location.hash}`;
|
||||
const b64url = (buffer) => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
const unb64url = (text) => Uint8Array.from(atob(text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "=")), (char) => char.charCodeAt(0));
|
||||
const localLink = () => h("a", {href: localUrl()}, localUrl());
|
||||
const KEEPASSXC_LOCALHOST = "KeePassXC lehnt Passkeys auf http://localhost ab, bis in KeePassXC unter Werkzeuge → Einstellungen → Browser-Integration → Fortgeschritten „Nutzung von localhost mit Passkeys zulassen“ (Allow using localhost with passkeys) angehakt ist; die Datenbank muss entsperrt sein. Sonst meldet KeePassXC-Browser „Passkeys request canceled.“ und Chrome öffnet seinen eigenen Passkey-Dialog.";
|
||||
|
||||
async function assertion(prepared, label) {
|
||||
if (!onLocalhost()) {
|
||||
toast(`${label}: nicht signiert`, [`Der Passkey signiert nur unter ${localUrl()}`], false);
|
||||
return null;
|
||||
}
|
||||
if (!prepared.credentials.length) {
|
||||
toast(`${label}: nicht signiert`, ["Kein Passkey gepinnt: Einstellungen → Passkey registrieren"], false);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const credential = await navigator.credentials.get({publicKey: {
|
||||
challenge: unb64url(prepared.challenge),
|
||||
rpId: RP_ID,
|
||||
allowCredentials: prepared.credentials.map((id) => ({type: "public-key", id: unb64url(id)})),
|
||||
userVerification: "preferred",
|
||||
timeout: 120000,
|
||||
}});
|
||||
const answer = credential.response;
|
||||
return {id: b64url(credential.rawId), clientDataJSON: b64url(answer.clientDataJSON), authenticatorData: b64url(answer.authenticatorData), signature: b64url(answer.signature)};
|
||||
} catch (error) {
|
||||
toast(`${label}: nicht signiert`, [`${(error && error.name) || error}: KeePassXC gesperrt oder abgebrochen; nichts wurde gesendet.`], false);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function proofFor(entries, label) {
|
||||
const result = await sendJson("/api/sign", {items: entries});
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label}: nicht möglich`, fail(result), false);
|
||||
return null;
|
||||
}
|
||||
if (result.data.payload === null) {
|
||||
return {proof: null};
|
||||
}
|
||||
const signed = await assertion(result.data, label);
|
||||
return signed ? {proof: {payload: result.data.payload, assertion: signed}} : null;
|
||||
}
|
||||
|
||||
async function registerPasskey(output) {
|
||||
if (!onLocalhost()) {
|
||||
output.replaceChildren(h("p", {class: "muted"}, "nicht registriert: WebAuthn braucht die Domain ", localLink()));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const credential = await navigator.credentials.create({publicKey: {
|
||||
rp: {id: RP_ID, name: "Agent Orchestrator"},
|
||||
user: {id: crypto.getRandomValues(new Uint8Array(16)), name: "operator", displayName: "Agent Orchestrator Operator"},
|
||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||
pubKeyCredParams: ALGORITHMS.map((alg) => ({type: "public-key", alg})),
|
||||
attestation: "none",
|
||||
authenticatorSelection: {residentKey: "preferred", userVerification: "preferred"},
|
||||
timeout: 120000,
|
||||
}});
|
||||
const answer = credential.response;
|
||||
const blob = b64url(new TextEncoder().encode(JSON.stringify({id: b64url(credential.rawId), attestationObject: b64url(answer.attestationObject), clientDataJSON: b64url(answer.clientDataJSON)})));
|
||||
const command = `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port ${window.location.port} --approvers ~/.config/kvb-orchestrator/approvers.json --credential '${blob}'`;
|
||||
output.replaceChildren(
|
||||
h("p", {class: "muted"}, "Diesen Befehl im Terminal des Hosts ausführen, außerhalb jeder Sandbox; erst dann gilt der Passkey. Der Hub pinnt nie selbst."),
|
||||
h("div", {class: "command"}, h("code", {}, command), copyButton(command)),
|
||||
h("label", {}, "Credential", h("textarea", {class: "field", readonly: true, rows: 3}, blob)));
|
||||
} catch (error) {
|
||||
output.replaceChildren(h("p", {class: "muted"}, `nicht registriert: ${(error && error.name) || error}`), h("p", {class: "muted"}, KEEPASSXC_LOCALHOST));
|
||||
}
|
||||
}
|
||||
|
||||
function passkeySection() {
|
||||
const output = h("div", {class: "mini", "aria-live": "polite"});
|
||||
const keys = state.approvers.length
|
||||
? h("ul", {class: "mini"}, state.approvers.map((key) => h("li", {}, pill(key.alg, "ok"), " ", h("code", {title: key.id}, `${key.id.slice(0, 16)}…`))))
|
||||
: h("p", {class: "muted"}, "Kein Passkey gepinnt: Runner, Hook und Commit-Skills verweigern jede Freigabe.");
|
||||
return h("section", {}, h("h3", {}, "Passkey"),
|
||||
h("p", {class: "muted"}, "Freigaben von Asks, Reviews und Operator-Befehlen signiert ein Passkey in KeePassXC; Runner, Hook und Commit-Skills prüfen die Signatur selbst gegen die gepinnten Schlüssel."),
|
||||
keys,
|
||||
h("p", {class: "muted"}, KEEPASSXC_LOCALHOST),
|
||||
onLocalhost() ? null : h("p", {class: "muted"}, "WebAuthn braucht die Domain: ", localLink()),
|
||||
h("div", {}, btn("Passkey registrieren", "btn small soft", () => registerPasskey(output))),
|
||||
output);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
function animate(duration, frame, done) {
|
||||
const began = performance.now();
|
||||
const run = (now) => {
|
||||
const progress = Math.min((now - began) / duration, 1);
|
||||
frame(progress);
|
||||
if (progress < 1) {
|
||||
window.requestAnimationFrame(run);
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
};
|
||||
window.requestAnimationFrame(run);
|
||||
}
|
||||
|
||||
function travel(layer, row, target) {
|
||||
const from = positions.get(row.source);
|
||||
const shade = tone(row.class);
|
||||
if (!layer.isConnected || !from) {
|
||||
return;
|
||||
}
|
||||
if (target === "*") {
|
||||
const ring = s("circle", {class: `pulse-ring t-${shade}`, cx: fixed(from.x), cy: fixed(from.y), r: 18});
|
||||
layer.append(ring);
|
||||
animate(1400, (t) => {
|
||||
ring.setAttribute("r", fixed(18 + 54 * t));
|
||||
ring.setAttribute("opacity", fixed(1 - t));
|
||||
}, () => ring.remove());
|
||||
return;
|
||||
}
|
||||
const to = positions.get(target);
|
||||
if (!to) {
|
||||
return;
|
||||
}
|
||||
const control = Space.bend(from, to);
|
||||
const dot = s("circle", {class: `pulse t-${shade}`, r: 5});
|
||||
layer.append(dot);
|
||||
animate(1200, (t) => {
|
||||
const u = 1 - t;
|
||||
dot.setAttribute("cx", fixed(u * u * from.x + 2 * u * t * control.x + t * t * to.x));
|
||||
dot.setAttribute("cy", fixed(u * u * from.y + 2 * u * t * control.y + t * t * to.y));
|
||||
}, () => dot.remove());
|
||||
}
|
||||
|
||||
function pulses(rows) {
|
||||
if (calm.matches || ui.screen !== "agents" || ui.views.agents !== "orbit" || !live() || !graphDom || !graphDom.pulses) {
|
||||
return;
|
||||
}
|
||||
const layer = graphDom.pulses;
|
||||
rows.slice(-24).forEach((row, index) => {
|
||||
for (const target of row.targets.length ? row.targets : ["*"]) {
|
||||
window.setTimeout(() => travel(layer, row, target), index * 260);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
function radioVolume() {
|
||||
if (radio.element) {
|
||||
radio.element.volume = Math.min(Math.max(radio.level * settings.music.volume * (music.ducked ? DUCK : 1), 0), 1);
|
||||
}
|
||||
}
|
||||
|
||||
function fadeRadio(target, done) {
|
||||
window.clearInterval(radio.timer);
|
||||
if (radio.level === target) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
const from = radio.level;
|
||||
const began = performance.now();
|
||||
radio.timer = window.setInterval(() => {
|
||||
const progress = Math.min((performance.now() - began) / FADE, 1);
|
||||
radio.level = from + (target - from) * progress;
|
||||
radioVolume();
|
||||
if (progress >= 1) {
|
||||
window.clearInterval(radio.timer);
|
||||
done();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function stopStream() {
|
||||
radio.element.pause();
|
||||
radio.element.removeAttribute("src");
|
||||
radio.element.load();
|
||||
radio.playing = false;
|
||||
tuneMusic(FADE / 1000);
|
||||
}
|
||||
|
||||
function syncRadio() {
|
||||
const due = Ambience.activeStream(settings.streams, Ambience.slotOf(new Date()));
|
||||
const url = due ? due.url : "";
|
||||
if (url === radio.url && !radio.waiting) {
|
||||
return;
|
||||
}
|
||||
const origin = url ? new URL(url).origin : "";
|
||||
if (origin && !pageMedia.has(origin)) {
|
||||
if (state.media.includes(origin)) {
|
||||
window.location.reload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
radio.url = url;
|
||||
radio.waiting = false;
|
||||
fadeRadio(0, () => {
|
||||
if (radio.url !== url) {
|
||||
return;
|
||||
}
|
||||
if (!url) {
|
||||
stopStream();
|
||||
return;
|
||||
}
|
||||
radio.element.src = url;
|
||||
radio.element.play().then(() => {
|
||||
radio.playing = true;
|
||||
tuneMusic(FADE / 1000);
|
||||
fadeRadio(1, () => null);
|
||||
}).catch(() => {
|
||||
radio.waiting = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function duck(seconds) {
|
||||
music.ducked += 1;
|
||||
tuneMusic(0.15);
|
||||
radioVolume();
|
||||
window.setTimeout(() => {
|
||||
music.ducked -= 1;
|
||||
tuneMusic(1.5);
|
||||
radioVolume();
|
||||
}, (seconds + 0.6) * 1000);
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
if (settings.music.on) {
|
||||
context();
|
||||
}
|
||||
if (radio.waiting) {
|
||||
syncRadio();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
function fileList(id) {
|
||||
if (!files.has(id)) {
|
||||
files.set(id, "loading");
|
||||
fetch(`/reviews/${encodeURIComponent(id)}/files`, {cache: "no-store"})
|
||||
.then((response) => (response.ok ? response.json() : {files: null}))
|
||||
.then((data) => files.set(id, Array.isArray(data.files) ? data.files : null))
|
||||
.catch(() => files.set(id, null))
|
||||
.then(() => {
|
||||
if (ui.review === id) {
|
||||
drawReviewSheet();
|
||||
}
|
||||
});
|
||||
}
|
||||
return files.get(id);
|
||||
}
|
||||
|
||||
function reviewOf(id) {
|
||||
const pending = state.reviews.find((review) => review.id === id);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
const entry = stream.events.find((row) => row.kind === "review" && row.ref.id === id);
|
||||
return entry ? {id, title: entry.title, notes: entry.text, created_ts: entry.ts, worktree: entry.ref.worktree, branch: entry.ref.branch, tree: entry.ref.tree, gate: entry.ref.gate, score: entry.ref.score, status: entry.ref.status} : null;
|
||||
}
|
||||
|
||||
function reviewSheet(id) {
|
||||
ui.review = id;
|
||||
ui.file = 0;
|
||||
drawReviewSheet();
|
||||
}
|
||||
|
||||
function drawReviewSheet() {
|
||||
const review = reviewOf(ui.review);
|
||||
if (!review) {
|
||||
closeSheet();
|
||||
toast("Review nicht mehr im Zeitraum", [], false);
|
||||
return;
|
||||
}
|
||||
const item = state.inbox.find((entry) => entry.kind === "review" && entry.id === review.id);
|
||||
const list = fileList(review.id);
|
||||
const treeNav = h("nav", {class: "card tree", "aria-label": "Geänderte Dateien"}, treePane(list));
|
||||
const diff = h("section", {class: "card diff-pane", "aria-label": "Diff"}, diffPane(list));
|
||||
const id = ui.review;
|
||||
const head = h("article", {class: "card"},
|
||||
h("div", {class: "card-head"}, h("div", {}, h("p", {class: "muted"}, "eingereicht ", when(review.created_ts))), h("div", {class: "who"}, status(review.status || "pending"), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null)),
|
||||
meta([["Repo", review.worktree], ["Branch", review.branch], ["Tree", (review.tree || "").slice(0, 12), review.tree], ["Score", review.score]]),
|
||||
review.notes ? h("pre", {class: "notes"}, review.notes) : null,
|
||||
h("div", {class: "actions"},
|
||||
h("a", {class: "btn ghost", href: `/reviews/${encodeURIComponent(review.id)}/page`, target: "_blank", rel: "noopener noreferrer"}, "Vollständige Seite öffnen ↗"),
|
||||
h("span", {class: "spacer"}),
|
||||
item ? actionsFor(item).map((node) => {
|
||||
node.addEventListener("click", closeSheet);
|
||||
return node;
|
||||
}) : null));
|
||||
openSheet(review.title, [head, h("div", {class: "split"}, treeNav, diff)], true);
|
||||
ui.review = id;
|
||||
reviewDom = {tree: treeNav, diff};
|
||||
}
|
||||
|
||||
function nest(entries) {
|
||||
const root = {dirs: new Map(), files: []};
|
||||
for (const [file, index] of entries) {
|
||||
const parts = file.path.split("/");
|
||||
let node = root;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (!node.dirs.has(part)) {
|
||||
node.dirs.set(part, {dirs: new Map(), files: []});
|
||||
}
|
||||
node = node.dirs.get(part);
|
||||
}
|
||||
node.files.push([file, index, parts[parts.length - 1]]);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function stat(file) {
|
||||
return h("span", {class: "stat"}, h("span", {class: "add"}, `+${file.added}`), h("span", {class: "del"}, `-${file.removed}`));
|
||||
}
|
||||
|
||||
function pick(index, list) {
|
||||
ui.file = index;
|
||||
for (const entry of reviewDom.tree.querySelectorAll(".file-entry")) {
|
||||
if (Number(entry.dataset.index) === index) {
|
||||
entry.setAttribute("aria-current", "true");
|
||||
} else {
|
||||
entry.removeAttribute("aria-current");
|
||||
}
|
||||
}
|
||||
reviewDom.diff.replaceChildren(diffPane(list));
|
||||
}
|
||||
|
||||
function branch(node, list) {
|
||||
const items = [];
|
||||
for (const [name, child] of [...node.dirs].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
let label = name;
|
||||
let inner = child;
|
||||
while (!inner.files.length && inner.dirs.size === 1) {
|
||||
const [[next, deeper]] = inner.dirs;
|
||||
label = `${label}/${next}`;
|
||||
inner = deeper;
|
||||
}
|
||||
items.push(h("li", {}, h("details", {open: true}, h("summary", {}, h("span", {class: "dir"}, `${label}/`)), branch(inner, list))));
|
||||
}
|
||||
for (const [file, index, name] of node.files.sort((a, b) => a[2].localeCompare(b[2]))) {
|
||||
const [letter, tone] = FILE_STATUS[file.status] || ["?", "neutral"];
|
||||
items.push(h("li", {}, h("button", {
|
||||
type: "button", class: "file-entry", "data-index": index, "aria-current": index === ui.file ? "true" : null,
|
||||
title: file.old ? `${file.old} → ${file.path}` : file.path, on: {click: () => pick(index, list)},
|
||||
}, h("span", {class: "letter", "data-tone": tone}, letter), h("span", {class: "name"}, name), stat(file))));
|
||||
}
|
||||
return h("ul", {class: "tree-list"}, items);
|
||||
}
|
||||
|
||||
function treePane(list) {
|
||||
if (list === "loading") {
|
||||
return h("p", {class: "muted pad"}, "Lade Dateiliste …");
|
||||
}
|
||||
if (!Array.isArray(list) || !list.length) {
|
||||
return h("p", {class: "muted pad"}, "Keine Dateiliste. Die vollständige Seite zeigt alle Änderungen.");
|
||||
}
|
||||
const sections = [...new Set(list.map((file) => file.section))];
|
||||
return h("div", {}, sections.map((section) => h("div", {class: "tree-section"},
|
||||
sections.length > 1 ? h("h3", {class: "tree-title"}, SECTIONS[section] || section || "Dateien") : null,
|
||||
branch(nest(list.map((file, index) => [file, index]).filter(([file]) => file.section === section)), list))));
|
||||
}
|
||||
|
||||
function diffRow(line) {
|
||||
if (line.kind === "hunk" || line.kind === "more") {
|
||||
return h("tr", {class: line.kind}, h("td", {colspan: 3}), h("td", {class: "code"}, line.text));
|
||||
}
|
||||
return h("tr", {class: line.kind}, h("td", {class: "num"}, line.old), h("td", {class: "num"}, line.new), h("td", {class: "sign"}, SIGNS[line.kind] || " "), h("td", {class: "code"}, line.text));
|
||||
}
|
||||
|
||||
function diffPane(list) {
|
||||
if (!Array.isArray(list) || !list.length) {
|
||||
return h("p", {class: "muted pad"}, list === "loading" ? "Lade Änderungen …" : "Kein Diff verfügbar.");
|
||||
}
|
||||
const file = list[Math.min(ui.file, list.length - 1)];
|
||||
const [letter, tone] = FILE_STATUS[file.status] || ["?", "neutral"];
|
||||
return h("div", {},
|
||||
h("div", {class: "diff-head"},
|
||||
h("span", {class: "letter", "data-tone": tone}, letter),
|
||||
h("code", {class: "path"}, file.old ? `${file.old} → ${file.path}` : file.path),
|
||||
file.tags.map((tag) => pill(tag, "neutral")),
|
||||
h("span", {class: "spacer"}),
|
||||
stat(file)),
|
||||
file.detail ? h("p", {class: "muted pad"}, file.detail) : null,
|
||||
file.lines.length ? h("div", {class: "diff-scroll"}, h("table", {class: "diff"}, h("tbody", {}, file.lines.map(diffRow)))) : null);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
function rulesScreen() {
|
||||
const kind = h("select", {class: "field", "aria-label": "Art"}, h("option", {value: "ask"}, "Ask: Werkzeug + Befehlsmuster"), h("option", {value: "review"}, "Review: Branch-Muster + Gate grün"), h("option", {value: "decision"}, "Entscheidung: Empfehlung nehmen"));
|
||||
const tool = h("input", {class: "field", type: "text", placeholder: "Bash", value: "Bash"});
|
||||
const pattern = h("input", {class: "field", type: "text", placeholder: "make test*"});
|
||||
const scope = h("input", {class: "field", type: "text", value: "*", placeholder: "/home/…/Repositories/*"});
|
||||
const expiry = new Date(Date.now() + 7 * DAY * 1000);
|
||||
const expires = h("input", {class: "field", type: "date", value: localInput(expiry.getTime() / 1000).slice(0, 10), required: true});
|
||||
const enabled = h("input", {type: "checkbox", checked: true});
|
||||
const toolField = h("label", {}, "Werkzeug (Glob)", tool);
|
||||
const scopeField = h("label", {}, "Verzeichnis (Glob)", scope);
|
||||
const hint = h("p", {class: "muted"});
|
||||
const sync = () => {
|
||||
toolField.hidden = kind.value !== "ask";
|
||||
scopeField.hidden = kind.value === "decision";
|
||||
pattern.placeholder = {ask: "make test*", review: "feature/*", decision: "* (Agent-Muster)"}[kind.value];
|
||||
hint.textContent = {
|
||||
ask: "Gibt passende Asks in passenden Verzeichnissen frei, nie einen Befehl mit Warnung (rm -r, push, --force, sudo, Pipe in eine Shell …). Der Passkey signiert die Regel einmal; der Hook prüft Signatur und Muster selbst.",
|
||||
review: "Gibt Reviews passender Branches und Worktrees frei, nur wenn ihr Gate grün ist. Der Passkey signiert die Regel einmal; der Commit-Skill prüft Signatur und Muster selbst.",
|
||||
decision: "Nimmt bei Entscheidungen passender Agenten die empfohlene Option; Entscheidungen bleiben unsigniert.",
|
||||
}[kind.value];
|
||||
};
|
||||
kind.addEventListener("change", sync);
|
||||
sync();
|
||||
const form = h("form", {class: "rule-form"}, h("label", {}, "Art", kind), toolField, h("label", {}, "Muster (Glob)", pattern), scopeField, h("label", {}, "Läuft ab am", expires), h("label", {class: "check"}, enabled, "aktiv"), h("button", {type: "submit", class: "btn primary"}, "Regel anlegen"));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const end = Date.parse(`${expires.value}T23:59:59`) / 1000;
|
||||
const fields = {
|
||||
kind: kind.value,
|
||||
tool: kind.value === "ask" ? tool.value.trim() : "",
|
||||
pattern: pattern.value.trim() || (kind.value === "decision" ? "*" : ""),
|
||||
scope: kind.value === "decision" ? "*" : scope.value.trim(),
|
||||
expires: Number.isFinite(end) ? end : 0,
|
||||
};
|
||||
const prepared = await sendJson("/api/rules/sign", fields);
|
||||
if (!prepared.ok || !prepared.data) {
|
||||
toast("Regel nicht angelegt", fail(prepared), false);
|
||||
return;
|
||||
}
|
||||
const signed = prepared.data.sign ? await assertion(prepared.data, "Regel") : null;
|
||||
if (prepared.data.sign && !signed) {
|
||||
return;
|
||||
}
|
||||
const result = await sendJson("/api/rules", {rule: prepared.data.payload, assertion: signed, enabled: enabled.checked});
|
||||
toast(result.ok ? "Regel angelegt" : "Regel nicht angelegt", result.ok ? [] : fail(result), result.ok);
|
||||
await load();
|
||||
});
|
||||
const change = async (rule, verb) => {
|
||||
const result = await send(`/api/rules/${encodeURIComponent(rule.id)}/${verb}`, "", "application/json");
|
||||
if (!result.ok) {
|
||||
toast("Regel nicht geändert", [`${result.status} ${result.text}`.trim()], false);
|
||||
}
|
||||
await load();
|
||||
};
|
||||
const now = nowSeconds();
|
||||
const list = state.rules.length ? h("div", {class: "mini"}, state.rules.map((rule) => h("div", {class: "rule-row"},
|
||||
pill(rule.kind, KIND_TONES[rule.kind]),
|
||||
rule.tool ? h("code", {}, rule.tool) : null,
|
||||
h("code", {}, rule.pattern),
|
||||
rule.kind === "decision" ? null : h("code", {title: "Verzeichnis"}, rule.scope || "–"),
|
||||
rule.kind === "decision" ? null : pill(rule.signed ? "signiert" : "unsigniert: wirkt nicht", rule.signed ? "ok" : "bad"),
|
||||
rule.expires <= now ? pill("abgelaufen", "bad") : h("span", {class: "muted"}, `bis ${absolute.format(stampOf(rule.expires))}`),
|
||||
rule.enabled ? pill("aktiv", "ok") : pill("aus", "neutral"),
|
||||
h("span", {class: "spacer"}),
|
||||
btn(rule.enabled ? "Ausschalten" : "Einschalten", "btn small", () => change(rule, rule.enabled ? "disable" : "enable")),
|
||||
btn("Löschen", "btn small danger", () => {
|
||||
if (window.confirm("Regel löschen?")) {
|
||||
change(rule, "delete");
|
||||
}
|
||||
})))) : h("p", {class: "muted"}, "Noch keine Regel. Jede automatische Freigabe erscheint im Stream und lässt sich 3 s lang rückgängig machen.");
|
||||
return h("div", {class: "stack"}, h("h2", {class: "title"}, "Regeln für automatische Freigaben"), list, h("div", {class: "card"}, hint, form));
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
const navButtons = new Map();
|
||||
|
||||
function tabButton(id, label, hint) {
|
||||
const badge = Object.hasOwn(state.badges, id) ? h("span", {class: "badge", hidden: true}) : null;
|
||||
const button = h("button", {type: "button", id: `tab-${id}`, "aria-controls": "main", title: hint ? `${label} (${hint})` : label, on: {click: () => go(id, null)}}, label, badge);
|
||||
navButtons.set(id, {button, badge});
|
||||
return button;
|
||||
}
|
||||
|
||||
document.getElementById("tabs").append(...Nav.TABS.map(([id, label], index) => h("div", {class: "tab-group"},
|
||||
tabButton(id, label, `Taste ${index + 1}`),
|
||||
Nav.SUBS[id] ? h("div", {class: "subtabs"}, Nav.SUBS[id].map(([sub, name]) => tabButton(sub, name, ""))) : null)));
|
||||
|
||||
function measure() {
|
||||
document.documentElement.style.setProperty("--head", `${document.getElementById("head").offsetHeight}px`);
|
||||
}
|
||||
|
||||
function go(screen, view) {
|
||||
const hash = Nav.hashOf(screen, view || ui.views[screen] || null);
|
||||
if (window.location.hash === hash) {
|
||||
apply();
|
||||
} else {
|
||||
window.location.hash = hash;
|
||||
}
|
||||
}
|
||||
|
||||
function apply() {
|
||||
const route = Nav.route(window.location.hash);
|
||||
if (route.redirect) {
|
||||
window.history.replaceState(null, "", route.redirect);
|
||||
}
|
||||
ui.screen = route.screen;
|
||||
if (route.view) {
|
||||
ui.views[route.screen] = route.view;
|
||||
}
|
||||
if (document.startViewTransition && drawn.screen && !calm.matches) {
|
||||
document.startViewTransition(() => render(true));
|
||||
} else {
|
||||
render(true);
|
||||
}
|
||||
}
|
||||
|
||||
function signature() {
|
||||
if (ui.screen === "agents") {
|
||||
return JSON.stringify([ui.views.agents, depth()]);
|
||||
}
|
||||
return JSON.stringify(ui.screen === "artifacts" ? [ui.views.artifacts] : ui.screen === "rules" ? state.rules : []);
|
||||
}
|
||||
|
||||
function screenOf() {
|
||||
return {stream: streamScreen, inbox: inboxScreen, rules: rulesScreen, agents: agentsScreen, artifacts: artifactsScreen}[ui.screen]();
|
||||
}
|
||||
|
||||
function render(force) {
|
||||
if (ui.node !== null && !state.graph.nodes.some((node) => node.id === ui.node)) {
|
||||
ui.node = null;
|
||||
}
|
||||
for (const [id, {button}] of navButtons) {
|
||||
button.setAttribute("aria-current", ui.screen === id ? "page" : "false");
|
||||
}
|
||||
const sig = signature();
|
||||
if (force || drawn.screen !== ui.screen || drawn.sig !== sig) {
|
||||
graphDom = null;
|
||||
stream.dom = null;
|
||||
tree.dom = null;
|
||||
inboxDom = null;
|
||||
inboxSig = "";
|
||||
const main = document.getElementById("main");
|
||||
main.setAttribute("aria-labelledby", `tab-${ui.screen}`);
|
||||
main.replaceChildren(screenOf());
|
||||
drawn.screen = ui.screen;
|
||||
drawn.sig = sig;
|
||||
}
|
||||
stage();
|
||||
drawSide();
|
||||
drawToolbar();
|
||||
drawInbox();
|
||||
drawStatus();
|
||||
tick();
|
||||
}
|
||||
|
||||
function drawStatus() {
|
||||
for (const [id, count] of Object.entries(state.badges)) {
|
||||
const {badge} = navButtons.get(id);
|
||||
badge.textContent = String(count);
|
||||
badge.hidden = count === 0;
|
||||
badge.className = `badge${id === "inbox" && count ? " urgent" : ""}`;
|
||||
}
|
||||
const banner = document.getElementById("mayday");
|
||||
const silence = state.silence;
|
||||
banner.hidden = !silence;
|
||||
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), btn("aufheben", "btn small", () => confirmAct("clear-mayday"))] : []));
|
||||
document.title = `${state.badges.inbox ? `(${state.badges.inbox}) ` : ""}Agent Orchestrator`;
|
||||
window.setTimeout(measure, 0);
|
||||
}
|
||||
|
||||
function toggleLeitstand() {
|
||||
const on = !document.body.classList.contains("leitstand");
|
||||
document.body.classList.toggle("leitstand", on);
|
||||
if (on && document.documentElement.requestFullscreen && !document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch(() => null);
|
||||
}
|
||||
if (!on && document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => null);
|
||||
}
|
||||
window.setTimeout(measure, 50);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
function adoptSettings(next) {
|
||||
settings = next;
|
||||
settingsSig = JSON.stringify(next);
|
||||
if (space.edits === space.saved) {
|
||||
space.views = {graph: {...next.space.graph}, sky: {...next.space.sky}};
|
||||
space.glides = {graph: null, sky: null};
|
||||
requestSpace();
|
||||
}
|
||||
sky.mode = "";
|
||||
drawSky();
|
||||
syncMusic();
|
||||
syncRadio();
|
||||
musicButton();
|
||||
}
|
||||
|
||||
async function saveSettings(next) {
|
||||
const result = await sendJson("/api/settings", next);
|
||||
if (!result.ok || !result.data) {
|
||||
toast("Einstellungen nicht gespeichert", [`${result.status} ${result.text}`.trim()], false);
|
||||
return false;
|
||||
}
|
||||
const fresh = result.data.streams.map((stream) => new URL(stream.url).origin).filter((origin) => !pageMedia.has(origin));
|
||||
adoptSettings(result.data);
|
||||
if (fresh.length) {
|
||||
toast("Neuer Sender", ["Die Seite lädt neu, damit der Browser ihn abspielen darf."], true);
|
||||
window.setTimeout(() => window.location.reload(), 1500);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function streamRow(stream, rows, list) {
|
||||
const field = (attrs) => h("input", {class: "field", required: true, ...attrs});
|
||||
const name = field({type: "text", value: stream.name, placeholder: "Sendername", maxlength: 120});
|
||||
const url = field({type: "url", value: stream.url, placeholder: "https://…"});
|
||||
const from = field({type: "time", value: stream.from});
|
||||
const to = field({type: "time", value: stream.to === "24:00" ? "00:00" : stream.to});
|
||||
const on = h("input", {type: "checkbox", checked: stream.on});
|
||||
const days = WEEKDAYS.map((label, index) => h("input", {type: "checkbox", checked: stream.days.includes(index), "aria-label": label}));
|
||||
const entry = {read: () => ({
|
||||
name: name.value.trim(),
|
||||
url: url.value.trim(),
|
||||
from: from.value,
|
||||
to: to.value === "00:00" && stream.to === "24:00" ? "24:00" : to.value,
|
||||
days: days.flatMap((box, index) => (box.checked ? [index] : [])),
|
||||
on: on.checked,
|
||||
})};
|
||||
entry.row = h("div", {class: "stream-row"},
|
||||
h("div", {class: "grid"}, h("label", {}, "Name", name), h("label", {}, "Stream-URL", url), h("label", {}, "von", from), h("label", {}, "bis", to)),
|
||||
h("div", {class: "days"}, days.map((box, index) => h("label", {class: "check"}, box, WEEKDAYS[index])), h("label", {class: "check"}, on, "aktiv"), h("span", {class: "spacer"}),
|
||||
btn("Entfernen", "btn small danger", () => {
|
||||
rows.splice(rows.indexOf(entry), 1);
|
||||
entry.row.remove();
|
||||
})));
|
||||
rows.push(entry);
|
||||
list.append(entry.row);
|
||||
}
|
||||
|
||||
function ambience() {
|
||||
const tool = (id, label, title, run) => h("button", {type: "button", id, class: "btn small", title, on: {click: run}}, label);
|
||||
const section = h("section", {}, h("h3", {}, "Ambiente"), h("div", {class: "toolbar"},
|
||||
tool("sky-look", "🔭 Himmel", "Himmel ansehen: ziehen dreht den Blick (Taste h)", () => {
|
||||
closeSheet();
|
||||
skyLook(true);
|
||||
}),
|
||||
tool("music-toggle", "♪ Musik", "Generative Musik an oder aus", toggleMusic),
|
||||
tool("leitstand", "Leitstand", "Leitstand: Vollbild mit großer Schrift", toggleLeitstand),
|
||||
tool("notify", "Benachrichtigungen", "Desktop-Benachrichtigungen des Browsers erlauben", askNotify)));
|
||||
window.setTimeout(() => {
|
||||
musicButton();
|
||||
notifyLabel();
|
||||
}, 0);
|
||||
return section;
|
||||
}
|
||||
|
||||
function settingsSheet() {
|
||||
const number = (value, min, max, label) => h("input", {class: "field", type: "number", min, max, step: "any", value, required: true, "aria-label": label});
|
||||
const latitude = number(settings.latitude, -90, 90, "Breite");
|
||||
const longitude = number(settings.longitude, -180, 180, "Länge");
|
||||
const theme = h("select", {class: "field"}, THEME_CHOICES.map(([id, label]) => h("option", {value: id, selected: settings.theme === id}, label)));
|
||||
const colors = Object.fromEntries(COLOR_FIELDS.map(([id]) => [id, h("input", {type: "color", value: settings.colors[id]})]));
|
||||
const custom = h("div", {class: "grid"}, COLOR_FIELDS.map(([id, label]) => h("label", {}, label, colors[id])));
|
||||
const verdict = h("p", {class: "muted", "aria-live": "polite"});
|
||||
const judge = () => {
|
||||
custom.hidden = theme.value !== "custom";
|
||||
verdict.hidden = theme.value !== "custom";
|
||||
const panel = Ambience.THEMES[Ambience.modeOf(colors.top.value, colors.bottom.value)].panel;
|
||||
const ratio = Ambience.contrast(Ambience.hex(colors.accent.value), Ambience.hex(panel));
|
||||
verdict.textContent = `Akzent auf Flächen: Kontrast ${ratio.toFixed(1)} : 1${ratio < 3 ? " – zu schwach, wähle einen kräftigeren Akzent" : ""}`;
|
||||
};
|
||||
theme.addEventListener("change", judge);
|
||||
Object.values(colors).forEach((input) => input.addEventListener("input", judge));
|
||||
judge();
|
||||
const musicOn = h("input", {type: "checkbox", checked: settings.music.on});
|
||||
const volume = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.music.volume, "aria-label": "Gesamtlautstärke"});
|
||||
const tones = SOUND_KINDS.map(([id, label]) => {
|
||||
const on = h("input", {type: "checkbox", checked: settings.sounds[id].on, "aria-label": `${label} an`});
|
||||
const level = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.sounds[id].volume, "aria-label": `${label} Lautstärke`});
|
||||
const loudest = () => Math.max(...tones.map((tone) => Number(tone.level.value)), Number(volume.value));
|
||||
return {id, on, level, row: h("div", {class: "sound-row"}, h("strong", {}, label), h("label", {class: "check"}, on, "an"), level, btn("Test", "btn small ghost", () => play(id, id === "mayday" ? loudest() : Number(level.value))))};
|
||||
});
|
||||
const notify = h("input", {type: "checkbox", checked: settings.notify});
|
||||
const speechOn = h("input", {type: "checkbox", checked: settings.speech.on, disabled: !state.speech.tts});
|
||||
const spoken = SPEECH_CLASSES.map(([id, label]) => ({id, label, box: h("input", {type: "checkbox", checked: id === "mayday" || settings.speech.classes.includes(id), disabled: id === "mayday", title: id === "mayday" ? "MAYDAY wird immer vorgelesen" : null})}));
|
||||
const stations = Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now))
|
||||
.map(([key, entry]) => ({key, label: entry.now, box: h("input", {type: "checkbox", checked: !settings.speech.muted.includes(key), "aria-label": `${entry.now} vorlesen`})}));
|
||||
const absent = settings.speech.muted.filter((key) => !people()[key]);
|
||||
const sample = btn("Test", "btn small ghost", () => say({text: state.speech.sample, voice: state.speech.operator, tone: "direct", cls: "newsletter"}));
|
||||
sample.disabled = !state.speech.tts;
|
||||
const list = h("div", {class: "mini"});
|
||||
const rows = [];
|
||||
settings.streams.forEach((stream) => streamRow(stream, rows, list));
|
||||
const form = h("form", {class: "settings"},
|
||||
ambience(),
|
||||
h("section", {}, h("h3", {}, "Ort"), h("p", {class: "muted"}, "Sonne, Mond und Sterne stehen dort, wo du sie von hier aus siehst, Blick nach Süden; unter 🔭 Himmel (Taste h) drehst du den Blick. Dezimalgrad, Nord und Ost positiv."),
|
||||
h("div", {class: "grid"}, h("label", {}, "Breite", latitude), h("label", {}, "Länge", longitude))),
|
||||
h("section", {}, h("h3", {}, "Darstellung"), h("label", {}, "Farben", theme), custom, verdict),
|
||||
h("section", {}, h("h3", {}, "Musik"), h("p", {class: "muted"}, "Generative Ambient-Musik, offline erzeugt: ruhig bei leerer Inbox, etwas gespannter, wenn etwas wartet, leiser unter Meldungen."),
|
||||
h("div", {class: "grid"}, h("label", {class: "check"}, musicOn, "Musik an"), h("label", {}, "Gesamtlautstärke (Musik und Radio)", volume))),
|
||||
h("section", {}, h("h3", {}, "Töne und Benachrichtigungen"), h("p", {class: "muted"}, "MAYDAY und PAN-PAN sind immer am lautesten. Bei mehreren neuen Ereignissen spielt nur das dringendste."),
|
||||
tones.map((tone) => tone.row), h("label", {class: "check"}, notify, "Desktop-Benachrichtigungen zeigen, wenn der Browser sie erlaubt")),
|
||||
h("section", {}, h("h3", {}, "Funk vorlesen"),
|
||||
h("p", {class: "muted"}, state.speech.tts
|
||||
? "Neuer Funk wird in Seefunk-Sprechweise (SMCP) vorgelesen, eine Meldung nach der anderen, jede Station mit eigener Stimme; Musik und Radio werden solange leiser. Die Lautstärke folgt den Tönen oben, MAYDAY wird immer und am lautesten gesprochen."
|
||||
: `Der Funk bleibt stumm: ${state.speech.tts_off}.`),
|
||||
h("div", {class: "days"}, h("label", {class: "check"}, speechOn, "Funk vorlesen"), sample),
|
||||
h("div", {class: "days", role: "group", "aria-label": "Klassen"}, spoken.map((entry) => h("label", {class: "check"}, entry.box, entry.label))),
|
||||
stations.length ? h("div", {class: "days", role: "group", "aria-label": "Stationen"}, stations.map((entry) => h("label", {class: "check"}, entry.box, entry.label))) : null),
|
||||
h("section", {}, h("h3", {}, "Radio"), h("p", {class: "muted"}, "Internetradio zu festen Zeiten mit 3 s Ein- und Ausblenden; die Musik pausiert solange. Die Seite darf nur von diesen Adressen Ton laden."),
|
||||
list, h("div", {}, btn("+ Sender", "btn small soft", () => streamRow({name: "", url: "", from: "07:00", to: "09:00", days: [0, 1, 2, 3, 4], on: true}, rows, list)))),
|
||||
passkeySection(),
|
||||
h("div", {class: "sheet-foot"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), h("button", {type: "submit", class: "btn primary"}, "Speichern")));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
context();
|
||||
const next = {
|
||||
latitude: Number(latitude.value),
|
||||
longitude: Number(longitude.value),
|
||||
theme: theme.value,
|
||||
colors: Object.fromEntries(COLOR_FIELDS.map(([id]) => [id, colors[id].value])),
|
||||
music: {on: musicOn.checked, volume: Number(volume.value)},
|
||||
sounds: Object.fromEntries(tones.map((tone) => [tone.id, {on: tone.on.checked, volume: Number(tone.level.value)}])),
|
||||
notify: notify.checked,
|
||||
streams: rows.map((entry) => entry.read()),
|
||||
speech: {
|
||||
on: speechOn.checked,
|
||||
classes: spoken.filter((entry) => entry.box.checked).map((entry) => entry.id),
|
||||
muted: [...absent, ...stations.filter((entry) => !entry.box.checked).map((entry) => entry.key)],
|
||||
},
|
||||
space: savedSpace(),
|
||||
};
|
||||
if (await saveSettings(next)) {
|
||||
closeSheet();
|
||||
toast("Einstellungen gespeichert", [], true);
|
||||
}
|
||||
});
|
||||
openSheet("Einstellungen und Ambiente", [form], false);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
const skyMoment = () => (live() ? Date.now() : (ui.asOf === null ? state.until : ui.asOf) * 1000);
|
||||
|
||||
function applyTheme(sun) {
|
||||
const custom = settings.theme === "custom";
|
||||
const mode = custom ? Ambience.modeOf(settings.colors.top, settings.colors.bottom) : settings.theme === "sky" ? Ambience.modeAt(sun.alt) : settings.theme;
|
||||
const glass = Boolean(window.CSS && CSS.supports("(backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))")) && !window.matchMedia("(prefers-reduced-transparency: reduce)").matches;
|
||||
const key = [mode, custom ? settings.colors.accent : "", glass].join("|");
|
||||
if (key === sky.mode) {
|
||||
return;
|
||||
}
|
||||
sky.mode = key;
|
||||
const root = document.documentElement;
|
||||
const tokens = Ambience.THEMES[mode];
|
||||
for (const [name, value] of Object.entries(tokens)) {
|
||||
root.style.setProperty(`--${name}`, value);
|
||||
}
|
||||
if (custom) {
|
||||
root.style.setProperty("--accent", settings.colors.accent);
|
||||
root.style.setProperty("--accent-soft", Ambience.css(Ambience.mix(Ambience.hex(settings.colors.accent), Ambience.hex(tokens.panel), 0.84)));
|
||||
}
|
||||
root.style.setProperty("--veil", `${glass ? Ambience.GLASS * 100 : 100}%`);
|
||||
root.style.setProperty("--card", `${glass ? Ambience.CARD * 100 : 100}%`);
|
||||
root.style.colorScheme = mode;
|
||||
root.dataset.mode = mode;
|
||||
requestSpace();
|
||||
}
|
||||
|
||||
function drawMoon(paint, at, radius, moon) {
|
||||
const tilt = (moon.tilt * Math.PI) / 180;
|
||||
paint.save();
|
||||
paint.translate(at.x, at.y);
|
||||
paint.rotate(Math.atan2(-Math.cos(tilt), -Math.sin(tilt)));
|
||||
const halo = paint.createRadialGradient(0, 0, radius, 0, 0, radius * 4);
|
||||
halo.addColorStop(0, `rgb(235 240 255 / ${(0.28 * moon.lit).toFixed(3)})`);
|
||||
halo.addColorStop(1, "rgb(235 240 255 / 0)");
|
||||
paint.fillStyle = halo;
|
||||
paint.fillRect(-radius * 4, -radius * 4, radius * 8, radius * 8);
|
||||
paint.fillStyle = "rgb(60 68 92 / 0.55)";
|
||||
paint.beginPath();
|
||||
paint.arc(0, 0, radius, 0, Math.PI * 2);
|
||||
paint.fill();
|
||||
paint.fillStyle = "#f3f0e4";
|
||||
paint.beginPath();
|
||||
paint.arc(0, 0, radius, -Math.PI / 2, Math.PI / 2, false);
|
||||
paint.ellipse(0, 0, Math.abs(2 * moon.lit - 1) * radius, radius, 0, Math.PI / 2, -Math.PI / 2, moon.lit < 0.5);
|
||||
paint.closePath();
|
||||
paint.fill();
|
||||
paint.restore();
|
||||
}
|
||||
|
||||
function drawSky() {
|
||||
const ms = skyMoment();
|
||||
const sun = Ambience.sun(ms, settings.latitude, settings.longitude);
|
||||
const moon = Ambience.moon(ms, settings.latitude, settings.longitude);
|
||||
applyTheme(sun);
|
||||
const view = seenView("sky");
|
||||
const facing = COMPASS[Space.compass(view.azimuth)];
|
||||
const turned = ["azimuth", "altitude", "fov"].some((key) => Math.abs(view[key] - Space.DEFAULTS.sky[key]) >= 0.5);
|
||||
const moonText = moon.alt > 0 ? ` · Mond ${Math.round(moon.lit * 100)} % ${moon.waxing ? "zunehmend" : "abnehmend"}` : "";
|
||||
document.getElementById("sky-note").textContent = `${sun.alt > 0 ? "☀" : "☾"} ${STAGE_LABELS[Ambience.stage(sun.alt)]}${moonText}${turned ? ` · Blick ${facing[0]}` : ""}`;
|
||||
if (space.look) {
|
||||
const words = `Blick nach ${facing[1]} · ${((Math.round(view.azimuth) % 360) + 360) % 360}° · Höhe ${Math.round(view.altitude)}° · Sichtfeld ${Math.round(view.fov)}°`;
|
||||
space.compass.textContent = words;
|
||||
sky.canvas.setAttribute("aria-label", `Himmel, ${words}. Pfeiltasten drehen den Blick, Plus und Minus zoomen, 0 setzt zurück, Esc beendet.`);
|
||||
}
|
||||
const canvas = sky.canvas;
|
||||
const paint = canvas.getContext("2d");
|
||||
if (!paint) {
|
||||
return;
|
||||
}
|
||||
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) {
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
}
|
||||
paint.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
const sight = Space.lens(view, width, height);
|
||||
const place = (alt, az) => Space.place(sight, alt, az);
|
||||
const inside = (at, margin) => at.x >= -margin && at.x <= width + margin && at.y >= -margin && at.y <= height + margin;
|
||||
const seen = (body, at) => body.alt > -2 && Math.abs(at.lon) <= view.fov / 2 + 10 && inside(at, 100);
|
||||
const colors = settings.theme === "custom" ? {top: Ambience.hex(settings.colors.top), bottom: Ambience.hex(settings.colors.bottom)} : Ambience.skyAt(sun.alt);
|
||||
const low = place(0, view.azimuth);
|
||||
const high = place(90, view.azimuth);
|
||||
const air = paint.createLinearGradient(low.x + (high.x - low.x) / Space.REACH, low.y + (high.y - low.y) / Space.REACH, low.x, low.y);
|
||||
air.addColorStop(0, Ambience.css(colors.top));
|
||||
air.addColorStop(1, Ambience.css(colors.bottom));
|
||||
paint.fillStyle = air;
|
||||
paint.fillRect(0, 0, width, height);
|
||||
const dusk = Math.max(0, 1 - Math.abs(sun.alt) / 14);
|
||||
const glowAt = place(Math.max(sun.alt, -3), sun.az);
|
||||
if (dusk > 0 && Math.abs(glowAt.lon) <= 135) {
|
||||
const x = Math.min(Math.max(glowAt.x, sight.cx - sight.kx * 100), sight.cx + sight.kx * 100);
|
||||
const glow = paint.createRadialGradient(x, glowAt.y, 0, x, glowAt.y, width * 0.5);
|
||||
glow.addColorStop(0, `rgb(255 186 128 / ${(0.45 * dusk).toFixed(3)})`);
|
||||
glow.addColorStop(1, "rgb(255 186 128 / 0)");
|
||||
paint.fillStyle = glow;
|
||||
paint.fillRect(0, 0, width, height);
|
||||
}
|
||||
const light = Ambience.starlight(sun.alt);
|
||||
if (light > 0) {
|
||||
const spin = Ambience.sidereal(ms) + settings.longitude;
|
||||
paint.fillStyle = "#ffffff";
|
||||
for (const star of sky.stars) {
|
||||
const spot = Ambience.horizontal(spin - star.ra, star.dec, settings.latitude);
|
||||
const at = spot.alt > 1 ? place(spot.alt, spot.az) : null;
|
||||
if (at && Math.abs(at.lon) <= view.fov / 2 && inside(at, 4)) {
|
||||
paint.globalAlpha = light * (0.25 + 0.75 * star.glow);
|
||||
paint.beginPath();
|
||||
paint.arc(at.x, at.y, 0.5 + star.glow * 1.2, 0, Math.PI * 2);
|
||||
paint.fill();
|
||||
}
|
||||
}
|
||||
paint.globalAlpha = 1;
|
||||
}
|
||||
const sunAt = place(sun.alt, sun.az);
|
||||
if (seen(sun, sunAt)) {
|
||||
const at = sunAt;
|
||||
const halo = paint.createRadialGradient(at.x, at.y, 0, at.x, at.y, 90);
|
||||
halo.addColorStop(0, "rgb(255 236 200 / 0.55)");
|
||||
halo.addColorStop(1, "rgb(255 236 200 / 0)");
|
||||
paint.fillStyle = halo;
|
||||
paint.fillRect(at.x - 90, at.y - 90, 180, 180);
|
||||
paint.fillStyle = Ambience.css(Ambience.mix([255, 170, 90], [255, 246, 220], Math.min(Math.max(sun.alt / 20, 0), 1)));
|
||||
paint.beginPath();
|
||||
paint.arc(at.x, at.y, 15, 0, Math.PI * 2);
|
||||
paint.fill();
|
||||
}
|
||||
const moonAt = place(moon.alt, moon.az);
|
||||
if (seen(moon, moonAt)) {
|
||||
drawMoon(paint, moonAt, 12, moon);
|
||||
}
|
||||
const edge = Space.horizon(sight, 96);
|
||||
const bottom = Math.max(height, ...edge.map((at) => at.y)) + 2;
|
||||
const trace = (lift) => edge.forEach((at, index) => (index ? paint.lineTo(at.x, at.y + lift) : paint.moveTo(at.x, at.y + lift)));
|
||||
const ground = paint.createLinearGradient(0, low.y, 0, low.y + height - sight.cy);
|
||||
ground.addColorStop(0, Ambience.css(Ambience.mix(colors.bottom, [4, 6, 10], 0.72)));
|
||||
ground.addColorStop(1, Ambience.css(Ambience.mix(colors.bottom, [4, 6, 10], 0.9)));
|
||||
paint.fillStyle = ground;
|
||||
paint.beginPath();
|
||||
trace(0);
|
||||
paint.lineTo(edge[edge.length - 1].x, bottom);
|
||||
paint.lineTo(edge[0].x, bottom);
|
||||
paint.closePath();
|
||||
paint.fill();
|
||||
paint.strokeStyle = "rgb(255 255 255 / 0.18)";
|
||||
paint.lineWidth = 1;
|
||||
paint.beginPath();
|
||||
trace(0.5);
|
||||
paint.stroke();
|
||||
paint.fillStyle = "rgb(255 255 255 / 0.5)";
|
||||
paint.font = "600 11px system-ui, sans-serif";
|
||||
paint.textAlign = "center";
|
||||
COMPASS.forEach(([label], index) => {
|
||||
const at = place(0, index * 45);
|
||||
if (Math.abs(at.lon) <= view.fov / 2 + 0.5) {
|
||||
paint.fillText(label, Math.min(Math.max(at.x, 14), width - 14), at.y + 16);
|
||||
}
|
||||
});
|
||||
if (space.glides.sky) {
|
||||
redrawSky();
|
||||
}
|
||||
}
|
||||
|
||||
function redrawSky() {
|
||||
window.cancelAnimationFrame(sky.frame);
|
||||
sky.frame = window.requestAnimationFrame(drawSky);
|
||||
}
|
||||
|
||||
function skyLook(on) {
|
||||
const canvas = sky.canvas;
|
||||
space.look = on;
|
||||
document.body.classList.toggle("sky-look", on);
|
||||
document.getElementById("sky-panel").hidden = !on;
|
||||
if (on) {
|
||||
canvas.removeAttribute("aria-hidden");
|
||||
canvas.setAttribute("tabindex", "0");
|
||||
canvas.setAttribute("role", "application");
|
||||
canvas.setAttribute("aria-roledescription", "Himmel");
|
||||
drawSky();
|
||||
canvas.focus();
|
||||
return;
|
||||
}
|
||||
for (const name of ["tabindex", "role", "aria-roledescription", "aria-label"]) {
|
||||
canvas.removeAttribute(name);
|
||||
}
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
document.getElementById("settings-open").focus();
|
||||
}
|
||||
|
||||
function skyPanel() {
|
||||
const panel = document.getElementById("sky-panel");
|
||||
const step = (text, label, run) => h("button", {type: "button", class: "btn small", title: label, "aria-label": label, on: {click: run}}, text);
|
||||
space.compass = h("span", {class: "compass", "aria-live": "polite"});
|
||||
panel.append(space.compass,
|
||||
step("◀", "Blick nach links drehen (Pfeil links)", () => skyHooks.turnStep(-1, 0)),
|
||||
step("▶", "Blick nach rechts drehen (Pfeil rechts)", () => skyHooks.turnStep(1, 0)),
|
||||
step("▲", "Blick heben (Pfeil hoch)", () => skyHooks.turnStep(0, -1)),
|
||||
step("▼", "Blick senken (Pfeil runter)", () => skyHooks.turnStep(0, 1)),
|
||||
step("−", "Sichtfeld weiten (Minus)", () => skyHooks.zoomStep(1 / ZOOM)),
|
||||
step("+", "Hineinzoomen (Plus)", () => skyHooks.zoomStep(ZOOM)),
|
||||
step("⟲ Zurücksetzen", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")),
|
||||
step("Fertig", "Himmel verlassen (Esc)", () => skyLook(false)));
|
||||
steer(sky.canvas, "sky", skyHooks);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
function shownCursor() {
|
||||
const glide = space.cursor;
|
||||
if (glide.to === null) {
|
||||
return state.until;
|
||||
}
|
||||
const progress = calm.matches ? 1 : Math.min((performance.now() - glide.start) / PLANE, 1);
|
||||
return progress >= 1 ? glide.to : glide.from + (glide.to - glide.from) * Space.ease(progress);
|
||||
}
|
||||
|
||||
function sceneInput() {
|
||||
const span = [Math.min(Math.max(state.since, state.first), state.until), state.until];
|
||||
const range = ui.asOf !== null && ui.preset !== -1 ? [Math.min(state.first, span[0]), Math.max(state.now, span[1])] : span;
|
||||
const lives = {};
|
||||
const subagents = {};
|
||||
for (const [key, entry] of space.subs) {
|
||||
if (entry.data) {
|
||||
lives[key] = [entry.data.since, entry.data.until];
|
||||
subagents[key] = entry.data.subagents;
|
||||
}
|
||||
}
|
||||
return {
|
||||
axis: depth(), range, span, cursor: shownCursor(), nodes: state.graph.nodes, track: state.graph.track, now: Object.fromEntries(layout(state.graph.nodes)),
|
||||
lives, subagents, radio: state.radio, grants: state.grants, chair: chairKey(), cap: Space.ARC_CAP,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSpace() {
|
||||
const input = sceneInput();
|
||||
space.sceneCursor = input.cursor;
|
||||
space.scene = Space.scene(input);
|
||||
}
|
||||
|
||||
function spaceLabel() {
|
||||
const hint = AXES.find(([id]) => id === depth())[2];
|
||||
return `3D-Graph, ${hint}. Pfeiltasten drehen, Umschalt+Pfeile verschieben, Plus und Minus zoomen, 0 setzt zurück. Alle Weltlinien stehen darunter als Schaltflächen.`;
|
||||
}
|
||||
|
||||
function stageSpace() {
|
||||
if (!graphDom.space) {
|
||||
const canvas = h("canvas", {class: "space", tabindex: 0, role: "application", "aria-roledescription": "3D-Graph", "aria-label": spaceLabel(), "aria-describedby": "space-help"});
|
||||
steer(canvas, "graph", graphHooks);
|
||||
graphDom.space = {canvas, note: h("div", {class: "space-note", "aria-live": "polite"}), list: h("div", {class: "space-list"}), data: null, version: -1, node: undefined, words: "", open: false};
|
||||
graphDom.stage.replaceChildren(canvas, graphDom.space.note, graphDom.space.list);
|
||||
}
|
||||
if (space.cursor.to !== state.until) {
|
||||
space.cursor = {from: space.cursor.to === null ? state.until : shownCursor(), to: state.until, start: performance.now()};
|
||||
}
|
||||
if (wantsTrack() && !state.graph.track.length && Date.now() - space.trackAsked > TRACK_RETRY) {
|
||||
space.trackAsked = Date.now();
|
||||
load();
|
||||
}
|
||||
buildSpace();
|
||||
const held = graphDom.space;
|
||||
if (held.data !== state || held.version !== space.version || held.node !== ui.node) {
|
||||
held.data = state;
|
||||
held.version = space.version;
|
||||
held.node = ui.node;
|
||||
drawSpaceList();
|
||||
refreshSubs();
|
||||
}
|
||||
drawSpaceNote();
|
||||
requestSpace();
|
||||
}
|
||||
|
||||
function drawSpaceNote() {
|
||||
const built = space.scene;
|
||||
const format = (ts) => absolute.format(stampOf(ts));
|
||||
const axis = built.axis === "time" ? `Zeitachse ${format(built.range[0])} – ${format(built.range[1])}` : `Stand ${format(built.span[1])}`;
|
||||
const words = `${built.lines.length} Weltlinien · ${built.arcs.length} Bögen · ${axis}${built.dropped ? ` · ${built.dropped} weitere Ereignisse nur im Stream` : ""}`;
|
||||
if (graphDom.space.words === words) {
|
||||
return;
|
||||
}
|
||||
graphDom.space.words = words;
|
||||
const dropped = built.dropped ? [h("span", {}, "Gezeichnet: Mayday und Pan-Pan, Spawns, Sicherheit, Freigaben, dann die neuesten."), btn("Funk im Stream", "btn small soft", () => useView("radio"))] : [];
|
||||
graphDom.space.note.replaceChildren(h("span", {}, words), ...dropped);
|
||||
}
|
||||
|
||||
function drawSpaceList() {
|
||||
const built = space.scene;
|
||||
const held = graphDom.space;
|
||||
const chip = (line) => {
|
||||
const text = line.kind === "twig" ? short(line.title) : Space.nameAt(line.timeline, built.span[1]);
|
||||
const former = [...new Set(line.timeline.map((entry) => entry.name))];
|
||||
const hint = line.kind === "twig" ? `${line.title} · Transkript im Stream zeigen` : `${NODE_KINDS[line.kind]}${former.length > 1 ? ` · Namen: ${former.join(" → ")}` : ""}`;
|
||||
return h("button", {type: "button", class: "chip", title: hint, "aria-label": `${text}: ${hint}`, "aria-pressed": String(line.node !== null && line.node === ui.node), on: {click: () => pickLine(line)}}, line.kind === "twig" ? text : [face(text, false), text]);
|
||||
};
|
||||
const twigs = built.lines.filter((line) => line.kind === "twig");
|
||||
const more = twigs.length ? h("details", {open: held.open}, h("summary", {}, `${twigs.length} Subagenten aus den Transkripten`), h("div", {class: "chips"}, twigs.map(chip))) : null;
|
||||
if (more) {
|
||||
more.addEventListener("toggle", () => {
|
||||
held.open = more.open;
|
||||
});
|
||||
}
|
||||
held.list.replaceChildren(h("div", {class: "chips", role: "group", "aria-label": "Weltlinien"}, built.lines.filter((line) => line.kind !== "twig").map(chip)), more || "");
|
||||
}
|
||||
|
||||
async function refreshSubs() {
|
||||
if (!graph3d() || space.loading) {
|
||||
return;
|
||||
}
|
||||
const due = state.graph.nodes.filter((node) => (node.kind === "session" || node.kind === "chair") && SESSION_ID.test(node.id))
|
||||
.filter((node) => !space.subs.has(node.id) || Date.now() - space.subs.get(node.id).at > SUBS_TTL);
|
||||
if (!due.length) {
|
||||
return;
|
||||
}
|
||||
space.loading = true;
|
||||
await Promise.all(due.map(async (node) => {
|
||||
let data = null;
|
||||
try {
|
||||
const response = await fetch(`/api/subagents?session=${encodeURIComponent(node.id)}`, {cache: "no-store"});
|
||||
data = response.ok ? await response.json() : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
space.subs.set(node.id, {at: Date.now(), data});
|
||||
}));
|
||||
space.loading = false;
|
||||
space.version += 1;
|
||||
stage();
|
||||
}
|
||||
|
||||
function spaceStyle() {
|
||||
if (!space.style || space.styleKey !== sky.mode) {
|
||||
const css = window.getComputedStyle(document.documentElement);
|
||||
const value = (name) => css.getPropertyValue(`--${name}`).trim();
|
||||
const roles = {line: "line-strong", accent: "accent", muted: "muted", violet: "violet", direct: "direct", broadcast: "broadcast", alarm: "alarm", security: "security", grant: "grant", panel: "panel", text: "text"};
|
||||
space.styleKey = sky.mode;
|
||||
space.style = {
|
||||
colors: Object.fromEntries(Object.entries(roles).map(([role, name]) => [role, value(name)])),
|
||||
font: `560 ${value("fs-sm")} ${value("sans")}`, strong: `700 ${value("fs-sm")} ${value("sans")}`, level: "Tiefe",
|
||||
};
|
||||
}
|
||||
const built = space.scene;
|
||||
return {
|
||||
...space.style, selected: ui.node, cursor: live() ? "Jetzt" : "Stand",
|
||||
format: (ts) => (built.range[1] - built.range[0] > DAY ? absolute : clock).format(stampOf(ts)),
|
||||
};
|
||||
}
|
||||
|
||||
function requestSpace() {
|
||||
if (!space.frame) {
|
||||
space.frame = window.requestAnimationFrame(paintSpace);
|
||||
}
|
||||
}
|
||||
|
||||
function paintSpace() {
|
||||
space.frame = 0;
|
||||
if (!graph3d() || !graphDom || !graphDom.space || !space.scene) {
|
||||
return;
|
||||
}
|
||||
const canvas = graphDom.space.canvas;
|
||||
const paint = canvas.getContext("2d");
|
||||
if (!paint) {
|
||||
return;
|
||||
}
|
||||
const cursor = shownCursor();
|
||||
if (cursor !== space.sceneCursor) {
|
||||
buildSpace();
|
||||
}
|
||||
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) {
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
}
|
||||
paint.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
space.cam = Space.camera(seenView("graph"), width, height);
|
||||
Space.paint(paint, space.scene, space.cam, spaceStyle());
|
||||
if (space.glides.graph || cursor !== space.cursor.to) {
|
||||
requestSpace();
|
||||
}
|
||||
}
|
||||
|
||||
function pickLine(line) {
|
||||
if (line.kind === "twig") {
|
||||
focusSub(line.session, line.sub);
|
||||
} else {
|
||||
select(line.node);
|
||||
}
|
||||
}
|
||||
|
||||
function pickSpace(x, y) {
|
||||
const hit = space.scene && space.cam ? Space.pick(space.scene, space.cam, x, y, 10) : null;
|
||||
if (!hit) {
|
||||
return;
|
||||
}
|
||||
if (hit.kind === "line") {
|
||||
pickLine(hit.line);
|
||||
} else if (hit.arc.kind === "spawn") {
|
||||
focusSub(hit.arc.source, hit.arc.sub);
|
||||
} else if (hit.arc.kind === "grant") {
|
||||
select(hit.arc.target);
|
||||
} else {
|
||||
useSaved({name: "", kinds: ["radio"], waiting: false, q: "", agent: hit.arc.row >= 0 ? state.radio[hit.arc.row].from : nameOf(hit.arc.source)});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
const depth = () => (space.views.graph.axis === "spawn" ? "spawn" : "time");
|
||||
|
||||
function spaceTools() {
|
||||
return h("div", {class: "space-tools"},
|
||||
h("div", {class: "segmented", role: "group", "aria-label": "Tiefenachse"}, AXES.map(([id, label, hint]) => h("button", {type: "button", "aria-pressed": String(depth() === id), title: hint, "aria-label": `Tiefenachse ${label}: ${hint}`, on: {click: () => chooseAxis(id)}}, label))),
|
||||
h("button", {type: "button", class: "btn small ghost", title: "Drehung, Zoom und Verschiebung zurücksetzen (Taste 0)", "aria-label": "3D-Ansicht zurücksetzen", on: {click: () => resetView("graph")}}, "⟲ Ansicht"),
|
||||
h("span", {class: "muted", id: "space-help"}, "Ziehen dreht · Umschalt+Ziehen verschiebt · Rad oder zwei Finger zoomen · Pfeile, + / −, 0"));
|
||||
}
|
||||
|
||||
const graph3d = () => ui.screen === "agents" && ui.views.agents === "3d";
|
||||
const wantsTrack = () => graph3d() && depth() === "time";
|
||||
|
||||
function chooseAxis(id) {
|
||||
if (id === depth()) {
|
||||
return;
|
||||
}
|
||||
setView("graph", {...space.views.graph, axis: id}, false);
|
||||
render(false);
|
||||
}
|
||||
|
||||
function stage() {
|
||||
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree") {
|
||||
return;
|
||||
}
|
||||
graphDom.pulses = null;
|
||||
if (graph3d()) {
|
||||
stageSpace();
|
||||
return;
|
||||
}
|
||||
graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.views.agents]());
|
||||
}
|
||||
|
||||
function drawSide() {
|
||||
if (graphDom && ui.screen === "agents" && ui.views.agents !== "tree") {
|
||||
graphDom.aside.replaceChildren(...side());
|
||||
}
|
||||
}
|
||||
|
||||
function seenView(kind) {
|
||||
const glide = space.glides[kind];
|
||||
const progress = glide && !calm.matches ? Math.min((performance.now() - glide.start) / GLIDE, 1) : 1;
|
||||
if (progress >= 1) {
|
||||
space.glides[kind] = null;
|
||||
return space.views[kind];
|
||||
}
|
||||
return Space.tween(kind, glide.from, space.views[kind], Space.ease(progress));
|
||||
}
|
||||
|
||||
function settle(kind) {
|
||||
if (space.glides[kind]) {
|
||||
space.views[kind] = Space.clampView(kind, seenView(kind));
|
||||
space.glides[kind] = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setView(kind, next, smooth) {
|
||||
space.glides[kind] = smooth && !calm.matches ? {from: seenView(kind), start: performance.now()} : null;
|
||||
space.views[kind] = Space.clampView(kind, next);
|
||||
space.edits += 1;
|
||||
window.clearTimeout(space.timer);
|
||||
space.timer = window.setTimeout(persistViews, SAVE_DELAY);
|
||||
if (kind === "graph") {
|
||||
requestSpace();
|
||||
} else {
|
||||
redrawSky();
|
||||
}
|
||||
}
|
||||
|
||||
const nudge = (kind, change, smooth) => setView(kind, change(space.views[kind]), smooth);
|
||||
const savedSpace = () => ({graph: Space.tidy(space.views.graph), sky: Space.tidy(space.views.sky)});
|
||||
|
||||
async function persistViews() {
|
||||
const mark = space.edits;
|
||||
if (await saveSettings({...settings, space: savedSpace()}) && space.edits === mark) {
|
||||
space.saved = mark;
|
||||
}
|
||||
}
|
||||
|
||||
function resetView(kind) {
|
||||
setView(kind, kind === "graph" ? {...Space.DEFAULTS.graph, axis: space.views.graph.axis} : {...Space.DEFAULTS.sky}, true);
|
||||
}
|
||||
|
||||
function steerKey(event, kind, hooks) {
|
||||
const arrows = {ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1]};
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
if (Object.hasOwn(arrows, event.key)) {
|
||||
const [x, y] = arrows[event.key];
|
||||
if (event.shiftKey && hooks.panStep) {
|
||||
hooks.panStep(x, y);
|
||||
} else {
|
||||
hooks.turnStep(x, y);
|
||||
}
|
||||
} else if (event.key === "+" || event.key === "=") {
|
||||
hooks.zoomStep(ZOOM);
|
||||
} else if (event.key === "-" || event.key === "_") {
|
||||
hooks.zoomStep(1 / ZOOM);
|
||||
} else if (event.key === "0") {
|
||||
resetView(kind);
|
||||
} else if (event.key === "Escape" && kind === "sky") {
|
||||
skyLook(false);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function steer(element, kind, hooks) {
|
||||
const pointers = new Map();
|
||||
let travel = 0;
|
||||
let spread = 0;
|
||||
const gap = () => {
|
||||
const [a, b] = [...pointers.values()];
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
};
|
||||
element.addEventListener("pointerdown", (event) => {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
element.setPointerCapture(event.pointerId);
|
||||
pointers.set(event.pointerId, {x: event.clientX, y: event.clientY});
|
||||
settle(kind);
|
||||
travel = pointers.size === 1 ? 0 : Infinity;
|
||||
spread = pointers.size === 2 ? gap() : 0;
|
||||
});
|
||||
element.addEventListener("pointermove", (event) => {
|
||||
const last = pointers.get(event.pointerId);
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
const dx = event.clientX - last.x;
|
||||
const dy = event.clientY - last.y;
|
||||
pointers.set(event.pointerId, {x: event.clientX, y: event.clientY});
|
||||
travel += Math.abs(dx) + Math.abs(dy);
|
||||
if (pointers.size === 2) {
|
||||
const next = gap();
|
||||
if (spread > 0 && next > 0) {
|
||||
hooks.zoom(next / spread);
|
||||
}
|
||||
spread = next;
|
||||
} else if (pointers.size === 1 && travel > 3) {
|
||||
if (event.shiftKey && hooks.pan) {
|
||||
hooks.pan(dx, dy);
|
||||
} else {
|
||||
hooks.rotate(dx, dy);
|
||||
}
|
||||
}
|
||||
});
|
||||
const release = (event) => {
|
||||
if (!pointers.delete(event.pointerId)) {
|
||||
return;
|
||||
}
|
||||
spread = pointers.size === 2 ? gap() : 0;
|
||||
if (event.type === "pointerup" && !pointers.size && travel <= 3 && hooks.pick) {
|
||||
const box = element.getBoundingClientRect();
|
||||
hooks.pick(event.clientX - box.left, event.clientY - box.top);
|
||||
}
|
||||
};
|
||||
element.addEventListener("pointerup", release);
|
||||
element.addEventListener("pointercancel", release);
|
||||
element.addEventListener("wheel", (event) => {
|
||||
event.preventDefault();
|
||||
settle(kind);
|
||||
hooks.zoom(Math.exp(-event.deltaY * [1, 16, 400][event.deltaMode] * 0.0015));
|
||||
}, {passive: false});
|
||||
element.addEventListener("keydown", (event) => steerKey(event, kind, hooks));
|
||||
}
|
||||
|
||||
const graphHooks = {
|
||||
rotate: (dx, dy) => nudge("graph", (view) => ({...view, yaw: view.yaw - dx * 0.4, pitch: view.pitch + dy * 0.4}), false),
|
||||
pan: (dx, dy) => nudge("graph", (view) => ({...view, pan_x: view.pan_x + dx / (space.cam ? space.cam.scale : 1), pan_y: view.pan_y + dy / (space.cam ? space.cam.scale : 1)}), false),
|
||||
zoom: (factor) => nudge("graph", (view) => ({...view, zoom: view.zoom * factor}), false),
|
||||
turnStep: (x, y) => nudge("graph", (view) => ({...view, yaw: view.yaw - x * TURN, pitch: view.pitch + y * TURN}), true),
|
||||
panStep: (x, y) => nudge("graph", (view) => ({...view, pan_x: view.pan_x + x * NUDGE, pan_y: view.pan_y + y * NUDGE}), true),
|
||||
zoomStep: (factor) => nudge("graph", (view) => ({...view, zoom: view.zoom * factor}), true),
|
||||
pick: (x, y) => pickSpace(x, y),
|
||||
};
|
||||
|
||||
const skyLens = () => Space.lens(seenView("sky"), window.innerWidth, window.innerHeight);
|
||||
|
||||
const skyHooks = {
|
||||
rotate: (dx, dy) => {
|
||||
const sight = skyLens();
|
||||
nudge("sky", (view) => ({...view, azimuth: view.azimuth - dx / sight.kx, altitude: view.altitude + dy / sight.ky}), false);
|
||||
},
|
||||
pan: null,
|
||||
zoom: (factor) => nudge("sky", (view) => ({...view, fov: view.fov / factor}), false),
|
||||
turnStep: (x, y) => nudge("sky", (view) => ({...view, azimuth: view.azimuth + x * TURN, altitude: view.altitude - y * TURN}), true),
|
||||
panStep: null,
|
||||
zoomStep: (factor) => nudge("sky", (view) => ({...view, fov: view.fov / factor}), true),
|
||||
pick: null,
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
function loudness(kind) {
|
||||
return Voice.loudness(kind, settings);
|
||||
}
|
||||
|
||||
function speechFailed(lines) {
|
||||
if (Date.now() - speech.warned > SPEECH_QUIET) {
|
||||
speech.warned = Date.now();
|
||||
toast("Funk vorlesen fehlgeschlagen", lines, false);
|
||||
}
|
||||
}
|
||||
|
||||
function say(item) {
|
||||
speech.pending = Voice.enqueue(speech.pending, item, SPEECH_QUEUE);
|
||||
pump();
|
||||
}
|
||||
|
||||
async function pump() {
|
||||
if (speech.busy || !speech.pending.length) {
|
||||
return;
|
||||
}
|
||||
const sound = context();
|
||||
if (!sound) {
|
||||
speech.pending = [];
|
||||
return;
|
||||
}
|
||||
const [item, ...rest] = speech.pending;
|
||||
speech.pending = rest;
|
||||
speech.busy = true;
|
||||
const next = () => {
|
||||
speech.busy = false;
|
||||
pump();
|
||||
};
|
||||
try {
|
||||
const response = await fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Orchestrator": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: item.text, voice: item.voice}), cache: "no-store"});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${(await response.text()).trim()}`);
|
||||
}
|
||||
const buffer = await sound.decodeAudioData(await response.arrayBuffer());
|
||||
const source = sound.createBufferSource();
|
||||
const gain = sound.createGain();
|
||||
gain.gain.value = loudness(item.tone);
|
||||
source.buffer = buffer;
|
||||
source.connect(gain).connect(sound.destination);
|
||||
source.addEventListener("ended", next, {once: true});
|
||||
duck(buffer.duration);
|
||||
source.start();
|
||||
} catch (error) {
|
||||
speechFailed([String(error.message || error)]);
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
function speakRows(rows) {
|
||||
if (!settings.speech.on || !state.speech.tts) {
|
||||
return;
|
||||
}
|
||||
for (const row of rows.filter((entry) => entry.ts >= state.now - SPEECH_FRESH && Voice.speakable(entry, settings.speech))) {
|
||||
say({text: row.speech, voice: row.voice, tone: Voice.toneOf(row, HUMAN), cls: row.class});
|
||||
}
|
||||
}
|
||||
|
||||
function micButton(field) {
|
||||
const button = h("button", {type: "button", class: "btn small ghost icon-btn mic", "aria-pressed": "false", "aria-label": "Spracheingabe: gedrückt halten und sprechen", title: "Gedrückt halten und sprechen; der Text landet im Feld und wird nicht gesendet"}, "🎙");
|
||||
const reason = !window.isSecureContext || !navigator.mediaDevices
|
||||
? "Das Mikrofon braucht einen sicheren Kontext: öffne den Hub über 127.0.0.1 oder localhost"
|
||||
: !window.MediaRecorder ? "Dieser Browser kann nicht aufnehmen" : !state.speech.stt ? `Spracheingabe ist aus: ${state.speech.stt_off}` : "";
|
||||
if (reason) {
|
||||
button.disabled = true;
|
||||
button.title = reason;
|
||||
return button;
|
||||
}
|
||||
const press = (event) => {
|
||||
event.preventDefault();
|
||||
record(field, button);
|
||||
};
|
||||
button.addEventListener("pointerdown", press);
|
||||
for (const name of ["pointerup", "pointerleave", "pointercancel"]) {
|
||||
button.addEventListener(name, release);
|
||||
}
|
||||
button.addEventListener("keydown", (event) => {
|
||||
if ((event.key === " " || event.key === "Enter") && !event.repeat) {
|
||||
press(event);
|
||||
}
|
||||
});
|
||||
button.addEventListener("keyup", (event) => {
|
||||
if (event.key === " " || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
release();
|
||||
}
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
async function record(field, button) {
|
||||
if (mic.held || mic.recorder || mic.busy) {
|
||||
return;
|
||||
}
|
||||
mic.held = true;
|
||||
let media = null;
|
||||
try {
|
||||
media = await navigator.mediaDevices.getUserMedia({audio: {channelCount: 1, echoCancellation: true, noiseSuppression: true}});
|
||||
} catch (error) {
|
||||
mic.held = false;
|
||||
toast("Mikrofon nicht verfügbar", [String(error.message || error)], false);
|
||||
return;
|
||||
}
|
||||
if (!mic.held) {
|
||||
media.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
const recorder = new MediaRecorder(media);
|
||||
const chunks = [];
|
||||
recorder.addEventListener("dataavailable", (event) => {
|
||||
if (event.data.size) {
|
||||
chunks.push(event.data);
|
||||
}
|
||||
});
|
||||
recorder.addEventListener("stop", () => {
|
||||
media.getTracks().forEach((track) => track.stop());
|
||||
mic.recorder = null;
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
transcribe(new Blob(chunks, {type: recorder.mimeType}), field, button);
|
||||
}, {once: true});
|
||||
mic.recorder = recorder;
|
||||
recorder.start();
|
||||
button.setAttribute("aria-pressed", "true");
|
||||
mic.timer = window.setTimeout(release, state.speech.seconds * 1000);
|
||||
}
|
||||
|
||||
function release() {
|
||||
mic.held = false;
|
||||
window.clearTimeout(mic.timer);
|
||||
if (mic.recorder && mic.recorder.state === "recording") {
|
||||
mic.recorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function transcribe(blob, field, button) {
|
||||
const sound = context();
|
||||
if (!sound || !blob.size) {
|
||||
return;
|
||||
}
|
||||
mic.busy = true;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const decoded = await sound.decodeAudioData(await blob.arrayBuffer());
|
||||
const rate = state.speech.rate;
|
||||
const length = Voice.frames(decoded.duration, rate, state.speech.seconds);
|
||||
if (length < rate / 4) {
|
||||
throw new Error("zu kurz: halte die Taste, solange du sprichst");
|
||||
}
|
||||
const offline = new OfflineAudioContext(1, length, rate);
|
||||
const source = offline.createBufferSource();
|
||||
source.buffer = decoded;
|
||||
source.connect(offline.destination);
|
||||
source.start();
|
||||
const rendered = await offline.startRendering();
|
||||
const result = await send("/api/stt", Voice.wav(rendered.getChannelData(0), rate), "audio/wav");
|
||||
if (!result.ok || !result.data) {
|
||||
throw new Error(`${result.status} ${result.text}`.trim());
|
||||
}
|
||||
if (result.data.text) {
|
||||
field.value = [field.value.trim(), result.data.text].filter(Boolean).join(" ");
|
||||
field.dispatchEvent(new Event("input", {bubbles: true}));
|
||||
}
|
||||
field.focus();
|
||||
} catch (error) {
|
||||
toast("Spracheingabe fehlgeschlagen", [String(error.message || error)], false);
|
||||
} finally {
|
||||
mic.busy = false;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
function stored(key, fallback) {
|
||||
try {
|
||||
const text = window.localStorage.getItem(key);
|
||||
return text === null ? fallback : JSON.parse(text);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function keep(key, value) {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validViews(value) {
|
||||
const names = new Set(KINDS.map(([id]) => id));
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((view) => view && typeof view.name === "string" && view.name && Array.isArray(view.kinds) && view.kinds.length && view.kinds.every((name) => names.has(name)))
|
||||
.map((view) => ({name: view.name.slice(0, 60), kinds: view.kinds, waiting: view.waiting === true, q: typeof view.q === "string" ? view.q : "", agent: typeof view.agent === "string" ? view.agent : ""}));
|
||||
}
|
||||
|
||||
let state = JSON.parse(document.getElementById("state").textContent);
|
||||
const KINDS = Object.entries(state.kinds);
|
||||
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
||||
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
|
||||
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
|
||||
const tree = {data: null, busy: false, q: "", open: new Set(), dom: null};
|
||||
const palette = {items: [], index: 0, input: null, list: null, plan: null, box: null};
|
||||
const known = {inbox: null, alarms: null, radio: null, batches: new Set()};
|
||||
const motion = {current: new Map(), from: new Map(), target: new Map(), start: 0, duration: 0, frame: 0, asOf: undefined};
|
||||
const drawn = {screen: "", sig: ""};
|
||||
const files = new Map();
|
||||
const pageMedia = new Set(state.media);
|
||||
const sky = {canvas: document.getElementById("sky"), stars: Ambience.stars(320, 7), mode: "", frame: 0};
|
||||
const music = {gain: null, filter: null, drone: [], timer: 0, tension: 0, ducked: 0};
|
||||
const radio = {element: null, url: "", level: 0, timer: 0, playing: false, waiting: false};
|
||||
const speech = {pending: [], busy: false, warned: 0};
|
||||
const mic = {recorder: null, held: false, busy: false, timer: 0};
|
||||
const space = {
|
||||
views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0,
|
||||
scene: null, sceneCursor: null, cam: null, frame: 0, look: false, compass: null, subs: new Map(), loading: false, version: 0,
|
||||
cursor: {from: 0, to: null, start: 0}, style: null, styleKey: "", trackAsked: 0,
|
||||
};
|
||||
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
|
||||
let settings = state.settings;
|
||||
let settingsSig = JSON.stringify(settings);
|
||||
let drawerDom = null;
|
||||
let timeDom = null;
|
||||
let graphDom = null;
|
||||
let reviewDom = null;
|
||||
let inboxDom = null;
|
||||
let replay = 0;
|
||||
let positions = new Map();
|
||||
let inboxSig = "";
|
||||
let audio = null;
|
||||
let searchTimer = 0;
|
||||
@@ -0,0 +1,186 @@
|
||||
async function fetchEvents() {
|
||||
if (stream.busy) {
|
||||
stream.again = true;
|
||||
return;
|
||||
}
|
||||
const key = `${windowKey()}&${filterQuery()}`;
|
||||
if (stream.key !== key) {
|
||||
stream.key = key;
|
||||
stream.events = [];
|
||||
stream.entries = [];
|
||||
stream.last = 0;
|
||||
}
|
||||
stream.busy = true;
|
||||
try {
|
||||
const response = await fetch(`/api/events?${windowQuery()}&${filterQuery()}&after=${stream.last}`, {cache: "no-store"});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (stream.key === key) {
|
||||
const seen = new Set(stream.entries.map((entry) => entry.id));
|
||||
stream.events = data.events;
|
||||
stream.entries = [...stream.entries, ...data.entries.filter((entry) => !seen.has(entry.id))].sort((a, b) => a.ts - b.ts).slice(-STREAM_KEEP);
|
||||
stream.last = data.last;
|
||||
stream.sessions = data.sessions;
|
||||
stream.error = "";
|
||||
}
|
||||
} else {
|
||||
stream.error = `${response.status} ${(await response.text()).trim()}`;
|
||||
}
|
||||
} catch {
|
||||
ui.offline = true;
|
||||
}
|
||||
stream.busy = false;
|
||||
drawStream();
|
||||
if (stream.again) {
|
||||
stream.again = false;
|
||||
fetchEvents();
|
||||
}
|
||||
}
|
||||
|
||||
async function expand(entry, holder, button) {
|
||||
button.disabled = true;
|
||||
const response = await fetch(`/api/stream/${entry.ref.entry}`, {cache: "no-store"});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
entry.text = data.text;
|
||||
entry.ref.truncated = false;
|
||||
holder.textContent = data.text;
|
||||
button.remove();
|
||||
} else {
|
||||
button.textContent = "Nicht mehr verfügbar";
|
||||
}
|
||||
}
|
||||
|
||||
function focusSub(session, sub) {
|
||||
filter.view = "";
|
||||
filter.saved = "";
|
||||
filter.kinds = new Set(["transcript"]);
|
||||
filter.waiting = false;
|
||||
filter.session = session;
|
||||
filter.sub = sub;
|
||||
closeAgent();
|
||||
if (ui.screen === "stream" && stream.dom) {
|
||||
refilter();
|
||||
} else {
|
||||
go("stream", null);
|
||||
}
|
||||
}
|
||||
|
||||
function inboxOf(entry) {
|
||||
const id = entry.kind === "operator" ? entry.ref.task : entry.ref.id;
|
||||
return state.inbox.find((item) => item.kind === entry.kind && item.id === id) || null;
|
||||
}
|
||||
|
||||
function kindPill(entry) {
|
||||
if (entry.kind === "radio") {
|
||||
return klass(entry.part);
|
||||
}
|
||||
if (entry.kind === "transcript") {
|
||||
return pill((PARTS.find(([id]) => id === entry.part) || [entry.part, entry.part])[1], PART_TONES[entry.part] || "neutral");
|
||||
}
|
||||
return pill((KINDS.find(([id]) => id === entry.kind) || [entry.kind, entry.kind])[1], KIND_TONES[entry.kind] || "neutral");
|
||||
}
|
||||
|
||||
function extras(entry) {
|
||||
const ref = entry.ref;
|
||||
if (entry.kind === "review") {
|
||||
return [status(ref.status), ref.gate ? pill(ref.gate === "pass" ? "Gate grün" : "Gate rot", ref.gate === "pass" ? "ok" : "bad") : null];
|
||||
}
|
||||
if (entry.kind === "ask") {
|
||||
return [status(ref.status), ...ref.warnings.map((warning) => pill(warning, "bad"))];
|
||||
}
|
||||
if (entry.kind === "decision") {
|
||||
return [status(ref.status), ref.choice ? pill(ref.choice, "ok") : null];
|
||||
}
|
||||
if (entry.kind === "cron") {
|
||||
return [pill(ref.active ? "aktiv" : "inaktiv", ref.active ? "ok" : "neutral")];
|
||||
}
|
||||
if (entry.kind === "run" || entry.kind === "operator" || entry.kind === "register") {
|
||||
return [ref.status ? status(ref.status) : null];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function body(entry) {
|
||||
if (entry.kind === "transcript") {
|
||||
const text = h("pre", {}, entry.text);
|
||||
const more = entry.ref.truncated ? h("button", {type: "button", class: "btn small ghost"}, "Ganzen Text zeigen") : null;
|
||||
if (more) {
|
||||
more.addEventListener("click", () => expand(entry, text, more));
|
||||
}
|
||||
return [text, more];
|
||||
}
|
||||
if (entry.kind === "ask" || entry.kind === "operator") {
|
||||
const where = entry.kind === "ask" ? entry.ref.cwd : entry.ref.directory;
|
||||
return [h("div", {class: "command"}, h("code", {}, entry.kind === "ask" ? entry.text : entry.title), copyButton(entry.kind === "ask" ? entry.text : entry.title)), h("span", {class: "muted"}, `in ${dash(where)}`), entry.kind === "operator" && entry.text !== "-" ? h("p", {class: "text"}, entry.text) : null];
|
||||
}
|
||||
if (entry.kind === "review") {
|
||||
return [entry.text ? h("pre", {class: "notes"}, entry.text) : null, h("div", {class: "line-actions"}, btn("Diff ansehen", "btn small soft", () => reviewSheet(entry.ref.id)))];
|
||||
}
|
||||
if (entry.kind === "cron") {
|
||||
const ref = entry.ref;
|
||||
const ended = ref.deleted ? [" · beendet ", when(ref.deleted)] : [];
|
||||
return [entry.text ? h("pre", {}, entry.text) : null, h("span", {class: "muted"}, "angelegt ", when(ref.created), ended)];
|
||||
}
|
||||
if (entry.kind === "run") {
|
||||
return [entry.text ? h("pre", {}, entry.text) : null];
|
||||
}
|
||||
return [entry.text ? h("p", {class: "text"}, entry.text) : null];
|
||||
}
|
||||
|
||||
function eventRow(entry) {
|
||||
const item = entry.waiting ? inboxOf(entry) : null;
|
||||
const alarm = entry.kind === "radio" && (entry.part === "mayday" || entry.part === "pan-pan");
|
||||
const renamed = entry.now && entry.now !== entry.agent ? h("span", {title: `jetzt: ${entry.now}`}, person(entry.agent)) : person(entry.agent);
|
||||
const who = entry.agent ? [face(entry.agent, false), renamed] : entry.session ? h("strong", {title: entry.session}, entry.session.slice(0, 8)) : null;
|
||||
const headline = entry.kind === "transcript" || entry.kind === "ask" || entry.kind === "operator" ? null : h("span", {class: "headline"}, entry.title);
|
||||
return h("li", {class: `entry k-${entry.kind}${entry.waiting ? " waiting" : ""}${alarm ? " alarm" : ""}`},
|
||||
h("div", {class: "msg-meta"}, when(entry.ts), kindPill(entry), extras(entry)),
|
||||
h("div", {},
|
||||
h("div", {class: "route"}, who, entry.sub ? h("button", {type: "button", class: "linkish muted", on: {click: () => focusSub(entry.session, entry.sub)}}, `› Subagent ${entry.sub.slice(0, 12)}`) : null, entry.kind === "ask" ? h("strong", {}, entry.title) : null, headline),
|
||||
body(entry),
|
||||
item ? h("div", {class: "line-actions"}, actionsFor(item)) : null));
|
||||
}
|
||||
|
||||
function drawStream() {
|
||||
if (!stream.dom || ui.screen !== "stream") {
|
||||
return;
|
||||
}
|
||||
const floor = live() && ui.preset > 0 ? nowSeconds() - ui.preset : -Infinity;
|
||||
const rows = [...stream.events, ...stream.entries.filter((entry) => entry.ts >= floor)].sort((a, b) => b.ts - a.ts || (a.id < b.id ? 1 : -1));
|
||||
const shown = rows.slice(0, FEED_LIMIT);
|
||||
stream.dom.count.textContent = `${rows.length} Einträge${live() ? " · folgt live" : ""}`;
|
||||
if (stream.error) {
|
||||
stream.dom.list.replaceChildren(emptyState("Stream nicht lesbar", stream.error));
|
||||
return;
|
||||
}
|
||||
stream.dom.list.replaceChildren(rows.length
|
||||
? h("div", {class: "card flush"}, h("ol", {class: "feed"}, shown.map(eventRow)),
|
||||
rows.length > shown.length ? h("p", {class: "footnote"}, `${rows.length - shown.length} ältere Einträge ausgeblendet; grenze mit Filter oder Zeitraum ein.`) : null)
|
||||
: emptyState(stream.busy ? "Lade …" : "Keine Einträge", activeWaiting() ? "Gerade wartet nichts auf dich." : "Im gewählten Zeitraum und Filter gibt es nichts."));
|
||||
}
|
||||
|
||||
function streamScreen() {
|
||||
const search = h("input", {type: "search", placeholder: "Suchen … agent:Name art:funk seit:2h (/)", value: filter.q, "aria-label": "Stream durchsuchen: Wörter, agent:, art: und seit:", on: {input: (event) => {
|
||||
filter.q = event.target.value;
|
||||
filter.saved = "";
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = window.setTimeout(fetchEvents, 300);
|
||||
}}});
|
||||
const count = h("span", {class: "muted"});
|
||||
const categories = h("div", {class: "chips", role: "group", "aria-label": "Kategorien"});
|
||||
const kinds = h("div", {class: "chips", role: "group", "aria-label": "Arten"});
|
||||
const parts = h("div", {class: "chips", role: "group", "aria-label": "Transkript-Teile"});
|
||||
const saved = h("div", {class: "chips", role: "group", "aria-label": "Gespeicherte Ansichten"});
|
||||
const focus = h("div", {class: "chips"});
|
||||
const list = h("div", {});
|
||||
const more = h("details", {class: "menu"}, h("summary", {}, "Filter ▾"), h("div", {class: "menu-box end"},
|
||||
h("h3", {}, "Arten"), kinds, h("h3", {}, "Transkript-Teile"), parts, h("h3", {}, "Gespeicherte Ansichten"), saved, h("h3", {}, "Eigener Zeitraum"), customRange()));
|
||||
stream.dom = {search, count, categories, kinds, parts, saved, focus, list};
|
||||
window.setTimeout(() => {
|
||||
drawFilters();
|
||||
drawStream();
|
||||
fetchEvents();
|
||||
}, 0);
|
||||
return h("div", {}, h("div", {class: "filters"}, categories, search, more, count), focus, list);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
function windowQuery() {
|
||||
if (ui.preset === -1) {
|
||||
return `from=${Math.floor(ui.from)}&to=${Math.ceil(ui.to)}`;
|
||||
}
|
||||
const end = ui.asOf === null ? nowSeconds() : ui.asOf;
|
||||
const begin = ui.preset === 0 ? 0 : end - ui.preset;
|
||||
return `from=${Math.floor(begin)}${ui.asOf === null ? "" : `&to=${Math.ceil(end)}`}`;
|
||||
}
|
||||
|
||||
const localInput = (ts) => {
|
||||
const date = stampOf(ts);
|
||||
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
function customRange() {
|
||||
const from = h("input", {type: "datetime-local", "aria-label": "Von", value: localInput(ui.from === null ? state.since : ui.from)});
|
||||
const to = h("input", {type: "datetime-local", "aria-label": "Bis", value: localInput(ui.to === null ? state.until : ui.to)});
|
||||
return h("span", {class: "custom"}, from, h("span", {class: "muted"}, "bis"), to,
|
||||
h("button", {type: "button", class: "btn small soft", on: {click: () => {
|
||||
const begin = Date.parse(from.value) / 1000;
|
||||
const end = Date.parse(to.value) / 1000;
|
||||
if (!Number.isFinite(begin) || !Number.isFinite(end) || begin >= end) {
|
||||
toast("Zeitraum ungültig", ["„Von“ muss vor „Bis“ liegen."], false);
|
||||
return;
|
||||
}
|
||||
stopReplay();
|
||||
ui.preset = -1;
|
||||
ui.from = begin;
|
||||
ui.to = end;
|
||||
reload();
|
||||
}}}, "Anwenden"));
|
||||
}
|
||||
|
||||
function timeBar() {
|
||||
const bar = document.getElementById("time");
|
||||
const presets = h("div", {class: "segmented", role: "group", "aria-label": "Zeitraum"});
|
||||
const summary = h("summary", {title: "Zeitraum und Wiedergabe"});
|
||||
const slider = h("input", {type: "range", min: 0, max: 1000, step: 1, value: 1000, "aria-label": "Stand der Wiedergabe"});
|
||||
const play = h("button", {type: "button", class: "btn small soft"}, "▶ Wiedergabe");
|
||||
const liveButton = h("button", {type: "button", class: "btn small ghost"}, "Live");
|
||||
const label = h("span", {class: "window-label"});
|
||||
for (const [seconds, name] of PRESETS.filter(([seconds]) => seconds !== -1)) {
|
||||
presets.append(h("button", {type: "button", "data-preset": seconds, on: {click: () => choosePreset(seconds)}}, name));
|
||||
}
|
||||
slider.addEventListener("input", () => {
|
||||
stopReplay();
|
||||
const span = state.now - state.first;
|
||||
ui.asOf = Number(slider.value) >= 1000 ? null : state.first + (span * Number(slider.value)) / 1000;
|
||||
reload();
|
||||
});
|
||||
play.addEventListener("click", () => (ui.playing ? stopReplay() : startReplay()));
|
||||
liveButton.addEventListener("click", () => {
|
||||
stopReplay();
|
||||
ui.asOf = null;
|
||||
if (ui.preset === -1) {
|
||||
ui.preset = DAY;
|
||||
}
|
||||
reload();
|
||||
});
|
||||
bar.append(summary, h("div", {class: "menu-box end"}, h("h3", {}, "Zeitraum"), presets, h("h3", {}, "Wiedergabe"), h("div", {class: "replay"}, play, slider, liveButton), label,
|
||||
h("p", {class: "muted"}, "Einen eigenen Zeitraum setzt Filter ▾ im Stream.")));
|
||||
timeDom = {summary, presets, slider, play, live: liveButton, label};
|
||||
}
|
||||
|
||||
function reload() {
|
||||
load();
|
||||
if (ui.screen === "stream") {
|
||||
fetchEvents();
|
||||
}
|
||||
}
|
||||
|
||||
function choosePreset(seconds) {
|
||||
stopReplay();
|
||||
ui.preset = seconds;
|
||||
reload();
|
||||
}
|
||||
|
||||
function drawTime() {
|
||||
if (!timeDom) {
|
||||
return;
|
||||
}
|
||||
for (const button of timeDom.presets.children) {
|
||||
button.setAttribute("aria-pressed", String(Number(button.dataset.preset) === ui.preset));
|
||||
}
|
||||
timeDom.summary.textContent = `${(PRESETS.find(([seconds]) => seconds === ui.preset) || [0, "eigener"])[1]}${ui.asOf === null ? "" : " · Wiedergabe"} ▾`;
|
||||
timeDom.slider.disabled = ui.preset === -1;
|
||||
timeDom.play.disabled = ui.preset === -1;
|
||||
timeDom.live.disabled = live();
|
||||
const span = Math.max(state.now - state.first, 1);
|
||||
if (!ui.playing) {
|
||||
timeDom.slider.value = String(ui.asOf === null ? 1000 : Math.round((1000 * (ui.asOf - state.first)) / span));
|
||||
}
|
||||
timeDom.label.textContent = `${absolute.format(stampOf(state.since))} – ${absolute.format(stampOf(state.until))}${live() ? " · live" : ""}`;
|
||||
}
|
||||
|
||||
function startReplay() {
|
||||
if (ui.preset === -1) {
|
||||
return;
|
||||
}
|
||||
const began = state.first;
|
||||
const span = Math.max(state.now - began, 1);
|
||||
const resume = ui.asOf !== null && ui.asOf < state.now - 60;
|
||||
let progress = resume ? (ui.asOf - began) / span : 0;
|
||||
ui.playing = true;
|
||||
timeDom.play.textContent = "❚❚ Pause";
|
||||
const advance = async () => {
|
||||
if (!ui.playing) {
|
||||
return;
|
||||
}
|
||||
progress = Math.min(progress + 1 / 48, 1);
|
||||
ui.asOf = progress >= 1 ? null : began + span * progress;
|
||||
timeDom.slider.value = String(Math.round(progress * 1000));
|
||||
await load();
|
||||
if (ui.screen === "stream") {
|
||||
fetchEvents();
|
||||
}
|
||||
if (progress >= 1) {
|
||||
stopReplay();
|
||||
} else if (ui.playing) {
|
||||
replay = window.setTimeout(advance, 500);
|
||||
}
|
||||
};
|
||||
advance();
|
||||
}
|
||||
|
||||
function stopReplay() {
|
||||
ui.playing = false;
|
||||
window.clearTimeout(replay);
|
||||
if (timeDom) {
|
||||
timeDom.play.textContent = "▶ Wiedergabe";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
function treeScreen() {
|
||||
const search = h("input", {type: "search", placeholder: "Repo, Branch, Pfad oder Agent filtern …", value: tree.q, "aria-label": "Tree filtern", on: {input: (event) => {
|
||||
tree.q = event.target.value;
|
||||
drawTree();
|
||||
}}});
|
||||
const count = h("span", {class: "muted"});
|
||||
const list = h("div", {class: "repos"});
|
||||
tree.dom = {list, count};
|
||||
window.setTimeout(() => {
|
||||
drawTree();
|
||||
fetchTree();
|
||||
}, 0);
|
||||
return h("div", {}, h("div", {class: "filters"}, search, count, btn("Neu laden", "btn small soft", fetchTree)), list);
|
||||
}
|
||||
|
||||
async function fetchTree() {
|
||||
if (tree.busy) {
|
||||
return;
|
||||
}
|
||||
tree.busy = true;
|
||||
try {
|
||||
const response = await fetch("/api/tree", {cache: "no-store"});
|
||||
if (response.ok) {
|
||||
tree.data = await response.json();
|
||||
}
|
||||
} catch {
|
||||
ui.offline = true;
|
||||
}
|
||||
tree.busy = false;
|
||||
drawTree();
|
||||
}
|
||||
|
||||
function worktreeRow(wt) {
|
||||
const review = wt.review;
|
||||
return h("div", {class: "wt"},
|
||||
h("code", {}, wt.path),
|
||||
h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)),
|
||||
wt.dirty === null ? pill("fehlt", "neutral") : wt.dirty ? pill("geändert", "warn") : pill("sauber", "ok"),
|
||||
wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "niemand"),
|
||||
review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null] : null,
|
||||
copyButton(wt.path));
|
||||
}
|
||||
|
||||
function drawTree() {
|
||||
if (!tree.dom) {
|
||||
return;
|
||||
}
|
||||
if (!tree.data) {
|
||||
tree.dom.list.replaceChildren(emptyState(tree.busy ? "Lese Repositories …" : "Kein Tree", "Die Repositories werden höchstens jede Minute neu gelesen."));
|
||||
return;
|
||||
}
|
||||
const q = tree.q.trim().toLowerCase();
|
||||
const hit = (...texts) => !q || texts.some((text) => String(text).toLowerCase().includes(q));
|
||||
const repos = [];
|
||||
for (const repo of tree.data.repos) {
|
||||
const branches = repo.branches.filter((entry) => hit(repo.name, entry.name, ...entry.worktrees.flatMap((wt) => [wt.path, ...wt.workers])));
|
||||
const detached = repo.detached.filter((wt) => hit(repo.name, wt.path, ...wt.workers));
|
||||
if (branches.length || detached.length) {
|
||||
repos.push({repo, branches, detached});
|
||||
}
|
||||
}
|
||||
tree.dom.count.textContent = `${repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}`;
|
||||
if (!repos.length) {
|
||||
tree.dom.list.replaceChildren(emptyState("Nichts gefunden", "Kein Repository passt zum Filter."));
|
||||
return;
|
||||
}
|
||||
tree.dom.list.replaceChildren(...repos.map(({repo, branches, detached}) => {
|
||||
const trees = repo.branches.flatMap((entry) => entry.worktrees).concat(repo.detached);
|
||||
const dirty = trees.filter((wt) => wt.dirty).length;
|
||||
const details = h("details", {class: "card repo", open: Boolean(q) || tree.open.has(repo.path)},
|
||||
h("summary", {}, h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`), dirty ? pill(`${dirty} geändert`, "warn") : null),
|
||||
branches.map((entry) => h("div", {class: "branch-row"},
|
||||
h("div", {class: "branch-name"}, h("strong", {}, entry.name), entry.upstream ? h("span", {class: "muted"}, `↔ ${entry.upstream}`) : h("span", {class: "muted"}, "ohne Upstream"),
|
||||
entry.ahead ? pill(`${entry.ahead} voraus`, "info") : null, entry.behind ? pill(`${entry.behind} zurück`, "warn") : null, entry.gone ? pill("Upstream weg", "bad") : null,
|
||||
h("span", {class: "muted", title: entry.head}, entry.head.slice(0, 10))),
|
||||
entry.worktrees.map(worktreeRow))),
|
||||
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "losgelöster HEAD")), detached.map(worktreeRow)) : null);
|
||||
details.addEventListener("toggle", () => {
|
||||
if (details.open) {
|
||||
tree.open.add(repo.path);
|
||||
} else {
|
||||
tree.open.delete(repo.path);
|
||||
}
|
||||
});
|
||||
return details;
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
const SpacePaint = (() => {
|
||||
"use strict";
|
||||
const {clamp, project, depthSort} = typeof SpaceView === "object" ? SpaceView : require("./space-view.js");
|
||||
const LOOKS = {
|
||||
guide: ["line", 1, 1],
|
||||
"line:operator": ["accent", 2.4, 0.9],
|
||||
"line:chair": ["accent", 2, 0.85],
|
||||
"line:session": ["muted", 1.6, 0.8],
|
||||
"line:subagent": ["violet", 1.2, 0.75],
|
||||
"line:twig": ["violet", 1, 0.6],
|
||||
"line:selected": ["accent", 3.2, 1],
|
||||
"arc:direct": ["direct", 1.3, 0.6],
|
||||
"arc:broadcast": ["broadcast", 1.2, 0.55],
|
||||
"arc:alarm": ["alarm", 2, 0.9],
|
||||
"arc:security": ["security", 1.5, 0.75],
|
||||
"arc:grant": ["grant", 1.6, 0.75],
|
||||
"arc:spawn": ["violet", 1.4, 0.8],
|
||||
};
|
||||
const DISCS = {operator: 9, chair: 7, session: 5.5, subagent: 3.5, twig: 3};
|
||||
const RIM = 330;
|
||||
const HALO = 14;
|
||||
const SLICE = 25;
|
||||
const ROUND = 48;
|
||||
const MARGIN = 40;
|
||||
|
||||
function ring(center, radius, count) {
|
||||
return Array.from({length: count}, (_, index) => {
|
||||
const angle = (2 * Math.PI * index) / count;
|
||||
return {x: center.x + radius * Math.cos(angle), y: center.y + radius * Math.sin(angle), z: center.z};
|
||||
});
|
||||
}
|
||||
|
||||
function arcPoints(arc) {
|
||||
return arc.kind === "broadcast" ? ring(arc.from, HALO, 12) : [arc.from, arc.via, arc.to];
|
||||
}
|
||||
|
||||
function visible(cam, marks) {
|
||||
let [left, right, upper, lower] = [Infinity, -Infinity, Infinity, -Infinity];
|
||||
for (const mark of marks) {
|
||||
if (mark === null) {
|
||||
return false;
|
||||
}
|
||||
left = Math.min(left, mark.x);
|
||||
right = Math.max(right, mark.x);
|
||||
upper = Math.min(upper, mark.y);
|
||||
lower = Math.max(lower, mark.y);
|
||||
}
|
||||
return right >= -MARGIN && left <= cam.width + MARGIN && lower >= -MARGIN && upper <= cam.height + MARGIN;
|
||||
}
|
||||
|
||||
function mean(marks) {
|
||||
return marks.reduce((sum, mark) => sum + mark.depth, 0) / marks.length;
|
||||
}
|
||||
|
||||
function pen(ctx, colors, look) {
|
||||
const [role, width, alpha] = LOOKS[look];
|
||||
ctx.strokeStyle = colors[role];
|
||||
ctx.lineWidth = width;
|
||||
ctx.globalAlpha = alpha;
|
||||
}
|
||||
|
||||
function paint(ctx, built, cam, style) {
|
||||
const colors = style.colors;
|
||||
const stats = {items: 0, arcs: 0, culled: 0, batches: 0, labels: 0};
|
||||
const buckets = new Map(Object.keys(LOOKS).map((look) => [look, []]));
|
||||
const others = [];
|
||||
const marksOf = (points) => points.map((entry) => project(cam, entry));
|
||||
const stroke = (look, marks, type, closed) => {
|
||||
if (!visible(cam, marks)) {
|
||||
stats.culled += 1;
|
||||
return false;
|
||||
}
|
||||
buckets.get(look).push({type, look, marks, closed, depth: Math.round(mean(marks) / SLICE) * SLICE});
|
||||
return true;
|
||||
};
|
||||
const label = (at, text, tone, strong) => {
|
||||
const mark = project(cam, at);
|
||||
if (mark && visible(cam, [mark])) {
|
||||
others.push({type: "label", mark, text, tone, strong, depth: mark.depth - 1});
|
||||
}
|
||||
};
|
||||
for (const guide of built.guides) {
|
||||
const marks = marksOf(ring({x: 0, y: 0, z: guide.z}, RIM, ROUND));
|
||||
if (stroke("guide", marks, "path", true)) {
|
||||
label({x: 0, y: -RIM, z: guide.z}, built.axis === "time" ? style.format(guide.ts) : `${style.level} ${guide.level}`, "muted", false);
|
||||
}
|
||||
}
|
||||
if (built.plane) {
|
||||
const marks = marksOf(ring({x: 0, y: 0, z: built.plane.z}, RIM, ROUND));
|
||||
if (visible(cam, marks)) {
|
||||
others.push({type: "plane", marks, depth: mean(marks)});
|
||||
label({x: RIM * Math.SQRT1_2, y: -RIM * Math.SQRT1_2, z: built.plane.z}, `${style.cursor} ${style.format(built.plane.ts)}`, "accent", true);
|
||||
}
|
||||
}
|
||||
built.lines.forEach((line) => {
|
||||
const marks = marksOf(line.points);
|
||||
const look = line.node !== null && line.node === style.selected ? "line:selected" : `line:${line.kind}`;
|
||||
for (let index = 1; index < marks.length; index += 1) {
|
||||
stroke(look, [marks[index - 1], marks[index]], "path", false);
|
||||
}
|
||||
const tail = marks[marks.length - 1];
|
||||
if ((line.alive || built.axis !== "time") && tail && visible(cam, [tail])) {
|
||||
others.push({type: "disc", mark: tail, look, radius: DISCS[line.kind] * clamp(tail.size, 0.5, 2.5), dim: !line.alive, depth: tail.depth - 0.5});
|
||||
}
|
||||
});
|
||||
for (const arc of built.arcs) {
|
||||
if (stroke(`arc:${arc.tone}`, marksOf(arcPoints(arc)), arc.kind === "broadcast" ? "path" : "curve", arc.kind === "broadcast")) {
|
||||
stats.arcs += 1;
|
||||
}
|
||||
}
|
||||
for (const entry of built.labels) {
|
||||
label(entry.at, entry.text, "text", built.lines[entry.line].node !== null && built.lines[entry.line].node === style.selected);
|
||||
}
|
||||
const items = depthSort([...[...buckets.values()].flat(), ...others]);
|
||||
stats.items = items.length;
|
||||
ctx.clearRect(0, 0, cam.width, cam.height);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.textAlign = "center";
|
||||
let open = "";
|
||||
const flush = () => {
|
||||
if (open) {
|
||||
ctx.stroke();
|
||||
stats.batches += 1;
|
||||
open = "";
|
||||
}
|
||||
};
|
||||
for (const item of items) {
|
||||
const marks = item.marks;
|
||||
if (item.type === "path" || item.type === "curve") {
|
||||
if (item.look !== open) {
|
||||
flush();
|
||||
pen(ctx, colors, item.look);
|
||||
ctx.beginPath();
|
||||
open = item.look;
|
||||
}
|
||||
ctx.moveTo(marks[0].x, marks[0].y);
|
||||
if (item.type === "curve") {
|
||||
ctx.quadraticCurveTo(marks[1].x, marks[1].y, marks[2].x, marks[2].y);
|
||||
} else {
|
||||
for (let index = 1; index < marks.length; index += 1) {
|
||||
ctx.lineTo(marks[index].x, marks[index].y);
|
||||
}
|
||||
if (item.closed) {
|
||||
ctx.closePath();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
if (item.type === "plane") {
|
||||
ctx.beginPath();
|
||||
marks.forEach((mark, index) => (index ? ctx.lineTo(mark.x, mark.y) : ctx.moveTo(mark.x, mark.y)));
|
||||
ctx.closePath();
|
||||
ctx.globalAlpha = 0.1;
|
||||
ctx.fillStyle = colors.accent;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.strokeStyle = colors.accent;
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.stroke();
|
||||
} else if (item.type === "disc") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(item.mark.x, item.mark.y, item.radius, 0, 2 * Math.PI);
|
||||
ctx.globalAlpha = item.dim ? 0.5 : 1;
|
||||
ctx.fillStyle = colors.panel;
|
||||
ctx.fill();
|
||||
pen(ctx, colors, item.look);
|
||||
ctx.globalAlpha = item.dim ? 0.5 : 1;
|
||||
ctx.stroke();
|
||||
} else {
|
||||
stats.labels += 1;
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.font = item.strong ? style.strong : style.font;
|
||||
ctx.lineWidth = 4;
|
||||
ctx.strokeStyle = colors.panel;
|
||||
ctx.strokeText(item.text, item.mark.x, item.mark.y - 10);
|
||||
ctx.fillStyle = colors[item.tone];
|
||||
ctx.fillText(item.text, item.mark.x, item.mark.y - 10);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
ctx.globalAlpha = 1;
|
||||
return stats;
|
||||
}
|
||||
|
||||
return {arcPoints, paint};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = SpacePaint;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
const SpacePick = (() => {
|
||||
"use strict";
|
||||
const {clamp, project} = typeof SpaceView === "object" ? SpaceView : require("./space-view.js");
|
||||
const {arcPoints} = typeof SpacePaint === "object" ? SpacePaint : require("./space-paint.js");
|
||||
const CURVE = 8;
|
||||
|
||||
function gap(x, y, a, b) {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const length = dx * dx + dy * dy;
|
||||
const share = length ? clamp(((x - a.x) * dx + (y - a.y) * dy) / length, 0, 1) : 0;
|
||||
return Math.hypot(a.x + dx * share - x, a.y + dy * share - y);
|
||||
}
|
||||
|
||||
function nearest(x, y, marks) {
|
||||
if (marks.some((mark) => mark === null)) {
|
||||
return {distance: Infinity, depth: Infinity};
|
||||
}
|
||||
if (marks.length === 1) {
|
||||
return {distance: Math.hypot(marks[0].x - x, marks[0].y - y), depth: marks[0].depth};
|
||||
}
|
||||
let best = {distance: Infinity, depth: Infinity};
|
||||
for (let index = 1; index < marks.length; index += 1) {
|
||||
const distance = gap(x, y, marks[index - 1], marks[index]);
|
||||
if (distance < best.distance) {
|
||||
best = {distance, depth: (marks[index - 1].depth + marks[index].depth) / 2};
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function curve(marks) {
|
||||
if (marks.some((mark) => mark === null)) {
|
||||
return marks;
|
||||
}
|
||||
const [a, b, c] = marks;
|
||||
return Array.from({length: CURVE + 1}, (_, index) => {
|
||||
const t = index / CURVE;
|
||||
const u = 1 - t;
|
||||
return {x: u * u * a.x + 2 * u * t * b.x + t * t * c.x, y: u * u * a.y + 2 * u * t * b.y + t * t * c.y, depth: u * u * a.depth + 2 * u * t * b.depth + t * t * c.depth};
|
||||
});
|
||||
}
|
||||
|
||||
function pick(built, cam, x, y, reach) {
|
||||
let best = null;
|
||||
const consider = (found, hit) => {
|
||||
if (found.distance > reach) {
|
||||
return;
|
||||
}
|
||||
if (!best || found.distance < best.distance - 0.5 || (found.distance <= best.distance + 0.5 && found.depth < best.depth)) {
|
||||
best = {...hit, distance: found.distance, depth: found.depth};
|
||||
}
|
||||
};
|
||||
built.lines.forEach((line, index) => consider(nearest(x, y, line.points.map((entry) => project(cam, entry))), {kind: "line", index, line}));
|
||||
built.arcs.forEach((arc, index) => {
|
||||
const marks = arcPoints(arc).map((entry) => project(cam, entry));
|
||||
consider(nearest(x, y, arc.kind === "broadcast" ? [...marks, marks[0]] : curve(marks)), {kind: "arc", index, arc});
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
return {pick};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = SpacePick;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
const SpaceScene = (() => {
|
||||
"use strict";
|
||||
const {LEVELS, clamp, turn, depthOf} = typeof SpaceView === "object" ? SpaceView : require("./space-view.js");
|
||||
const TONES = {post: "direct", call: "direct", receipt: "direct", newsletter: "broadcast", broadcast: "broadcast", mayday: "alarm", "pan-pan": "alarm", security: "security", grant: "grant", spawn: "spawn"};
|
||||
const RANKS = {alarm: 0, spawn: 1, security: 2, grant: 3, direct: 4, broadcast: 5};
|
||||
const MOON = 38;
|
||||
const MOON_SPREAD = 0.5;
|
||||
const TWIG = 30;
|
||||
const TWIG_SPREAD = 0.45;
|
||||
const ARC_CAP = 1500;
|
||||
|
||||
function bend(from, to) {
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
return {x: ((from.x + to.x) / 2) * 0.82 - (dy / length) * 22, y: ((from.y + to.y) / 2) * 0.82 + (dx / length) * 22};
|
||||
}
|
||||
|
||||
function satellite(base, index, count, reach, spread) {
|
||||
const angle = base.angle + (index - (count - 1) / 2) * spread;
|
||||
return {x: base.x + reach * Math.cos(angle), y: base.y + reach * Math.sin(angle), angle};
|
||||
}
|
||||
|
||||
function moon(base, index, count) {
|
||||
return satellite(base, index, count, MOON, MOON_SPREAD);
|
||||
}
|
||||
|
||||
function nameAt(timeline, ts) {
|
||||
const valid = timeline.filter((entry) => entry.since <= ts);
|
||||
return (valid.length ? valid[valid.length - 1] : timeline[0]).name;
|
||||
}
|
||||
|
||||
function spotAt(track, id, ts) {
|
||||
let after = 0;
|
||||
while (after < track.length && track[after].ts < ts) {
|
||||
after += 1;
|
||||
}
|
||||
const late = track[Math.min(after, track.length - 1)].orbit[id];
|
||||
const early = track[Math.max(after - 1, 0)].orbit[id];
|
||||
if (!late || !early) {
|
||||
return late || early || null;
|
||||
}
|
||||
const span = after > 0 && after < track.length ? track[after].ts - track[after - 1].ts : 0;
|
||||
const share = span > 0 ? (ts - track[after - 1].ts) / span : 1;
|
||||
return {angle: turn(early.angle, late.angle, share), radius: early.radius + (late.radius - early.radius) * share};
|
||||
}
|
||||
|
||||
function born(row) {
|
||||
return row.since === null ? row.started : row.since;
|
||||
}
|
||||
|
||||
function tree(rows) {
|
||||
const order = [...rows].sort((a, b) => born(a) - born(b) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
||||
const parents = {};
|
||||
order.forEach((row, index) => {
|
||||
const elders = order.slice(0, index).filter((other) => other.depth === row.depth - 1);
|
||||
const living = elders.filter((other) => other.until === null || other.until >= born(row));
|
||||
const chosen = (living.length ? living : elders).at(-1);
|
||||
parents[row.id] = row.depth > 1 && chosen ? chosen.id : "";
|
||||
});
|
||||
return {order, parents};
|
||||
}
|
||||
|
||||
function capArcs(arcs, cap) {
|
||||
if (arcs.length <= cap) {
|
||||
return {kept: arcs, dropped: 0};
|
||||
}
|
||||
const ranks = arcs.map((arc) => (Object.hasOwn(RANKS, arc.tone) ? RANKS[arc.tone] : RANKS.direct));
|
||||
const order = arcs.map((arc, index) => index).sort((a, b) => ranks[a] - ranks[b] || arcs[b].ts - arcs[a].ts || a - b);
|
||||
const chosen = order.slice(0, cap).sort((a, b) => a - b);
|
||||
return {kept: chosen.map((index) => arcs[index]), dropped: arcs.length - cap};
|
||||
}
|
||||
|
||||
function scene(input) {
|
||||
const {axis, range, span, cursor, nodes, track, now, lives, subagents, radio, grants, chair, cap} = input;
|
||||
const floor = span[0];
|
||||
const top = Math.max(Math.min(cursor, span[1]), floor);
|
||||
const last = track.length ? track[track.length - 1].ts : -Infinity;
|
||||
const byId = new Map(nodes.map((node) => [node.id, node]));
|
||||
const moons = new Map();
|
||||
for (const node of nodes.filter((entry) => entry.kind === "subagent")) {
|
||||
moons.set(node.parent, [...(moons.get(node.parent) || []), node]);
|
||||
}
|
||||
const lines = [];
|
||||
const labels = [];
|
||||
const arcs = [];
|
||||
const point = (spot, ts, level) => ({x: spot.x, y: spot.y, z: depthOf(axis, {ts, level}, range), ts});
|
||||
const headed = (spot) => ({x: spot.x, y: spot.y, angle: spot.x === 0 && spot.y === 0 ? -Math.PI / 2 : Math.atan2(spot.y, spot.x)});
|
||||
const stamps = (start, end) => (axis === "time" && start < end ? [start, ...track.map((sample) => sample.ts).filter((ts) => ts > start && ts < end), end] : [end]);
|
||||
|
||||
function anchor(id, ts) {
|
||||
const node = byId.get(id);
|
||||
if (!node || node.kind === "operator") {
|
||||
return {x: 0, y: 0};
|
||||
}
|
||||
if (node.kind === "subagent") {
|
||||
const family = moons.get(node.parent);
|
||||
return moon(headed(anchor(byId.has(node.parent) ? node.parent : chair, ts)), family.indexOf(node), family.length);
|
||||
}
|
||||
const spot = axis === "time" && ts < last ? spotAt(track, id, ts) : null;
|
||||
return spot ? {x: spot.radius * Math.cos(spot.angle), y: spot.radius * Math.sin(spot.angle)} : now[id] || {x: 0, y: 0};
|
||||
}
|
||||
|
||||
function within(life) {
|
||||
const start = Math.max(floor, life && life[0] !== null ? life[0] : floor);
|
||||
const end = Math.min(top, life && life[1] !== null ? life[1] : top);
|
||||
return start <= end ? [start, end] : [floor, top];
|
||||
}
|
||||
|
||||
function addLine(entry, place, start, end) {
|
||||
const points = stamps(start, end).map((ts) => point(place(ts), ts, entry.level));
|
||||
const index = lines.length;
|
||||
lines.push({...entry, points});
|
||||
if (entry.kind === "subagent" || (entry.kind === "twig" && !entry.alive)) {
|
||||
return;
|
||||
}
|
||||
const tail = points[points.length - 1];
|
||||
labels.push({line: index, at: tail, text: nameAt(entry.timeline, tail.ts)});
|
||||
if (axis !== "time") {
|
||||
return;
|
||||
}
|
||||
const head = points[0];
|
||||
const changes = entry.timeline.filter((change) => change.since > head.ts && change.since < tail.ts);
|
||||
for (const change of changes) {
|
||||
labels.push({line: index, at: point(place(change.since), change.since, entry.level), text: change.name});
|
||||
}
|
||||
if (changes.length) {
|
||||
labels.push({line: index, at: head, text: nameAt(entry.timeline, head.ts)});
|
||||
}
|
||||
}
|
||||
|
||||
function connect(source, targets, tone, ts, row, count, kind) {
|
||||
const moment = axis === "time" ? ts : top;
|
||||
const from = point(anchor(source, moment), moment, 0);
|
||||
if (!targets.length) {
|
||||
arcs.push({kind: "broadcast", tone, ts, source, target: "*", sub: "", row, count, from, via: from, to: from});
|
||||
return;
|
||||
}
|
||||
for (const target of targets) {
|
||||
if (target !== source && byId.has(target)) {
|
||||
const to = point(anchor(target, moment), moment, 0);
|
||||
arcs.push({kind, tone, ts, source, target, sub: "", row, count, from, via: {...bend(from, to), z: from.z}, to});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
const owner = node.kind === "subagent" ? node.parent : node.id;
|
||||
const [start, end] = within(lives[owner]);
|
||||
const timeline = node.timeline && node.timeline.length ? node.timeline : [{name: node.label, since: floor}];
|
||||
addLine({node: node.id, kind: node.kind, session: owner, sub: "", level: node.kind === "subagent" ? 1 : 0, timeline, title: node.label, alive: end >= top}, (ts) => anchor(node.id, ts), start, end);
|
||||
}
|
||||
for (const session of Object.keys(subagents).sort()) {
|
||||
if (!byId.has(session)) {
|
||||
continue;
|
||||
}
|
||||
const {order, parents} = tree(subagents[session]);
|
||||
const families = new Map();
|
||||
for (const row of order) {
|
||||
families.set(parents[row.id], [...(families.get(parents[row.id]) || []), row.id]);
|
||||
}
|
||||
const chains = new Map();
|
||||
for (const row of order) {
|
||||
const family = families.get(parents[row.id]);
|
||||
chains.set(row.id, [...(chains.get(parents[row.id]) || []), {index: family.indexOf(row.id), count: family.length}]);
|
||||
}
|
||||
const reach = (chain, ts) => chain.reduce((spot, link) => satellite(spot, link.index, link.count, TWIG, TWIG_SPREAD), headed(anchor(session, ts)));
|
||||
for (const row of order) {
|
||||
const since = born(row);
|
||||
if (since > top || (row.until !== null && row.until < floor)) {
|
||||
continue;
|
||||
}
|
||||
const chain = chains.get(row.id);
|
||||
const end = Math.min(top, row.until === null ? top : row.until);
|
||||
const alive = row.until === null || row.until >= top;
|
||||
const name = row.type || "Subagent";
|
||||
addLine({node: null, kind: "twig", session, sub: row.id, level: row.depth, timeline: [{name, since}], title: `${name}: ${row.description}`, alive}, (ts) => reach(chain, ts), Math.max(floor, since), end);
|
||||
if (axis !== "time" || since >= floor) {
|
||||
const moment = axis === "time" ? since : end;
|
||||
const from = point(reach(chain.slice(0, -1), moment), moment, row.depth - 1);
|
||||
const to = point(reach(chain, moment), moment, row.depth);
|
||||
arcs.push({kind: "spawn", tone: "spawn", ts: since, source: session, target: session, sub: row.id, row: -1, count: 1, from, via: {x: (from.x + to.x) / 2, y: (from.y + to.y) / 2, z: (from.z + to.z) / 2}, to});
|
||||
}
|
||||
}
|
||||
}
|
||||
const heard = radio.map((row, index) => ({row, index})).filter(({row}) => row.ts >= floor && row.ts <= top && byId.has(row.source));
|
||||
const granted = grants.map((grant, index) => ({grant, index})).filter(({grant}) => grant.ts >= floor && grant.ts <= top && byId.has(chair));
|
||||
if (axis === "time") {
|
||||
for (const {row, index} of heard) {
|
||||
connect(row.source, row.targets, TONES[row.class] || "direct", row.ts, index, 1, "message");
|
||||
}
|
||||
for (const {grant, index} of granted) {
|
||||
connect(chair, [grant.target], "grant", grant.ts, index, 1, "grant");
|
||||
}
|
||||
} else {
|
||||
const groups = new Map();
|
||||
const gather = (source, targets, tone, ts, kind) => {
|
||||
for (const target of targets.length ? targets : ["*"]) {
|
||||
const key = `${kind}\u0000${source}\u0000${target}\u0000${tone}`;
|
||||
const group = groups.get(key) || {source, target, tone, ts, kind, count: 0};
|
||||
group.count += 1;
|
||||
group.ts = Math.max(group.ts, ts);
|
||||
groups.set(key, group);
|
||||
}
|
||||
};
|
||||
for (const {row} of heard) {
|
||||
gather(row.source, row.targets, TONES[row.class] || "direct", row.ts, "message");
|
||||
}
|
||||
for (const {grant} of granted) {
|
||||
gather(chair, [grant.target], "grant", grant.ts, "grant");
|
||||
}
|
||||
for (const group of groups.values()) {
|
||||
connect(group.source, group.target === "*" ? [] : [group.target], group.tone, group.ts, -1, group.count, group.kind);
|
||||
}
|
||||
}
|
||||
const {kept, dropped} = capArcs(arcs, cap);
|
||||
const levels = [...new Set(lines.map((line) => clamp(line.level, 0, LEVELS)))].sort((a, b) => a - b);
|
||||
const guides = axis === "time" ? range.map((ts) => ({z: depthOf(axis, {ts, level: 0}, range), ts, level: 0})) : levels.map((level) => ({z: depthOf(axis, {ts: top, level}, range), ts: top, level}));
|
||||
const plane = axis === "time" ? {z: depthOf(axis, {ts: top, level: 0}, range), ts: top} : null;
|
||||
return {axis, range, span: [floor, top], lines, labels, arcs: kept, guides, plane, total: arcs.length, dropped};
|
||||
}
|
||||
|
||||
return {TONES, ARC_CAP, bend, moon, nameAt, tree, capArcs, scene};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = SpaceScene;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
const SpaceView = (() => {
|
||||
"use strict";
|
||||
const RAD = Math.PI / 180;
|
||||
const DEFAULTS = {
|
||||
graph: {axis: "none", yaw: 30, pitch: 20, zoom: 1, pan_x: 0, pan_y: 0},
|
||||
sky: {azimuth: 180, altitude: 0, fov: 180},
|
||||
};
|
||||
const LIMITS = {
|
||||
graph: {yaw: [-180, 180], pitch: [-90, 90], zoom: [0.25, 4], pan_x: [-2000, 2000], pan_y: [-2000, 2000]},
|
||||
sky: {azimuth: [0, 360], altitude: [-30, 85], fov: [20, 240]},
|
||||
};
|
||||
const AXES = ["none", "time", "spawn"];
|
||||
const DEPTH = 600;
|
||||
const LEVELS = 4;
|
||||
const FRAME = 860;
|
||||
const DISTANCE = 1600;
|
||||
const NEAR = 120;
|
||||
const HORIZON = 0.86;
|
||||
const REACH = 0.94;
|
||||
const clamp = (value, low, high) => Math.min(Math.max(value, low), high);
|
||||
const signed = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
||||
const around = (deg) => ((deg % 360) + 360) % 360;
|
||||
|
||||
function ease(progress) {
|
||||
return progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2;
|
||||
}
|
||||
|
||||
function turn(from, to, share) {
|
||||
const full = 2 * Math.PI;
|
||||
const delta = ((((to - from + Math.PI) % full) + full) % full) - Math.PI;
|
||||
return from + delta * share;
|
||||
}
|
||||
|
||||
function rotation(yaw, pitch) {
|
||||
const [cy, sy, cp, sp] = [Math.cos(yaw * RAD), Math.sin(yaw * RAD), Math.cos(pitch * RAD), Math.sin(pitch * RAD)];
|
||||
return [cy, 0, sy, sp * sy, cp, -sp * cy, -cp * sy, sp, cp * cy];
|
||||
}
|
||||
|
||||
function apply(matrix, point) {
|
||||
return {
|
||||
x: matrix[0] * point.x + matrix[1] * point.y + matrix[2] * point.z,
|
||||
y: matrix[3] * point.x + matrix[4] * point.y + matrix[5] * point.z,
|
||||
z: matrix[6] * point.x + matrix[7] * point.y + matrix[8] * point.z,
|
||||
};
|
||||
}
|
||||
|
||||
function camera(view, width, height) {
|
||||
return {matrix: rotation(view.yaw, view.pitch), scale: (Math.min(width, height) / FRAME) * view.zoom, panX: view.pan_x, panY: view.pan_y, cx: width / 2, cy: height / 2, width, height};
|
||||
}
|
||||
|
||||
function project(cam, point) {
|
||||
const turned = apply(cam.matrix, point);
|
||||
const reach = DISTANCE + turned.z;
|
||||
if (reach < NEAR) {
|
||||
return null;
|
||||
}
|
||||
const size = (cam.scale * DISTANCE) / reach;
|
||||
return {x: cam.cx + (turned.x + cam.panX) * size, y: cam.cy + (turned.y + cam.panY) * size, depth: turned.z, size};
|
||||
}
|
||||
|
||||
function depthSort(items) {
|
||||
return [...items].sort((a, b) => b.depth - a.depth);
|
||||
}
|
||||
|
||||
function depthOf(axis, sample, range) {
|
||||
if (axis === "time") {
|
||||
const [low, high] = range;
|
||||
const share = high > low ? clamp((sample.ts - low) / (high - low), 0, 1) : 1;
|
||||
return DEPTH * (0.5 - share);
|
||||
}
|
||||
if (axis === "spawn") {
|
||||
return -DEPTH / 2 + (DEPTH / LEVELS) * clamp(sample.level, 0, LEVELS);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function clampView(kind, view) {
|
||||
const limits = LIMITS[kind];
|
||||
const wrapped = kind === "graph" ? {...view, yaw: signed(view.yaw)} : {...view, azimuth: around(view.azimuth)};
|
||||
return Object.fromEntries(Object.entries(wrapped).map(([key, value]) => [key, Object.hasOwn(limits, key) ? clamp(value, limits[key][0], limits[key][1]) : value]));
|
||||
}
|
||||
|
||||
function tidy(view) {
|
||||
return Object.fromEntries(Object.entries(view).map(([key, value]) => [key, typeof value === "number" ? Math.round(value * 100) / 100 : value]));
|
||||
}
|
||||
|
||||
function tween(kind, from, to, share) {
|
||||
const angle = kind === "graph" ? "yaw" : "azimuth";
|
||||
return Object.fromEntries(Object.entries(to).map(([key, value]) => {
|
||||
if (typeof value !== "number") {
|
||||
return [key, value];
|
||||
}
|
||||
if (key === angle) {
|
||||
return [key, turn(from[key] * RAD, value * RAD, share) / RAD];
|
||||
}
|
||||
return [key, from[key] + (value - from[key]) * share];
|
||||
}));
|
||||
}
|
||||
|
||||
function lens(view, width, height) {
|
||||
return {matrix: rotation(-view.azimuth, -view.altitude), kx: width / view.fov, ky: ((REACH * HORIZON * height) / 90) * (180 / view.fov), cx: width / 2, cy: HORIZON * height, width, height, view};
|
||||
}
|
||||
|
||||
function place(sight, alt, az) {
|
||||
const flat = Math.cos(alt * RAD);
|
||||
const turned = apply(sight.matrix, {x: Math.sin(az * RAD) * flat, y: -Math.sin(alt * RAD), z: Math.cos(az * RAD) * flat});
|
||||
const lon = Math.atan2(turned.x, turned.z) / RAD;
|
||||
const lat = Math.asin(clamp(-turned.y, -1, 1)) / RAD;
|
||||
return {x: sight.cx + sight.kx * lon, y: sight.cy - sight.ky * lat, lon, lat};
|
||||
}
|
||||
|
||||
function horizon(sight, steps) {
|
||||
return Array.from({length: steps + 1}, (_, index) => place(sight, 0, sight.view.azimuth - 180 + (360 * index) / steps)).sort((a, b) => a.x - b.x);
|
||||
}
|
||||
|
||||
function compass(azimuth) {
|
||||
return Math.round(around(azimuth) / 45) % 8;
|
||||
}
|
||||
|
||||
return {
|
||||
DEFAULTS, LIMITS, AXES, DEPTH, LEVELS, REACH,
|
||||
clamp, ease, turn, rotation, apply, camera, project, depthSort, depthOf, clampView, tidy, tween, lens, place, horizon, compass,
|
||||
};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = SpaceView;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
const Space = (() => {
|
||||
"use strict";
|
||||
const {DEFAULTS, LIMITS, AXES, DEPTH, REACH, ease, turn, rotation, apply, camera, project, depthSort, depthOf, clampView, tidy, tween, lens, place, horizon, compass} = typeof SpaceView === "object" ? SpaceView : require("./space-view.js");
|
||||
const {TONES, ARC_CAP, bend, moon, nameAt, tree, capArcs, scene} = typeof SpaceScene === "object" ? SpaceScene : require("./space-scene.js");
|
||||
const {paint} = typeof SpacePaint === "object" ? SpacePaint : require("./space-paint.js");
|
||||
const {pick} = typeof SpacePick === "object" ? SpacePick : require("./space-pick.js");
|
||||
|
||||
return {
|
||||
DEFAULTS, LIMITS, AXES, TONES, ARC_CAP, DEPTH, REACH,
|
||||
ease, turn, bend, moon, rotation, apply, camera, project, depthSort, depthOf, clampView, tidy, tween, nameAt, tree, capArcs, scene, paint, pick,
|
||||
lens, place, horizon, compass,
|
||||
};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = Space;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
const Voice = (() => {
|
||||
"use strict";
|
||||
const RANKS = {mayday: 0, "pan-pan": 1, security: 2};
|
||||
const ALARMS = new Set(["mayday", "pan-pan"]);
|
||||
const ROUTINE = 3;
|
||||
|
||||
function toneOf(row, human) {
|
||||
if (ALARMS.has(row.class)) {
|
||||
return "mayday";
|
||||
}
|
||||
return row.class === "security" || row.to.includes(human) ? "direct" : "radio";
|
||||
}
|
||||
|
||||
function loudness(tone, settings) {
|
||||
if (tone !== "mayday") {
|
||||
return settings.sounds[tone].volume;
|
||||
}
|
||||
return Math.max(...Object.values(settings.sounds).map((sound) => sound.volume), settings.music.volume);
|
||||
}
|
||||
|
||||
function speakable(row, speech) {
|
||||
if (!speech.on || !row.speech || !row.voice) {
|
||||
return false;
|
||||
}
|
||||
return row.class === "mayday" || (speech.classes.includes(row.class) && !speech.muted.includes(row.source));
|
||||
}
|
||||
|
||||
function rank(item) {
|
||||
return Object.hasOwn(RANKS, item.cls) ? RANKS[item.cls] : ROUTINE;
|
||||
}
|
||||
|
||||
function enqueue(pending, item, limit) {
|
||||
const at = pending.findIndex((other) => rank(other) > rank(item));
|
||||
const next = at < 0 ? [...pending, item] : [...pending.slice(0, at), item, ...pending.slice(at)];
|
||||
return next.slice(0, limit);
|
||||
}
|
||||
|
||||
function frames(duration, rate, seconds) {
|
||||
return Math.max(Math.min(Math.ceil(duration * rate), rate * seconds), 0);
|
||||
}
|
||||
|
||||
function wav(samples, rate) {
|
||||
const size = samples.length * 2;
|
||||
const buffer = new ArrayBuffer(44 + size);
|
||||
const view = new DataView(buffer);
|
||||
const text = (offset, value) => [...value].forEach((char, index) => view.setUint8(offset + index, char.charCodeAt(0)));
|
||||
text(0, "RIFF");
|
||||
view.setUint32(4, 36 + size, true);
|
||||
text(8, "WAVE");
|
||||
text(12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, rate, true);
|
||||
view.setUint32(28, rate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
text(36, "data");
|
||||
view.setUint32(40, size, true);
|
||||
samples.forEach((sample, index) => {
|
||||
const level = Math.max(-1, Math.min(1, sample));
|
||||
view.setInt16(44 + index * 2, level < 0 ? level * 0x8000 : level * 0x7fff, true);
|
||||
});
|
||||
return buffer;
|
||||
}
|
||||
|
||||
return {toneOf, loudness, speakable, enqueue, frames, wav};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = Voice;
|
||||
}
|
||||
Reference in new issue
Block a user