232 lines
7.9 KiB
Python
232 lines
7.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Merge display-only worker activity into dashboard session transcripts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
|
|
HELPER_MARKER = '''@app.get("/api/sessions/{session_id}/messages")
|
|
async def get_session_messages(session_id: str, profile: Optional[str] = None):
|
|
'''
|
|
|
|
HELPER_REPLACEMENT = '''def _run_activity_messages(session_id: str) -> List[Dict[str, Any]]:
|
|
"""Read a bounded activity journal without treating it as agent history."""
|
|
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()
|
|
root = get_hermes_home() / "run-activity"
|
|
if root.is_symlink():
|
|
return []
|
|
paths = (
|
|
root / f"{digest}.previous.jsonl",
|
|
root / f"{digest}.jsonl",
|
|
)
|
|
entries: List[Dict[str, Any]] = []
|
|
for path in paths:
|
|
try:
|
|
if path.is_symlink() or not path.is_file():
|
|
continue
|
|
if path.stat().st_size > 600_000:
|
|
continue
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
item = json.loads(line)
|
|
if not isinstance(item, dict):
|
|
continue
|
|
content = item.get("content")
|
|
timestamp = item.get("timestamp")
|
|
if not isinstance(content, str) or not isinstance(
|
|
timestamp, (int, float)
|
|
):
|
|
continue
|
|
entries.append(
|
|
{
|
|
"role": "assistant",
|
|
"content": content[:1_000],
|
|
"timestamp": float(timestamp),
|
|
"observed": True,
|
|
}
|
|
)
|
|
except (OSError, UnicodeError, ValueError, json.JSONDecodeError):
|
|
continue
|
|
return entries[-1_000:]
|
|
|
|
|
|
@app.get("/api/sessions/{session_id}/messages")
|
|
async def get_session_messages(
|
|
session_id: str,
|
|
profile: Optional[str] = None,
|
|
limit: Optional[int] = None,
|
|
):
|
|
'''
|
|
|
|
MESSAGES_BEFORE = ''' messages = db.get_messages(sid)
|
|
return {"session_id": sid, "messages": messages}
|
|
'''
|
|
|
|
MESSAGES_AFTER = ''' messages = [
|
|
*db.get_messages(sid),
|
|
*_run_activity_messages(sid),
|
|
]
|
|
messages.sort(key=lambda item: float(item.get("timestamp") or 0.0))
|
|
total_messages = len(messages)
|
|
if limit is not None:
|
|
messages = messages[-max(1, min(int(limit), 10_000)) :]
|
|
return {
|
|
"session_id": sid,
|
|
"messages": messages,
|
|
"total_messages": total_messages,
|
|
}
|
|
'''
|
|
|
|
|
|
LATEST_ROWS_BEFORE = ''' "SELECT id, parent_session_id, started_at FROM sessions"
|
|
).fetchall()
|
|
for row in raw_rows:
|
|
rows.append({
|
|
"id": row_get(row, "id", 0),
|
|
"parent_session_id": row_get(row, "parent_session_id", 1),
|
|
"started_at": row_get(row, "started_at", 2),
|
|
})
|
|
'''
|
|
|
|
LATEST_ROWS_AFTER = ''' "SELECT id, parent_session_id, started_at, ended_at FROM sessions"
|
|
).fetchall()
|
|
for row in raw_rows:
|
|
rows.append({
|
|
"id": row_get(row, "id", 0),
|
|
"parent_session_id": row_get(row, "parent_session_id", 1),
|
|
"started_at": row_get(row, "started_at", 2),
|
|
"ended_at": row_get(row, "ended_at", 3),
|
|
})
|
|
'''
|
|
|
|
LATEST_SELECTION_BEFORE = ''' children = {}
|
|
for row in rows:
|
|
rid = row.get("id")
|
|
parent = row.get("parent_session_id")
|
|
if rid and parent:
|
|
children.setdefault(parent, []).append(row)
|
|
|
|
def started(row):
|
|
try:
|
|
return float(row.get("started_at") or 0)
|
|
except Exception:
|
|
return 0.0
|
|
|
|
current = sid
|
|
path = [sid]
|
|
seen = {sid}
|
|
|
|
while children.get(current):
|
|
candidates = [r for r in children[current] if r.get("id") not in seen]
|
|
if not candidates:
|
|
break
|
|
candidates.sort(key=started, reverse=True)
|
|
current = candidates[0]["id"]
|
|
path.append(current)
|
|
seen.add(current)
|
|
|
|
return current, path
|
|
'''
|
|
|
|
LATEST_SELECTION_AFTER = ''' children = {}
|
|
rows_by_id = {}
|
|
for row in rows:
|
|
rid = row.get("id")
|
|
parent = row.get("parent_session_id")
|
|
if rid:
|
|
rows_by_id[rid] = row
|
|
if rid and parent:
|
|
children.setdefault(parent, []).append(row)
|
|
|
|
def started(row):
|
|
try:
|
|
return float(row.get("started_at") or 0)
|
|
except Exception:
|
|
return 0.0
|
|
|
|
# Old dashboard versions rewrote the URL to a delegated leaf without
|
|
# retaining its objective. Recover only the immediate objective parent.
|
|
# Climbing to the oldest ancestor mixes unrelated workstreams that happen
|
|
# to share a long-lived dashboard/handoff session.
|
|
anchor = sid
|
|
if not children.get(anchor):
|
|
parent = (rows_by_id.get(anchor) or {}).get("parent_session_id")
|
|
if parent and parent in rows_by_id:
|
|
anchor = parent
|
|
sid = anchor
|
|
|
|
root = rows_by_id.get(sid) or db.get_session(sid) or {"id": sid}
|
|
descendants = [(root, [sid])]
|
|
stack = [(sid, [sid])]
|
|
seen = {sid}
|
|
while stack:
|
|
parent, parent_path = stack.pop()
|
|
candidates = sorted(children.get(parent, []), key=started, reverse=True)
|
|
for row in candidates:
|
|
child = row.get("id")
|
|
if not child or child in seen:
|
|
continue
|
|
seen.add(child)
|
|
child_path = [*parent_path, child]
|
|
descendants.append((row, child_path))
|
|
stack.append((child, child_path))
|
|
|
|
# A durable parent can resume after a delegated reviewer exits. Prefer the
|
|
# newest still-open member of the lineage instead of stranding the browser
|
|
# on the most recently created (but already finished) child.
|
|
# An ended objective can retain orphaned children whose process died before
|
|
# SessionDB recorded ended_at. Do not let those stale rows defeat a newer,
|
|
# completed repair/review branch. While the objective itself is live, an
|
|
# open child or the resumed parent still wins as before.
|
|
active = (
|
|
[item for item in descendants if item[0].get("ended_at") is None]
|
|
if root.get("ended_at") is None
|
|
else []
|
|
)
|
|
if active:
|
|
row, path = max(active, key=lambda item: (started(item[0]), len(item[1])))
|
|
return row.get("id") or sid, path
|
|
|
|
leaves = [
|
|
item for item in descendants
|
|
if not children.get(item[0].get("id"))
|
|
]
|
|
row, path = max(leaves or descendants, key=lambda item: started(item[0]))
|
|
return row.get("id") or sid, path
|
|
'''
|
|
|
|
|
|
def patch(source: Path, destination: Path) -> None:
|
|
"""Apply the activity projection and fail closed on upstream drift."""
|
|
content = source.read_text(encoding="utf-8")
|
|
if HELPER_MARKER not in content:
|
|
raise RuntimeError("Hermes dashboard activity helper context changed")
|
|
if MESSAGES_BEFORE not in content:
|
|
raise RuntimeError("Hermes dashboard messages context changed")
|
|
if LATEST_ROWS_BEFORE not in content:
|
|
raise RuntimeError("Hermes dashboard lineage row context changed")
|
|
if LATEST_SELECTION_BEFORE not in content:
|
|
raise RuntimeError("Hermes dashboard lineage selection context changed")
|
|
content = content.replace(HELPER_MARKER, HELPER_REPLACEMENT, 1)
|
|
content = content.replace(LATEST_ROWS_BEFORE, LATEST_ROWS_AFTER, 1)
|
|
content = content.replace(LATEST_SELECTION_BEFORE, LATEST_SELECTION_AFTER, 1)
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_text(
|
|
content.replace(MESSAGES_BEFORE, MESSAGES_AFTER, 1),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("source", type=Path)
|
|
parser.add_argument("destination", type=Path)
|
|
args = parser.parse_args()
|
|
patch(args.source, args.destination)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|