Requirement 001 phase 6, S1:
- Fit: Agents and Artifacts in a graph view fill the window from 861 px
up (body.fit flex column); the e2e walk probes #agents/orbit at every
width for a page no taller than the window.
- Rename once: a session seen under two names by one source (two claims)
flipped its name on every state build, so the Stream logged a rename
each poll. Names.observe keeps only the newest sighting per session
and source, and the timeline emits each (session, before, after) pair
once.
- Deutschlandfunk: root cause was the signed media-src, which never
listed the redirect target f111.rndfnk.com that st01.sslstream.dlf.de
answers with (fixed in ba97f7c). The e2e now replays that chain, a
302 from one https origin to a second, in Chromium and Firefox, with
self-signed throwaway certificates the drivers accept.
- Squelch: a settings slider 0..1 scales every tone; at 0 no beep plays,
music on or off (Voice.beep skips a silent tone). Stored settings
without the key load with 1.0.
- Tree: the missing tree file error names `pkgmgr branch tree` and the
release and install step that makes the timer write it.
Evidence: make test 655 OK, ruff and markdownlint clean
(/tmp/s1-make-test-1.log); e2e 17 OK, none skipped (/tmp/s1-e2e-3.log);
MEDUSA 14 files, 0 findings.
🎯 Fix: 94 %
💥 Break: 4 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 90
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
156 lines
6.3 KiB
Python
156 lines
6.3 KiB
Python
from __future__ import annotations
|
|
|
|
import itertools
|
|
import json
|
|
import math
|
|
import re
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
from .council import HUMAN, agent
|
|
from .events import event
|
|
from .records import read_json, write_atomic
|
|
from .transcripts import STREAM_AGE
|
|
|
|
PSEUDO = "name:"
|
|
TITLE_SCAN = 10.0
|
|
SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
|
|
|
|
|
class Directory:
|
|
def __init__(self, entries: list[dict]) -> None:
|
|
self.by_session: dict[str, list[dict]] = {}
|
|
self.by_name: dict[str, list[str]] = {}
|
|
for entry in entries:
|
|
self.by_session.setdefault(entry["session"], []).append(entry)
|
|
holders = self.by_name.setdefault(entry["name"], [])
|
|
if entry["session"] in holders:
|
|
holders.remove(entry["session"])
|
|
holders.append(entry["session"])
|
|
|
|
def at(self, session: str, ts: float) -> str:
|
|
own = self.by_session.get(session)
|
|
if not own:
|
|
return ""
|
|
valid = [entry for entry in own if entry["first_seen"] <= ts]
|
|
return (valid[-1] if valid else own[0])["name"]
|
|
|
|
def now(self, session: str) -> str:
|
|
own = self.by_session.get(session)
|
|
return own[-1]["name"] if own else ""
|
|
|
|
def find(self, name: str, ts: float) -> str:
|
|
holders = self.by_name.get(name, [])
|
|
real = [session for session in holders if not session.startswith(PSEUDO)]
|
|
valid = [session for session in real if self.at(session, ts) == name]
|
|
chosen = valid or real or holders
|
|
return chosen[-1] if chosen else ""
|
|
|
|
def key(self, name: str, ts: float) -> str:
|
|
if not name or name == HUMAN:
|
|
return name
|
|
return self.find(name, ts) or f"{PSEUDO}{name}"
|
|
|
|
def label(self, key: str, ts: float) -> str:
|
|
return self.at(key, ts) or key.removeprefix(PSEUDO)
|
|
|
|
def names(self, key: str) -> list[str]:
|
|
return list(dict.fromkeys(entry["name"] for entry in self.by_session.get(key, [])))
|
|
|
|
def timeline(self, key: str) -> list[dict]:
|
|
return [{"name": entry["name"], "since": entry["first_seen"]} for entry in self.by_session.get(key, [])]
|
|
|
|
def aliases(self, name: str) -> set[str]:
|
|
return set(self.by_name.get(name, []))
|
|
|
|
def renames(self, begin: float, end: float) -> list[dict]:
|
|
found = []
|
|
shown = set()
|
|
for session, own in self.by_session.items():
|
|
for before, after in itertools.pairwise(own):
|
|
pair = (session, before["name"], after["name"])
|
|
if before["name"] == after["name"] or pair in shown:
|
|
continue
|
|
shown.add(pair)
|
|
if begin <= after["first_seen"] <= end:
|
|
found.append(event({
|
|
"id": f"rename:{session}:{after['first_seen']}", "ts": after["first_seen"], "kind": "rename", "agent": after["name"],
|
|
"now": self.now(session), "session": session, "title": f"{before['name']} heißt jetzt {after['name']}",
|
|
"text": f"Quelle: {after['source']}", "ref": {"before": before["name"], "after": after["name"], "source": after["source"]},
|
|
}))
|
|
return found
|
|
|
|
|
|
class Names:
|
|
def __init__(self, file: Path) -> None:
|
|
self.file = file
|
|
self.lock = threading.Lock()
|
|
self.titled = -math.inf
|
|
data = read_json(file)
|
|
data = data if isinstance(data, dict) else {}
|
|
entries, observed = data.get("entries"), data.get("observed")
|
|
keys = {"session", "name", "first_seen", "source"}
|
|
self.entries = [entry for entry in entries if isinstance(entry, dict) and set(entry) == keys] if isinstance(entries, list) else []
|
|
self.observed: dict[str, str] = observed if isinstance(observed, dict) else {}
|
|
|
|
def observe(self, sightings: list[tuple[str, str, float | None, str]], now: float) -> None:
|
|
"""Args:
|
|
sightings: (session, name, seen, source); of several sightings of
|
|
one session from one source only the newest counts, so two
|
|
claims under different names cannot flip the name back and
|
|
forth on every state build.
|
|
"""
|
|
newest: dict[tuple[str, str], tuple[str, str, float | None, str]] = {}
|
|
for sighting in sightings:
|
|
key = (sighting[0], sighting[3])
|
|
if key not in newest or (sighting[2] or -math.inf) >= (newest[key][2] or -math.inf):
|
|
newest[key] = sighting
|
|
with self.lock:
|
|
changed = False
|
|
for session, name, seen, source in newest.values():
|
|
key = f"{session}\x00{source}"
|
|
if not session or not name or self.observed.get(key) == name:
|
|
continue
|
|
self.observed[key] = name
|
|
changed = True
|
|
history = [entry for entry in self.entries if entry["session"] == session]
|
|
if history and history[-1]["name"] == name:
|
|
continue
|
|
floor = history[-1]["first_seen"] if history else -math.inf
|
|
first = seen if seen is not None and seen > floor else max(now, floor)
|
|
self.entries.append({"session": session, "name": name, "first_seen": first, "source": source})
|
|
if changed:
|
|
write_atomic(self.file, json.dumps({"entries": self.entries, "observed": self.observed}, sort_keys=True).encode("utf-8"))
|
|
|
|
def listed(self) -> list[dict]:
|
|
with self.lock:
|
|
return [dict(entry) for entry in self.entries]
|
|
|
|
def due(self, now: float) -> bool:
|
|
with self.lock:
|
|
if now - self.titled < TITLE_SCAN:
|
|
return False
|
|
self.titled = now
|
|
return True
|
|
|
|
|
|
def title_sightings(root: Path, claimed: set[str], now: float) -> list[tuple[str, str, float | None, str]]:
|
|
try:
|
|
paths = sorted(root.glob("*/*/custom-title.json"))
|
|
except OSError:
|
|
return []
|
|
found = []
|
|
for path in paths:
|
|
session = path.parent.name
|
|
try:
|
|
changed = path.stat().st_mtime
|
|
except OSError:
|
|
continue
|
|
if not SESSION_ID.fullmatch(session) or (session not in claimed and changed < now - STREAM_AGE):
|
|
continue
|
|
data = read_json(path)
|
|
name = agent(data.get("customTitle") if isinstance(data, dict) else None)
|
|
if name:
|
|
found.append((session, name, changed, "title"))
|
|
return found
|