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.
Files changed (42) hide show
  1. package/README.md +14 -11
  2. package/bin/cli.js +124 -54
  3. package/hooks/uv-out-notify.sh +19 -12
  4. package/hooks/watchtower-end.sh +23 -0
  5. package/hooks/watchtower-notify.sh +11 -0
  6. package/hooks/watchtower-send.sh +6 -3
  7. package/hooks/watchtower-tokens.sh +61 -0
  8. package/package.json +6 -3
  9. package/personas/auto.json +24 -0
  10. package/personas/professional.json +24 -0
  11. package/personas/spike.json +24 -0
  12. package/personas/sport.json +24 -0
  13. package/uv.sh +1 -1
  14. package/watchtower/README.md +13 -18
  15. package/watchtower/app/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/watchtower/app/__pycache__/db.cpython-312.pyc +0 -0
  17. package/watchtower/app/__pycache__/main.cpython-312.pyc +0 -0
  18. package/watchtower/app/__pycache__/models.cpython-312.pyc +0 -0
  19. package/watchtower/app/db.py +95 -51
  20. package/watchtower/app/main.py +4 -6
  21. package/watchtower/app/models.py +5 -0
  22. package/watchtower/app/routers/__pycache__/__init__.cpython-312.pyc +0 -0
  23. package/watchtower/app/routers/__pycache__/control.cpython-312.pyc +0 -0
  24. package/watchtower/app/routers/__pycache__/ingest.cpython-312.pyc +0 -0
  25. package/watchtower/app/routers/__pycache__/query.cpython-312.pyc +0 -0
  26. package/watchtower/app/routers/__pycache__/settings.cpython-312.pyc +0 -0
  27. package/watchtower/app/routers/__pycache__/stream.cpython-312.pyc +0 -0
  28. package/watchtower/app/routers/control.py +174 -58
  29. package/watchtower/app/routers/ingest.py +101 -46
  30. package/watchtower/app/routers/query.py +77 -28
  31. package/watchtower/app/routers/settings.py +34 -0
  32. package/watchtower/app/routers/stream.py +3 -5
  33. package/watchtower/app/services/__pycache__/__init__.cpython-312.pyc +0 -0
  34. package/watchtower/app/services/__pycache__/checkpoint.cpython-312.pyc +0 -0
  35. package/watchtower/app/services/__pycache__/tmux.cpython-312.pyc +0 -0
  36. package/watchtower/app/services/checkpoint.py +64 -22
  37. package/watchtower/requirements.txt +1 -1
  38. package/watchtower/static/dashboard.html +427 -299
  39. package/watchtower/watchtower.db +0 -0
  40. package/watchtower/Dockerfile +0 -9
  41. package/watchtower/docker-compose.yml +0 -22
  42. 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 emits a NOTIFY so connected dashboards update without polling."""
3
- import asyncpg
4
- from fastapi import APIRouter, Depends, Request
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, con: asyncpg.Connection = Depends(db.db)) -> dict:
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 con.execute(
34
+ await db.execute(
29
35
  """INSERT INTO events (session_id, event_type, tool_name, command, payload)
30
- VALUES ($1, $2, $3, $4, $5)""",
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 con.execute(
41
+ await db.execute(
36
42
  """INSERT INTO sessions (id, name, kind, purpose, priority, persona, cwd)
37
- VALUES ($1, $2, $3, $4, $5, $6, $7)
43
+ VALUES (?, ?, ?, ?, ?, ?, ?)
38
44
  ON CONFLICT (id) DO UPDATE SET
39
- name = COALESCE($2, sessions.name),
40
- kind = COALESCE($3, sessions.kind),
41
- purpose = COALESCE($4, sessions.purpose),
42
- priority = COALESCE($5, sessions.priority),
43
- persona = COALESCE($6, sessions.persona),
44
- cwd = COALESCE($7, sessions.cwd)""",
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
- await db.notify(con, {"type": "event", "session_id": sid, "event_type": event_type})
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, con: asyncpg.Connection = Depends(db.db)) -> dict:
54
- await con.execute(
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 ($1, $2, $3, $4, $5, $6, $7, $8, 'active')
79
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')
57
80
  ON CONFLICT (id) DO UPDATE SET
58
- name = COALESCE($2, sessions.name),
59
- persona = COALESCE($3, sessions.persona),
60
- cwd = COALESCE($4, sessions.cwd),
61
- worktree = COALESCE($5, sessions.worktree),
62
- branch = COALESCE($6, sessions.branch),
63
- pid = COALESCE($7, sessions.pid),
64
- tmux_target = COALESCE($8, sessions.tmux_target),
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
- await db.notify(con, {"type": "session", "session_id": s.id})
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, con: asyncpg.Connection = Depends(db.db)) -> dict:
74
- row = await con.fetchrow(
75
- """INSERT INTO approvals (session_id, tool_name, command, request, status)
76
- VALUES ($1, $2, $3, $4, 'pending')
77
- RETURNING id""",
78
- a.session_id, a.tool_name, a.command, a.request,
79
- )
80
- await con.execute(
81
- "UPDATE sessions SET state = 'awaiting_human' WHERE id = $1", a.session_id
82
- )
83
- await db.notify(con, {
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": row["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": row["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, con: asyncpg.Connection = Depends(db.db)) -> dict:
148
+ async def update_state(id: str, s: StateUpdate) -> dict:
95
149
  if s.state == "terminated":
96
- await con.execute(
97
- "UPDATE sessions SET state = $2, ended_at = now() WHERE id = $1", id, s.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 con.execute("UPDATE sessions SET state = $2 WHERE id = $1", id, s.state)
101
- await db.notify(con, {"type": "session", "session_id": id, "state": s.state})
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 polls/loads."""
1
+ """Query router: read-only endpoints the dashboard loads."""
2
+ import json
2
3
  import os
4
+ import re
3
5
 
4
- import asyncpg
5
- from fastapi import APIRouter, Depends, HTTPException
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(con: asyncpg.Connection = Depends(db.db)) -> list[dict]:
14
- rows = await con.fetch(
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, con: asyncpg.Connection = Depends(db.db)) -> dict:
30
- row = await con.fetchrow("SELECT * FROM sessions WHERE id = $1", id)
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 dict(row)
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
- id: str, limit: int = 100, con: asyncpg.Connection = Depends(db.db)
39
- ) -> list[dict]:
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 [dict(r) for r in rows]
67
+ return _decode(rows, "payload")
48
68
 
49
69
 
50
70
  @router.get("/sessions/{id}/artifacts")
51
- async def get_session_artifacts(
52
- id: str, con: asyncpg.Connection = Depends(db.db)
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
- status: str = "pending", con: asyncpg.Connection = Depends(db.db)
80
- ) -> list[dict]:
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 [dict(r) for r in rows]
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
- NOTIFY payloads land on the broadcaster (see app/db.py) and fan out here."""
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
- async with db.pool.acquire() as con:
18
- rows = await con.fetch("SELECT * FROM sessions ORDER BY started_at DESC")
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:
@@ -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 Postgres and the cwd's
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
- lines = []
44
- for e in events:
45
- ts = e["created_at"].isoformat() if e["created_at"] else ""
46
- bits = [e["event_type"] or "Event"]
47
- if e["tool_name"]:
48
- bits.append(e["tool_name"])
49
- if e["command"]:
50
- bits.append(f"`{e['command']}`")
51
- lines.append(f"- {ts} — {' '.join(bits)}")
52
- activity = "\n".join(lines) if lines else "_No recorded events._"
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
- ## What's in progress
107
+ ## Session
63
108
 
64
- <!-- TODO: optional semantic summary via `claude -p --model haiku`.
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
- ## Recent activity
111
+ ## What was done
69
112
 
70
- {activity}
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
- rows = await db.pool.fetch(
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 = $1
128
+ WHERE session_id = ?
86
129
  ORDER BY created_at DESC
87
- LIMIT 50""",
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()
@@ -1,3 +1,3 @@
1
1
  fastapi==0.115.6
2
2
  uvicorn[standard]==0.34.0
3
- asyncpg==0.30.0
3
+ aiosqlite==0.20.0