uv-suite 0.30.0 → 0.32.0
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.
- package/README.md +14 -11
- package/bin/cli.js +124 -54
- package/hooks/uv-out-notify.sh +19 -12
- package/hooks/watchtower-end.sh +23 -0
- package/hooks/watchtower-notify.sh +11 -0
- package/hooks/watchtower-send.sh +6 -3
- package/hooks/watchtower-tokens.sh +61 -0
- package/package.json +6 -3
- package/personas/auto.json +24 -0
- package/personas/professional.json +24 -0
- package/personas/spike.json +24 -0
- package/personas/sport.json +24 -0
- package/uv.sh +1 -1
- package/watchtower/README.md +13 -18
- package/watchtower/app/__pycache__/__init__.cpython-312.pyc +0 -0
- package/watchtower/app/__pycache__/db.cpython-312.pyc +0 -0
- package/watchtower/app/__pycache__/main.cpython-312.pyc +0 -0
- package/watchtower/app/__pycache__/models.cpython-312.pyc +0 -0
- package/watchtower/app/db.py +95 -51
- package/watchtower/app/main.py +4 -6
- package/watchtower/app/models.py +5 -0
- package/watchtower/app/routers/__pycache__/__init__.cpython-312.pyc +0 -0
- package/watchtower/app/routers/__pycache__/control.cpython-312.pyc +0 -0
- package/watchtower/app/routers/__pycache__/ingest.cpython-312.pyc +0 -0
- package/watchtower/app/routers/__pycache__/query.cpython-312.pyc +0 -0
- package/watchtower/app/routers/__pycache__/settings.cpython-312.pyc +0 -0
- package/watchtower/app/routers/__pycache__/stream.cpython-312.pyc +0 -0
- package/watchtower/app/routers/control.py +174 -58
- package/watchtower/app/routers/ingest.py +101 -46
- package/watchtower/app/routers/query.py +77 -28
- package/watchtower/app/routers/settings.py +34 -0
- package/watchtower/app/routers/stream.py +3 -5
- package/watchtower/app/services/__pycache__/__init__.cpython-312.pyc +0 -0
- package/watchtower/app/services/__pycache__/checkpoint.cpython-312.pyc +0 -0
- package/watchtower/app/services/__pycache__/tmux.cpython-312.pyc +0 -0
- package/watchtower/app/services/checkpoint.py +64 -22
- package/watchtower/requirements.txt +1 -1
- package/watchtower/static/dashboard.html +427 -299
- package/watchtower/watchtower.db +0 -0
- package/watchtower/Dockerfile +0 -9
- package/watchtower/docker-compose.yml +0 -22
- package/watchtower/schema.sql +0 -43
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
"""Ingest router: hooks and `uvs` POST here. Writes events/sessions/approvals
|
|
2
|
-
and
|
|
3
|
-
import
|
|
4
|
-
|
|
2
|
+
and broadcasts to connected dashboards (in-process, no polling)."""
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Request
|
|
5
7
|
|
|
6
8
|
from app import db
|
|
7
|
-
from app.models import ApprovalIn, SessionRegister, StateUpdate
|
|
9
|
+
from app.models import ApprovalIn, SessionRegister, StateUpdate, TokensIn
|
|
8
10
|
|
|
9
11
|
router = APIRouter()
|
|
10
12
|
|
|
13
|
+
# Serializes the check-then-write in create_approval so concurrent hooks
|
|
14
|
+
# (PermissionRequest + Notification fire together) collapse to one attention item.
|
|
15
|
+
_approval_lock = asyncio.Lock()
|
|
16
|
+
|
|
11
17
|
|
|
12
18
|
@router.post("/events")
|
|
13
|
-
async def ingest_event(req: Request
|
|
19
|
+
async def ingest_event(req: Request) -> dict:
|
|
14
20
|
body = await req.json()
|
|
15
21
|
|
|
16
22
|
sid = body.get("uvs_session_id") or body.get("session_id")
|
|
@@ -25,78 +31,127 @@ async def ingest_event(req: Request, con: asyncpg.Connection = Depends(db.db)) -
|
|
|
25
31
|
priority = body.get("session_priority") or body.get("priority")
|
|
26
32
|
persona = body.get("persona")
|
|
27
33
|
|
|
28
|
-
await
|
|
34
|
+
await db.execute(
|
|
29
35
|
"""INSERT INTO events (session_id, event_type, tool_name, command, payload)
|
|
30
|
-
VALUES (
|
|
31
|
-
sid, event_type, tool_name, command, body,
|
|
36
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
37
|
+
sid, event_type, tool_name, command, json.dumps(body, default=str),
|
|
32
38
|
)
|
|
33
39
|
|
|
34
40
|
if sid:
|
|
35
|
-
await
|
|
41
|
+
await db.execute(
|
|
36
42
|
"""INSERT INTO sessions (id, name, kind, purpose, priority, persona, cwd)
|
|
37
|
-
VALUES (
|
|
43
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
38
44
|
ON CONFLICT (id) DO UPDATE SET
|
|
39
|
-
name = COALESCE(
|
|
40
|
-
kind = COALESCE(
|
|
41
|
-
purpose = COALESCE(
|
|
42
|
-
priority = COALESCE(
|
|
43
|
-
persona = COALESCE(
|
|
44
|
-
cwd = COALESCE(
|
|
45
|
+
name = COALESCE(excluded.name, sessions.name),
|
|
46
|
+
kind = COALESCE(excluded.kind, sessions.kind),
|
|
47
|
+
purpose = COALESCE(excluded.purpose, sessions.purpose),
|
|
48
|
+
priority = COALESCE(excluded.priority, sessions.priority),
|
|
49
|
+
persona = COALESCE(excluded.persona, sessions.persona),
|
|
50
|
+
cwd = COALESCE(excluded.cwd, sessions.cwd)""",
|
|
45
51
|
sid, name, kind, purpose, priority, persona, cwd,
|
|
46
52
|
)
|
|
47
53
|
|
|
48
|
-
|
|
54
|
+
# The user responded → clear any pending attention item for this session.
|
|
55
|
+
if sid and event_type == "UserPromptSubmit":
|
|
56
|
+
pending = await db.fetch(
|
|
57
|
+
"SELECT id FROM approvals WHERE session_id = ? AND status = 'pending'", sid
|
|
58
|
+
)
|
|
59
|
+
for p in pending:
|
|
60
|
+
await db.execute(
|
|
61
|
+
"UPDATE approvals SET status = 'resolved', decided_at = CURRENT_TIMESTAMP WHERE id = ?",
|
|
62
|
+
p["id"],
|
|
63
|
+
)
|
|
64
|
+
db.notify({"type": "approval", "id": p["id"], "session_id": sid, "status": "resolved"})
|
|
65
|
+
if pending:
|
|
66
|
+
await db.execute("UPDATE sessions SET state = 'active' WHERE id = ?", sid)
|
|
67
|
+
|
|
68
|
+
db.notify({
|
|
69
|
+
"type": "event", "session_id": sid,
|
|
70
|
+
"event_type": event_type, "tool_name": tool_name, "command": command,
|
|
71
|
+
})
|
|
49
72
|
return {"ok": True}
|
|
50
73
|
|
|
51
74
|
|
|
52
75
|
@router.post("/sessions/register")
|
|
53
|
-
async def register_session(s: SessionRegister
|
|
54
|
-
await
|
|
76
|
+
async def register_session(s: SessionRegister) -> dict:
|
|
77
|
+
await db.execute(
|
|
55
78
|
"""INSERT INTO sessions (id, name, persona, cwd, worktree, branch, pid, tmux_target, state)
|
|
56
|
-
VALUES (
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')
|
|
57
80
|
ON CONFLICT (id) DO UPDATE SET
|
|
58
|
-
name = COALESCE(
|
|
59
|
-
persona = COALESCE(
|
|
60
|
-
cwd = COALESCE(
|
|
61
|
-
worktree = COALESCE(
|
|
62
|
-
branch = COALESCE(
|
|
63
|
-
pid = COALESCE(
|
|
64
|
-
tmux_target = COALESCE(
|
|
81
|
+
name = COALESCE(excluded.name, sessions.name),
|
|
82
|
+
persona = COALESCE(excluded.persona, sessions.persona),
|
|
83
|
+
cwd = COALESCE(excluded.cwd, sessions.cwd),
|
|
84
|
+
worktree = COALESCE(excluded.worktree, sessions.worktree),
|
|
85
|
+
branch = COALESCE(excluded.branch, sessions.branch),
|
|
86
|
+
pid = COALESCE(excluded.pid, sessions.pid),
|
|
87
|
+
tmux_target = COALESCE(excluded.tmux_target, sessions.tmux_target),
|
|
65
88
|
state = 'active'""",
|
|
66
89
|
s.id, s.name, s.persona, s.cwd, s.worktree, s.branch, s.pid, s.tmux_target,
|
|
67
90
|
)
|
|
68
|
-
|
|
91
|
+
db.notify({"type": "session", "session_id": s.id})
|
|
69
92
|
return {"ok": True}
|
|
70
93
|
|
|
71
94
|
|
|
72
95
|
@router.post("/approvals")
|
|
73
|
-
async def create_approval(a: ApprovalIn
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
async def create_approval(a: ApprovalIn) -> dict:
|
|
97
|
+
# One attention item per session: refresh the existing pending one rather than
|
|
98
|
+
# stacking duplicates (PermissionRequest + Notification fire together for one prompt).
|
|
99
|
+
# Locked so the concurrent pair can't both miss the existing row and insert twice.
|
|
100
|
+
req_json = json.dumps(a.request, default=str)
|
|
101
|
+
async with _approval_lock:
|
|
102
|
+
existing = await db.fetchrow(
|
|
103
|
+
"SELECT * FROM approvals WHERE session_id = ? AND status = 'pending' ORDER BY id DESC LIMIT 1",
|
|
104
|
+
a.session_id,
|
|
105
|
+
)
|
|
106
|
+
if existing:
|
|
107
|
+
approval_id = existing["id"]
|
|
108
|
+
# Prefer the tool-specific source (PermissionRequest) over a generic
|
|
109
|
+
# Notification, regardless of which arrives second.
|
|
110
|
+
tool = a.tool_name or existing["tool_name"]
|
|
111
|
+
command = a.command if a.tool_name else (existing["command"] or a.command)
|
|
112
|
+
await db.execute(
|
|
113
|
+
"UPDATE approvals SET tool_name = ?, command = ?, request = ?, "
|
|
114
|
+
"created_at = CURRENT_TIMESTAMP WHERE id = ?",
|
|
115
|
+
tool, command, req_json, approval_id,
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
approval_id = await db.insert(
|
|
119
|
+
"""INSERT INTO approvals (session_id, tool_name, command, request, status)
|
|
120
|
+
VALUES (?, ?, ?, ?, 'pending')""",
|
|
121
|
+
a.session_id, a.tool_name, a.command, req_json,
|
|
122
|
+
)
|
|
123
|
+
await db.execute("UPDATE sessions SET state = 'awaiting_human' WHERE id = ?", a.session_id)
|
|
124
|
+
db.notify({
|
|
84
125
|
"type": "approval",
|
|
85
|
-
"id":
|
|
126
|
+
"id": approval_id,
|
|
86
127
|
"session_id": a.session_id,
|
|
87
128
|
"tool_name": a.tool_name,
|
|
88
129
|
"command": a.command,
|
|
89
130
|
})
|
|
90
|
-
return {"ok": True, "id":
|
|
131
|
+
return {"ok": True, "id": approval_id}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@router.post("/sessions/{id}/tokens")
|
|
135
|
+
async def update_tokens(id: str, t: TokensIn) -> dict:
|
|
136
|
+
await db.execute(
|
|
137
|
+
"UPDATE sessions SET input_tokens = ?, output_tokens = ? WHERE id = ?",
|
|
138
|
+
t.input_tokens, t.output_tokens, id,
|
|
139
|
+
)
|
|
140
|
+
db.notify({
|
|
141
|
+
"type": "session", "session_id": id,
|
|
142
|
+
"input_tokens": t.input_tokens, "output_tokens": t.output_tokens,
|
|
143
|
+
})
|
|
144
|
+
return {"ok": True}
|
|
91
145
|
|
|
92
146
|
|
|
93
147
|
@router.post("/sessions/{id}/state")
|
|
94
|
-
async def update_state(id: str, s: StateUpdate
|
|
148
|
+
async def update_state(id: str, s: StateUpdate) -> dict:
|
|
95
149
|
if s.state == "terminated":
|
|
96
|
-
await
|
|
97
|
-
"UPDATE sessions SET state =
|
|
150
|
+
await db.execute(
|
|
151
|
+
"UPDATE sessions SET state = ?, ended_at = CURRENT_TIMESTAMP WHERE id = ?",
|
|
152
|
+
s.state, id,
|
|
98
153
|
)
|
|
99
154
|
else:
|
|
100
|
-
await
|
|
101
|
-
|
|
155
|
+
await db.execute("UPDATE sessions SET state = ? WHERE id = ?", s.state, id)
|
|
156
|
+
db.notify({"type": "session", "session_id": id, "state": s.state})
|
|
102
157
|
return {"ok": True}
|
|
@@ -1,17 +1,31 @@
|
|
|
1
|
-
"""Query router: read-only endpoints the dashboard
|
|
1
|
+
"""Query router: read-only endpoints the dashboard loads."""
|
|
2
|
+
import json
|
|
2
3
|
import os
|
|
4
|
+
import re
|
|
3
5
|
|
|
4
|
-
import
|
|
5
|
-
from fastapi import
|
|
6
|
+
from fastapi import APIRouter, HTTPException
|
|
7
|
+
from fastapi.responses import PlainTextResponse
|
|
6
8
|
|
|
7
9
|
from app import db
|
|
8
10
|
|
|
11
|
+
|
|
12
|
+
def _decode(rows: list[dict], col: str) -> list[dict]:
|
|
13
|
+
"""JSON columns are stored as TEXT; decode them back to objects for the API."""
|
|
14
|
+
for r in rows:
|
|
15
|
+
if isinstance(r.get(col), str):
|
|
16
|
+
try:
|
|
17
|
+
r[col] = json.loads(r[col])
|
|
18
|
+
except (ValueError, TypeError):
|
|
19
|
+
pass
|
|
20
|
+
return rows
|
|
21
|
+
|
|
22
|
+
|
|
9
23
|
router = APIRouter()
|
|
10
24
|
|
|
11
25
|
|
|
12
26
|
@router.get("/sessions")
|
|
13
|
-
async def list_sessions(
|
|
14
|
-
|
|
27
|
+
async def list_sessions() -> list[dict]:
|
|
28
|
+
return await db.fetch(
|
|
15
29
|
"""SELECT s.*,
|
|
16
30
|
(SELECT count(*) FROM events e
|
|
17
31
|
WHERE e.session_id = s.id AND e.tool_name IS NOT NULL) AS tool_calls,
|
|
@@ -22,36 +36,40 @@ async def list_sessions(con: asyncpg.Connection = Depends(db.db)) -> list[dict]:
|
|
|
22
36
|
(SELECT max(e.created_at) FROM events e WHERE e.session_id = s.id),
|
|
23
37
|
s.started_at) DESC"""
|
|
24
38
|
)
|
|
25
|
-
return [dict(r) for r in rows]
|
|
26
39
|
|
|
27
40
|
|
|
28
41
|
@router.get("/sessions/{id}")
|
|
29
|
-
async def get_session(id: str
|
|
30
|
-
row = await
|
|
42
|
+
async def get_session(id: str) -> dict:
|
|
43
|
+
row = await db.fetchrow("SELECT * FROM sessions WHERE id = ?", id)
|
|
31
44
|
if row is None:
|
|
32
45
|
raise HTTPException(status_code=404, detail="session not found")
|
|
33
|
-
return
|
|
46
|
+
return row
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.get("/events")
|
|
50
|
+
async def list_events(limit: int = 60) -> list[dict]:
|
|
51
|
+
"""Recent events across all sessions, oldest-first — backfill for the heartbeat."""
|
|
52
|
+
rows = await db.fetch(
|
|
53
|
+
"SELECT id, session_id, event_type, tool_name, command, created_at "
|
|
54
|
+
"FROM events ORDER BY id DESC LIMIT ?",
|
|
55
|
+
limit,
|
|
56
|
+
)
|
|
57
|
+
rows.reverse()
|
|
58
|
+
return rows
|
|
34
59
|
|
|
35
60
|
|
|
36
61
|
@router.get("/sessions/{id}/events")
|
|
37
|
-
async def get_session_events(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
rows = await con.fetch(
|
|
41
|
-
"""SELECT * FROM events
|
|
42
|
-
WHERE session_id = $1
|
|
43
|
-
ORDER BY created_at DESC
|
|
44
|
-
LIMIT $2""",
|
|
62
|
+
async def get_session_events(id: str, limit: int = 100) -> list[dict]:
|
|
63
|
+
rows = await db.fetch(
|
|
64
|
+
"SELECT * FROM events WHERE session_id = ? ORDER BY created_at DESC LIMIT ?",
|
|
45
65
|
id, limit,
|
|
46
66
|
)
|
|
47
|
-
return
|
|
67
|
+
return _decode(rows, "payload")
|
|
48
68
|
|
|
49
69
|
|
|
50
70
|
@router.get("/sessions/{id}/artifacts")
|
|
51
|
-
async def get_session_artifacts(
|
|
52
|
-
|
|
53
|
-
) -> list[dict]:
|
|
54
|
-
row = await con.fetchrow("SELECT cwd FROM sessions WHERE id = $1", id)
|
|
71
|
+
async def get_session_artifacts(id: str) -> list[dict]:
|
|
72
|
+
row = await db.fetchrow("SELECT cwd FROM sessions WHERE id = ?", id)
|
|
55
73
|
if row is None:
|
|
56
74
|
raise HTTPException(status_code=404, detail="session not found")
|
|
57
75
|
|
|
@@ -75,10 +93,41 @@ async def get_session_artifacts(
|
|
|
75
93
|
|
|
76
94
|
|
|
77
95
|
@router.get("/approvals")
|
|
78
|
-
async def list_approvals(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
rows = await con.fetch(
|
|
82
|
-
"SELECT * FROM approvals WHERE status = $1 ORDER BY created_at DESC", status
|
|
96
|
+
async def list_approvals(status: str = "pending") -> list[dict]:
|
|
97
|
+
rows = await db.fetch(
|
|
98
|
+
"SELECT * FROM approvals WHERE status = ? ORDER BY created_at DESC", status
|
|
83
99
|
)
|
|
84
|
-
return
|
|
100
|
+
return _decode(rows, "request")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def _checkpoints_dir(id: str) -> str:
|
|
104
|
+
row = await db.fetchrow("SELECT cwd FROM sessions WHERE id = ?", id)
|
|
105
|
+
if row is None:
|
|
106
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
107
|
+
return os.path.join(row["cwd"] or "", "uv-out", "sessions", id, "checkpoints")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.get("/sessions/{id}/checkpoints")
|
|
111
|
+
async def list_checkpoints(id: str) -> list[dict]:
|
|
112
|
+
d = await _checkpoints_dir(id)
|
|
113
|
+
if not os.path.isdir(d):
|
|
114
|
+
return []
|
|
115
|
+
out = [
|
|
116
|
+
{"name": fn, "size": os.path.getsize(os.path.join(d, fn)),
|
|
117
|
+
"modified": os.path.getmtime(os.path.join(d, fn))}
|
|
118
|
+
for fn in os.listdir(d)
|
|
119
|
+
if os.path.isfile(os.path.join(d, fn))
|
|
120
|
+
]
|
|
121
|
+
out.sort(key=lambda c: c["modified"], reverse=True)
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@router.get("/sessions/{id}/checkpoints/{name}", response_class=PlainTextResponse)
|
|
126
|
+
async def read_checkpoint(id: str, name: str) -> str:
|
|
127
|
+
if not re.fullmatch(r"[A-Za-z0-9._-]+", name) or ".." in name:
|
|
128
|
+
raise HTTPException(status_code=400, detail="invalid checkpoint name")
|
|
129
|
+
full = os.path.join(await _checkpoints_dir(id), name)
|
|
130
|
+
if not os.path.isfile(full):
|
|
131
|
+
raise HTTPException(status_code=404, detail="checkpoint not found")
|
|
132
|
+
with open(full) as f:
|
|
133
|
+
return f.read()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Settings router: small key-value config the dashboard reads/writes."""
|
|
2
|
+
from fastapi import APIRouter, HTTPException
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from app import db
|
|
6
|
+
|
|
7
|
+
router = APIRouter()
|
|
8
|
+
|
|
9
|
+
# Whitelisted settings and their permitted values — settings are not arbitrary writes.
|
|
10
|
+
ALLOWED = {
|
|
11
|
+
"terminal_app": {"auto", "terminal", "iterm"},
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SettingIn(BaseModel):
|
|
16
|
+
key: str
|
|
17
|
+
value: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get("/settings")
|
|
21
|
+
async def get_settings() -> dict:
|
|
22
|
+
rows = await db.fetch("SELECT key, value FROM settings")
|
|
23
|
+
return {r["key"]: r["value"] for r in rows}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.post("/settings")
|
|
27
|
+
async def set_setting(s: SettingIn) -> dict:
|
|
28
|
+
allowed = ALLOWED.get(s.key)
|
|
29
|
+
if allowed is None:
|
|
30
|
+
raise HTTPException(status_code=400, detail=f"unknown setting: {s.key}")
|
|
31
|
+
if s.value not in allowed:
|
|
32
|
+
raise HTTPException(status_code=400, detail=f"invalid value for {s.key}: {s.value}")
|
|
33
|
+
await db.set_setting(s.key, s.value)
|
|
34
|
+
return {"ok": True, "key": s.key, "value": s.value}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""Stream router: a single WebSocket the dashboard connects to for live updates.
|
|
2
|
-
|
|
2
|
+
Updates land on the in-process broadcaster (see app/db.py) and fan out here."""
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
5
|
|
|
@@ -14,10 +14,8 @@ router = APIRouter()
|
|
|
14
14
|
async def live(ws: WebSocket) -> None:
|
|
15
15
|
await ws.accept()
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
snapshot = {"type": "snapshot", "sessions": [dict(r) for r in rows]}
|
|
20
|
-
await ws.send_text(json.dumps(snapshot, default=str))
|
|
17
|
+
sessions = await db.fetch("SELECT * FROM sessions ORDER BY started_at DESC")
|
|
18
|
+
await ws.send_text(json.dumps({"type": "snapshot", "sessions": sessions}, default=str))
|
|
21
19
|
|
|
22
20
|
q = db.broadcaster.subscribe()
|
|
23
21
|
try:
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""Out-of-band session checkpoints.
|
|
2
2
|
|
|
3
3
|
Writes a markdown checkpoint from the session's recent events + git state.
|
|
4
|
-
No live session is required — everything is read from
|
|
4
|
+
No live session is required — everything is read from the database and the cwd's
|
|
5
5
|
git repo, so we can checkpoint a session we don't own (e.g. before closing it).
|
|
6
6
|
"""
|
|
7
|
+
import collections
|
|
8
|
+
import json
|
|
7
9
|
import os
|
|
8
10
|
import subprocess
|
|
9
11
|
from datetime import datetime, timezone
|
|
@@ -36,38 +38,79 @@ def _git_state(cwd: str) -> str:
|
|
|
36
38
|
)
|
|
37
39
|
|
|
38
40
|
|
|
41
|
+
def _summarize(events: list) -> str:
|
|
42
|
+
"""Derive 'what was done' from event payloads: the prompts the user sent (the
|
|
43
|
+
actual asks), files edited, and a tool breakdown. Events arrive newest-first."""
|
|
44
|
+
prompts, files, tools = [], collections.Counter(), collections.Counter()
|
|
45
|
+
for e in events:
|
|
46
|
+
raw = e.get("payload")
|
|
47
|
+
try:
|
|
48
|
+
p = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
|
49
|
+
except (ValueError, TypeError):
|
|
50
|
+
p = {}
|
|
51
|
+
|
|
52
|
+
if e.get("event_type") == "UserPromptSubmit":
|
|
53
|
+
txt = (p.get("prompt") or p.get("user_prompt") or "").strip()
|
|
54
|
+
if txt:
|
|
55
|
+
prompts.append(" ".join(txt.split())[:240])
|
|
56
|
+
|
|
57
|
+
tool = e.get("tool_name") or p.get("tool_name")
|
|
58
|
+
if tool:
|
|
59
|
+
tools[tool] += 1
|
|
60
|
+
fp = (p.get("tool_input") or {}).get("file_path")
|
|
61
|
+
if fp and tool in ("Edit", "Write", "MultiEdit"):
|
|
62
|
+
files[fp] += 1
|
|
63
|
+
|
|
64
|
+
parts = []
|
|
65
|
+
if prompts:
|
|
66
|
+
asks = "\n".join(f"- {t}" for t in reversed(prompts[:12])) # chronological
|
|
67
|
+
parts.append(f"**Asked ({len(prompts)}):**\n{asks}")
|
|
68
|
+
if files:
|
|
69
|
+
touched = "\n".join(f"- `{f}` ({n}×)" for f, n in files.most_common(15))
|
|
70
|
+
parts.append(f"**Files changed:**\n{touched}")
|
|
71
|
+
if tools:
|
|
72
|
+
breakdown = ", ".join(f"{n}× {t}" for t, n in tools.most_common(10))
|
|
73
|
+
parts.append(f"**Tool usage:** {breakdown}")
|
|
74
|
+
return "\n\n".join(parts) if parts else "_No recorded activity yet._"
|
|
75
|
+
|
|
76
|
+
|
|
39
77
|
def _render(session: dict, events: list, git_state: str, created: str) -> str:
|
|
40
78
|
sid = session["id"]
|
|
41
79
|
name = session.get("name") or sid
|
|
42
80
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
81
|
+
meta_rows = [
|
|
82
|
+
("Name", session.get("name")),
|
|
83
|
+
("Kind", session.get("kind")),
|
|
84
|
+
("Purpose", session.get("purpose")),
|
|
85
|
+
("Priority", session.get("priority")),
|
|
86
|
+
("Persona", session.get("persona")),
|
|
87
|
+
("Parent", session.get("parent_id")),
|
|
88
|
+
("Started", session.get("started_at")),
|
|
89
|
+
("cwd", session.get("cwd")),
|
|
90
|
+
]
|
|
91
|
+
meta = "\n".join(f"- **{k}:** {v}" for k, v in meta_rows if v)
|
|
92
|
+
|
|
93
|
+
work = _summarize(events)
|
|
53
94
|
|
|
54
95
|
return f"""---
|
|
55
96
|
session: {sid}
|
|
97
|
+
name: {session.get("name") or ""}
|
|
98
|
+
kind: {session.get("kind") or ""}
|
|
99
|
+
priority: {session.get("priority") or ""}
|
|
100
|
+
parent: {session.get("parent_id") or ""}
|
|
56
101
|
created: {created}
|
|
57
102
|
source: watchtower
|
|
58
103
|
---
|
|
59
104
|
|
|
60
105
|
# Checkpoint: {name}
|
|
61
106
|
|
|
62
|
-
##
|
|
107
|
+
## Session
|
|
63
108
|
|
|
64
|
-
|
|
65
|
-
v1 is mechanical (events + git). Do not depend on `claude` being present. -->
|
|
66
|
-
_Mechanical checkpoint — see recent activity and git state below._
|
|
109
|
+
{meta}
|
|
67
110
|
|
|
68
|
-
##
|
|
111
|
+
## What was done
|
|
69
112
|
|
|
70
|
-
{
|
|
113
|
+
{work}
|
|
71
114
|
|
|
72
115
|
## Git state
|
|
73
116
|
|
|
@@ -79,15 +122,14 @@ async def write_checkpoint(session: dict) -> str:
|
|
|
79
122
|
sid = session["id"]
|
|
80
123
|
cwd = session.get("cwd") or os.getcwd()
|
|
81
124
|
|
|
82
|
-
|
|
83
|
-
"""SELECT event_type, tool_name, command, created_at
|
|
125
|
+
events = await db.fetch(
|
|
126
|
+
"""SELECT event_type, tool_name, command, payload, created_at
|
|
84
127
|
FROM events
|
|
85
|
-
WHERE session_id =
|
|
128
|
+
WHERE session_id = ?
|
|
86
129
|
ORDER BY created_at DESC
|
|
87
|
-
LIMIT
|
|
130
|
+
LIMIT 200""",
|
|
88
131
|
sid,
|
|
89
132
|
)
|
|
90
|
-
events = [dict(r) for r in rows]
|
|
91
133
|
|
|
92
134
|
now = datetime.now(timezone.utc)
|
|
93
135
|
created = now.isoformat()
|