feat(ui): interactive graph, strip context menus, days by activity, message times, limit status words

This commit is contained in:
hh
2026-09-02 04:24:15 +02:00
parent 025f2dbc3f
commit c6401f1114
27 changed files with 1619 additions and 381 deletions
+15 -3
View File
@@ -161,7 +161,10 @@ def build_entries(
return entries
def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
def messages_from_entries(
entries: Iterable[Mapping[str, Any]], *, with_ts: bool = False
) -> list[dict[str, Any]]:
"""Messages as the model saw them; ``with_ts`` adds when each one was written."""
out: list[dict[str, Any]] = []
last_message_id: str | None = None
for entry in entries:
@@ -180,7 +183,13 @@ def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str
):
out[-1]["content"].extend(results)
else:
out.append({"role": "user", "content": _user_content(content)})
message_out: dict[str, Any] = {
"role": "user",
"content": _user_content(content),
}
if with_ts:
message_out["ts"] = entry.get("timestamp")
out.append(message_out)
last_message_id = None
continue
blocks = _assistant_blocks(content)
@@ -193,7 +202,10 @@ def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str
):
out[-1]["content"].extend(blocks)
else:
out.append({"role": "assistant", "content": blocks})
message_out: dict[str, Any] = {"role": "assistant", "content": blocks}
if with_ts:
message_out["ts"] = entry.get("timestamp")
out.append(message_out)
last_message_id = message_id
return out
+54 -9
View File
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.conversations.kinds import Kind
__all__ = ["compose", "files_touched", "granules", "skill_sets"]
__all__ = ["bash_paths", "compose", "files_touched", "granules", "skill_sets"]
CHARS_PER_TOKEN = 3.2
READ_TOOLS = frozenset({"Read", "NotebookRead"})
@@ -21,6 +21,40 @@ WRITE_TOOLS = frozenset({"Write", "Edit", "MultiEdit", "NotebookEdit"})
SEARCH_TOOLS = frozenset({"Glob", "Grep"})
PATH_KEYS = ("file_path", "notebook_path", "path", "file", "filename")
_FRONTMATTER = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
# A shell command names files as quoted strings or bare words; only notes
# and plain text files count, code and binaries are the agent's own business.
_SHELL_TOKEN = re.compile(r'"([^"\n]+)"|\'([^\'\n]+)\'|([^\s"\'`;|&()<>]+)')
_NOTE_SUFFIXES = (".md", ".canvas", ".txt", ".csv", ".json", ".yaml", ".yml")
_SHELL_CD = re.compile(r'(?:^|&&|;|\|\|)\s*cd\s+("([^"]+)"|\'([^\']+)\'|(\S+))')
_SHELL_WRITES = re.compile(
r"write_text|write_bytes|>>?\s*[\"\']?[^\s&|]|\btee\b|\bsed\s+-i|\bmv\b|\brm\b"
r"|\btouch\b|\bcp\b|open\([^)]*[\"\'][wa]"
)
def bash_paths(command: str, cwd: Path | None) -> tuple[list[str], Path | None, bool]:
"""Files a shell command names, where it leaves the shell, whether it writes.
Relative names resolve against ``cwd``, or against the directory of a
leading ``cd`` in the same command. Absolute names stay as they are.
"""
here = cwd
for match in _SHELL_CD.finditer(command):
target = match.group(2) or match.group(3) or match.group(4) or ""
target = target.strip()
if not target:
continue
here = Path(target) if target.startswith("/") else (here or Path()) / target
found: list[str] = []
for match in _SHELL_TOKEN.finditer(command):
raw = (match.group(1) or match.group(2) or match.group(3) or "").strip()
if not raw.lower().endswith(_NOTE_SUFFIXES) or "*" in raw:
continue
path = Path(raw) if raw.startswith("/") else (here or Path()) / raw
text = str(path)
if text not in found:
found.append(text)
return found, here, bool(_SHELL_WRITES.search(command))
def _tokens(chars: int) -> int:
@@ -56,27 +90,38 @@ def files_touched(
"""Files named in tool inputs, with how they were touched, plus tool counts."""
files: dict[str, dict[str, Any]] = {}
counts: Counter[str] = Counter()
# The shell keeps its directory between calls; follow it.
shell_cwd = cwd
def touch(raw: str, how: str, stamp: str) -> None:
key = _relative(raw, cwd)
row = files.setdefault(
key, {"path": key, "reads": 0, "writes": 0, "other": 0, "last_at": stamp}
)
row[how] += 1
row["last_at"] = max(row["last_at"], stamp)
for name, tool_input, stamp in _tool_uses(entries):
counts[name] += 1
if not isinstance(tool_input, dict):
continue
if name == "Bash" and isinstance(tool_input.get("command"), str):
paths, shell_cwd, writes = bash_paths(tool_input["command"], shell_cwd)
for raw in paths:
touch(raw, "writes" if writes else "reads", stamp)
continue
raw = next(
(v for k in PATH_KEYS if isinstance(v := tool_input.get(k), str) and v),
None,
)
if raw is None or (name in SEARCH_TOOLS and "file_path" not in tool_input):
continue
key = _relative(raw, cwd)
row = files.setdefault(
key, {"path": key, "reads": 0, "writes": 0, "other": 0, "last_at": stamp}
)
if name in READ_TOOLS:
row["reads"] += 1
touch(raw, "reads", stamp)
elif name in WRITE_TOOLS:
row["writes"] += 1
touch(raw, "writes", stamp)
else:
row["other"] += 1
row["last_at"] = max(row["last_at"], stamp)
touch(raw, "other", stamp)
ordered = sorted(files.values(), key=lambda r: r["last_at"], reverse=True)
return ordered, dict(counts)
+3 -1
View File
@@ -474,7 +474,9 @@ class Rows(State):
return await load_messages(
session, conversation_id=cast("int", conv.id)
)
return messages_from_entries(cast("Any", await self.entries(conv)))
return messages_from_entries(
cast("Any", await self.entries(conv)), with_ts=True
)
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
if conv.session_id is None:
+19 -1
View File
@@ -750,7 +750,25 @@ def build_app( # noqa: PLR0915
)
gateway = _sum_usage(await _usage_rows(runtime, since, now))
gateway["since"] = since.isoformat(timespec="seconds")
windows.append({**_limit_public(row), "gateway": gateway})
# The API sends a figure only when a window nears its limit;
# the last one inside this window is the best lower bound.
known = next(
(
r
for r in rows
if r.window == window
and r.utilization is not None
and _aware(r.ts) >= since
),
None,
)
windows.append(
{
**_limit_public(row),
"gateway": gateway,
"last_known": _limit_public(known) if known else None,
}
)
windows.sort(
key=lambda w: (WINDOWS.get(w["window"], timedelta.max), w["window"])
)