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
@@ -26,7 +26,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install --group lint
|
pip install --group lint
|
||||||
pip install --requirement hub/requirements.txt
|
pip install --requirement orchestrator/requirements.txt
|
||||||
sudo apt-get update && sudo apt-get install -y shellcheck
|
sudo apt-get update && sudo apt-get install -y shellcheck
|
||||||
npm install -g markdownlint-cli2
|
npm install -g markdownlint-cli2
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: install project test update budget hub-install hub-check hub-pin hub-runner hub-hook watch-check
|
.PHONY: install project test update budget orchestrator-install orchestrator-check orchestrator-pin orchestrator-runner orchestrator-hook watch-check
|
||||||
|
|
||||||
# Install the skills globally into $HOME (.agents/skills + .claude/skills).
|
# Install the skills globally into $HOME (.agents/skills + .claude/skills).
|
||||||
install:
|
install:
|
||||||
@@ -24,25 +24,25 @@ update:
|
|||||||
budget:
|
budget:
|
||||||
@python3 scripts/budget.py --repo . --top 5 $(if $(INDEX),--index)
|
@python3 scripts/budget.py --repo . --top 5 $(if $(INDEX),--index)
|
||||||
|
|
||||||
# Install, build and start only the kvb-hub, without touching the installed skills.
|
# Migrate, install, build and start only the Agent Orchestrator, without touching the installed skills.
|
||||||
hub-install:
|
orchestrator-install:
|
||||||
@bash scripts/hub.sh install
|
@bash scripts/orchestrator.sh install
|
||||||
|
|
||||||
# Check the installed kvb-hub: image, container, logs, HTTP, pinned keys, hook, runner, disk.
|
# Check the installed Agent Orchestrator: image, container, logs, HTTP, pinned keys, hook, runner, disk.
|
||||||
hub-check:
|
orchestrator-check:
|
||||||
@bash scripts/hub.sh check
|
@bash scripts/orchestrator.sh check
|
||||||
|
|
||||||
# Param: CREDENTIAL the blob the hub's "Passkey registrieren" shows: make hub-pin CREDENTIAL=<blob>
|
# Param: CREDENTIAL the blob "Passkey registrieren" shows: make orchestrator-pin CREDENTIAL=<blob>
|
||||||
hub-pin:
|
orchestrator-pin:
|
||||||
@bash scripts/hub.sh pin "$(CREDENTIAL)"
|
@bash scripts/orchestrator.sh pin "$(CREDENTIAL)"
|
||||||
|
|
||||||
# Install, enable and restart the kvb-hub runner user service.
|
# Install, enable and restart the Agent Orchestrator runner user service.
|
||||||
hub-runner:
|
orchestrator-runner:
|
||||||
@bash scripts/hub.sh runner
|
@bash scripts/orchestrator.sh runner
|
||||||
|
|
||||||
# Register the kvb-hub PermissionRequest hook in ~/.claude/settings.json.
|
# Register the Agent Orchestrator PermissionRequest hook in ~/.claude/settings.json.
|
||||||
hub-hook:
|
orchestrator-hook:
|
||||||
@bash scripts/hub.sh hook
|
@bash scripts/orchestrator.sh hook
|
||||||
|
|
||||||
# Show whether the agents-council watch hook is stuck.
|
# Show whether the agents-council watch hook is stuck.
|
||||||
watch-check:
|
watch-check:
|
||||||
|
|||||||
@@ -380,8 +380,8 @@ flowchart LR
|
|||||||
|
|
||||||
- Node.js (provides `npx skills`).
|
- Node.js (provides `npx skills`).
|
||||||
- `jq` (only required for `make update`).
|
- `jq` (only required for `make update`).
|
||||||
- Python `jinja2` for `make test` (pinned in `hub/requirements.txt`).
|
- Python `jinja2` for `make test` (pinned in `orchestrator/requirements.txt`).
|
||||||
- Docker with the compose plugin (optional, runs the kvb-hub review hub).
|
- Docker with the compose plugin (optional, runs the Agent Orchestrator).
|
||||||
|
|
||||||
## Installation 🚀
|
## Installation 🚀
|
||||||
|
|
||||||
@@ -399,11 +399,11 @@ make project TARGET=/path/to/repo
|
|||||||
|
|
||||||
Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `kvb-dream` points each agent at `/kvb-dream` at most once every twelve hours (stamped per session in `~/.claude/.dream-stamps/`), and `kvb-coach` runs its short form at the first prompt of every full hour, once across all agents (stamped in `~/.claude/.coach-hours/`); the global install also enables the `kvb-dream-notify.timer` systemd user timer, which recommends `/kvb-dream` in a GNOME notification daily at 17:00 local time and catches up once after a missed run. A PreToolUse gate from the `kvb-org-organize` skill blocks edits to files in a git worktree until the session holds a claim (`skills/kvb-org-organize/scripts/council.py`), and its resource watch (`skills/kvb-org-organize/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.claude/agents-council/config.json` exists, and because its hooks run that config's commands outside the sandbox, the install adds `Edit(~/.claude/agents-council/**)` to `permissions.deny`, which also covers Write in every permission mode. The `kvb-org-radio` skill registers five hooks: its prompt hook and a PostToolBatch hook deliver the immediate radio (`call`, `broadcast`, `security`, `pan-pan`, `mayday`) mid-turn, a Stop hook delivers the post (`post`, `newsletter` and call receipts) when the turn ends, an `asyncRewake` Stop hook wakes an idle session for the radio, and a PreToolUse hook refuses every writing action while a mayday holds, except those of its sender and of the agents that join it with `radio.py assist`. The PreToolUse hook of the `kvb-org-queue` skill sends heavy commands through `queue.py hold` once the config sets `queue_enforce`, and holds a held command to the settings' Bash deny and ask rules, which see only `queue.py hold`. The global install also installs the MEDUSA security scanner with pipx for `kvb-check-security`, unless `medusa` is already on the PATH. Two hooks of the `kvb-org-perform` skill (`skills/kvb-org-perform/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Every hook runs `/usr/bin/python3 -I`, so neither the `python3` of a sandbox-writable virtualenv on the `PATH` nor `PYTHONPATH` reaches the hooks, which run outside the sandbox; older bare `python3` hook entries are rewritten. The reminder hooks run `/usr/bin/bash` and pin `PATH=/usr/bin:/bin`, and the council scripts keep only the `PATH` directories this uid cannot write (`skills/kvb-org-organize/scripts/hookpath.py`), so no binary an agent plants on the inherited `PATH` runs outside the sandbox. Existing settings are preserved.
|
Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `kvb-dream` points each agent at `/kvb-dream` at most once every twelve hours (stamped per session in `~/.claude/.dream-stamps/`), and `kvb-coach` runs its short form at the first prompt of every full hour, once across all agents (stamped in `~/.claude/.coach-hours/`); the global install also enables the `kvb-dream-notify.timer` systemd user timer, which recommends `/kvb-dream` in a GNOME notification daily at 17:00 local time and catches up once after a missed run. A PreToolUse gate from the `kvb-org-organize` skill blocks edits to files in a git worktree until the session holds a claim (`skills/kvb-org-organize/scripts/council.py`), and its resource watch (`skills/kvb-org-organize/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.claude/agents-council/config.json` exists, and because its hooks run that config's commands outside the sandbox, the install adds `Edit(~/.claude/agents-council/**)` to `permissions.deny`, which also covers Write in every permission mode. The `kvb-org-radio` skill registers five hooks: its prompt hook and a PostToolBatch hook deliver the immediate radio (`call`, `broadcast`, `security`, `pan-pan`, `mayday`) mid-turn, a Stop hook delivers the post (`post`, `newsletter` and call receipts) when the turn ends, an `asyncRewake` Stop hook wakes an idle session for the radio, and a PreToolUse hook refuses every writing action while a mayday holds, except those of its sender and of the agents that join it with `radio.py assist`. The PreToolUse hook of the `kvb-org-queue` skill sends heavy commands through `queue.py hold` once the config sets `queue_enforce`, and holds a held command to the settings' Bash deny and ask rules, which see only `queue.py hold`. The global install also installs the MEDUSA security scanner with pipx for `kvb-check-security`, unless `medusa` is already on the PATH. Two hooks of the `kvb-org-perform` skill (`skills/kvb-org-perform/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Every hook runs `/usr/bin/python3 -I`, so neither the `python3` of a sandbox-writable virtualenv on the `PATH` nor `PYTHONPATH` reaches the hooks, which run outside the sandbox; older bare `python3` hook entries are rewritten. The reminder hooks run `/usr/bin/bash` and pin `PATH=/usr/bin:/bin`, and the council scripts keep only the `PATH` directories this uid cannot write (`skills/kvb-org-organize/scripts/hookpath.py`), so no binary an agent plants on the inherited `PATH` runs outside the sandbox. Existing settings are preserved.
|
||||||
|
|
||||||
The global install also runs the kvb-hub, a local review page in which the operator approves or rejects the agents' staged commits (the `kvb-git-diff` skill): it copies `hub/` to `~/.local/share/kvb-hub`, starts it there with `docker compose up -d --build` (restart `unless-stopped`, bound to `127.0.0.1` only, state in `~/.local/state/kvb-hub`) and registers its MCP server for Claude Code at user scope. Its port is `KVB_HUB_PORT` in `hub/.env`; the hub page is `http://localhost:<port>/`, the MCP endpoint `/mcp`. Without docker the hub is skipped and the commit skills ask in chat. The hub reads new radio traffic aloud in maritime phraseology and takes push-to-talk input; its image builds both engines from pinned, sha256-verified sources and runs each as a separate process: Piper (`piper-tts`, GPL-3.0-or-later) with the `en_US-libritts-high` voice (904 speakers, trained on LibriTTS, CC BY 4.0, from `rhasspy/piper-voices`) and whisper.cpp (MIT) with the multilingual `ggml-base-q8_0` model (OpenAI Whisper weights, MIT). The operator's own voice is `KVB_HUB_OPERATOR_VOICE` in `hub/.env`.
|
The global install also runs the Agent Orchestrator, a local review page in which the operator approves or rejects the agents' staged commits (the `kvb-git-diff` skill): it copies `orchestrator/` to `~/.local/share/kvb-orchestrator`, starts it there with `docker compose up -d --build` (restart `unless-stopped`, bound to `127.0.0.1` only, state in `~/.local/state/kvb-orchestrator`) and registers its MCP server for Claude Code at user scope. Its port is `KVB_ORCHESTRATOR_PORT` in `orchestrator/.env`; the hub page is `http://localhost:<port>/`, the MCP endpoint `/mcp`. Without docker the hub is skipped and the commit skills ask in chat. The hub reads new radio traffic aloud in maritime phraseology and takes push-to-talk input; its image builds both engines from pinned, sha256-verified sources and runs each as a separate process: Piper (`piper-tts`, GPL-3.0-or-later) with the `en_US-libritts-high` voice (904 speakers, trained on LibriTTS, CC BY 4.0, from `rhasspy/piper-voices`) and whisper.cpp (MIT) with the multilingual `ggml-base-q8_0` model (OpenAI Whisper weights, MIT). The operator's own voice is `KVB_ORCHESTRATOR_OPERATOR_VOICE` in `orchestrator/.env`.
|
||||||
|
|
||||||
Without reinstalling the skills, `make hub-install` installs, builds and starts only the hub, `make hub-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, disk; exit 1 on a failure), `make hub-pin CREDENTIAL=<blob>` pins the passkey that "Passkey registrieren" shows, `make hub-runner` enables the runner service, and `make hub-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier kvb-hub entry and keeping the file mode, after a copy to `settings.json.before-kvb-hub-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage).
|
Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=<blob>` pins the passkey that "Passkey registrieren" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage).
|
||||||
|
|
||||||
The hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:<port>`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:<port>/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Passkey registrieren" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-hub/client/approval.py pin --port <port> --approvers ~/.config/kvb-hub/approvers.json --credential '<blob>'`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-hub/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-hub-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-hub-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port <port> --timeout <seconds> --approvers ~/.config/kvb-hub/approvers.json --replay ~/.local/state/kvb-hub-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and rejections stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (messages, decision choices and rules, board actions, rejections) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap.
|
The hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:<port>`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:<port>/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Passkey registrieren" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port <port> --approvers ~/.config/kvb-orchestrator/approvers.json --credential '<blob>'`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-orchestrator/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-orchestrator-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-orchestrator-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port <port> --timeout <seconds> --approvers ~/.config/kvb-orchestrator/approvers.json --replay ~/.local/state/kvb-orchestrator-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and rejections stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (messages, decision choices and rules, board actions, rejections) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap.
|
||||||
|
|
||||||
Restart your agent afterwards so it loads the new skills.
|
Restart your agent afterwards so it loads the new skills.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
KVB_HUB_PORT=8737
|
|
||||||
KVB_HUB_LLM_URL=
|
|
||||||
KVB_HUB_LLM_MODEL=
|
|
||||||
KVB_HUB_LLM_KEY_FILE=
|
|
||||||
KVB_HUB_OPERATOR_VOICE=en_US-libritts-high:0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
NAME = "kvb-hub"
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=kvb-hub runner: runs the operator commands a pinned passkey signed in the kvb-hub
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
EnvironmentFile=%h/.local/share/kvb-hub/.env
|
|
||||||
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-hub/client/runner.py --port ${KVB_HUB_PORT} --interval 2 --approvers %h/.config/kvb-hub/approvers.json --replay %h/.local/state/kvb-hub-approvals/runner.json
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
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 boardContent() {
|
|
||||||
if (ui.order === null || ui.order.some((name) => !state.priority.includes(name)) || ui.order.length !== state.priority.length) {
|
|
||||||
ui.order = [...state.priority];
|
|
||||||
}
|
|
||||||
const [mode, modeTone] = NIGHT[state.night] || [state.night, "neutral"];
|
|
||||||
const broadcast = h("textarea", {"aria-label": "Rundruf", placeholder: "Nachricht an alle Agenten …", on: {input: (event) => {
|
|
||||||
ui.draft = event.target.value;
|
|
||||||
}}});
|
|
||||||
broadcast.value = ui.draft;
|
|
||||||
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;
|
|
||||||
boardSheet();
|
|
||||||
});
|
|
||||||
list.append(item);
|
|
||||||
});
|
|
||||||
const changed = JSON.stringify(ui.order) !== JSON.stringify(state.priority);
|
|
||||||
return h("div", {class: "board"},
|
|
||||||
h("article", {class: `card${state.silence ? " alarm" : ""}`},
|
|
||||||
h("div", {class: "card-head"}, h("h3", {}, "Mayday"), state.silence ? pill("Funkstille", "bad") : pill("ruhig", "ok")),
|
|
||||||
state.silence ? h("p", {class: "text"}, `${state.silence.by}: ${state.silence.text}`) : h("p", {class: "muted"}, "Keine Funkstille aktiv."),
|
|
||||||
h("div", {class: "actions"}, h("button", {type: "button", class: "btn danger", disabled: !state.silence, on: {click: () => confirmAct("clear-mayday")}}, "Mayday aufheben"))),
|
|
||||||
h("article", {class: "card"},
|
|
||||||
h("div", {class: "card-head"}, h("h3", {}, "Rundruf an alle")),
|
|
||||||
broadcast,
|
|
||||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), h("button", {type: "button", class: "btn soft", on: {click: async () => {
|
|
||||||
if (!ui.draft.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (await act("broadcast", "Rundruf", {text: ui.draft})) {
|
|
||||||
ui.draft = "";
|
|
||||||
broadcast.value = "";
|
|
||||||
}
|
|
||||||
}}}, "Senden"))),
|
|
||||||
h("article", {class: "card"},
|
|
||||||
h("div", {class: "card-head"}, h("h3", {}, "Tag und Nacht"), pill(mode, modeTone)),
|
|
||||||
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", () => confirmAct(id))))),
|
|
||||||
h("article", {class: "card"},
|
|
||||||
h("div", {class: "card-head"}, h("h3", {}, "Priorität der Agenten"), counter(ui.order.length)),
|
|
||||||
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];
|
|
||||||
boardSheet();
|
|
||||||
}}}, "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;
|
|
||||||
boardSheet();
|
|
||||||
}
|
|
||||||
}}}, "Reihenfolge speichern"))));
|
|
||||||
}
|
|
||||||
|
|
||||||
function boardSheet() {
|
|
||||||
openSheet("Steuerung", [boardContent()], true);
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
const nav = document.getElementById("tabs");
|
|
||||||
const screenButtons = new Map(SCREENS.map(([id, label], index) => {
|
|
||||||
const button = h("button", {type: "button", role: "tab", id: `tab-${id}`, "aria-controls": "main", title: `${label} (Taste ${index + 1})`, on: {click: () => go(id, null)}}, label);
|
|
||||||
nav.append(button);
|
|
||||||
return [id, button];
|
|
||||||
}));
|
|
||||||
|
|
||||||
function measure() {
|
|
||||||
document.documentElement.style.setProperty("--head", `${document.getElementById("head").offsetHeight}px`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function go(screen, sub) {
|
|
||||||
const hash = screen === "graph" ? `#graph/${sub || ui.graph}` : `#${screen}`;
|
|
||||||
if (window.location.hash === hash) {
|
|
||||||
apply();
|
|
||||||
} else {
|
|
||||||
window.location.hash = hash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function apply() {
|
|
||||||
const [screen, sub] = decodeURIComponent(window.location.hash.slice(1)).split("/");
|
|
||||||
ui.screen = SCREENS.some(([id]) => id === screen) ? screen : "stream";
|
|
||||||
if (GRAPHS.some(([id]) => id === sub)) {
|
|
||||||
ui.graph = sub;
|
|
||||||
}
|
|
||||||
if (document.startViewTransition && drawn.screen && !calm.matches) {
|
|
||||||
document.startViewTransition(() => render(true));
|
|
||||||
} else {
|
|
||||||
render(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function signature() {
|
|
||||||
return JSON.stringify(ui.screen === "graph" ? [ui.graph, space.views.graph.axis] : []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(force) {
|
|
||||||
if (ui.node !== null && !state.graph.nodes.some((node) => node.id === ui.node)) {
|
|
||||||
ui.node = null;
|
|
||||||
}
|
|
||||||
for (const [id, button] of screenButtons) {
|
|
||||||
button.setAttribute("aria-selected", String(ui.screen === id));
|
|
||||||
}
|
|
||||||
const sig = signature();
|
|
||||||
if (force || drawn.screen !== ui.screen || drawn.sig !== sig) {
|
|
||||||
graphDom = null;
|
|
||||||
stream.dom = null;
|
|
||||||
tree.dom = null;
|
|
||||||
const main = document.getElementById("main");
|
|
||||||
main.setAttribute("aria-labelledby", `tab-${ui.screen}`);
|
|
||||||
main.replaceChildren({stream: streamScreen, graph: graphScreen, tree: treeScreen}[ui.screen]());
|
|
||||||
drawn.screen = ui.screen;
|
|
||||||
drawn.sig = sig;
|
|
||||||
}
|
|
||||||
stage();
|
|
||||||
drawSide();
|
|
||||||
drawInbox();
|
|
||||||
drawStatus();
|
|
||||||
tick();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawStatus() {
|
|
||||||
const alarms = alarmRows().length;
|
|
||||||
const fresh = state.now - ACTIVE;
|
|
||||||
const agents = new Set(state.claims.filter((claim) => claim.beat !== null && claim.beat >= fresh).map((claim) => claim.agent)).size;
|
|
||||||
const jobs = state.jobs.filter((job) => job.status === "queued" || job.status === "running").length;
|
|
||||||
const [mode] = NIGHT[state.night] || [state.night];
|
|
||||||
const waiting = waitingCount();
|
|
||||||
const box = (value, label, tone) => h("div", {class: `stat-box${tone ? ` ${tone}` : ""}`}, h("span", {class: "num"}, String(value)), h("span", {class: "lbl"}, label));
|
|
||||||
document.getElementById("status").replaceChildren(
|
|
||||||
box(waiting, "wartet auf dich", waiting ? "urgent" : ""),
|
|
||||||
box(alarms, "Mayday", alarms ? "alarm" : ""),
|
|
||||||
box(agents, "Agenten aktiv", ""),
|
|
||||||
box(jobs, "Runner", ""),
|
|
||||||
box(mode, "Modus", ""));
|
|
||||||
const badge = document.getElementById("inbox-count");
|
|
||||||
badge.textContent = String(waiting);
|
|
||||||
badge.className = `badge${alarms ? " alarm" : waiting ? " urgent" : ""}`;
|
|
||||||
document.title = `${waiting ? `(${waiting}) ` : ""}kvb-hub`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,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
|
||||||
@@ -35,7 +35,7 @@ COPY --from=whisper /out/whisper-cli /opt/whisper/whisper-cli
|
|||||||
RUN chmod 0755 /opt/voices /opt/whisper
|
RUN chmod 0755 /opt/voices /opt/whisper
|
||||||
|
|
||||||
COPY server.py /app/server.py
|
COPY server.py /app/server.py
|
||||||
COPY kvb_hub /app/kvb_hub
|
COPY kvb_orchestrator /app/kvb_orchestrator
|
||||||
COPY client/signing.py /app/client/signing.py
|
COPY client/signing.py /app/client/signing.py
|
||||||
COPY templates /app/templates
|
COPY templates /app/templates
|
||||||
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Args:
|
"""Args:
|
||||||
pin --port <port> --approvers <file> --credential <blob>: pins the passkey
|
pin --port <port> --approvers <file> --credential <blob>: pins the passkey
|
||||||
that the kvb-hub settings panel registered (the blob it shows) in
|
that the Agent Orchestrator settings panel registered (the blob it shows) in
|
||||||
<file>, written with mode 0600. Run it on the host, outside every
|
<file>, written with mode 0600. Run it on the host, outside every
|
||||||
sandbox, e.g. --approvers ~/.config/kvb-hub/approvers.json.
|
sandbox, e.g. --approvers ~/.config/kvb-orchestrator/approvers.json.
|
||||||
verify --port <port> --approvers <file> --replay <file> --review <id>
|
verify --port <port> --approvers <file> --replay <file> --review <id>
|
||||||
--gate <pass|fail>: run in the worktree right before the commit;
|
--gate <pass|fail>: run in the worktree right before the commit;
|
||||||
fetches the review's signature from the kvb-hub on 127.0.0.1:<port>
|
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
|
and checks it against the repository, worktree, branch and the tree
|
||||||
git write-tree prints, as this process sees them.
|
git write-tree prints, as this process sees them.
|
||||||
|
|
||||||
@@ -57,9 +57,9 @@ def fetch(port: int, path: str) -> dict:
|
|||||||
try:
|
try:
|
||||||
data = load(request, SLACK)
|
data = load(request, SLACK)
|
||||||
except (OSError, ValueError) as error:
|
except (OSError, ValueError) as error:
|
||||||
raise Refused(f"the kvb-hub did not answer: {error}") from error
|
raise Refused(f"the Agent Orchestrator did not answer: {error}") from error
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise Refused("the kvb-hub answered no object")
|
raise Refused("the Agent Orchestrator answered no object")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ import json
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
SLACK = 15.0
|
SLACK = 15.0
|
||||||
GUARD = {"X-Kvb-Hub": "1"}
|
GUARD = {"X-Kvb-Orchestrator": "1"}
|
||||||
|
|
||||||
|
|
||||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
@@ -202,7 +202,7 @@ def key_type(alg: int, spki: bytes) -> bool:
|
|||||||
def by_openssl(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool:
|
def by_openssl(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool:
|
||||||
if not key_type(alg, spki):
|
if not key_type(alg, spki):
|
||||||
return False
|
return False
|
||||||
with tempfile.TemporaryDirectory(prefix="kvb-hub-verify-") as folder:
|
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, 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")
|
key.write_text(f"-----BEGIN PUBLIC KEY-----\n{base64.encodebytes(spki).decode('ascii')}-----END PUBLIC KEY-----\n", encoding="ascii")
|
||||||
message.write_bytes(data)
|
message.write_bytes(data)
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Args:
|
"""Args:
|
||||||
--port: port of the kvb-hub on 127.0.0.1; the signature's origin is
|
--port: port of the Agent Orchestrator on 127.0.0.1; the signature's origin is
|
||||||
http://localhost:<port>.
|
http://localhost:<port>.
|
||||||
--timeout: seconds to wait for the operator's decision; keep it below the
|
--timeout: seconds to wait for the operator's decision; keep it below the
|
||||||
hook's timeout in settings.json.
|
hook's timeout in settings.json.
|
||||||
--approvers: the pinned passkeys, e.g. ~/.config/kvb-hub/approvers.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.
|
--replay: this hook's replay store, outside the hub's reach, e.g.
|
||||||
~/.local/state/kvb-hub-approvals/hook.json.
|
~/.local/state/kvb-orchestrator-approvals/hook.json.
|
||||||
stdin: the Claude Code PermissionRequest hook input.
|
stdin: the Claude Code PermissionRequest hook input.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
stdout: the PermissionRequest decision JSON: allow only when a pinned
|
stdout: the PermissionRequest decision JSON: allow only when a pinned
|
||||||
passkey signed exactly this tool, command, cwd, session and ask id;
|
passkey signed exactly this tool, command, cwd, session and ask id;
|
||||||
deny when the operator denied in the kvb-hub. Nothing on timeout,
|
deny when the operator denied in the Agent Orchestrator. Nothing on timeout,
|
||||||
error, a missing or invalid signature, a value longer than the hub
|
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.
|
takes or a hub that is down, so Claude Code shows its own dialog.
|
||||||
The exit code is 0.
|
The exit code is 0.
|
||||||
@@ -36,7 +36,7 @@ NAME_LIMIT = 200
|
|||||||
COMMAND_LIMIT = 4000
|
COMMAND_LIMIT = 4000
|
||||||
PATH_LIMIT = 4096
|
PATH_LIMIT = 4096
|
||||||
LIMITS = {"tool": NAME_LIMIT, "command": COMMAND_LIMIT, "cwd": PATH_LIMIT, "session": NAME_LIMIT, "agent": NAME_LIMIT}
|
LIMITS = {"tool": NAME_LIMIT, "command": COMMAND_LIMIT, "cwd": PATH_LIMIT, "session": NAME_LIMIT, "agent": NAME_LIMIT}
|
||||||
DENIED = "The operator denied this in the kvb-hub."
|
DENIED = "The operator denied this in the Agent Orchestrator."
|
||||||
|
|
||||||
|
|
||||||
def summary(tool_input: object) -> str:
|
def summary(tool_input: object) -> str:
|
||||||
@@ -82,7 +82,7 @@ def decision(answer: dict, item: dict, trust: verifier.Trust) -> dict | None:
|
|||||||
try:
|
try:
|
||||||
verifier.check(answer.get("proof"), item, "", trust, time.time())
|
verifier.check(answer.get("proof"), item, "", trust, time.time())
|
||||||
except (signing.Refused, OSError) as error:
|
except (signing.Refused, OSError) as error:
|
||||||
print(f"kvb-hub: not signed, Claude Code asks instead: {error}", file=sys.stderr)
|
print(f"kvb-orchestrator: not signed, Claude Code asks instead: {error}", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
return {"behavior": "allow"}
|
return {"behavior": "allow"}
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ def main() -> int:
|
|||||||
ask = facts(hook)
|
ask = facts(hook)
|
||||||
too_long = [name for name, limit in LIMITS.items() if len(ask[name]) > limit]
|
too_long = [name for name, limit in LIMITS.items() if len(ask[name]) > limit]
|
||||||
if too_long:
|
if too_long:
|
||||||
print(f"kvb-hub: {', '.join(too_long)} too long to sign in full, Claude Code asks instead", file=sys.stderr)
|
print(f"kvb-orchestrator: {', '.join(too_long)} too long to sign in full, Claude Code asks instead", file=sys.stderr)
|
||||||
return 0
|
return 0
|
||||||
ask_id = create(base, ask, args.timeout)
|
ask_id = create(base, ask, args.timeout)
|
||||||
answer = {"status": "pending"}
|
answer = {"status": "pending"}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Args:
|
"""Args:
|
||||||
--port: port of the kvb-hub on 127.0.0.1; the signature's origin is
|
--port: port of the Agent Orchestrator on 127.0.0.1; the signature's origin is
|
||||||
http://localhost:<port>.
|
http://localhost:<port>.
|
||||||
--interval: seconds between two polls of the hub.
|
--interval: seconds between two polls of the hub.
|
||||||
--approvers: the pinned passkeys, e.g. ~/.config/kvb-hub/approvers.json.
|
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
|
||||||
--replay: the runner's replay store, outside the hub's reach, e.g.
|
--replay: the runner's replay store, outside the hub's reach, e.g.
|
||||||
~/.local/state/kvb-hub-approvals/runner.json.
|
~/.local/state/kvb-orchestrator-approvals/runner.json.
|
||||||
--once: handle the queued jobs once and exit.
|
--once: handle the queued jobs once and exit.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Runs as the operator, outside any sandbox, every operator command a
|
Runs as the operator, outside any sandbox, every operator command a
|
||||||
pinned passkey signed in the kvb-hub: exactly the signed text, with bash
|
pinned passkey signed in the Agent Orchestrator: exactly the signed text, with bash
|
||||||
in the signed directory, each signature once and before its expiry.
|
in the signed directory, each signature once and before its expiry.
|
||||||
Refuses every other job. Posts the last 200 output lines back while it
|
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.
|
runs and the exit code at its end. The exit code is 0.
|
||||||
File renamed without changes.
@@ -95,7 +95,7 @@ def consume(store: Path, key: str, expires: int, credential: str, count: int, no
|
|||||||
|
|
||||||
def check(proof: object, item: dict, gate: str, trust: Trust, now: float) -> None:
|
def check(proof: object, item: dict, gate: str, trust: Trust, now: float) -> None:
|
||||||
"""Args:
|
"""Args:
|
||||||
proof: {"payload", "assertion"} the kvb-hub relayed for the item.
|
proof: {"payload", "assertion"} the Agent Orchestrator relayed for the item.
|
||||||
item: the executor's own item, built from what it is about to do.
|
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
|
gate: pass or fail as the executor itself saw it; a rule approves a
|
||||||
review on pass only.
|
review on pass only.
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
name: kvb-hub
|
name: kvb-orchestrator
|
||||||
|
|
||||||
services:
|
services:
|
||||||
hub:
|
hub:
|
||||||
build: .
|
build: .
|
||||||
image: kvb-hub:local
|
image: kvb-orchestrator:local
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
user: "${KVB_HUB_USER:?written into .env by scripts/install.sh}"
|
user: "${KVB_ORCHESTRATOR_USER:?written into .env by scripts/install.sh}"
|
||||||
read_only: true
|
read_only: true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
@@ -20,7 +20,7 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${KVB_HUB_PORT:?set in .env}:8080"
|
- "127.0.0.1:${KVB_ORCHESTRATOR_PORT:?set in .env}:8080"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
command:
|
command:
|
||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
- --listen-port
|
- --listen-port
|
||||||
- "8080"
|
- "8080"
|
||||||
- --public-port
|
- --public-port
|
||||||
- "${KVB_HUB_PORT:?set in .env}"
|
- "${KVB_ORCHESTRATOR_PORT:?set in .env}"
|
||||||
- --council
|
- --council
|
||||||
- /tmp/agents-council
|
- /tmp/agents-council
|
||||||
- --pages
|
- --pages
|
||||||
@@ -37,7 +37,7 @@ services:
|
|||||||
- --state
|
- --state
|
||||||
- /state
|
- /state
|
||||||
- --host-state
|
- --host-state
|
||||||
- "${KVB_HUB_STATE:?written into .env by scripts/install.sh}"
|
- "${KVB_ORCHESTRATOR_STATE:?written into .env by scripts/install.sh}"
|
||||||
- --skills
|
- --skills
|
||||||
- /skills
|
- /skills
|
||||||
- --transcripts
|
- --transcripts
|
||||||
@@ -45,23 +45,23 @@ services:
|
|||||||
- --templates
|
- --templates
|
||||||
- /app/templates
|
- /app/templates
|
||||||
- --repos
|
- --repos
|
||||||
- "${KVB_HUB_REPOS:?written into .env by scripts/install.sh}"
|
- "${KVB_ORCHESTRATOR_REPOS:?written into .env by scripts/install.sh}"
|
||||||
- --worktrees
|
- --worktrees
|
||||||
- "${KVB_HUB_WORKTREES:?written into .env by scripts/install.sh}"
|
- "${KVB_ORCHESTRATOR_WORKTREES:?written into .env by scripts/install.sh}"
|
||||||
- --timers
|
- --timers
|
||||||
- /timers
|
- /timers
|
||||||
- --llm-url
|
- --llm-url
|
||||||
- "${KVB_HUB_LLM_URL}"
|
- "${KVB_ORCHESTRATOR_LLM_URL}"
|
||||||
- --llm-model
|
- --llm-model
|
||||||
- "${KVB_HUB_LLM_MODEL}"
|
- "${KVB_ORCHESTRATOR_LLM_MODEL}"
|
||||||
- --llm-key-file
|
- --llm-key-file
|
||||||
- /run/kvb-hub/llm.key
|
- /run/kvb-orchestrator/llm.key
|
||||||
- --piper
|
- --piper
|
||||||
- /opt/piper/bin/piper
|
- /opt/piper/bin/piper
|
||||||
- --voices
|
- --voices
|
||||||
- /opt/voices
|
- /opt/voices
|
||||||
- --operator-voice
|
- --operator-voice
|
||||||
- "${KVB_HUB_OPERATOR_VOICE:?set in .env}"
|
- "${KVB_ORCHESTRATOR_OPERATOR_VOICE:?set in .env}"
|
||||||
- --whisper
|
- --whisper
|
||||||
- /opt/whisper/whisper-cli
|
- /opt/whisper/whisper-cli
|
||||||
- --whisper-model
|
- --whisper-model
|
||||||
@@ -78,55 +78,55 @@ services:
|
|||||||
target: /tmp
|
target: /tmp
|
||||||
read_only: true
|
read_only: true
|
||||||
- type: bind
|
- type: bind
|
||||||
source: "${KVB_HUB_COUNCIL:?written into .env by scripts/install.sh}"
|
source: "${KVB_ORCHESTRATOR_COUNCIL:?written into .env by scripts/install.sh}"
|
||||||
target: /tmp/agents-council
|
target: /tmp/agents-council
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
create_host_path: false
|
||||||
- type: bind
|
- type: bind
|
||||||
source: "${KVB_HUB_SKILLS:?written into .env by scripts/install.sh}"
|
source: "${KVB_ORCHESTRATOR_SKILLS:?written into .env by scripts/install.sh}"
|
||||||
target: /skills
|
target: /skills
|
||||||
read_only: true
|
read_only: true
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
create_host_path: false
|
||||||
- type: bind
|
- type: bind
|
||||||
source: "${KVB_HUB_PROJECTS:?written into .env by scripts/install.sh}"
|
source: "${KVB_ORCHESTRATOR_PROJECTS:?written into .env by scripts/install.sh}"
|
||||||
target: /projects
|
target: /projects
|
||||||
read_only: true
|
read_only: true
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
create_host_path: false
|
||||||
- type: bind
|
- type: bind
|
||||||
source: "${KVB_HUB_STATE:?written into .env by scripts/install.sh}"
|
source: "${KVB_ORCHESTRATOR_STATE:?written into .env by scripts/install.sh}"
|
||||||
target: /state
|
target: /state
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
create_host_path: false
|
||||||
- type: bind
|
- type: bind
|
||||||
source: "${KVB_HUB_REPOS:?written into .env by scripts/install.sh}"
|
source: "${KVB_ORCHESTRATOR_REPOS:?written into .env by scripts/install.sh}"
|
||||||
target: "${KVB_HUB_REPOS:?written into .env by scripts/install.sh}"
|
target: "${KVB_ORCHESTRATOR_REPOS:?written into .env by scripts/install.sh}"
|
||||||
read_only: true
|
read_only: true
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
create_host_path: false
|
||||||
- type: bind
|
- type: bind
|
||||||
source: "${KVB_HUB_WORKTREES:?written into .env by scripts/install.sh}"
|
source: "${KVB_ORCHESTRATOR_WORKTREES:?written into .env by scripts/install.sh}"
|
||||||
target: "${KVB_HUB_WORKTREES:?written into .env by scripts/install.sh}"
|
target: "${KVB_ORCHESTRATOR_WORKTREES:?written into .env by scripts/install.sh}"
|
||||||
read_only: true
|
read_only: true
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
create_host_path: false
|
||||||
- type: bind
|
- type: bind
|
||||||
source: "${KVB_HUB_TIMERS:?written into .env by scripts/install.sh}"
|
source: "${KVB_ORCHESTRATOR_TIMERS:?written into .env by scripts/install.sh}"
|
||||||
target: /timers
|
target: /timers
|
||||||
read_only: true
|
read_only: true
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
create_host_path: false
|
||||||
- type: bind
|
- type: bind
|
||||||
source: "${KVB_HUB_APPROVERS:?written into .env by scripts/install.sh}"
|
source: "${KVB_ORCHESTRATOR_APPROVERS:?written into .env by scripts/install.sh}"
|
||||||
target: /approvers
|
target: /approvers
|
||||||
read_only: true
|
read_only: true
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
create_host_path: false
|
||||||
- type: bind
|
- type: bind
|
||||||
# /dev/null is an empty key file, which server.py --llm-key-file reads as no key
|
# /dev/null is an empty key file, which server.py --llm-key-file reads as no key
|
||||||
source: "${KVB_HUB_LLM_KEY_FILE:-/dev/null}"
|
source: "${KVB_ORCHESTRATOR_LLM_KEY_FILE:-/dev/null}"
|
||||||
target: /run/kvb-hub/llm.key
|
target: /run/kvb-orchestrator/llm.key
|
||||||
read_only: true
|
read_only: true
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
create_host_path: false
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
NAME = "kvb-orchestrator"
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .records import epoch, read_json, shown
|
from .records import epoch, read_json, shown
|
||||||
@@ -7,8 +8,12 @@ from .records import epoch, read_json, shown
|
|||||||
NEWS = "news.jsonl"
|
NEWS = "news.jsonl"
|
||||||
HUMAN = "Operator"
|
HUMAN = "Operator"
|
||||||
SESSION = " session"
|
SESSION = " session"
|
||||||
ALARMS = (("MAYDAY", "mayday"), ("PAN-PAN", "pan-pan"))
|
NOTE = re.compile(r"(\S.*?)\s+\([^()]*\)")
|
||||||
CLASSES = ("broadcast", "direct", "security", "pan-pan", "mayday")
|
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")
|
MODES = ("evening", "sunset")
|
||||||
RADIO_LIMIT = 5000
|
RADIO_LIMIT = 5000
|
||||||
GRANT_LIMIT = 50
|
GRANT_LIMIT = 50
|
||||||
@@ -19,6 +24,9 @@ def agent(name: object) -> str:
|
|||||||
if not isinstance(name, str):
|
if not isinstance(name, str):
|
||||||
return ""
|
return ""
|
||||||
text = name.strip()
|
text = name.strip()
|
||||||
|
noted = NOTE.fullmatch(text)
|
||||||
|
if noted and not subagent(text):
|
||||||
|
text = noted.group(1)
|
||||||
if text.lower().endswith(SESSION):
|
if text.lower().endswith(SESSION):
|
||||||
return text[: -len(SESSION)].strip()
|
return text[: -len(SESSION)].strip()
|
||||||
return text
|
return text
|
||||||
@@ -33,13 +41,26 @@ def ordered(names: set[str]) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def kind(row: dict, names: list[str]) -> str:
|
def kind(row: dict, names: list[str]) -> str:
|
||||||
if row.get("class") in CLASSES:
|
"""Args:
|
||||||
return row["class"]
|
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")
|
text = row.get("text")
|
||||||
for prefix, alarm in ALARMS:
|
for prefix, alarm in PREFIXES:
|
||||||
if isinstance(text, str) and text.lstrip().startswith(prefix):
|
if cls is None and isinstance(text, str) and text.lstrip().startswith(prefix):
|
||||||
return alarm
|
return alarm
|
||||||
return "direct" if names else "broadcast"
|
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:
|
def radio_row(row: dict, ts: float) -> dict:
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -14,23 +16,66 @@ from .records import read_jsonl, shown
|
|||||||
from .register import status_of
|
from .register import status_of
|
||||||
from .transcripts import LOG_KEEP, STREAM_CUT, STREAM_KINDS
|
from .transcripts import LOG_KEEP, STREAM_CUT, STREAM_KINDS
|
||||||
|
|
||||||
EVENT_KINDS = ("radio", "review", "ask", "decision", "operator", "register", "rename", "cron", "rule", "approval", "run", "transcript")
|
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 = {
|
VIEWS = {
|
||||||
"fuer-dich": {"label": "Für dich", "kinds": list(EVENT_KINDS[:-1]), "waiting": True},
|
"fuer-dich": {"label": "Für dich", "kinds": ["radio", *WORK], "waiting": True},
|
||||||
"reviews": {"label": "Reviews", "kinds": ["review"], "waiting": False},
|
"funk": {"label": "Funk", "kinds": ["radio"], "waiting": False},
|
||||||
"asks": {"label": "Asks", "kinds": ["ask"], "waiting": False},
|
"arbeit": {"label": "Arbeit", "kinds": WORK, "waiting": False},
|
||||||
"entscheidungen": {"label": "Entscheidungen", "kinds": ["decision"], "waiting": False},
|
"transkripte": {"label": "Transkripte", "kinds": ["transcript"], "waiting": False},
|
||||||
"operator": {"label": "Operator", "kinds": ["operator", "run"], "waiting": False},
|
|
||||||
"register": {"label": "Register", "kinds": ["register", "rename"], "waiting": False},
|
|
||||||
"radio": {"label": "Radio", "kinds": ["radio"], "waiting": False},
|
|
||||||
"crons": {"label": "Crons", "kinds": ["cron"], "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:
|
def event(fields: dict) -> dict:
|
||||||
return {"part": "", "agent": "", "now": "", "session": "", "sub": "", "text": "", "waiting": False, "ref": {}, **fields}
|
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:
|
def event_filters(query: dict[str, list[str]]) -> dict:
|
||||||
view = single(query, "view") or ""
|
view = single(query, "view") or ""
|
||||||
if view:
|
if view:
|
||||||
@@ -48,13 +93,17 @@ def event_filters(query: dict[str, list[str]]) -> dict:
|
|||||||
if not chosen <= set(STREAM_KINDS):
|
if not chosen <= set(STREAM_KINDS):
|
||||||
raise ToolError(f"parts must be a comma list of {', '.join(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")}
|
texts = {name: single(query, name) or "" for name in ("agent", "session", "sub")}
|
||||||
return {"kinds": kinds, "waiting": waiting, "parts": chosen, "q": (single(query, "q") or "").lower(), "aliases": set(), **texts}
|
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:
|
def wanted(entry: dict, filters: dict) -> bool:
|
||||||
return all(
|
return all(
|
||||||
(
|
(
|
||||||
entry["kind"] in filters["kinds"],
|
entry["kind"] in filters["kinds"],
|
||||||
|
filters["since"] is None or entry["ts"] >= filters["since"],
|
||||||
not filters["waiting"] or entry["waiting"],
|
not filters["waiting"] or entry["waiting"],
|
||||||
not filters["agent"] or entry["agent"] == filters["agent"] or entry["session"] in filters["aliases"],
|
not filters["agent"] or entry["agent"] == filters["agent"] or entry["session"] in filters["aliases"],
|
||||||
not filters["session"] or entry["session"] == filters["session"],
|
not filters["session"] or entry["session"] == filters["session"],
|
||||||
@@ -11,7 +11,7 @@ from . import NAME
|
|||||||
from .errors import Conflict, EngineError, Missing, ToolError, Unavailable
|
from .errors import Conflict, EngineError, Missing, ToolError, Unavailable
|
||||||
from .hub import Hub
|
from .hub import Hub
|
||||||
|
|
||||||
GUARD = "X-Kvb-Hub"
|
GUARD = "X-Kvb-Orchestrator"
|
||||||
PLAIN_CSP = "default-src 'none'; frame-ancestors 'none'"
|
PLAIN_CSP = "default-src 'none'; frame-ancestors 'none'"
|
||||||
LENGTH = re.compile(r"[0-9]{1,9}")
|
LENGTH = re.compile(r"[0-9]{1,9}")
|
||||||
QUERY_FIELDS = 32
|
QUERY_FIELDS = 32
|
||||||
@@ -26,7 +26,7 @@ FIELDS = {
|
|||||||
"to": (re.compile(LINE.format(200)), "an agent name, 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"),
|
"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"),
|
"tool": (re.compile(LINE.format(200)), "one line of 1 to 200 characters"),
|
||||||
"id": (re.compile(r"[0-9a-f]{16}"), "an id the kvb-hub returned"),
|
"id": (re.compile(r"[0-9a-f]{16}"), "an id the Agent Orchestrator returned"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
File renamed without changes.
@@ -72,7 +72,7 @@ class Handler(Exchange):
|
|||||||
if self.client():
|
if self.client():
|
||||||
self.answer(hub.runner_jobs, HTTPStatus.OK)
|
self.answer(hub.runner_jobs, HTTPStatus.OK)
|
||||||
else:
|
else:
|
||||||
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Hub header and no origin only")
|
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Orchestrator header and no origin only")
|
||||||
elif entry:
|
elif entry:
|
||||||
self.answer(lambda: hub.entry(int(entry.group(1))), HTTPStatus.OK)
|
self.answer(lambda: hub.entry(int(entry.group(1))), HTTPStatus.OK)
|
||||||
elif item and item.group(1) == "asks" and item.group(3) is None:
|
elif item and item.group(1) == "asks" and item.group(3) is None:
|
||||||
@@ -151,18 +151,18 @@ class Handler(Exchange):
|
|||||||
if path == "/api/asks":
|
if path == "/api/asks":
|
||||||
if self.local(host):
|
if self.local(host):
|
||||||
return lambda body: self.answer(lambda: self.server.hub.ask(body), HTTPStatus.CREATED)
|
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-Hub header and no foreign origin only")
|
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: local POST with the X-Kvb-Orchestrator header and no foreign origin only")
|
||||||
return None
|
return None
|
||||||
if RUNNER_ROUTE.fullmatch(path):
|
if RUNNER_ROUTE.fullmatch(path):
|
||||||
if self.client():
|
if self.client():
|
||||||
return lambda body: self.runner(path, body)
|
return lambda body: self.runner(path, body)
|
||||||
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Hub header and no origin only")
|
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Orchestrator header and no origin only")
|
||||||
return None
|
return None
|
||||||
action = self.action(path)
|
action = self.action(path)
|
||||||
if action is None:
|
if action is None:
|
||||||
self.send_text(HTTPStatus.NOT_FOUND, "not found")
|
self.send_text(HTTPStatus.NOT_FOUND, "not found")
|
||||||
elif not self.guarded(host):
|
elif not self.guarded(host):
|
||||||
self.send_text(HTTPStatus.FORBIDDEN, "forbidden: same-origin POST with the X-Kvb-Hub header only")
|
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:
|
elif path == STT_ROUTE and self.headers.get_content_type() != WAV_TYPE:
|
||||||
self.send_text(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, f"use {WAV_TYPE}")
|
self.send_text(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, f"use {WAV_TYPE}")
|
||||||
else:
|
else:
|
||||||
@@ -39,13 +39,15 @@ class Places:
|
|||||||
|
|
||||||
|
|
||||||
def environment(templates: Path) -> jinja2.Environment:
|
def environment(templates: Path) -> jinja2.Environment:
|
||||||
return jinja2.Environment(
|
env = jinja2.Environment(
|
||||||
loader=jinja2.FileSystemLoader(templates),
|
loader=jinja2.FileSystemLoader(templates),
|
||||||
autoescape=True,
|
autoescape=True,
|
||||||
undefined=jinja2.StrictUndefined,
|
undefined=jinja2.StrictUndefined,
|
||||||
trim_blocks=True,
|
trim_blocks=True,
|
||||||
lstrip_blocks=True,
|
lstrip_blocks=True,
|
||||||
)
|
)
|
||||||
|
env.policies["json.dumps_kwargs"] = {"sort_keys": False}
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
File renamed without changes.
File renamed without changes.
@@ -25,7 +25,7 @@ class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|||||||
|
|
||||||
|
|
||||||
LLM_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect)
|
LLM_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect)
|
||||||
SCREENS = ("stream", "graph", "tree")
|
SCREENS = ("stream", "inbox", "rules", "agents", "artifacts")
|
||||||
IDS = {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "Ids from items."}
|
IDS = {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "Ids from items."}
|
||||||
NL_ACTIONS = [
|
NL_ACTIONS = [
|
||||||
{"name": "approve", "description": "Approve inbox items; a decision takes its recommended option.", "parameters": {"ids": IDS}},
|
{"name": "approve", "description": "Approve inbox items; a decision takes its recommended option.", "parameters": {"ids": IDS}},
|
||||||
@@ -43,7 +43,7 @@ NL_ACTIONS = [
|
|||||||
def nl_messages(text: str, items: list[dict], agents: list[str]) -> list[dict]:
|
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]
|
schemas = [{"name": action["name"], "description": action["description"], "arguments": action["parameters"]} for action in NL_ACTIONS]
|
||||||
system = (
|
system = (
|
||||||
"You turn one command of the kvb-hub operator into hub actions. Reply with exactly one JSON object "
|
"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 '
|
'{"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)}. "
|
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.'
|
'Use only ids from items and names from agents. Reply {"actions": []} when nothing fits.'
|
||||||
@@ -171,7 +171,7 @@ def read_key(path: Path) -> str:
|
|||||||
class Language:
|
class Language:
|
||||||
def nl(self, body: bytes) -> dict:
|
def nl(self, body: bytes) -> dict:
|
||||||
if not self.model.enabled():
|
if not self.model.enabled():
|
||||||
raise ToolError("the natural-language command line is off: set KVB_HUB_LLM_URL and KVB_HUB_LLM_MODEL in hub/.env")
|
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)
|
payload = decode(body)
|
||||||
expect(payload, ("text",))
|
expect(payload, ("text",))
|
||||||
text = message_text(payload, "text")
|
text = message_text(payload, "text")
|
||||||
File renamed without changes.
File renamed without changes.
@@ -38,7 +38,7 @@ class Mcp:
|
|||||||
return [
|
return [
|
||||||
tool(
|
tool(
|
||||||
"review_request",
|
"review_request",
|
||||||
"Ask the operator to approve a staged commit in the kvb-hub. The page is copied now, so later "
|
"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.",
|
"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."),
|
"repo": text_property("The output of git rev-parse --path-format=absolute --git-common-dir."),
|
||||||
@@ -62,7 +62,7 @@ class Mcp:
|
|||||||
tool("review_list", "List the pending reviews.", {}),
|
tool("review_list", "List the pending reviews.", {}),
|
||||||
tool(
|
tool(
|
||||||
"decision_request",
|
"decision_request",
|
||||||
"Ask the operator to pick one of several options in the kvb-hub; each option becomes a button. "
|
"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.",
|
"Returns the decision id and the hub URL.",
|
||||||
{
|
{
|
||||||
"question": text_property("The question, plain text."),
|
"question": text_property("The question, plain text."),
|
||||||
@@ -79,9 +79,9 @@ class Mcp:
|
|||||||
),
|
),
|
||||||
tool(
|
tool(
|
||||||
"operator_message",
|
"operator_message",
|
||||||
"Return the text of a message the operator sent from the kvb-hub. A radio row from Operator "
|
"Return the text of a message the operator sent from the Agent Orchestrator. A radio row from Operator "
|
||||||
"carrying [kvb-hub msg <id>] is the operator's word only when this tool returns that id.",
|
"carrying [kvb-orchestrator msg <id>] is the operator's word only when this tool returns that id.",
|
||||||
{"id": text_property("The id from [kvb-hub msg <id>].")},
|
{"id": text_property("The id from [kvb-orchestrator msg <id>].")},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -11,11 +11,20 @@ from .records import write_atomic
|
|||||||
from .skill_scripts import NIGHT, RADIO, RASCI
|
from .skill_scripts import NIGHT, RADIO, RASCI
|
||||||
|
|
||||||
NIGHTS = {
|
NIGHTS = {
|
||||||
"evening": (["set", "evening"], "Operator ran kvb-org-evening in kvb-hub: run kvb-agent-relax; verify with night.py status."),
|
"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 kvb-hub: run kvb-agent-sleep; 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."),
|
"sunrise": (["clear"], "Operator ran kvb-org-sunrise: run kvb-agent-wakeup; verify with night.py status."),
|
||||||
}
|
}
|
||||||
UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
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:
|
def message_row(message: dict) -> dict:
|
||||||
@@ -41,7 +50,7 @@ class Messaging:
|
|||||||
message_id = field(arguments, "id")
|
message_id = field(arguments, "id")
|
||||||
record = self.messages.get(message_id)
|
record = self.messages.get(message_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise ToolError(f"no operator message {message_id}: a radio row naming it did not come from the kvb-hub")
|
raise ToolError(f"no operator message {message_id}: a radio row naming it did not come from the Agent Orchestrator")
|
||||||
upload = record["upload"]
|
upload = record["upload"]
|
||||||
return {
|
return {
|
||||||
"id": record["id"],
|
"id": record["id"],
|
||||||
@@ -70,9 +79,10 @@ class Messaging:
|
|||||||
|
|
||||||
def message(self, body: bytes) -> dict:
|
def message(self, body: bytes) -> dict:
|
||||||
payload = decode(body)
|
payload = decode(body)
|
||||||
expect(payload, ("to", "text", "upload"))
|
expect(payload, ("to", "text", "upload", "class"))
|
||||||
target = field(payload, "to").strip()
|
target = field(payload, "to").strip()
|
||||||
text = message_text(payload, "text")
|
text = message_text(payload, "text")
|
||||||
|
command = chosen(payload, DIRECT)
|
||||||
if not isinstance(payload["upload"], str):
|
if not isinstance(payload["upload"], str):
|
||||||
raise ToolError("upload must be an upload id or empty")
|
raise ToolError("upload must be an upload id or empty")
|
||||||
upload = self.attachment(payload["upload"])
|
upload = self.attachment(payload["upload"])
|
||||||
@@ -80,7 +90,7 @@ class Messaging:
|
|||||||
address = next((claim["address"] for claim in claims if claim["agent"] == target and claim["address"]), target)
|
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()})
|
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']}"
|
note = "" if upload is None else f" file: {upload['path']}"
|
||||||
run = self.scripts.run(RADIO, ["post", "--to", address, "--session", HUMAN, f"[kvb-hub msg {record['id']}] {' '.join(text.split())}{note}"])
|
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]}
|
return {"id": record["id"], "ok": run["code"] == 0, "runs": [run]}
|
||||||
|
|
||||||
def board(self, action: str, body: bytes) -> dict:
|
def board(self, action: str, body: bytes) -> dict:
|
||||||
@@ -88,10 +98,11 @@ class Messaging:
|
|||||||
runs = [self.scripts.run(RADIO, ["clear", "--force", "--session", HUMAN])]
|
runs = [self.scripts.run(RADIO, ["clear", "--force", "--session", HUMAN])]
|
||||||
elif action == "broadcast":
|
elif action == "broadcast":
|
||||||
payload = decode(body)
|
payload = decode(body)
|
||||||
expect(payload, ("text",))
|
expect(payload, ("text", "class"))
|
||||||
text = message_text(payload, "text")
|
text = message_text(payload, "text")
|
||||||
|
command = chosen(payload, TO_ALL)
|
||||||
record = self.messages.add({"to": "*", "address": "*", "text": text, "upload": None, "created_ts": time.time()})
|
record = self.messages.add({"to": "*", "address": "*", "text": text, "upload": None, "created_ts": time.time()})
|
||||||
runs = [self.scripts.run(RADIO, ["post", "--session", HUMAN, f"[kvb-hub msg {record['id']}] {' '.join(text.split())}"])]
|
runs = [self.scripts.run(RADIO, [*command, "--session", HUMAN, f"[kvb-orchestrator msg {record['id']}] {' '.join(text.split())}"])]
|
||||||
elif action == "order":
|
elif action == "order":
|
||||||
payload = decode(body)
|
payload = decode(body)
|
||||||
expect(payload, ("agents",))
|
expect(payload, ("agents",))
|
||||||
@@ -107,7 +118,7 @@ class Messaging:
|
|||||||
peers = ordered({claim["address"] for claim in read_claims(self.council) if claim["address"] and claim["agent"] != 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:
|
if peers and runs[0]["code"] == 0:
|
||||||
targets = [part for peer in peers for part in ("--to", peer)]
|
targets = [part for peer in peers for part in ("--to", peer)]
|
||||||
runs.append(self.scripts.run(RADIO, ["send", "direct", "--wake", *targets, "--session", HUMAN, text]))
|
runs.append(self.scripts.run(RADIO, [*DIRECT["call"], *targets, "--session", HUMAN, text]))
|
||||||
else:
|
else:
|
||||||
raise Missing(action)
|
raise Missing(action)
|
||||||
return {"ok": all(run["code"] == 0 for run in runs), "runs": runs}
|
return {"ok": all(run["code"] == 0 for run in runs), "runs": runs}
|
||||||
File renamed without changes.
File renamed without changes.
@@ -9,8 +9,9 @@ SPOKEN_TEXT = 500
|
|||||||
SPOKEN_NAME = 60
|
SPOKEN_NAME = 60
|
||||||
SPOKEN_GROUP = 120
|
SPOKEN_GROUP = 120
|
||||||
ALARM_WORDS = {"pan-pan": "PAN-PAN", "security": "SECURITE"}
|
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)
|
CALLS = re.compile(r"^(?:(?:MAYDAY|PAN[ -]?PAN|SECURIT[EÉY])\b[\s:,.;!-]*)+", re.IGNORECASE)
|
||||||
HUB_MARK = re.compile(r"\[kvb-hub msg [0-9a-f]{16}\]")
|
HUB_MARK = re.compile(r"\[kvb-orchestrator msg [0-9a-f]{16}\]")
|
||||||
HUB_FILE = re.compile(r"\s+file: /\S*$")
|
HUB_FILE = re.compile(r"\s+file: /\S*$")
|
||||||
SPOKEN_LINE = re.compile(LINE.format(SPEECH_LIMIT))
|
SPOKEN_LINE = re.compile(LINE.format(SPEECH_LIMIT))
|
||||||
|
|
||||||
@@ -34,8 +35,7 @@ def spoken_text(cls: str, text: str) -> str:
|
|||||||
|
|
||||||
def phrase(cls: str, sender: str, called: list[str], text: str) -> str:
|
def phrase(cls: str, sender: str, called: list[str], text: str) -> str:
|
||||||
"""Args:
|
"""Args:
|
||||||
cls: the radio class of the row: broadcast, direct, security, pan-pan
|
cls: the radio class of the row, one of council.CLASSES or receipt.
|
||||||
or mayday.
|
|
||||||
sender: the sender's name valid at the row's time.
|
sender: the sender's name valid at the row's time.
|
||||||
called: the names of the called stations valid at the row's time.
|
called: the names of the called stations valid at the row's time.
|
||||||
text: the radio text.
|
text: the radio text.
|
||||||
@@ -50,7 +50,7 @@ def phrase(cls: str, sender: str, called: list[str], text: str) -> str:
|
|||||||
elif cls in ALARM_WORDS:
|
elif cls in ALARM_WORDS:
|
||||||
word = ALARM_WORDS[cls]
|
word = ALARM_WORDS[cls]
|
||||||
head, end = f"{word} {word} {word}, all stations, this is {station}", "over"
|
head, end = f"{word} {word} {word}, all stations, this is {station}", "over"
|
||||||
elif cls == "direct" and called:
|
elif cls in ADDRESSED and called:
|
||||||
group = clip(" and ".join(clip(name, SPOKEN_NAME) for name in called), SPOKEN_GROUP)
|
group = clip(" and ".join(clip(name, SPOKEN_NAME) for name in called), SPOKEN_GROUP)
|
||||||
head, end = f"{group}, {group}, this is {station}", "over"
|
head, end = f"{group}, {group}, this is {station}", "over"
|
||||||
else:
|
else:
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -36,7 +36,7 @@ SETTINGS = {
|
|||||||
},
|
},
|
||||||
"notify": True,
|
"notify": True,
|
||||||
"streams": [],
|
"streams": [],
|
||||||
"speech": {"on": False, "classes": ["security", "pan-pan", "mayday"], "muted": []},
|
"speech": {"on": False, "classes": ["mayday", "pan-pan", "security"], "muted": []},
|
||||||
"space": {
|
"space": {
|
||||||
"graph": {"axis": "none", "yaw": 30.0, "pitch": 20.0, "zoom": 1.0, "pan_x": 0.0, "pan_y": 0.0},
|
"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},
|
"sky": {"azimuth": 180.0, "altitude": 0.0, "fov": 180.0},
|
||||||
@@ -48,6 +48,7 @@ SPACE_LIMITS = {
|
|||||||
"sky": {"azimuth": (0.0, 360.0), "altitude": (-30.0, 85.0), "fov": (20.0, 240.0)},
|
"sky": {"azimuth": (0.0, 360.0), "altitude": (-30.0, 85.0), "fov": (20.0, 240.0)},
|
||||||
}
|
}
|
||||||
MUTED_LIMIT = 200
|
MUTED_LIMIT = 200
|
||||||
|
LEGACY_CLASSES = {"direct": ("post", "call")}
|
||||||
|
|
||||||
|
|
||||||
def section(value: object, name: str, keys: tuple[str, ...]) -> dict:
|
def section(value: object, name: str, keys: tuple[str, ...]) -> dict:
|
||||||
@@ -172,6 +173,15 @@ def valid_settings(raw: object) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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]:
|
def media_origins(settings: dict) -> list[str]:
|
||||||
return sorted({stream_origin(stream["url"]) for stream in settings["streams"]})
|
return sorted({stream_origin(stream["url"]) for stream in settings["streams"]})
|
||||||
|
|
||||||
@@ -180,7 +190,7 @@ class Settings:
|
|||||||
def __init__(self, file: Path) -> None:
|
def __init__(self, file: Path) -> None:
|
||||||
self.file = file
|
self.file = file
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
stored = read_json(file)
|
stored = migrated(read_json(file))
|
||||||
try:
|
try:
|
||||||
self.current = valid_settings({**SETTINGS, **stored} if isinstance(stored, dict) else stored)
|
self.current = valid_settings({**SETTINGS, **stored} if isinstance(stored, dict) else stored)
|
||||||
except ToolError:
|
except ToolError:
|
||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
SIGNING_SPEC = importlib.util.spec_from_file_location("kvb_hub_signing", Path(__file__).resolve().parents[1] / "client" / "signing.py")
|
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 = importlib.util.module_from_spec(SIGNING_SPEC)
|
||||||
SIGNING_SPEC.loader.exec_module(signing)
|
SIGNING_SPEC.loader.exec_module(signing)
|
||||||
|
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -14,9 +14,10 @@ from .council import (
|
|||||||
silence_of,
|
silence_of,
|
||||||
)
|
)
|
||||||
from .errors import ToolError
|
from .errors import ToolError
|
||||||
from .events import VIEWS
|
from .events import KINDS, VIEWS
|
||||||
from .fields import decode, instant, single
|
from .fields import decode, instant, single
|
||||||
from .graph import graph
|
from .graph import graph
|
||||||
|
from .jobs import JOB_OPEN
|
||||||
from .messaging import message_row
|
from .messaging import message_row
|
||||||
from .orbits import TRACK_STEPS, track
|
from .orbits import TRACK_STEPS, track
|
||||||
from .phrasing import phrase
|
from .phrasing import phrase
|
||||||
@@ -28,12 +29,21 @@ from .skill_scripts import OUTPUT_LIMIT
|
|||||||
|
|
||||||
RADIO_WINDOW = 86400
|
RADIO_WINDOW = 86400
|
||||||
JOBS_SHOWN = 20
|
JOBS_SHOWN = 20
|
||||||
|
ACTIVE = 900
|
||||||
INDEX_CSP = (
|
INDEX_CSP = (
|
||||||
"default-src 'none'; script-src 'nonce-{0}'; style-src 'nonce-{0}'; connect-src 'self'; media-src {1}; "
|
"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'"
|
"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:
|
class View:
|
||||||
def span(self, query: dict[str, list[str]], now: float) -> tuple[float, float]:
|
def span(self, query: dict[str, list[str]], now: float) -> tuple[float, float]:
|
||||||
end = instant(query, "to")
|
end = instant(query, "to")
|
||||||
@@ -80,17 +90,20 @@ class View:
|
|||||||
drawn["track"] = track(drawn["nodes"], radio, tasks, claims, (min(max(begin, first), end), end), directory, steps)
|
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"}
|
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()
|
settings = self.settings.get()
|
||||||
|
inbox = self.inbox(now, tasks, directory)
|
||||||
return {
|
return {
|
||||||
"settings": settings,
|
"settings": settings,
|
||||||
"media": media_origins(settings),
|
"media": media_origins(settings),
|
||||||
"speech": self.speech.describe(),
|
"speech": self.speech.describe(),
|
||||||
"people": people,
|
"people": people,
|
||||||
"inbox": self.inbox(now, tasks, directory),
|
"inbox": inbox,
|
||||||
|
"badges": badges(inbox, claims, self.jobs.listed(), now),
|
||||||
"pending": self.batches.listed(),
|
"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()],
|
"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(),
|
"approvers": self.keys(),
|
||||||
"jobs": jobs,
|
"jobs": jobs,
|
||||||
"views": VIEWS,
|
"views": VIEWS,
|
||||||
|
"kinds": dict(KINDS),
|
||||||
"llm": self.model.enabled(),
|
"llm": self.model.enabled(),
|
||||||
"now": now,
|
"now": now,
|
||||||
"since": begin,
|
"since": begin,
|
||||||
File renamed without changes.
File renamed without changes.
@@ -48,14 +48,14 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from kvb_hub import NAME
|
from kvb_orchestrator import NAME
|
||||||
from kvb_hub.engines import Engines
|
from kvb_orchestrator.engines import Engines
|
||||||
from kvb_hub.errors import ToolError
|
from kvb_orchestrator.errors import ToolError
|
||||||
from kvb_hub.exchange import HubServer
|
from kvb_orchestrator.exchange import HubServer
|
||||||
from kvb_hub.handler import Handler
|
from kvb_orchestrator.handler import Handler
|
||||||
from kvb_hub.hub import Places, open_hub
|
from kvb_orchestrator.hub import Places, open_hub
|
||||||
from kvb_hub.language import Model, read_key
|
from kvb_orchestrator.language import Model, read_key
|
||||||
from kvb_hub.skill_scripts import Scripts
|
from kvb_orchestrator.skill_scripts import Scripts
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -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
|
||||||
File renamed without changes.
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<title>{% set waiting = state.inbox | rejectattr("until") | list | length %}{% if waiting %}({{ waiting }}) {% endif %}kvb-hub</title>
|
<title>{% if state.badges.inbox %}({{ state.badges.inbox }}) {% endif %}Agent Orchestrator</title>
|
||||||
<style nonce="{{ nonce }}">
|
<style nonce="{{ nonce }}">
|
||||||
:root {
|
:root {
|
||||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px; --sp-6: 32px; --sp-7: 48px;
|
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 24px; --sp-6: 32px; --sp-7: 48px;
|
||||||
@@ -28,62 +28,58 @@ body { min-height: 100vh; margin: 0; background: transparent; color: var(--text)
|
|||||||
button, input, select { font: inherit; color: inherit; }
|
button, input, select { font: inherit; color: inherit; }
|
||||||
code, pre { font-family: var(--mono); font-size: var(--fs-sm); }
|
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); }
|
: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, .timebar, main, .rail, .drawer, .sheet-box, .palette-box, .toast, .inbox-bar, .sky-panel { -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur); transition: background-color .8s var(--ease), border-color .8s var(--ease); }
|
.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); }
|
::view-transition-old(root), ::view-transition-new(root) { animation-duration: .22s; animation-timing-function: var(--ease); }
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; scroll-behavior: auto !important; }
|
*, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; scroll-behavior: auto !important; }
|
||||||
}
|
}
|
||||||
.head { position: sticky; top: 0; z-index: 6; }
|
.head { position: sticky; top: 0; z-index: 6; }
|
||||||
.top { display: grid; grid-template-columns: auto auto 1fr auto; align-items: center; gap: var(--sp-4) var(--sp-5); padding: var(--sp-3) var(--sp-5); background: var(--glass-panel); border-bottom: 1px solid var(--line); }
|
.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; align-items: center; gap: var(--sp-3); }
|
.brand { display: flex; flex: 1; align-items: center; gap: var(--sp-3); min-width: 0; }
|
||||||
.brand-text { display: grid; line-height: var(--lh-tight); }
|
.brand-text { display: grid; line-height: var(--lh-tight); }
|
||||||
.sky-note { color: var(--muted); font-size: var(--fs-xs); white-space: nowrap; }
|
.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); }
|
.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; }
|
h1 { margin: 0; font-size: var(--fs-lg); font-weight: 680; letter-spacing: -.01em; }
|
||||||
.tabs { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
.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; }
|
.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; }
|
.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); }
|
.btn[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
|
||||||
.tabs button:hover { background: var(--hover); color: var(--text); }
|
.tabs button:hover { background: var(--hover); color: var(--text); }
|
||||||
.tabs button[aria-selected="true"] { background: var(--accent-soft); color: var(--accent); }
|
.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 { 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.urgent { background: var(--accent); color: var(--on-accent); }
|
||||||
.badge.alarm { background: var(--alarm); color: #fff; }
|
.badge.alarm { background: var(--alarm); color: #fff; }
|
||||||
.status { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; justify-content: center; }
|
|
||||||
.stat-box { display: grid; min-width: 56px; padding: 1px 10px; border-radius: 10px; background: var(--sunken); text-align: center; line-height: 1.25; }
|
|
||||||
.stat-box .num { font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
||||||
.stat-box .lbl { color: var(--muted); font-size: var(--fs-2xs); }
|
|
||||||
.stat-box.urgent { background: var(--accent-soft); color: var(--accent); }
|
|
||||||
.stat-box.alarm { background: var(--bad-soft); color: var(--bad); }
|
|
||||||
.tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; }
|
.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 { 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::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); }
|
.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; }
|
body.leitstand { --fs-xs: 13px; --fs-sm: 14px; --fs-md: 16px; --fs-lg: 18px; --fs-xl: 21px; --fs-2xl: 26px; --fs-3xl: 44px; }
|
||||||
body.leitstand .top { grid-template-columns: auto auto 1fr auto; }
|
.shell { padding: var(--sp-4) var(--sp-4) var(--sp-6); }
|
||||||
body.leitstand .stat-box { min-width: 128px; padding: var(--sp-2) var(--sp-4); }
|
|
||||||
body.leitstand .stat-box .num { font-size: var(--fs-3xl); }
|
|
||||||
body.leitstand .stat-box .lbl { font-size: var(--fs-sm); }
|
|
||||||
.shell { display: grid; grid-template-columns: minmax(0, 1fr) 410px; align-items: start; gap: var(--sp-4); 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); }
|
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); }
|
||||||
.rail { position: sticky; top: calc(var(--head) + var(--sp-4)); height: calc(100vh - var(--head) - var(--sp-4) * 2); overflow: auto; padding: var(--sp-4); background: var(--glass-panel); border: 1px solid color-mix(in srgb, var(--line) 70%, transparent); border-radius: var(--radius-lg); box-shadow: var(--shadow); }
|
|
||||||
.inbox-bar { display: none; }
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.shell { grid-template-columns: minmax(0, 1fr); }
|
|
||||||
.rail { display: none; position: fixed; top: auto; right: 0; bottom: 56px; left: 0; z-index: 15; height: min(78vh, 680px); border-width: 1px 0 0; border-radius: var(--radius-lg) var(--radius-lg) 0 0; background: var(--glass-card); box-shadow: var(--shadow-lift); }
|
|
||||||
body.inbox-open .rail { display: block; }
|
|
||||||
.inbox-bar { position: fixed; right: 0; bottom: 0; left: 0; z-index: 16; display: flex; align-items: center; justify-content: center; gap: var(--sp-3); height: 56px; border: 0; border-top: 1px solid var(--line); background: var(--glass-card); font-weight: 650; cursor: pointer; }
|
|
||||||
.top { grid-template-columns: 1fr; }
|
|
||||||
.status, .tools { justify-content: flex-start; }
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.top { gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); }
|
.top, .navbar, .banner { gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); }
|
||||||
.status { flex-wrap: nowrap; overflow-x: auto; }
|
.shell { padding: var(--sp-2) var(--sp-2) var(--sp-6); }
|
||||||
.shell { gap: var(--sp-2); padding: var(--sp-2) var(--sp-2) var(--sp-6); }
|
|
||||||
main { padding: var(--sp-3) var(--sp-3) 88px; border-radius: var(--radius); }
|
main { padding: var(--sp-3) var(--sp-3) 88px; border-radius: var(--radius); }
|
||||||
.timebar { padding: var(--sp-2) var(--sp-3); }
|
.menu-box { position: fixed; top: auto; right: var(--sp-2); left: var(--sp-2); width: auto; }
|
||||||
.timebar .replay { flex-basis: 100%; min-width: 0; }
|
.replay { flex-basis: 100%; min-width: 0; }
|
||||||
.entry { grid-template-columns: minmax(0, 1fr); }
|
|
||||||
.sheet { padding: 0; }
|
.sheet { padding: 0; }
|
||||||
.sheet-box { min-height: 100%; border-radius: 0; }
|
.sheet-box { min-height: 100%; border-radius: 0; }
|
||||||
.settings .grid { grid-template-columns: minmax(0, 1fr); }
|
.settings .grid { grid-template-columns: minmax(0, 1fr); }
|
||||||
@@ -180,6 +176,11 @@ table.diff tr.more td { padding: 8px 16px; color: var(--muted); font-style: ital
|
|||||||
.feed { margin: 0; padding: 0; list-style: none; }
|
.feed { margin: 0; padding: 0; list-style: none; }
|
||||||
.footnote { margin: 0; padding: 12px 18px; color: var(--muted); font-size: var(--fs-sm); }
|
.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); }
|
.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:last-child { border-bottom: 0; }
|
||||||
.entry:hover { background: var(--hover); }
|
.entry:hover { background: var(--hover); }
|
||||||
.entry.waiting { box-shadow: inset 3px 0 0 var(--warn); }
|
.entry.waiting { box-shadow: inset 3px 0 0 var(--warn); }
|
||||||
@@ -255,11 +256,8 @@ svg .mark:hover { opacity: 1; }
|
|||||||
svg .mark:hover line, svg .mark:hover path { stroke-width: 2.6; }
|
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 { fill: var(--c); filter: drop-shadow(0 0 4px var(--c)); }
|
||||||
svg .pulse-ring { fill: none; stroke: var(--c); stroke-width: 2.5; }
|
svg .pulse-ring { fill: none; stroke: var(--c); stroke-width: 2.5; }
|
||||||
.timebar { 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); }
|
input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); }
|
||||||
.timebar 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; }
|
||||||
.timebar .replay { min-width: 320px; }
|
|
||||||
.custom { display: inline-flex; align-items: center; gap: 6px; }
|
|
||||||
.custom[hidden] { display: none; }
|
|
||||||
.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 { 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); }
|
.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 { padding: 0; border: 0; background: none; color: inherit; font-weight: 650; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
|
||||||
@@ -274,7 +272,6 @@ textarea { width: 100%; min-height: 84px; padding: 8px 10px; border: 1px solid v
|
|||||||
.bubble.mine { margin-left: 34px; background: var(--accent-soft); border-color: transparent; }
|
.bubble.mine { margin-left: 34px; background: var(--accent-soft); border-color: transparent; }
|
||||||
.bubble.theirs { margin-right: 34px; }
|
.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)); }
|
.toasts { position: fixed; right: 18px; bottom: 18px; z-index: 40; display: grid; gap: 10px; width: min(440px, calc(100vw - 36px)); }
|
||||||
@media (max-width: 1100px) { .toasts { bottom: 72px; } }
|
|
||||||
.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 { 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.bad { border-left-color: var(--bad); }
|
||||||
.toast.wait { border-left-color: var(--warn); cursor: default; }
|
.toast.wait { border-left-color: var(--warn); cursor: default; }
|
||||||
@@ -355,7 +352,7 @@ textarea { width: 100%; min-height: 84px; padding: 8px 10px; border: 1px solid v
|
|||||||
.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-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 { padding: 6px 10px 2px; }
|
||||||
.space-list summary { margin: 4px 0; color: var(--muted); font-size: var(--fs-sm); cursor: pointer; }
|
.space-list summary { margin: 4px 0; color: var(--muted); font-size: var(--fs-sm); cursor: pointer; }
|
||||||
body.sky-look .shell, body.sky-look .inbox-bar { visibility: hidden; }
|
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 { z-index: 5; pointer-events: auto; cursor: grab; touch-action: none; }
|
||||||
body.sky-look .sky:active { cursor: grabbing; }
|
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 { 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); }
|
||||||
@@ -367,28 +364,23 @@ body.sky-look .sky:active { cursor: grabbing; }
|
|||||||
<canvas class="sky" id="sky" aria-hidden="true"></canvas>
|
<canvas class="sky" id="sky" aria-hidden="true"></canvas>
|
||||||
<div class="head" id="head">
|
<div class="head" id="head">
|
||||||
<header class="top">
|
<header class="top">
|
||||||
<div class="brand"><span class="logo" aria-hidden="true"></span><div class="brand-text"><h1>kvb-hub</h1><span class="sky-note" id="sky-note"></span></div></div>
|
<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>
|
||||||
<nav class="tabs" id="tabs" role="tablist" aria-label="Ansichten"></nav>
|
|
||||||
<div class="status" id="status" aria-live="polite"></div>
|
|
||||||
<div class="tools">
|
<div class="tools">
|
||||||
<span class="updated" id="updated" aria-live="polite"></span>
|
<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" id="palette-open" title="Befehle (Strg+K)">⌘ Befehle</button>
|
<button type="button" class="btn small ghost icon-btn" id="settings-open" title="Einstellungen und Ambiente" aria-label="Einstellungen">⚙</button>
|
||||||
<button type="button" class="btn small ghost" id="board-open">Steuerung</button>
|
|
||||||
<button type="button" class="btn small ghost" id="rules-open">Regeln</button>
|
|
||||||
<button type="button" class="btn small ghost" id="sky-look" aria-pressed="false" title="Himmel ansehen: ziehen dreht den Blick (Taste h)">🔭 Himmel</button>
|
|
||||||
<button type="button" class="btn small ghost" id="music-toggle" aria-pressed="false" title="Generative Musik an oder aus">♪ Musik</button>
|
|
||||||
<button type="button" class="btn small ghost" id="leitstand" title="Leitstand: Vollbild mit großem Status">Leitstand</button>
|
|
||||||
<button type="button" class="btn small ghost" id="notify" hidden>Benachrichtigungen aktivieren</button>
|
|
||||||
<button type="button" class="btn small ghost icon-btn" id="settings-open" title="Einstellungen" aria-label="Einstellungen">⚙</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="timebar" id="timebar" role="group" aria-label="Zeitraum"></div>
|
<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>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<main id="main" role="tabpanel"></main>
|
<main id="main"></main>
|
||||||
<aside class="rail" id="rail" aria-label="Inbox"></aside>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="inbox-bar" id="inbox-bar">Inbox <span class="badge" id="inbox-count">0</span></button>
|
|
||||||
<aside class="drawer" id="drawer" aria-label="Agent" hidden></aside>
|
<aside class="drawer" id="drawer" aria-label="Agent" hidden></aside>
|
||||||
<div class="sheet" id="sheet" hidden></div>
|
<div class="sheet" id="sheet" hidden></div>
|
||||||
<div class="toasts" id="toasts" aria-live="polite"></div>
|
<div class="toasts" id="toasts" aria-live="polite"></div>
|
||||||
@@ -400,6 +392,8 @@ body.sky-look .sky:active { cursor: grabbing; }
|
|||||||
</script>
|
</script>
|
||||||
<script nonce="{{ nonce }}">
|
<script nonce="{{ nonce }}">
|
||||||
{% include "voice.js" %}
|
{% include "voice.js" %}
|
||||||
|
|
||||||
|
{% include "nav.js" %}
|
||||||
</script>
|
</script>
|
||||||
<script nonce="{{ nonce }}">
|
<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 #}
|
{# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
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 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 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 hubIds = new Set(sent.map((entry) => entry.id));
|
||||||
const relayed = radio.filter((entry) => !(entry.who === HUMAN && [...hubIds].some((id) => entry.text.includes(`[kvb-hub msg ${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);
|
return [...relayed, ...sent].sort((a, b) => a.ts - b.ts).slice(-80);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
})) : h("p", {class: "muted"}, "Keine Subagenten."));
|
})) : h("p", {class: "muted"}, "Keine Subagenten."));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deliver(text, file) {
|
async function deliver(text, file, cls) {
|
||||||
let upload = "";
|
let upload = "";
|
||||||
if (file) {
|
if (file) {
|
||||||
if (file.size > UPLOAD_LIMIT) {
|
if (file.size > UPLOAD_LIMIT) {
|
||||||
@@ -72,16 +72,17 @@
|
|||||||
}
|
}
|
||||||
upload = stored.data.id;
|
upload = stored.data.id;
|
||||||
}
|
}
|
||||||
return message(nameOf(ui.agent), text, upload);
|
return message(nameOf(ui.agent), text, upload, cls);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function message(to, text, upload) {
|
async function message(to, text, upload, cls) {
|
||||||
const result = await sendJson("/api/messages", {to, text, upload});
|
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) {
|
if (!result.ok || !result.data) {
|
||||||
toast(`Nachricht an ${to} fehlgeschlagen`, [`${result.status} ${result.text}`.trim()], false);
|
toast(`${label} an ${to} fehlgeschlagen`, [`${result.status} ${result.text}`.trim()], false);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
toast(`Nachricht an ${to}: ${result.data.ok ? "gefunkt" : "gespeichert, Funk fehlgeschlagen"}`, [`kvb-hub msg ${result.data.id}`, ...runLines(result.data)], result.data.ok);
|
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();
|
await load();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -94,14 +95,17 @@
|
|||||||
const text = h("textarea", {"aria-label": `Nachricht an ${name}`, placeholder: `Nachricht an ${name} …`});
|
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 file = h("input", {type: "file", class: "field", "aria-label": "Datei anhängen"});
|
||||||
const button = h("button", {type: "submit", class: "btn primary"}, "Senden");
|
const button = h("button", {type: "submit", class: "btn primary"}, "Senden");
|
||||||
const form = h("form", {class: "drawer-form"}, text, file, h("div", {class: "actions"}, h("span", {class: "muted"}, "Funk direkt von Operator, prüfbar mit operator_message"), h("span", {class: "spacer"}), micButton(text), button));
|
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) => {
|
form.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!text.value.trim()) {
|
if (!text.value.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
if (await deliver(text.value, file.files[0] || null)) {
|
if (await deliver(text.value, file.files[0] || null, picked(classes))) {
|
||||||
text.value = "";
|
text.value = "";
|
||||||
file.value = "";
|
file.value = "";
|
||||||
}
|
}
|
||||||
@@ -20,20 +20,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyButton = document.getElementById("notify");
|
|
||||||
function notifyLabel() {
|
function notifyLabel() {
|
||||||
if (!("Notification" in window)) {
|
const button = document.getElementById("notify");
|
||||||
|
if (!button) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const labels = {granted: "Benachrichtigungen an", denied: "Benachrichtigungen blockiert", default: "Benachrichtigungen aktivieren"};
|
const permission = "Notification" in window ? Notification.permission : "unsupported";
|
||||||
notifyButton.hidden = Notification.permission === "granted";
|
const labels = {granted: "Benachrichtigungen an", denied: "Benachrichtigungen blockiert", default: "Benachrichtigungen aktivieren", unsupported: "Benachrichtigungen fehlen im Browser"};
|
||||||
notifyButton.textContent = labels[Notification.permission] || labels.default;
|
button.textContent = labels[permission] || labels.default;
|
||||||
notifyButton.disabled = Notification.permission !== "default";
|
button.disabled = permission !== "default";
|
||||||
}
|
}
|
||||||
notifyButton.addEventListener("click", async () => {
|
|
||||||
await Notification.requestPermission();
|
async function askNotify() {
|
||||||
|
if ("Notification" in window) {
|
||||||
|
await Notification.requestPermission();
|
||||||
|
}
|
||||||
notifyLabel();
|
notifyLabel();
|
||||||
});
|
}
|
||||||
|
|
||||||
function announce() {
|
function announce() {
|
||||||
const alarms = new Map(alarmRows().map((row) => [`${row.ts}|${row.from}`, row]));
|
const alarms = new Map(alarmRows().map((row) => [`${row.ts}|${row.from}`, row]));
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
async function send(path, body, type) {
|
async function send(path, body, type) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(path, {method: "POST", headers: {"X-Kvb-Hub": "1", "Content-Type": type}, body, cache: "no-store"});
|
const response = await fetch(path, {method: "POST", headers: {"X-Kvb-Orchestrator": "1", "Content-Type": type}, body, cache: "no-store"});
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
let data = null;
|
let data = null;
|
||||||
try {
|
try {
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -9,12 +9,8 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.getElementById("palette-open").addEventListener("click", openPalette);
|
document.getElementById("palette-open").addEventListener("click", openPalette);
|
||||||
document.getElementById("board-open").addEventListener("click", boardSheet);
|
|
||||||
document.getElementById("rules-open").addEventListener("click", rulesSheet);
|
|
||||||
document.getElementById("settings-open").addEventListener("click", settingsSheet);
|
document.getElementById("settings-open").addEventListener("click", settingsSheet);
|
||||||
document.getElementById("music-toggle").addEventListener("click", toggleMusic);
|
document.addEventListener("click", (event) => closeMenus(event.target instanceof Element ? event.target.closest("details.menu") : null));
|
||||||
document.getElementById("leitstand").addEventListener("click", toggleLeitstand);
|
|
||||||
document.getElementById("inbox-bar").addEventListener("click", () => document.body.classList.toggle("inbox-open"));
|
|
||||||
document.addEventListener("fullscreenchange", () => {
|
document.addEventListener("fullscreenchange", () => {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
document.body.classList.remove("leitstand");
|
document.body.classList.remove("leitstand");
|
||||||
@@ -57,7 +53,7 @@
|
|||||||
window.setInterval(() => live() && drawSky(), 30000);
|
window.setInterval(() => live() && drawSky(), 30000);
|
||||||
window.setInterval(syncRadio, 20000);
|
window.setInterval(syncRadio, 20000);
|
||||||
window.setInterval(() => {
|
window.setInterval(() => {
|
||||||
if (ui.screen === "tree" && !document.hidden) {
|
if (tree.dom && !document.hidden) {
|
||||||
fetchTree();
|
fetchTree();
|
||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
@@ -2,22 +2,21 @@
|
|||||||
const DAY = 86400;
|
const DAY = 86400;
|
||||||
const CHAIR = "Maintainer";
|
const CHAIR = "Maintainer";
|
||||||
const HUMAN = "Operator";
|
const HUMAN = "Operator";
|
||||||
const SCREENS = [["stream", "Stream"], ["graph", "Graph"], ["tree", "Tree"]];
|
|
||||||
const GRAPHS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"]];
|
|
||||||
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "alle"], [-1, "eigener"]];
|
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "alle"], [-1, "eigener"]];
|
||||||
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
|
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
|
||||||
const PARTS = [["user", "Nutzer"], ["assistant", "Assistent"], ["tool_call", "Werkzeugaufruf"], ["tool_result", "Werkzeugergebnis"], ["system", "System"]];
|
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 PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
|
||||||
const 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"]];
|
|
||||||
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 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 ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Entscheidung", operator: "Operator"};
|
||||||
const NIGHT = {day: ["Tag", "ok"], evening: ["Abend", "warn"], sunset: ["Nacht", "violet"]};
|
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 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 UPLOAD_LIMIT = 50 * 1024 * 1024;
|
||||||
const STREAM_KEEP = 5000;
|
const STREAM_KEEP = 5000;
|
||||||
const CLASSES = {direct: "Direkt", broadcast: "Rundruf", mayday: "Mayday", "pan-pan": "Pan-Pan", security: "Sicherheit", grant: "Freigabe"};
|
const CLASSES = {post: "Post", call: "Call", newsletter: "Newsletter", broadcast: "Broadcast", mayday: "Mayday", "pan-pan": "Pan-Pan", security: "Security", receipt: "Gelesen", grant: "Freigabe"};
|
||||||
const PILLS = {direct: "info", broadcast: "teal", mayday: "bad", "pan-pan": "warn", security: "violet", grant: "ok"};
|
const PILLS = {post: "info", call: "info", newsletter: "teal", broadcast: "teal", mayday: "bad", "pan-pan": "warn", security: "violet", receipt: "neutral", grant: "ok"};
|
||||||
const LEGEND = [["direct", "Direkt"], ["broadcast", "Rundruf"], ["alarm", "Mayday / Pan-Pan"], ["security", "Sicherheit"], ["grant", "Queue-Freigabe"]];
|
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 = {
|
const STATUS = {
|
||||||
running: ["läuft", "ok"], queued: ["wartet", "info"], blocked: ["blockiert", "bad"], stale: ["veraltet", "warn"], pending: ["offen", "warn"], done: ["erledigt", "neutral"], stopped: ["gestoppt", "neutral"],
|
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"],
|
approved: ["freigegeben", "ok"], rejected: ["abgelehnt", "bad"], allowed: ["erlaubt", "ok"], denied: ["abgelehnt", "bad"], expired: ["abgelaufen", "neutral"], decided: ["entschieden", "ok"], failed: ["fehlgeschlagen", "bad"], refused: ["verweigert", "bad"],
|
||||||
@@ -31,11 +30,10 @@
|
|||||||
const FEED_LIMIT = 300;
|
const FEED_LIMIT = 300;
|
||||||
const MOVE = 2000;
|
const MOVE = 2000;
|
||||||
const RELAYOUT = 60000;
|
const RELAYOUT = 60000;
|
||||||
const ACTIVE = 900;
|
|
||||||
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||||
const STORE = {views: "kvb-hub.views"};
|
const STORE = {views: "kvb-orchestrator.views"};
|
||||||
const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, Asks, Entscheidungen"], ["direct", "Direktnachrichten an dich"], ["radio", "Aller Funk"]];
|
const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, Asks, Entscheidungen"], ["direct", "Post und Calls an dich"], ["radio", "Aller Funk"]];
|
||||||
const SPEECH_CLASSES = [["mayday", "MAYDAY"], ["pan-pan", "PAN-PAN"], ["security", "SECURITE"], ["direct", "Direktruf"], ["broadcast", "Rundruf"]];
|
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_QUEUE = 20;
|
||||||
const SPEECH_QUIET = 60000;
|
const SPEECH_QUIET = 60000;
|
||||||
const SPEECH_FRESH = 300;
|
const SPEECH_FRESH = 300;
|
||||||
@@ -44,7 +42,7 @@
|
|||||||
const STAGE_LABELS = {night: "Nacht", astronomical: "Astronomische Dämmerung", nautical: "Nautische Dämmerung", civil: "Bürgerliche Dämmerung", golden: "Goldene Stunde", day: "Tag"};
|
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 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 COMPASS = [["N", "Norden"], ["NO", "Nordosten"], ["O", "Osten"], ["SO", "Südosten"], ["S", "Süden"], ["SW", "Südwesten"], ["W", "Westen"], ["NW", "Nordwesten"]];
|
||||||
const AXES = [["none", "Flach", "Die heutige 2D-Ansicht"], ["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 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 SUBS_TTL = 60000;
|
||||||
const TURN = 10;
|
const TURN = 10;
|
||||||
const NUDGE = 40;
|
const NUDGE = 40;
|
||||||
@@ -64,7 +62,7 @@
|
|||||||
direct: [[740, 0, 0.2]],
|
direct: [[740, 0, 0.2]],
|
||||||
radio: [[520, 0, 0.07]],
|
radio: [[520, 0, 0.07]],
|
||||||
};
|
};
|
||||||
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Graph · Tree"], ["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 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 calm = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
const relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"});
|
const relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"});
|
||||||
const absolute = new Intl.DateTimeFormat("de-DE", {dateStyle: "medium", timeStyle: "short"});
|
const absolute = new Intl.DateTimeFormat("de-DE", {dateStyle: "medium", timeStyle: "short"});
|
||||||
@@ -107,6 +107,14 @@
|
|||||||
return button;
|
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) {
|
function openSheet(title, body, wide) {
|
||||||
const sheet = document.getElementById("sheet");
|
const sheet = document.getElementById("sheet");
|
||||||
sheet.replaceChildren(h("div", {class: `sheet-box${wide ? " wide" : ""}`, role: "dialog", "aria-modal": "true", "aria-label": title},
|
sheet.replaceChildren(h("div", {class: `sheet-box${wide ? " wide" : ""}`, role: "dialog", "aria-modal": "true", "aria-label": title},
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const kinds = activeKinds();
|
const kinds = activeKinds();
|
||||||
const presets = 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));
|
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: {
|
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),
|
click: () => useSaved(view),
|
||||||
keydown: (event) => {
|
keydown: (event) => {
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
dropView(view.name);
|
dropView(view.name);
|
||||||
}}}, "×")));
|
}}}, "×")));
|
||||||
stream.dom.presets.replaceChildren(...presets, ...mine, btn("+ Ansicht speichern", "btn small ghost", saveView));
|
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: () => {
|
const kindChips = KINDS.map(([id, label]) => h("button", {type: "button", class: "chip", "aria-pressed": String(kinds.has(id)), on: {click: () => {
|
||||||
customize();
|
customize();
|
||||||
if (filter.kinds.has(id) && filter.kinds.size > 1) {
|
if (filter.kinds.has(id) && filter.kinds.size > 1) {
|
||||||
@@ -138,14 +138,14 @@
|
|||||||
filter.parts.add(id);
|
filter.parts.add(id);
|
||||||
}
|
}
|
||||||
refilter();
|
refilter();
|
||||||
}}}, label)) : [];
|
}}}, label)) : [h("span", {class: "muted"}, "Erst mit der Art Transkripte.")];
|
||||||
const focus = filter.sub || filter.session ? h("span", {class: "chip", "aria-pressed": "true"}, filter.sub ? `Subagent ${filter.sub.slice(0, 12)}` : `Session ${filter.session.slice(0, 8)}`,
|
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: () => {
|
h("button", {type: "button", class: "x", "aria-label": "Filter entfernen", on: {click: () => {
|
||||||
filter.sub = "";
|
filter.sub = "";
|
||||||
filter.session = "";
|
filter.session = "";
|
||||||
|
filter.agent = "";
|
||||||
refilter();
|
refilter();
|
||||||
}}}, "×")) : null;
|
}}}, "×")) : null;
|
||||||
stream.dom.parts.replaceChildren(...parts, focus || "");
|
stream.dom.focus.replaceChildren(...(focus ? [focus] : []));
|
||||||
const names = [...new Set([...Object.values(stream.sessions), ...Object.values(people()).flatMap((entry) => entry.names), ...state.claims.map((claim) => claim.agent), ...state.priority].filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
|
||||||
stream.dom.agents.replaceChildren(h("option", {value: ""}, "Alle Agenten"), names.map((name) => h("option", {value: name, selected: name === filter.agent}, name)));
|
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function legend() {
|
function legend() {
|
||||||
const axis = graph3d() ? space.views.graph.axis : "none";
|
const axis = graph3d() ? depth() : "none";
|
||||||
const words = {
|
const words = {
|
||||||
none: "Linienstärke = Anzahl · Nähe = Zusammenarbeit der letzten Zeit",
|
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",
|
time: "Weltlinie = Agent, Lage = Nähe der Zusammenarbeit zu ihrer Zeit · Bogen = ein Ereignis · Ebene = Stand der Wiedergabe",
|
||||||
@@ -187,13 +187,23 @@
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function graphScreen() {
|
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 stageBox = h("div", {class: "stage"});
|
||||||
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
||||||
graphDom = {stage: stageBox, aside, pulses: null, space: null};
|
const toolbar = agentsToolbar();
|
||||||
return h("div", {class: "graph"},
|
graphDom = {stage: stageBox, aside, pulses: null, space: null, toolbar};
|
||||||
h("div", {class: "card controls"},
|
const body = view === "tree" ? later("Der Spawn-Baum") : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legend()), aside);
|
||||||
h("div", {class: "segmented", role: "tablist", "aria-label": "Darstellung"}, GRAPHS.map(([id, name]) => h("button", {type: "button", role: "tab", "aria-selected": String(ui.graph === id), on: {click: () => go("graph", id)}}, name))),
|
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? spaceTools() : null), body);
|
||||||
ui.graph === "orbit" ? spaceTools() : null),
|
}
|
||||||
h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legend()), aside));
|
|
||||||
|
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]}`));
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
const from = motion.from.get(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)}];
|
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.graph !== "swimlane") {
|
if (ui.views.agents !== "swimlane") {
|
||||||
stage();
|
stage();
|
||||||
}
|
}
|
||||||
motion.frame = progress < 1 ? window.requestAnimationFrame(step) : 0;
|
motion.frame = progress < 1 ? window.requestAnimationFrame(step) : 0;
|
||||||
@@ -159,7 +159,15 @@
|
|||||||
btn(item.pinned ? "lösen" : "anheften", "btn small ghost", () => mark(item, item.until, !item.pinned))));
|
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() {
|
function drawInbox() {
|
||||||
|
if (!inboxDom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const items = visibleInbox();
|
const items = visibleInbox();
|
||||||
ui.cursor = Math.min(ui.cursor, Math.max(items.length - 1, 0));
|
ui.cursor = Math.min(ui.cursor, Math.max(items.length - 1, 0));
|
||||||
const present = new Set(items.map(keyOf));
|
const present = new Set(items.map(keyOf));
|
||||||
@@ -182,13 +190,13 @@
|
|||||||
}}});
|
}}});
|
||||||
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 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})`);
|
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})`);
|
||||||
document.getElementById("rail").replaceChildren(
|
inboxDom.replaceChildren(
|
||||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Inbox", counter(waitingCount())), h("span", {class: "spacer"}),
|
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),
|
hidden ? h("label", {class: "check"}, snoozed, `${hidden} zurückgestellt`) : null),
|
||||||
h("div", {class: "inbox-actions"}, choose, all),
|
h("div", {class: "inbox-actions"}, choose, all),
|
||||||
onLocalhost() ? null : h("p", {class: "muted"}, "Freigaben signiert der Passkey nur unter ", localLink()),
|
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."));
|
items.length ? h("ol", {class: "inbox-list"}, items.map(inboxItem)) : emptyState("Alles erledigt", "Gerade wartet nichts auf dich."));
|
||||||
const current = document.querySelector(`#rail .item[data-index="${ui.cursor}"]`);
|
const current = inboxDom.querySelector(`.item[data-index="${ui.cursor}"]`);
|
||||||
if (current && document.activeElement === document.body) {
|
if (current && document.activeElement === document.body) {
|
||||||
current.scrollIntoView({block: "nearest"});
|
current.scrollIntoView({block: "nearest"});
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
function helpSheet() {
|
function helpSheet() {
|
||||||
openSheet("Tastenkürzel", [h("dl", {class: "keys"}, KEYS.map(([key, text]) => [h("dt", {}, key), h("dd", {}, text)])),
|
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ßem Status für den zweiten Bildschirm. Auf schmalen Bildschirmen öffnet die Leiste unten die Inbox.")], false);
|
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() {
|
function overlayOpen() {
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
}
|
}
|
||||||
ui.cursor = Math.min(Math.max(ui.cursor + delta, 0), items.length - 1);
|
ui.cursor = Math.min(Math.max(ui.cursor + delta, 0), items.length - 1);
|
||||||
drawInbox();
|
drawInbox();
|
||||||
const current = document.querySelector(`#rail .item[data-index="${ui.cursor}"]`);
|
const current = inboxDom && inboxDom.querySelector(`.item[data-index="${ui.cursor}"]`);
|
||||||
if (current) {
|
if (current) {
|
||||||
current.scrollIntoView({block: "nearest"});
|
current.scrollIntoView({block: "nearest"});
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
} else if (ui.agent !== null) {
|
} else if (ui.agent !== null) {
|
||||||
closeAgent();
|
closeAgent();
|
||||||
} else {
|
} else {
|
||||||
document.body.classList.remove("inbox-open");
|
closeMenus();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -51,18 +51,19 @@
|
|||||||
if (target instanceof HTMLElement && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))) {
|
if (target instanceof HTMLElement && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const current = visibleInbox()[ui.cursor];
|
const inInbox = ui.screen === "inbox";
|
||||||
|
const current = inInbox ? visibleInbox()[ui.cursor] : null;
|
||||||
const index = Number(event.key) - 1;
|
const index = Number(event.key) - 1;
|
||||||
if (/^[1-3]$/.test(event.key)) {
|
if (/^[1-3]$/.test(event.key)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
go(SCREENS[index][0], null);
|
go(Nav.TABS[index][0], null);
|
||||||
} else if (event.key === "j" || event.key === "k") {
|
} else if (inInbox && (event.key === "j" || event.key === "k")) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
moveCursor(event.key === "j" ? 1 : -1);
|
moveCursor(event.key === "j" ? 1 : -1);
|
||||||
} else if (event.key === "x") {
|
} else if (inInbox && event.key === "x") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggle(current);
|
toggle(current);
|
||||||
} else if (event.key === "a") {
|
} else if (inInbox && event.key === "a") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (current && !current.held) {
|
if (current && !current.held) {
|
||||||
approveOne(current);
|
approveOne(current);
|
||||||
File renamed without changes.
@@ -138,6 +138,9 @@
|
|||||||
|
|
||||||
function musicButton() {
|
function musicButton() {
|
||||||
const button = document.getElementById("music-toggle");
|
const button = document.getElementById("music-toggle");
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
button.setAttribute("aria-pressed", String(settings.music.on));
|
button.setAttribute("aria-pressed", String(settings.music.on));
|
||||||
button.textContent = settings.music.on ? "♪ Musik an" : "♪ Musik";
|
button.textContent = settings.music.on ? "♪ Musik an" : "♪ Musik";
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
function paletteItems() {
|
function paletteItems() {
|
||||||
return [
|
return [
|
||||||
...SCREENS.map(([id, label], index) => ({label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)})),
|
...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)}))]),
|
||||||
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Ansicht", run: () => useView(id)})),
|
...["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)})),
|
...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)})),
|
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})),
|
||||||
...ACTIONS.map(([id, label]) => ({label, hint: "Aktion", run: () => confirmAct(id)})),
|
{label: "Funk an alle …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
|
||||||
{label: "Steuerung …", hint: "Rundruf, Nacht, Priorität", run: boardSheet},
|
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Tag/Nacht", run: () => confirmAct(id)})),
|
||||||
{label: "Regeln …", hint: "automatische Freigaben", run: rulesSheet},
|
{label: "Priorität …", hint: "Reihenfolge der Agenten", run: prioritySheet},
|
||||||
{label: "Einstellungen …", hint: "Ort, Farben, Musik, Töne, Funk vorlesen, Radio", run: settingsSheet},
|
{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: 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: space.look ? "Himmel verlassen" : "Himmel ansehen", hint: "h", run: () => skyLook(!space.look)},
|
||||||
{label: "Leitstand umschalten", hint: "Vollbild", run: toggleLeitstand},
|
{label: "Leitstand umschalten", hint: "Ambiente", run: toggleLeitstand},
|
||||||
|
{label: "Benachrichtigungen aktivieren", hint: "Ambiente", run: askNotify},
|
||||||
{label: "Tastenkürzel", hint: "?", run: helpSheet},
|
{label: "Tastenkürzel", hint: "?", run: helpSheet},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -25,7 +28,7 @@
|
|||||||
if (raw) {
|
if (raw) {
|
||||||
items.push(state.llm
|
items.push(state.llm
|
||||||
? {label: `KI-Befehl: „${raw}“`, hint: "Tab", keep: true, run: () => askModel(raw)}
|
? {label: `KI-Befehl: „${raw}“`, hint: "Tab", keep: true, run: () => askModel(raw)}
|
||||||
: {label: "KI-Befehl ist aus", hint: "KVB_HUB_LLM_URL und KVB_HUB_LLM_MODEL in hub/.env setzen", keep: true, run: () => toast("KI-Befehl ist aus", ["Setze KVB_HUB_LLM_URL, KVB_HUB_LLM_MODEL und KVB_HUB_LLM_KEY_FILE in hub/.env und installiere neu."], false)});
|
: {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.items = items;
|
||||||
palette.index = Math.min(palette.index, Math.max(items.length - 1, 0));
|
palette.index = Math.min(palette.index, Math.max(items.length - 1, 0));
|
||||||
@@ -93,7 +96,7 @@
|
|||||||
} else if (action.name === "open") {
|
} else if (action.name === "open") {
|
||||||
go(args.view, null);
|
go(args.view, null);
|
||||||
} else if (action.name === "message") {
|
} else if (action.name === "message") {
|
||||||
await message(args.to, args.text, "");
|
await message(args.to, args.text, "", "post");
|
||||||
} else if (action.name === "filter") {
|
} else if (action.name === "filter") {
|
||||||
filter.q = args.q;
|
filter.q = args.q;
|
||||||
if (ui.screen === "stream" && stream.dom) {
|
if (ui.screen === "stream" && stream.dom) {
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const credential = await navigator.credentials.create({publicKey: {
|
const credential = await navigator.credentials.create({publicKey: {
|
||||||
rp: {id: RP_ID, name: "kvb-hub"},
|
rp: {id: RP_ID, name: "Agent Orchestrator"},
|
||||||
user: {id: crypto.getRandomValues(new Uint8Array(16)), name: "operator", displayName: "kvb-hub Operator"},
|
user: {id: crypto.getRandomValues(new Uint8Array(16)), name: "operator", displayName: "Agent Orchestrator Operator"},
|
||||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||||
pubKeyCredParams: ALGORITHMS.map((alg) => ({type: "public-key", alg})),
|
pubKeyCredParams: ALGORITHMS.map((alg) => ({type: "public-key", alg})),
|
||||||
attestation: "none",
|
attestation: "none",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
}});
|
}});
|
||||||
const answer = credential.response;
|
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 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-hub/client/approval.py pin --port ${window.location.port} --approvers ~/.config/kvb-hub/approvers.json --credential '${blob}'`;
|
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(
|
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("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("div", {class: "command"}, h("code", {}, command), copyButton(command)),
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pulses(rows) {
|
function pulses(rows) {
|
||||||
if (calm.matches || ui.screen !== "graph" || ui.graph !== "orbit" || !live() || !graphDom || !graphDom.pulses) {
|
if (calm.matches || ui.screen !== "agents" || ui.views.agents !== "orbit" || !live() || !graphDom || !graphDom.pulses) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const layer = graphDom.pulses;
|
const layer = graphDom.pulses;
|
||||||
File renamed without changes.
File renamed without changes.
@@ -1,4 +1,4 @@
|
|||||||
function rulesSheet() {
|
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 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 tool = h("input", {class: "field", type: "text", placeholder: "Bash", value: "Bash"});
|
||||||
const pattern = h("input", {class: "field", type: "text", placeholder: "make test*"});
|
const pattern = h("input", {class: "field", type: "text", placeholder: "make test*"});
|
||||||
@@ -44,7 +44,6 @@
|
|||||||
const result = await sendJson("/api/rules", {rule: prepared.data.payload, assertion: signed, enabled: enabled.checked});
|
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);
|
toast(result.ok ? "Regel angelegt" : "Regel nicht angelegt", result.ok ? [] : fail(result), result.ok);
|
||||||
await load();
|
await load();
|
||||||
rulesSheet();
|
|
||||||
});
|
});
|
||||||
const change = async (rule, verb) => {
|
const change = async (rule, verb) => {
|
||||||
const result = await send(`/api/rules/${encodeURIComponent(rule.id)}/${verb}`, "", "application/json");
|
const result = await send(`/api/rules/${encodeURIComponent(rule.id)}/${verb}`, "", "application/json");
|
||||||
@@ -52,7 +51,6 @@
|
|||||||
toast("Regel nicht geändert", [`${result.status} ${result.text}`.trim()], false);
|
toast("Regel nicht geändert", [`${result.status} ${result.text}`.trim()], false);
|
||||||
}
|
}
|
||||||
await load();
|
await load();
|
||||||
rulesSheet();
|
|
||||||
};
|
};
|
||||||
const now = nowSeconds();
|
const now = nowSeconds();
|
||||||
const list = state.rules.length ? h("div", {class: "mini"}, state.rules.map((rule) => h("div", {class: "rule-row"},
|
const list = state.rules.length ? h("div", {class: "mini"}, state.rules.map((rule) => h("div", {class: "rule-row"},
|
||||||
@@ -70,5 +68,5 @@
|
|||||||
change(rule, "delete");
|
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.");
|
})))) : h("p", {class: "muted"}, "Noch keine Regel. Jede automatische Freigabe erscheint im Stream und lässt sich 3 s lang rückgängig machen.");
|
||||||
openSheet("Regeln für automatische Freigaben", [list, hint, form], false);
|
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);
|
||||||
|
}
|
||||||
@@ -55,6 +55,23 @@
|
|||||||
list.append(entry.row);
|
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() {
|
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 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 latitude = number(settings.latitude, -90, 90, "Breite");
|
||||||
@@ -83,16 +100,17 @@
|
|||||||
});
|
});
|
||||||
const notify = h("input", {type: "checkbox", checked: settings.notify});
|
const notify = h("input", {type: "checkbox", checked: settings.notify});
|
||||||
const speechOn = h("input", {type: "checkbox", checked: settings.speech.on, disabled: !state.speech.tts});
|
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"})}));
|
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))
|
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`})}));
|
.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 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: "direct"}));
|
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;
|
sample.disabled = !state.speech.tts;
|
||||||
const list = h("div", {class: "mini"});
|
const list = h("div", {class: "mini"});
|
||||||
const rows = [];
|
const rows = [];
|
||||||
settings.streams.forEach((stream) => streamRow(stream, rows, list));
|
settings.streams.forEach((stream) => streamRow(stream, rows, list));
|
||||||
const form = h("form", {class: "settings"},
|
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("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("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", {}, "Darstellung"), h("label", {}, "Farben", theme), custom, verdict),
|
||||||
@@ -135,5 +153,5 @@
|
|||||||
toast("Einstellungen gespeichert", [], true);
|
toast("Einstellungen gespeichert", [], true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
openSheet("Einstellungen", [form], false);
|
openSheet("Einstellungen und Ambiente", [form], false);
|
||||||
}
|
}
|
||||||
@@ -170,10 +170,8 @@
|
|||||||
|
|
||||||
function skyLook(on) {
|
function skyLook(on) {
|
||||||
const canvas = sky.canvas;
|
const canvas = sky.canvas;
|
||||||
const button = document.getElementById("sky-look");
|
|
||||||
space.look = on;
|
space.look = on;
|
||||||
document.body.classList.toggle("sky-look", on);
|
document.body.classList.toggle("sky-look", on);
|
||||||
button.setAttribute("aria-pressed", String(on));
|
|
||||||
document.getElementById("sky-panel").hidden = !on;
|
document.getElementById("sky-panel").hidden = !on;
|
||||||
if (on) {
|
if (on) {
|
||||||
canvas.removeAttribute("aria-hidden");
|
canvas.removeAttribute("aria-hidden");
|
||||||
@@ -188,7 +186,7 @@
|
|||||||
canvas.removeAttribute(name);
|
canvas.removeAttribute(name);
|
||||||
}
|
}
|
||||||
canvas.setAttribute("aria-hidden", "true");
|
canvas.setAttribute("aria-hidden", "true");
|
||||||
button.focus();
|
document.getElementById("settings-open").focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function skyPanel() {
|
function skyPanel() {
|
||||||
@@ -205,5 +203,4 @@
|
|||||||
step("⟲ Zurücksetzen", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")),
|
step("⟲ Zurücksetzen", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")),
|
||||||
step("Fertig", "Himmel verlassen (Esc)", () => skyLook(false)));
|
step("Fertig", "Himmel verlassen (Esc)", () => skyLook(false)));
|
||||||
steer(sky.canvas, "sky", skyHooks);
|
steer(sky.canvas, "sky", skyHooks);
|
||||||
document.getElementById("sky-look").addEventListener("click", () => skyLook(!space.look));
|
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
axis: space.views.graph.axis, range, span, cursor: shownCursor(), nodes: state.graph.nodes, track: state.graph.track, now: Object.fromEntries(layout(state.graph.nodes)),
|
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,
|
lives, subagents, radio: state.radio, grants: state.grants, chair: chairKey(), cap: Space.ARC_CAP,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function spaceLabel() {
|
function spaceLabel() {
|
||||||
const hint = AXES.find(([id]) => id === space.views.graph.axis)[2];
|
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.`;
|
return `3D-Graph, ${hint}. Pfeiltasten drehen, Umschalt+Pfeile verschieben, Plus und Minus zoomen, 0 setzt zurück. Alle Weltlinien stehen darunter als Schaltflächen.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
|
const depth = () => (space.views.graph.axis === "spawn" ? "spawn" : "time");
|
||||||
|
|
||||||
function spaceTools() {
|
function spaceTools() {
|
||||||
const axis = space.views.graph.axis;
|
|
||||||
const deep = axis !== "none";
|
|
||||||
return h("div", {class: "space-tools"},
|
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(axis === id), title: hint, "aria-label": `Tiefenachse ${label}: ${hint}`, on: {click: () => chooseAxis(id)}}, label))),
|
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))),
|
||||||
deep ? 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") : null,
|
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"),
|
||||||
deep ? h("span", {class: "muted", id: "space-help"}, "Ziehen dreht · Umschalt+Ziehen verschiebt · Rad oder zwei Finger zoomen · Pfeile, + / −, 0") : null);
|
h("span", {class: "muted", id: "space-help"}, "Ziehen dreht · Umschalt+Ziehen verschiebt · Rad oder zwei Finger zoomen · Pfeile, + / −, 0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const graph3d = () => ui.screen === "graph" && ui.graph === "orbit" && space.views.graph.axis !== "none";
|
const graph3d = () => ui.screen === "agents" && ui.views.agents === "3d";
|
||||||
const wantsTrack = () => graph3d() && space.views.graph.axis === "time";
|
const wantsTrack = () => graph3d() && depth() === "time";
|
||||||
|
|
||||||
function chooseAxis(id) {
|
function chooseAxis(id) {
|
||||||
if (id === space.views.graph.axis) {
|
if (id === depth()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setView("graph", {...space.views.graph, axis: id}, false);
|
setView("graph", {...space.views.graph, axis: id}, false);
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stage() {
|
function stage() {
|
||||||
if (!graphDom || ui.screen !== "graph") {
|
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
graphDom.pulses = null;
|
graphDom.pulses = null;
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
stageSpace();
|
stageSpace();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.graph]());
|
graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.views.agents]());
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSide() {
|
function drawSide() {
|
||||||
if (graphDom && ui.screen === "graph") {
|
if (graphDom && ui.screen === "agents" && ui.views.agents !== "tree") {
|
||||||
graphDom.aside.replaceChildren(...side());
|
graphDom.aside.replaceChildren(...side());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
pump();
|
pump();
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Hub": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: item.text, voice: item.voice}), cache: "no-store"});
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(`${response.status} ${(await response.text()).trim()}`);
|
throw new Error(`${response.status} ${(await response.text()).trim()}`);
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let state = JSON.parse(document.getElementById("state").textContent);
|
let state = JSON.parse(document.getElementById("state").textContent);
|
||||||
const ui = {screen: "stream", graph: "orbit", 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 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 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 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 tree = {data: null, busy: false, q: "", open: new Set(), dom: null};
|
||||||
@@ -46,13 +47,14 @@
|
|||||||
scene: null, sceneCursor: null, cam: null, frame: 0, look: false, compass: null, subs: new Map(), loading: false, version: 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,
|
cursor: {from: 0, to: null, start: 0}, style: null, styleKey: "", trackAsked: 0,
|
||||||
};
|
};
|
||||||
let userViews = validViews(stored(STORE.views, []));
|
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
|
||||||
let settings = state.settings;
|
let settings = state.settings;
|
||||||
let settingsSig = JSON.stringify(settings);
|
let settingsSig = JSON.stringify(settings);
|
||||||
let drawerDom = null;
|
let drawerDom = null;
|
||||||
let timeDom = null;
|
let timeDom = null;
|
||||||
let graphDom = null;
|
let graphDom = null;
|
||||||
let reviewDom = null;
|
let reviewDom = null;
|
||||||
|
let inboxDom = null;
|
||||||
let replay = 0;
|
let replay = 0;
|
||||||
let positions = new Map();
|
let positions = new Map();
|
||||||
let inboxSig = "";
|
let inboxSig = "";
|
||||||
@@ -161,26 +161,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function streamScreen() {
|
function streamScreen() {
|
||||||
const agents = h("select", {"aria-label": "Agent", on: {change: (event) => {
|
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.agent = event.target.value;
|
|
||||||
filter.saved = "";
|
|
||||||
refilter();
|
|
||||||
}}});
|
|
||||||
const search = h("input", {type: "search", placeholder: "Suchen … (/)", value: filter.q, "aria-label": "Stream durchsuchen", on: {input: (event) => {
|
|
||||||
filter.q = event.target.value;
|
filter.q = event.target.value;
|
||||||
|
filter.saved = "";
|
||||||
window.clearTimeout(searchTimer);
|
window.clearTimeout(searchTimer);
|
||||||
searchTimer = window.setTimeout(fetchEvents, 300);
|
searchTimer = window.setTimeout(fetchEvents, 300);
|
||||||
}}});
|
}}});
|
||||||
const count = h("span", {class: "muted"});
|
const count = h("span", {class: "muted"});
|
||||||
const presets = h("div", {class: "chips", role: "group", "aria-label": "Gespeicherte Ansichten"});
|
const categories = h("div", {class: "chips", role: "group", "aria-label": "Kategorien"});
|
||||||
const kinds = h("div", {class: "chips", role: "group", "aria-label": "Arten"});
|
const kinds = h("div", {class: "chips", role: "group", "aria-label": "Arten"});
|
||||||
const parts = h("div", {class: "chips", role: "group", "aria-label": "Transkript-Teile"});
|
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 list = h("div", {});
|
||||||
stream.dom = {agents, search, count, presets, kinds, parts, list};
|
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(() => {
|
window.setTimeout(() => {
|
||||||
drawFilters();
|
drawFilters();
|
||||||
drawStream();
|
drawStream();
|
||||||
fetchEvents();
|
fetchEvents();
|
||||||
}, 0);
|
}, 0);
|
||||||
return h("div", {}, presets, h("div", {class: "filters"}, agents, search, count), kinds, parts, list);
|
return h("div", {}, h("div", {class: "filters"}, categories, search, more, count), focus, list);
|
||||||
}
|
}
|
||||||
@@ -12,12 +12,10 @@
|
|||||||
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||||
};
|
};
|
||||||
|
|
||||||
function timeBar() {
|
function customRange() {
|
||||||
const bar = document.getElementById("timebar");
|
const from = h("input", {type: "datetime-local", "aria-label": "Von", value: localInput(ui.from === null ? state.since : ui.from)});
|
||||||
const presets = h("div", {class: "segmented", role: "group", "aria-label": "Zeitraum"});
|
const to = h("input", {type: "datetime-local", "aria-label": "Bis", value: localInput(ui.to === null ? state.until : ui.to)});
|
||||||
const from = h("input", {type: "datetime-local", "aria-label": "Von"});
|
return h("span", {class: "custom"}, from, h("span", {class: "muted"}, "bis"), to,
|
||||||
const to = h("input", {type: "datetime-local", "aria-label": "Bis"});
|
|
||||||
const custom = h("span", {class: "custom", hidden: true}, from, h("span", {class: "muted"}, "bis"), to,
|
|
||||||
h("button", {type: "button", class: "btn small soft", on: {click: () => {
|
h("button", {type: "button", class: "btn small soft", on: {click: () => {
|
||||||
const begin = Date.parse(from.value) / 1000;
|
const begin = Date.parse(from.value) / 1000;
|
||||||
const end = Date.parse(to.value) / 1000;
|
const end = Date.parse(to.value) / 1000;
|
||||||
@@ -25,15 +23,23 @@
|
|||||||
toast("Zeitraum ungültig", ["„Von“ muss vor „Bis“ liegen."], false);
|
toast("Zeitraum ungültig", ["„Von“ muss vor „Bis“ liegen."], false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
stopReplay();
|
||||||
|
ui.preset = -1;
|
||||||
ui.from = begin;
|
ui.from = begin;
|
||||||
ui.to = end;
|
ui.to = end;
|
||||||
reload();
|
reload();
|
||||||
}}}, "Anwenden"));
|
}}}, "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 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 play = h("button", {type: "button", class: "btn small soft"}, "▶ Wiedergabe");
|
||||||
const liveButton = h("button", {type: "button", class: "btn small ghost"}, "Live");
|
const liveButton = h("button", {type: "button", class: "btn small ghost"}, "Live");
|
||||||
const label = h("span", {class: "window-label"});
|
const label = h("span", {class: "window-label"});
|
||||||
for (const [seconds, name] of PRESETS) {
|
for (const [seconds, name] of PRESETS.filter(([seconds]) => seconds !== -1)) {
|
||||||
presets.append(h("button", {type: "button", "data-preset": seconds, on: {click: () => choosePreset(seconds)}}, name));
|
presets.append(h("button", {type: "button", "data-preset": seconds, on: {click: () => choosePreset(seconds)}}, name));
|
||||||
}
|
}
|
||||||
slider.addEventListener("input", () => {
|
slider.addEventListener("input", () => {
|
||||||
@@ -51,8 +57,9 @@
|
|||||||
}
|
}
|
||||||
reload();
|
reload();
|
||||||
});
|
});
|
||||||
bar.append(presets, custom, h("div", {class: "replay"}, play, slider, liveButton, label));
|
bar.append(summary, h("div", {class: "menu-box end"}, h("h3", {}, "Zeitraum"), presets, h("h3", {}, "Wiedergabe"), h("div", {class: "replay"}, play, slider, liveButton), label,
|
||||||
timeDom = {presets, custom, from, to, slider, play, live: liveButton, label};
|
h("p", {class: "muted"}, "Einen eigenen Zeitraum setzt Filter ▾ im Stream.")));
|
||||||
|
timeDom = {summary, presets, slider, play, live: liveButton, label};
|
||||||
}
|
}
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
@@ -65,12 +72,6 @@
|
|||||||
function choosePreset(seconds) {
|
function choosePreset(seconds) {
|
||||||
stopReplay();
|
stopReplay();
|
||||||
ui.preset = seconds;
|
ui.preset = seconds;
|
||||||
if (seconds === -1) {
|
|
||||||
ui.from = ui.from === null ? state.since : ui.from;
|
|
||||||
ui.to = ui.to === null ? state.until : ui.to;
|
|
||||||
timeDom.from.value = localInput(ui.from);
|
|
||||||
timeDom.to.value = localInput(ui.to);
|
|
||||||
}
|
|
||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
for (const button of timeDom.presets.children) {
|
for (const button of timeDom.presets.children) {
|
||||||
button.setAttribute("aria-pressed", String(Number(button.dataset.preset) === ui.preset));
|
button.setAttribute("aria-pressed", String(Number(button.dataset.preset) === ui.preset));
|
||||||
}
|
}
|
||||||
timeDom.custom.hidden = ui.preset !== -1;
|
timeDom.summary.textContent = `${(PRESETS.find(([seconds]) => seconds === ui.preset) || [0, "eigener"])[1]}${ui.asOf === null ? "" : " · Wiedergabe"} ▾`;
|
||||||
timeDom.slider.disabled = ui.preset === -1;
|
timeDom.slider.disabled = ui.preset === -1;
|
||||||
timeDom.play.disabled = ui.preset === -1;
|
timeDom.play.disabled = ui.preset === -1;
|
||||||
timeDom.live.disabled = live();
|
timeDom.live.disabled = live();
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawTree() {
|
function drawTree() {
|
||||||
if (!tree.dom || ui.screen !== "tree") {
|
if (!tree.dom) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!tree.data) {
|
if (!tree.data) {
|
||||||
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,7 @@
|
|||||||
const SpaceScene = (() => {
|
const SpaceScene = (() => {
|
||||||
"use strict";
|
"use strict";
|
||||||
const {LEVELS, clamp, turn, depthOf} = typeof SpaceView === "object" ? SpaceView : require("./space-view.js");
|
const {LEVELS, clamp, turn, depthOf} = typeof SpaceView === "object" ? SpaceView : require("./space-view.js");
|
||||||
const TONES = {direct: "direct", broadcast: "broadcast", mayday: "alarm", "pan-pan": "alarm", security: "security", grant: "grant", spawn: "spawn"};
|
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 RANKS = {alarm: 0, spawn: 1, security: 2, grant: 3, direct: 4, broadcast: 5};
|
||||||
const MOON = 38;
|
const MOON = 38;
|
||||||
const MOON_SPREAD = 0.5;
|
const MOON_SPREAD = 0.5;
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
-174
@@ -1,174 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Usage: hub.sh install | check | pin <blob> | runner | hook
|
|
||||||
# install copy hub/ to ~/.local/share/kvb-hub, write its .env, build and
|
|
||||||
# start the container, register the MCP server for Claude Code
|
|
||||||
# check image imports, container, logs, HTTP, pinned keys, hook, runner,
|
|
||||||
# disk; exit 1 when a check fails
|
|
||||||
# pin pin the credential blob the hub's "Passkey registrieren" shows
|
|
||||||
# runner install, enable and restart the runner user service
|
|
||||||
# hook register the PermissionRequest hook in ~/.claude/settings.json,
|
|
||||||
# replacing an earlier kvb-hub entry, keeping everything else and the
|
|
||||||
# file mode, after a copy to settings.json.before-kvb-hub-hook
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
HUB="${HOME}/.local/share/kvb-hub"
|
|
||||||
STATE="${HOME}/.local/state/kvb-hub"
|
|
||||||
APPROVALS="${HOME}/.local/state/kvb-hub-approvals"
|
|
||||||
APPROVERS="${HOME}/.config/kvb-hub"
|
|
||||||
UNITS="${HOME}/.config/systemd/user"
|
|
||||||
# council.py ROOT: every agent must agree on this directory
|
|
||||||
COUNCIL="${AGENTS_COUNCIL_ROOT:-/tmp/agents-council}"
|
|
||||||
SETTINGS="${HOME}/.claude/settings.json"
|
|
||||||
LOCAL_SETTINGS="${HOME}/.claude/settings.local.json"
|
|
||||||
HOOK_TIMEOUT=110
|
|
||||||
RUNNER="kvb-hub-runner.service"
|
|
||||||
IMAGE="kvb-hub:local"
|
|
||||||
FLOOR_GB=15
|
|
||||||
FAILED=0
|
|
||||||
|
|
||||||
log() { printf '%s\n' "$*"; }
|
|
||||||
warn() { printf '%s\n' "$*" >&2; }
|
|
||||||
ok() { printf 'ok %s\n' "$*"; }
|
|
||||||
todo() { printf 'todo %s\n' "$*"; }
|
|
||||||
fail() {
|
|
||||||
printf 'FAIL %s\n' "$*"
|
|
||||||
FAILED=1
|
|
||||||
}
|
|
||||||
|
|
||||||
port_of() {
|
|
||||||
sed -n 's/^KVB_HUB_PORT=//p' "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
install_hub() {
|
|
||||||
command -v docker &>/dev/null || {
|
|
||||||
warn "skills: docker not found; skipped the kvb-hub review hub."
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
local port
|
|
||||||
port="$(port_of "${REPO_ROOT}/hub/.env")"
|
|
||||||
log "skills: starting the kvb-hub review hub on http://localhost:${port}/"
|
|
||||||
rm -rf "${HUB}"
|
|
||||||
mkdir -p "${HUB}" "${STATE}" "${APPROVALS}" "${COUNCIL}" "${HOME}/.claude/projects" "${HOME}/Repositories" "${HOME}/.local/share/worktrees" "${UNITS}" "${APPROVERS}"
|
|
||||||
chmod 700 "${APPROVERS}"
|
|
||||||
cp -a "${REPO_ROOT}/hub/." "${HUB}/"
|
|
||||||
chmod +x "${HUB}/client/permission_hook.py" "${HUB}/client/runner.py" "${HUB}/client/approval.py"
|
|
||||||
log "skills: the kvb-hub PermissionRequest hook client is ${HUB}/client/permission_hook.py"
|
|
||||||
log "skills: approvals need a pinned passkey: hub settings, 'Passkey registrieren', then 'make hub-pin CREDENTIAL=<blob>'; until then every executor refuses"
|
|
||||||
printf 'KVB_HUB_USER=%s:%s\nKVB_HUB_STATE=%s\nKVB_HUB_COUNCIL=%s\nKVB_HUB_SKILLS=%s\nKVB_HUB_PROJECTS=%s\nKVB_HUB_REPOS=%s\nKVB_HUB_WORKTREES=%s\nKVB_HUB_TIMERS=%s\nKVB_HUB_APPROVERS=%s\n' \
|
|
||||||
"$(id -u)" "$(id -g)" "${STATE}" "${COUNCIL}" "${HOME}/.claude/skills" "${HOME}/.claude/projects" "${HOME}/Repositories" "${HOME}/.local/share/worktrees" "${UNITS}" "${APPROVERS}" >>"${HUB}/.env"
|
|
||||||
if ! docker compose --file "${HUB}/compose.yml" up -d --build; then
|
|
||||||
warn "skills: docker compose failed; the kvb-hub stays down until 'make hub-install' succeeds."
|
|
||||||
fi
|
|
||||||
if ! command -v claude &>/dev/null; then
|
|
||||||
warn "skills: claude not found; skipped registering the kvb-hub MCP server."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
claude mcp remove --scope user kvb-hub &>/dev/null || true
|
|
||||||
if claude mcp add --transport http --scope user kvb-hub "http://127.0.0.1:${port}/mcp"; then
|
|
||||||
log "skills: registered the kvb-hub MCP server for Claude Code at user scope"
|
|
||||||
else
|
|
||||||
warn "skills: registering the kvb-hub MCP server for Claude Code failed."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
runner() {
|
|
||||||
if ! systemctl --user show-environment &>/dev/null; then
|
|
||||||
warn "skills: no systemd user session; the kvb-hub runner is off and approved operator commands wait."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
cp -f "${HUB}/systemd/${RUNNER}" "${UNITS}/"
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
systemctl --user enable "${RUNNER}"
|
|
||||||
systemctl --user restart "${RUNNER}"
|
|
||||||
log "skills: the kvb-hub runner service runs the operator commands approved in the hub ($(systemctl --user is-active "${RUNNER}"))"
|
|
||||||
}
|
|
||||||
|
|
||||||
check_keys() {
|
|
||||||
local file="${APPROVERS}/approvers.json"
|
|
||||||
if [[ ! -f "${file}" ]]; then
|
|
||||||
todo "no pinned passkey: hub settings, 'Passkey registrieren', then make hub-pin CREDENTIAL=<blob>"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
local modes
|
|
||||||
modes="$(stat -c '%a' "${APPROVERS}" "${file}" | paste -sd ' ')"
|
|
||||||
if [[ "${modes}" == "700 600" ]]; then
|
|
||||||
ok "pinned keys $(python3 -c 'import json,sys; print([c["alg"] for c in json.load(open(sys.argv[1]))["credentials"]])' "${file}") (modes ${modes})"
|
|
||||||
else
|
|
||||||
fail "pinned key modes are ${modes}, expected 700 600"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
check() {
|
|
||||||
local port code free
|
|
||||||
port="$(port_of "${HUB}/.env")"
|
|
||||||
if docker run --rm "${IMAGE}" --help >/dev/null; then ok "image ${IMAGE} imports every module"; else fail "image ${IMAGE} does not start"; fi
|
|
||||||
if docker compose --file "${HUB}/compose.yml" ps --status running --services | grep -qx hub; then ok "container running"; else fail "container not running"; fi
|
|
||||||
if docker compose --file "${HUB}/compose.yml" logs --tail 200 2>&1 | grep -q Traceback; then
|
|
||||||
fail "traceback in the hub log:"
|
|
||||||
docker compose --file "${HUB}/compose.yml" logs --tail 30
|
|
||||||
else
|
|
||||||
ok "no traceback in the last 200 log lines"
|
|
||||||
fi
|
|
||||||
code="$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${port}/" || true)"
|
|
||||||
if [[ "${code}" == "200" ]]; then ok "http://localhost:${port}/ answers 200"; else fail "http://localhost:${port}/ answers ${code}"; fi
|
|
||||||
check_keys
|
|
||||||
if grep -qs 'kvb-hub/client/permission_hook.py' "${SETTINGS}" "${LOCAL_SETTINGS}"; then
|
|
||||||
ok "PermissionRequest hook registered"
|
|
||||||
else
|
|
||||||
todo "PermissionRequest hook missing: make hub-hook"
|
|
||||||
fi
|
|
||||||
if [[ "$(systemctl --user is-active "${RUNNER}" 2>/dev/null || true)" == "active" ]]; then
|
|
||||||
ok "runner active; last lines:"
|
|
||||||
journalctl --user -u "${RUNNER}" -n 10 --no-pager
|
|
||||||
else
|
|
||||||
todo "runner off: make hub-runner"
|
|
||||||
fi
|
|
||||||
free="$(df --output=avail -BG / | tail -1 | tr -dc '0-9')"
|
|
||||||
if ((free >= FLOOR_GB)); then ok "/ has ${free} GB free"; else fail "/ has ${free} GB free, floor ${FLOOR_GB} GB"; fi
|
|
||||||
return "${FAILED}"
|
|
||||||
}
|
|
||||||
|
|
||||||
pin() {
|
|
||||||
[[ -n "${1:-}" ]] || {
|
|
||||||
warn "usage: make hub-pin CREDENTIAL=<blob from 'Passkey registrieren'>"
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
python3 "${HUB}/client/approval.py" pin --port "$(port_of "${HUB}/.env")" --approvers "${APPROVERS}/approvers.json" --credential "$1"
|
|
||||||
check_keys
|
|
||||||
return "${FAILED}"
|
|
||||||
}
|
|
||||||
|
|
||||||
hook() {
|
|
||||||
local command
|
|
||||||
command="python3 \"${HUB}/client/permission_hook.py\" --port $(port_of "${HUB}/.env") --timeout ${HOOK_TIMEOUT} --approvers \"${APPROVERS}/approvers.json\" --replay \"${APPROVALS}/hook.json\""
|
|
||||||
[[ -f "${SETTINGS}" ]] && cp -p "${SETTINGS}" "${SETTINGS}.before-kvb-hub-hook"
|
|
||||||
python3 - "${SETTINGS}" "${command}" "$((HOOK_TIMEOUT + 10))" <<'PY'
|
|
||||||
import json, os, sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
path, command, timeout = Path(sys.argv[1]), sys.argv[2], int(sys.argv[3])
|
|
||||||
settings = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
||||||
mode = path.stat().st_mode & 0o777 if path.exists() else 0o600
|
|
||||||
entries = settings.setdefault("hooks", {}).setdefault("PermissionRequest", [])
|
|
||||||
entries[:] = [entry for entry in entries if not any("kvb-hub/client/permission_hook.py" in hook.get("command", "") for hook in entry.get("hooks", []))]
|
|
||||||
entries.append({"matcher": "Bash", "hooks": [{"type": "command", "command": command, "timeout": timeout}]})
|
|
||||||
temporary = path.with_name(f"{path.name}.{os.getpid()}")
|
|
||||||
temporary.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
||||||
temporary.chmod(mode)
|
|
||||||
temporary.replace(path)
|
|
||||||
PY
|
|
||||||
log "skills: registered the kvb-hub PermissionRequest hook in ${SETTINGS} (copy before: ${SETTINGS}.before-kvb-hub-hook); restart Claude Code to load it"
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
install) install_hub ;;
|
|
||||||
check) check ;;
|
|
||||||
pin) pin "${2:-}" ;;
|
|
||||||
runner) runner ;;
|
|
||||||
hook) hook ;;
|
|
||||||
*)
|
|
||||||
warn "usage: hub.sh install | check | pin <blob> | runner | hook"
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
+3
-3
@@ -65,10 +65,10 @@ if [[ "${TARGET}" == "${HOME}" ]]; then
|
|||||||
warn "skills: no systemd user session; skipped the kvb-dream notification timer."
|
warn "skills: no systemd user session; skipped the kvb-dream notification timer."
|
||||||
fi
|
fi
|
||||||
if command -v docker &>/dev/null; then
|
if command -v docker &>/dev/null; then
|
||||||
bash "${REPO_ROOT}/scripts/hub.sh" install
|
bash "${REPO_ROOT}/scripts/orchestrator.sh" install
|
||||||
bash "${REPO_ROOT}/scripts/hub.sh" runner || true
|
bash "${REPO_ROOT}/scripts/orchestrator.sh" runner || true
|
||||||
else
|
else
|
||||||
warn "skills: docker not found; skipped the kvb-hub review hub."
|
warn "skills: docker not found; skipped the Agent Orchestrator."
|
||||||
fi
|
fi
|
||||||
log "skills: installing or updating the codebase-memory-mcp code graph for every detected agent"
|
log "skills: installing or updating the codebase-memory-mcp code graph for every detected agent"
|
||||||
if ! curl -fsSL --proto '=https' https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash; then
|
if ! curl -fsSL --proto '=https' https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash; then
|
||||||
|
|||||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user