yothere 1.5.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yothere/__init__.py +10 -0
- yothere/attention.py +236 -0
- yothere/board.py +220 -0
- yothere/brain/__init__.py +16 -0
- yothere/brain/registry.py +196 -0
- yothere/brain_advance.py +336 -0
- yothere/capture.py +158 -0
- yothere/card.py +297 -0
- yothere/cli.py +624 -0
- yothere/config.py +268 -0
- yothere/demo_brain.py +167 -0
- yothere/doctor.py +342 -0
- yothere/envcompat.py +59 -0
- yothere/experiments/fixtures.jsonl +18 -0
- yothere/fleet_sources/__init__.py +7 -0
- yothere/fleet_sources/remote.py +86 -0
- yothere/fleet_state.py +263 -0
- yothere/listen.py +390 -0
- yothere/llm.py +62 -0
- yothere/maintenance.py +190 -0
- yothere/mcp_register.py +200 -0
- yothere/mcp_server.py +121 -0
- yothere/notify.py +47 -0
- yothere/onboarding.py +501 -0
- yothere/presence.py +157 -0
- yothere/presets.py +161 -0
- yothere/pwa.py +295 -0
- yothere/ratelimit.py +143 -0
- yothere/render.py +518 -0
- yothere/runner.py +659 -0
- yothere/runner_watchdog.py +157 -0
- yothere/scope.py +293 -0
- yothere/service.py +429 -0
- yothere/spawn.py +123 -0
- yothere/store.py +248 -0
- yothere/task_source.py +721 -0
- yothere/tenant_supervisor.py +189 -0
- yothere/thread_model/__init__.py +30 -0
- yothere/thread_model/ask.py +171 -0
- yothere/thread_model/autonomy.py +171 -0
- yothere/thread_model/prompts/thread-system.md +174 -0
- yothere/thread_model/resume.py +542 -0
- yothere/thread_model/thread.py +357 -0
- yothere/thread_model/vt_config.py +182 -0
- yothere/thread_model/worker_policy.py +97 -0
- yothere/voice.py +418 -0
- yothere/voicecall/__init__.py +29 -0
- yothere/voicecall/auth.py +547 -0
- yothere/voicecall/bob_brain.py +392 -0
- yothere/voicecall/config.py +335 -0
- yothere/voicecall/dispatch.py +58 -0
- yothere/voicecall/echo_brain.py +120 -0
- yothere/voicecall/headless_call.py +823 -0
- yothere/voicecall/overview_state.py +428 -0
- yothere/voicecall/pipeline.py +720 -0
- yothere/voicecall/remote_brain.py +399 -0
- yothere/voicecall/requirements.lock +127 -0
- yothere/voicecall/requirements.txt +19 -0
- yothere/voicecall/session_manager.py +1808 -0
- yothere/voicecall/ubob_brain.py +333 -0
- yothere/voicecall/webrtc_server.py +2174 -0
- yothere/web.py +42 -0
- yothere/worker.py +156 -0
- yothere/worker_hooks/__init__.py +72 -0
- yothere/worker_hooks/send_deny_gate.py +311 -0
- yothere-1.5.2.dist-info/METADATA +201 -0
- yothere-1.5.2.dist-info/RECORD +70 -0
- yothere-1.5.2.dist-info/WHEEL +4 -0
- yothere-1.5.2.dist-info/entry_points.txt +4 -0
- yothere-1.5.2.dist-info/licenses/LICENSE +21 -0
yothere/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""yothere — an ambient-agent cockpit (formerly relay-cockpit).
|
|
2
|
+
|
|
3
|
+
Importing the package installs the YOTHERE_* <-> RELAY_* env aliasing so a host
|
|
4
|
+
that still exports the legacy RELAY_* names keeps working unchanged. This is a
|
|
5
|
+
pure os.environ read/fill with no filesystem side effects (importing yothere.*
|
|
6
|
+
stays side-effect-free for the config/onboarding modules that rely on it).
|
|
7
|
+
"""
|
|
8
|
+
from yothere.envcompat import apply_process_aliases as _apply_env_aliases
|
|
9
|
+
|
|
10
|
+
_apply_env_aliases()
|
yothere/attention.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Relay attention router — the moat.
|
|
2
|
+
|
|
3
|
+
Camp-A cockpits decide which AGENT runs next. Relay decides which thread wants
|
|
4
|
+
the HUMAN's eyes next, and protects focus while the rest of the fleet churns.
|
|
5
|
+
This module is that decision, kept deterministic and offline-first so it is
|
|
6
|
+
unit-testable and never depends on a model being reachable. The moat is this
|
|
7
|
+
deterministic ranking — NOT an online learning loop (there is none; nothing
|
|
8
|
+
here adapts from past recommendations).
|
|
9
|
+
|
|
10
|
+
This module's one responsibility: rank_fleet / recommend_focus — score every
|
|
11
|
+
normalized fleet row, order them, and name the one thread Phil should look at
|
|
12
|
+
next.
|
|
13
|
+
|
|
14
|
+
The Licklider ping guardrails (pull-on-glance default, quiet hours, focus
|
|
15
|
+
protection, rate-limit) live in the runner's inline gate, not here.
|
|
16
|
+
|
|
17
|
+
Autonomous advance selection (which non-focus threads to progress while Phil is
|
|
18
|
+
busy) is owned solely by runner.plan_tick, not this module.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime as _dt
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
try: # zoneinfo is stdlib on 3.9+; degrade to host-local time if tz is unknown
|
|
26
|
+
from zoneinfo import ZoneInfo
|
|
27
|
+
except Exception: # pragma: no cover - zoneinfo always present on our runtime
|
|
28
|
+
ZoneInfo = None # type: ignore
|
|
29
|
+
|
|
30
|
+
# The fleet state vocabulary is owned by fleet_state (the normalization layer);
|
|
31
|
+
# import it here instead of re-typing the literals.
|
|
32
|
+
from yothere.fleet_state import BLOCKED, IDLE, READY, RUNNING
|
|
33
|
+
|
|
34
|
+
# State base scores. Blocked beats ready: a blocked thread is stalled ON Phil
|
|
35
|
+
# and burns no compute waiting; a ready one is finished and can sit.
|
|
36
|
+
_STATE_BASE = {BLOCKED: 40.0, READY: 30.0, RUNNING: 5.0, IDLE: 0.0}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class RouterConfig:
|
|
41
|
+
timezone: str = "Europe/Berlin"
|
|
42
|
+
quiet_start_hour: int = 22
|
|
43
|
+
quiet_end_hour: int = 8
|
|
44
|
+
max_concurrent_advances: int = 2
|
|
45
|
+
min_advance_interval_s: int = 180
|
|
46
|
+
close_epsilon: float = 8.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class ScoreBreakdown:
|
|
51
|
+
row_id: str
|
|
52
|
+
source: str
|
|
53
|
+
title: str
|
|
54
|
+
state: str
|
|
55
|
+
needs_eyes: bool
|
|
56
|
+
total: float
|
|
57
|
+
parts: dict
|
|
58
|
+
reasons: tuple
|
|
59
|
+
focus: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _now() -> _dt.datetime:
|
|
63
|
+
return _dt.datetime.now(_dt.timezone.utc).astimezone()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_iso(s: str | None) -> _dt.datetime | None:
|
|
67
|
+
if not s:
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
dt = _dt.datetime.fromisoformat(s)
|
|
71
|
+
except ValueError:
|
|
72
|
+
return None
|
|
73
|
+
if dt.tzinfo is None:
|
|
74
|
+
dt = dt.replace(tzinfo=_dt.timezone.utc)
|
|
75
|
+
return dt
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _age_minutes(row: dict, now: _dt.datetime) -> float:
|
|
79
|
+
dt = _parse_iso(row.get("updated_at"))
|
|
80
|
+
if not dt:
|
|
81
|
+
return 0.0
|
|
82
|
+
return max(0.0, (now - dt).total_seconds() / 60.0)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def score_row(row: dict, now: _dt.datetime) -> ScoreBreakdown:
|
|
86
|
+
"""Additive weighted score: higher = wants Phil's eyes more, now."""
|
|
87
|
+
parts: dict = {}
|
|
88
|
+
reasons: list[str] = []
|
|
89
|
+
|
|
90
|
+
if row.get("needs_eyes"):
|
|
91
|
+
parts["needs_eyes"] = 100.0
|
|
92
|
+
reasons.append("needs your eyes")
|
|
93
|
+
else:
|
|
94
|
+
parts["needs_eyes"] = 0.0
|
|
95
|
+
|
|
96
|
+
state = row.get("state", RUNNING)
|
|
97
|
+
parts["state"] = _STATE_BASE.get(state, 0.0)
|
|
98
|
+
|
|
99
|
+
priority = (row.get("priority") or "normal").lower()
|
|
100
|
+
if priority == "urgent":
|
|
101
|
+
parts["urgency"] = 50.0
|
|
102
|
+
reasons.append("urgent")
|
|
103
|
+
elif priority == "high":
|
|
104
|
+
parts["urgency"] = 15.0
|
|
105
|
+
reasons.append("high priority")
|
|
106
|
+
elif priority == "low":
|
|
107
|
+
parts["urgency"] = -10.0
|
|
108
|
+
else:
|
|
109
|
+
parts["urgency"] = 0.0
|
|
110
|
+
|
|
111
|
+
# Deliverable is a TIEBREAK among ready threads, deliberately smaller than the
|
|
112
|
+
# blocked-vs-ready base gap (40-30=10) so a finished artifact never outranks a
|
|
113
|
+
# blocked thread that is actively stalling the fleet on Phil.
|
|
114
|
+
deliverable = state == READY and bool(row.get("artifacts") or row.get("last_progress"))
|
|
115
|
+
parts["deliverable"] = 8.0 if deliverable else 0.0
|
|
116
|
+
if deliverable:
|
|
117
|
+
reasons.append("has a finished artifact")
|
|
118
|
+
|
|
119
|
+
age = _age_minutes(row, now)
|
|
120
|
+
parts["staleness"] = min(15.0, age * 0.05)
|
|
121
|
+
|
|
122
|
+
if row.get("focus"):
|
|
123
|
+
parts["focus_pin"] = 12.0
|
|
124
|
+
reasons.append("you pinned this")
|
|
125
|
+
else:
|
|
126
|
+
parts["focus_pin"] = 0.0
|
|
127
|
+
|
|
128
|
+
total = round(sum(parts.values()), 3)
|
|
129
|
+
if state == BLOCKED:
|
|
130
|
+
reasons.append("blocked, waiting on you")
|
|
131
|
+
elif state == READY and not deliverable:
|
|
132
|
+
reasons.append("finished")
|
|
133
|
+
return ScoreBreakdown(
|
|
134
|
+
row_id=row["id"], source=row.get("source", "relay"), title=row.get("title", row["id"]),
|
|
135
|
+
state=state, needs_eyes=bool(row.get("needs_eyes")), total=total,
|
|
136
|
+
parts=parts, reasons=tuple(reasons), focus=bool(row.get("focus")),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def rank_fleet(rows: list[dict], now: _dt.datetime | None = None,
|
|
141
|
+
cfg: RouterConfig | None = None) -> list[ScoreBreakdown]:
|
|
142
|
+
# cfg is accepted for caller parity but no longer read here: scoring is
|
|
143
|
+
# cfg-free and recommend_focus owns the close_epsilon read.
|
|
144
|
+
now = now or _now()
|
|
145
|
+
scored = [score_row(r, now) for r in rows]
|
|
146
|
+
# Sort: score desc, then needs_eyes, then older-first (updated_at), then id.
|
|
147
|
+
age_by_id = {r["id"]: _age_minutes(r, now) for r in rows}
|
|
148
|
+
scored.sort(key=lambda s: (-s.total, not s.needs_eyes, -age_by_id.get(s.row_id, 0.0), s.row_id))
|
|
149
|
+
return scored
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def recommend_focus(ranked: list[ScoreBreakdown], cfg: RouterConfig | None = None) -> dict | None:
|
|
153
|
+
"""The single 'look at this next' rec.
|
|
154
|
+
|
|
155
|
+
Focus is single-source: the meta `focus` flag carried on each row (set by
|
|
156
|
+
`relay focus`/`spawn --focus`). A manually pinned thread wins even if it
|
|
157
|
+
ranks below the deterministic top; `margin` is signed (negative means the
|
|
158
|
+
pin overrode a higher-scored thread) and `pin_overrode_top` flags that so a
|
|
159
|
+
caller never mistakes the margin for an absolute gap.
|
|
160
|
+
"""
|
|
161
|
+
cfg = cfg or RouterConfig()
|
|
162
|
+
if not ranked:
|
|
163
|
+
return None
|
|
164
|
+
pinned = next((s for s in ranked if s.focus), None)
|
|
165
|
+
if pinned is not None:
|
|
166
|
+
top, source_reason = pinned, "manual pin"
|
|
167
|
+
else:
|
|
168
|
+
top, source_reason = ranked[0], "top-ranked"
|
|
169
|
+
# Runner-up is the best score among the OTHER threads (always the true next-best).
|
|
170
|
+
runner_up = max((s.total for s in ranked if s.row_id != top.row_id), default=0.0)
|
|
171
|
+
margin = round(top.total - runner_up, 3)
|
|
172
|
+
pin_overrode_top = source_reason == "manual pin" and top.row_id != ranked[0].row_id
|
|
173
|
+
if pin_overrode_top:
|
|
174
|
+
confidence = "pinned"
|
|
175
|
+
elif len(ranked) == 1:
|
|
176
|
+
# A one-thread fleet is not a contest: do not imply a margin it never had.
|
|
177
|
+
confidence = "only"
|
|
178
|
+
else:
|
|
179
|
+
confidence = "close" if margin <= cfg.close_epsilon else "clear"
|
|
180
|
+
return {
|
|
181
|
+
"focus_id": top.row_id,
|
|
182
|
+
"focus_title": top.title,
|
|
183
|
+
"focus_source": source_reason,
|
|
184
|
+
"score": top.total,
|
|
185
|
+
"runner_up_score": runner_up,
|
|
186
|
+
"margin": margin,
|
|
187
|
+
"pin_overrode_top": pin_overrode_top,
|
|
188
|
+
"confidence": confidence,
|
|
189
|
+
"reasons": list(top.reasons),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ── Licklider guardrails ─────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
def in_quiet_hours(now: _dt.datetime, cfg: RouterConfig) -> bool:
|
|
196
|
+
# Quiet hours are defined in cfg.timezone (Berlin by default), not host-local
|
|
197
|
+
# time. Convert before reading the hour so a runner on a non-Berlin clock
|
|
198
|
+
# still respects Phil's nights. Read the field defensively: runner passes a
|
|
199
|
+
# RunnerConfig that may lack .timezone, and an unknown tz falls back to
|
|
200
|
+
# host-local, so neither caller breaks.
|
|
201
|
+
tz = getattr(cfg, "timezone", None)
|
|
202
|
+
if tz and ZoneInfo is not None:
|
|
203
|
+
try:
|
|
204
|
+
now = now.astimezone(ZoneInfo(tz))
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
h = now.hour
|
|
208
|
+
if cfg.quiet_start_hour <= cfg.quiet_end_hour:
|
|
209
|
+
return cfg.quiet_start_hour <= h < cfg.quiet_end_hour
|
|
210
|
+
return h >= cfg.quiet_start_hour or h < cfg.quiet_end_hour
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
cfg = RouterConfig()
|
|
215
|
+
# Fixed afternoon time so the selftest is deterministic (never trips quiet hours).
|
|
216
|
+
now = _dt.datetime(2026, 6, 8, 14, 0, tzinfo=_dt.timezone.utc).astimezone()
|
|
217
|
+
rows = [
|
|
218
|
+
{"id": "a", "title": "Vault synthesis", "state": "blocked", "needs_eyes": True,
|
|
219
|
+
"priority": "normal", "updated_at": now.isoformat()},
|
|
220
|
+
{"id": "b", "title": "News scan", "state": "running", "needs_eyes": False,
|
|
221
|
+
"priority": "normal", "updated_at": (now - _dt.timedelta(minutes=5)).isoformat()},
|
|
222
|
+
{"id": "c", "title": "LinkedIn draft", "state": "ready", "needs_eyes": True,
|
|
223
|
+
"priority": "normal", "artifacts": ["x.md"], "updated_at": now.isoformat()},
|
|
224
|
+
{"id": "d", "title": "Background crawl", "state": "running", "needs_eyes": False,
|
|
225
|
+
"priority": "low", "updated_at": (now - _dt.timedelta(minutes=20)).isoformat()},
|
|
226
|
+
]
|
|
227
|
+
ranked = rank_fleet(rows, now, cfg)
|
|
228
|
+
assert ranked[0].row_id == "a", [s.row_id for s in ranked] # blocked+eyes wins
|
|
229
|
+
rec = recommend_focus(ranked, cfg)
|
|
230
|
+
assert rec["focus_id"] == "a"
|
|
231
|
+
# Advance selection lives in runner.plan_tick, and the ping guardrails live in
|
|
232
|
+
# the runner's inline gate, not here — see runner.py.
|
|
233
|
+
# A one-thread fleet is reported as "only", not a fake "clear" contest.
|
|
234
|
+
solo = recommend_focus(rank_fleet([rows[0]], now, cfg), cfg)
|
|
235
|
+
assert solo["confidence"] == "only", solo
|
|
236
|
+
print("attention selftest ok")
|
yothere/board.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Relay board — the standalone glance surface.
|
|
2
|
+
|
|
3
|
+
The Mission Control "Fleet" card is the at-a-glance summary embedded in the
|
|
4
|
+
Personal OS dashboard. THIS is the dedicated, richer board: the focus
|
|
5
|
+
recommendation up top, then every thread as a card with its state, live
|
|
6
|
+
progress line, needs-eyes badge, deliverable link, age, and source. It is the
|
|
7
|
+
screen half of Relay's voice-primary / screen-secondary model.
|
|
8
|
+
|
|
9
|
+
Read-only and fail-soft: it reads the fleet via fleet_state.load_fleet() and
|
|
10
|
+
ranks via attention. It NEVER writes thread state and never touches
|
|
11
|
+
reports/mission-control.html (that stays the dashboard's file). Every dynamic
|
|
12
|
+
field is run through html.escape — worker-authored progress strings are
|
|
13
|
+
attacker-influenced (an LLM emits arbitrary text), so this is load-bearing, not
|
|
14
|
+
cosmetic. No PAGE.format(): the HTML is concatenated, so there is no brace trap.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import datetime as _dt
|
|
19
|
+
import html as _html
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from yothere import attention as _att # noqa: E402
|
|
24
|
+
from yothere import card as _cardmod # noqa: E402 (module alias; _card() below is the row renderer)
|
|
25
|
+
from yothere.config import settings # noqa: E402
|
|
26
|
+
from yothere.fleet_state import BLOCKED, IDLE, READY, RUNNING, load_fleet # noqa: E402
|
|
27
|
+
|
|
28
|
+
OUT = settings.board_html_file
|
|
29
|
+
|
|
30
|
+
STATE_COLOR = {RUNNING: "#b58900", READY: "#2aa198", BLOCKED: "#dc322f", IDLE: "#8c8c8a"}
|
|
31
|
+
STATE_LABEL = {RUNNING: "WORKING", READY: "READY", BLOCKED: "BLOCKED", IDLE: "PARKED"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def esc(s: object) -> str:
|
|
35
|
+
return _html.escape(str(s if s is not None else ""))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _age(updated_at: str, now: _dt.datetime) -> str:
|
|
39
|
+
# Reuse attention._parse_iso so ISO parse + tz-coerce lives in one place.
|
|
40
|
+
dt = _att._parse_iso(updated_at)
|
|
41
|
+
if not dt:
|
|
42
|
+
return ""
|
|
43
|
+
secs = (now - dt).total_seconds()
|
|
44
|
+
if secs < 90:
|
|
45
|
+
return "just now"
|
|
46
|
+
if secs < 5400:
|
|
47
|
+
return f"{round(secs / 60)}m ago"
|
|
48
|
+
if secs < 36 * 3600:
|
|
49
|
+
return f"{round(secs / 3600)}h ago"
|
|
50
|
+
return f"{round(secs / 86400)}d ago"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _card(row: dict, now: _dt.datetime, is_focus: bool) -> str:
|
|
54
|
+
# Axis 3: a thread carrying a structured plan gets the rich progressive card
|
|
55
|
+
# (server-rendered from card.py, the single owner). card.esc() inside still
|
|
56
|
+
# escapes every worker-authored field. Threads without a plan keep the flat
|
|
57
|
+
# card below. build_plan_view returns None when there is no plan.
|
|
58
|
+
view = _cardmod.build_plan_view(row)
|
|
59
|
+
if view:
|
|
60
|
+
rich = _cardmod.render_card(view)
|
|
61
|
+
if is_focus:
|
|
62
|
+
rich = rich.replace('<div class="card">', '<div class="card focus">', 1)
|
|
63
|
+
return rich
|
|
64
|
+
state = row.get("state", RUNNING)
|
|
65
|
+
color = STATE_COLOR.get(state, "#8c8c8a")
|
|
66
|
+
label = STATE_LABEL.get(state, state.upper())
|
|
67
|
+
eye = '<span class="eye">needs eyes</span>' if row.get("needs_eyes") else ""
|
|
68
|
+
focus_cls = " focus" if is_focus else ""
|
|
69
|
+
age = _age(row.get("updated_at", ""), now)
|
|
70
|
+
prog = (row.get("last_progress") or "").strip()
|
|
71
|
+
prog_html = f'<div class="prog">{esc(prog[:200])}</div>' if prog else ""
|
|
72
|
+
# A blocked thread shows the exact decision it is waiting on, so the board
|
|
73
|
+
# says WHAT it needs, not just the last progress line. Worker-authored -> esc.
|
|
74
|
+
q_html = ""
|
|
75
|
+
if state == BLOCKED:
|
|
76
|
+
question = (row.get("question") or "").strip()
|
|
77
|
+
if question:
|
|
78
|
+
opts = [str(o).strip() for o in (row.get("options") or []) if str(o).strip()]
|
|
79
|
+
opt_html = ""
|
|
80
|
+
if opts:
|
|
81
|
+
chips = "".join(f'<span class="opt">{esc(o[:40])}</span>' for o in opts[:4])
|
|
82
|
+
opt_html = f'<div class="opts">{chips}</div>'
|
|
83
|
+
q_html = (f'<div class="ask"><span class="ask-l">needs</span> '
|
|
84
|
+
f'{esc(question[:240])}</div>{opt_html}')
|
|
85
|
+
arts = row.get("artifacts") or []
|
|
86
|
+
art_html = ""
|
|
87
|
+
if arts:
|
|
88
|
+
links = " · ".join(esc(Path(str(a)).name) for a in arts[:4])
|
|
89
|
+
art_html = f'<div class="arts">📄 {links}</div>'
|
|
90
|
+
src = esc(row.get("source", "relay"))
|
|
91
|
+
return (
|
|
92
|
+
f'<div class="card{focus_cls}">'
|
|
93
|
+
f'<div class="top"><span class="dot" style="background:{color}"></span>'
|
|
94
|
+
f'<span class="title">{esc(row.get("title", row.get("id", "thread")))}</span>{eye}</div>'
|
|
95
|
+
f'<div class="meta"><span class="state" style="color:{color}">{label}</span>'
|
|
96
|
+
f'<span class="src">{src}</span><span class="age">{esc(age)}</span></div>'
|
|
97
|
+
f'{q_html}{prog_html}{art_html}</div>'
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def render_board(fleet: list[dict], now: _dt.datetime | None = None) -> str:
|
|
102
|
+
now = now or _dt.datetime.now(_dt.timezone.utc).astimezone()
|
|
103
|
+
cfg = _att.RouterConfig()
|
|
104
|
+
ranked = _att.rank_fleet(fleet, now, cfg)
|
|
105
|
+
rec = _att.recommend_focus(ranked, cfg)
|
|
106
|
+
order = {s.row_id: i for i, s in enumerate(ranked)}
|
|
107
|
+
fleet_sorted = sorted(fleet, key=lambda r: order.get(r["id"], 999))
|
|
108
|
+
|
|
109
|
+
need = sum(1 for r in fleet if r.get("needs_eyes"))
|
|
110
|
+
running = sum(1 for r in fleet if r.get("state") == RUNNING)
|
|
111
|
+
|
|
112
|
+
if rec:
|
|
113
|
+
reasons = ", ".join(rec.get("reasons") or []) or "top of the queue"
|
|
114
|
+
focus_html = (
|
|
115
|
+
f'<div class="focus-banner"><span class="fl">FOCUS</span>'
|
|
116
|
+
f'<span class="ft">{esc(rec["focus_title"])}</span>'
|
|
117
|
+
f'<span class="fr">{esc(reasons)}</span></div>'
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
focus_html = ('<div class="focus-banner empty"><span class="ft">'
|
|
121
|
+
'Fleet is clear. Nothing needs your eyes.</span></div>')
|
|
122
|
+
|
|
123
|
+
cards = "".join(_card(r, now, r["id"] == (rec or {}).get("focus_id")) for r in fleet_sorted)
|
|
124
|
+
if not fleet:
|
|
125
|
+
cards = ('<div class="empty-state">No agent threads in flight. '
|
|
126
|
+
'Speak or type <code>relay spawn</code> to start one.</div>')
|
|
127
|
+
|
|
128
|
+
sub = f"{len(fleet)} threads · {running} working · {need} need eyes"
|
|
129
|
+
ts = now.strftime("%a %d %b · %H:%M:%S")
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
"<!DOCTYPE html><html lang=en><head><meta charset=UTF-8>"
|
|
133
|
+
"<meta name=viewport content='width=device-width, initial-scale=1'>"
|
|
134
|
+
"<meta name=robots content='noindex, nofollow'>"
|
|
135
|
+
"<meta http-equiv=refresh content=15>"
|
|
136
|
+
"<title>Relay · Fleet</title>"
|
|
137
|
+
"<style>"
|
|
138
|
+
"*{box-sizing:border-box;margin:0;padding:0}"
|
|
139
|
+
"body{font:15px/1.5 -apple-system,Inter,Segoe UI,sans-serif;background:#0f1419;color:#e6e6e6;padding:24px;max-width:900px;margin:0 auto}"
|
|
140
|
+
"header{display:flex;align-items:baseline;gap:14px;margin-bottom:18px}"
|
|
141
|
+
"h1{font-size:18px;font-weight:700;letter-spacing:-.01em}"
|
|
142
|
+
".sub{color:#8b98a5;font-size:13px}.ts{margin-left:auto;color:#5c6670;font-size:12px}"
|
|
143
|
+
".focus-banner{display:flex;align-items:center;gap:12px;background:linear-gradient(90deg,#1e2630,#1a212b);"
|
|
144
|
+
"border:1px solid #2c3744;border-left:3px solid #D97757;border-radius:10px;padding:13px 16px;margin-bottom:16px}"
|
|
145
|
+
".focus-banner.empty{border-left-color:#2aa198}"
|
|
146
|
+
".fl{font-size:10px;font-weight:700;letter-spacing:.08em;color:#D97757;background:#2a1d18;padding:3px 8px;border-radius:5px}"
|
|
147
|
+
".ft{font-weight:600;font-size:15px}.fr{margin-left:auto;color:#8b98a5;font-size:12px;text-align:right}"
|
|
148
|
+
".grid{display:flex;flex-direction:column;gap:10px}"
|
|
149
|
+
".card{background:#1a212b;border:1px solid #232c38;border-radius:10px;padding:13px 16px}"
|
|
150
|
+
".card.focus{border-color:#D97757;box-shadow:0 0 0 1px #D9775733}"
|
|
151
|
+
".top{display:flex;align-items:center;gap:9px}"
|
|
152
|
+
".dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto}"
|
|
153
|
+
".title{font-weight:600;font-size:14.5px}"
|
|
154
|
+
".eye{margin-left:auto;font-size:10px;font-weight:700;letter-spacing:.05em;color:#dc322f;background:#2a1517;padding:2px 7px;border-radius:5px}"
|
|
155
|
+
".meta{display:flex;gap:12px;align-items:center;margin-top:5px;font-size:11.5px}"
|
|
156
|
+
".state{font-weight:700;letter-spacing:.04em}.src{color:#5c6670}.age{margin-left:auto;color:#5c6670;font-variant-numeric:tabular-nums}"
|
|
157
|
+
".prog{margin-top:8px;font-size:13px;color:#c4cdd6}"
|
|
158
|
+
".ask{margin-top:8px;font-size:13px;color:#e6e6e6;background:#241a17;"
|
|
159
|
+
"border-left:2px solid #dc322f;border-radius:0 6px 6px 0;padding:6px 10px}"
|
|
160
|
+
".ask-l{font-size:10px;font-weight:700;letter-spacing:.05em;color:#dc322f;margin-right:6px;text-transform:uppercase}"
|
|
161
|
+
".opts{margin-top:5px;display:flex;gap:6px;flex-wrap:wrap}"
|
|
162
|
+
".opt{font-size:11px;color:#c4cdd6;background:#1a212b;border:1px solid #2c3744;border-radius:5px;padding:2px 8px}"
|
|
163
|
+
".arts{margin-top:6px;font-size:12px;color:#2aa198}"
|
|
164
|
+
"a.act-link{margin-left:14px;color:#D97757;font-size:12px;text-decoration:none;font-weight:600}"
|
|
165
|
+
"a.act-link:hover{text-decoration:underline}"
|
|
166
|
+
".empty-state{color:#5c6670;text-align:center;padding:48px 0;font-size:14px}"
|
|
167
|
+
".empty-state code{background:#1a212b;padding:2px 7px;border-radius:5px;color:#c4cdd6}"
|
|
168
|
+
# Card-scoped CSS from card.py so the rich progressive cards (badge, goal,
|
|
169
|
+
# step tree, next-action) render on the live board, in one place.
|
|
170
|
+
f"{_cardmod.CARD_CSS}"
|
|
171
|
+
"</style></head><body>"
|
|
172
|
+
f"<header><h1>Relay · Fleet</h1><span class=sub>{esc(sub)}</span>"
|
|
173
|
+
f"<a class=act-link href='http://127.0.0.1:8787/'>act →</a>"
|
|
174
|
+
f"<span class=ts>updated {esc(ts)} · 15s</span></header>"
|
|
175
|
+
f"{focus_html}<div class=grid>{cards}</div></body></html>"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def build(open_after: bool = False) -> Path:
|
|
180
|
+
# Scope the board to the active user (settings.user). "" -> None -> no
|
|
181
|
+
# filtering (today's behaviour for the single-user default).
|
|
182
|
+
fleet = load_fleet(user=settings.user or None)
|
|
183
|
+
OUT.write_text(render_board(fleet), encoding="utf-8")
|
|
184
|
+
if open_after:
|
|
185
|
+
import subprocess
|
|
186
|
+
subprocess.run(["open", str(OUT)], check=False)
|
|
187
|
+
return OUT
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
if len(sys.argv) > 1 and sys.argv[1] == "selftest":
|
|
192
|
+
now = _dt.datetime(2026, 6, 8, 14, 0, tzinfo=_dt.timezone.utc).astimezone()
|
|
193
|
+
# Empty fleet renders a clean empty state, no crash.
|
|
194
|
+
empty = render_board([], now)
|
|
195
|
+
assert "in flight" in empty and "FOCUS" not in empty
|
|
196
|
+
# XSS attempt in a worker-authored field must be escaped — including the
|
|
197
|
+
# blocked thread's question + options, which are also LLM-authored.
|
|
198
|
+
evil = [{"id": "x", "title": "<script>alert(1)</script>", "state": "blocked",
|
|
199
|
+
"needs_eyes": True, "last_progress": "<img src=x onerror=alert(2)>",
|
|
200
|
+
"question": "<b>which</b> path: A or B?",
|
|
201
|
+
"options": ["<i>opt A</i>", "opt B"],
|
|
202
|
+
"updated_at": now.isoformat(), "artifacts": ["a/b/c.md"]}]
|
|
203
|
+
html = render_board(evil, now)
|
|
204
|
+
# The only real vector is an unescaped tag delimiter. All payloads must
|
|
205
|
+
# appear ONLY in escaped form (inert text), never as live tags.
|
|
206
|
+
assert "<script>alert(1)" not in html, "raw <script> tag leaked"
|
|
207
|
+
assert "<img src=x onerror" not in html, "raw <img> tag leaked"
|
|
208
|
+
assert "<b>which" not in html and "<i>opt A" not in html, "raw question/option tag leaked"
|
|
209
|
+
assert "<script>alert(1)" in html and "<img src=x onerror" in html
|
|
210
|
+
assert "c.md" in html # artifact basename, path stripped
|
|
211
|
+
assert "FOCUS" in html and "needs eyes" in html
|
|
212
|
+
# The blocked thread surfaces its question + options, and the board links
|
|
213
|
+
# to the cockpit for actions.
|
|
214
|
+
assert "which" in html and "path: A or B" in html, "blocked question not shown"
|
|
215
|
+
assert "opt B" in html, "blocked options not shown"
|
|
216
|
+
assert "127.0.0.1:8787" in html, "cockpit deep-link missing"
|
|
217
|
+
print("board selftest ok")
|
|
218
|
+
else:
|
|
219
|
+
out = build(open_after="--open" in sys.argv)
|
|
220
|
+
print(f"built relay board -> {out}")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Brain registry — named brains a Relay thread can target.
|
|
2
|
+
|
|
3
|
+
A thread no longer has to run on the single hardcoded RELAY_REMOTE_BRAIN_URL.
|
|
4
|
+
``brains.yaml`` (at ``settings.brains_file``) maps a brain name to a connection
|
|
5
|
+
descriptor (local claude, a ubob daemon, or any Brain-Protocol endpoint); a
|
|
6
|
+
thread picks one by setting its meta ``brain`` key, and the fleet path resolves
|
|
7
|
+
the harness + url + token from this registry.
|
|
8
|
+
|
|
9
|
+
Back-compat: with no brains.yaml the registry falls back to today's env-driven
|
|
10
|
+
behaviour, so existing single-brain fleets are unaffected.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from yothere.brain.registry import BrainDescriptor, load_brains, resolve
|
|
15
|
+
|
|
16
|
+
__all__ = ["BrainDescriptor", "load_brains", "resolve"]
|