Skip to content

Commit faeb5fc

Browse files
kkrlstrmKai Karlstrom
authored andcommitted
Capture Claude's text narration from transcript file
Adds the "film room" missing piece: Claude's decisions and framing between tool calls, read from the Claude Code transcript JSONL at every Stop / SubagentStop / SessionEnd. - migrations/003_messages.py creates `messages` table (PK: message_id + block_index, idempotent ingest) - src/cc_logger/transcripts.py reads transcript JSONL and extracts only `text` blocks. Extended `thinking` blocks are encrypted by Anthropic (signature only, no plaintext) — that's an API-level choice we can't work around. - New Stop handler + transcript ingestion called from Stop, SubagentStop, and SessionEnd for live + reconciliation capture - install-hooks.py now installs a Stop hook too - inspect.py interleaves text blocks (· prefix) with tool calls so you can see "what Claude was thinking out loud" inline with "what it did" - docs/SCHEMA.md + docs/HOOKS.md updated; 8 new unit tests for transcript parsing (skips thinking blocks, handles malformed lines, etc.)
1 parent d4b2b18 commit faeb5fc

11 files changed

Lines changed: 462 additions & 9 deletions

File tree

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,29 @@ SESSION 94b8ee2b-b51f-4125-a116-82adaf4066af
1919
and propose which personas are reachable in each week of June-August.'
2020
2121
[root completed]
22+
· 'I'll start by pulling the campaign performance and matching it against
23+
district fiscal data, then fan out to verify against state sources.'
2224
Bash 'glab api "groups/12345/projects?search=accounts"' 3.0s ok
2325
Bash 'psql ... -c "SELECT campaign, replies FROM eb_campaigns ..."' 2.0s ok
2426
Bash 'ls /Users/me/Downloads/k12-district-fiscal-sustainability' 0ms ok
27+
· 'Got the baseline. Now I'll spawn three sub-agents in parallel: one for
28+
NY state data, one for Ohio, one for the cross-state academic calendar.'
2529
Agent 'Map district fiscal sustainability data' 38s ok
2630
[general-purpose ab72bd109071caf completed]
31+
· 'Searching state Comptroller / Auditor databases for fiscal-stress designations.'
2732
WebSearch 'K-12 district fiscal stress New York Comptroller 2024 2025' 5.9s ok
2833
WebFetch 'https://www.osc.ny.gov/state-agencies/audits/fiscal-stress' 8.2s ok
2934
WebSearch 'Ohio Auditor school district fiscal distress 2024' 6.1s ok
3035
WebFetch 'https://ohioauditor.gov/auditsearch/Reports/2024' 64s FAIL
36+
· 'Ohio Auditor blocked the fetch. Falling back to Comptroller summary.'
3137
... 47 more tool calls
3238
→ 'Found 31 districts in NY designated fiscal stress, 12 in OH...'
3339
Agent 'Cross-reference academic calendars by state' 5m 55s ok
3440
[general-purpose ab9dcc9453675d9 completed]
3541
... 64 tool calls
3642
... 7 more sub-agents
3743
38-
10 invocations (9 sub-agents), 556 tool calls (24 failed, 0 pending)
44+
10 invocations (9 sub-agents), 556 tool calls (24 failed, 0 pending), 142 text blocks
3945
```
4046

4147
`cc-logger insights` adds the cross-session view — power-law distribution of where your time goes, top failure domains, sub-agent fan-out patterns, hourly activity.
@@ -74,6 +80,7 @@ python scripts/install-hooks.py # wires the Claude Code hooks
7480
- Every sub-agent invocation (root + children, with linkage to the spawning `Agent` tool call)
7581
- Every tool call in the capture allowlist (Agent, Bash, Edit, Write, WebFetch, WebSearch, and `mcp__.*`)
7682
- Tool input + tool response payloads as JSONB; anything >50KB spills to a separate `artifacts` table
83+
- **Claude's text narration between tool calls** — read from the Claude Code transcript file at every `Stop` / `SubagentStop`, stored in the `messages` table. (Extended `thinking` blocks are encrypted by Anthropic — only `text` blocks are capturable.)
7784
- Optional regex redaction of common secret patterns before write (on by default)
7885

7986
## Privacy

docs/HOOKS.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ Official Claude Code hooks documentation: https://code.claude.com/docs/en/hooks.
1414
| `PostToolUse` | After a tool succeeds. **Filtered**. | Updates the matching `tool_calls` row with the response, `status='success'`, and duration. Spills payloads >50KB to `artifacts`. |
1515
| `PostToolUseFailure` | When a tool fails. **Filtered**. | Updates the matching `tool_calls` row with `error`, `status='failure'`, duration. |
1616
| `SubagentStart` | Sub-agent spawned. | Inserts an `agent_invocations` row. Resolves the parent `Agent` tool_call by `subagent_type` match. |
17-
| `SubagentStop` | Sub-agent finishes. | Updates the matching `agent_invocations` row with `last_message`, `ended_at`, `status='completed'`. |
18-
| `SessionEnd` | Session ends (exit, logout, kill, etc.). | Updates `sessions.ended_at` + `end_reason`. Sweeps any still-pending `tool_calls` and `agent_invocations` for this session to `orphaned`. |
17+
| `SubagentStop` | Sub-agent finishes. | Updates the matching `agent_invocations` row with `last_message`, `ended_at`, `status='completed'`. **Also reads the sub-agent's transcript file and ingests `text` blocks into the `messages` table.** |
18+
| `Stop` | Root agent finishes a turn (one response to one user message). | Reads the root transcript file at `transcript_path`, extracts assistant `text` blocks, INSERTs them into `messages` (idempotent on `message_id` + `block_index`). This is where Claude's mid-process narration lands. |
19+
| `SessionEnd` | Session ends (exit, logout, kill, etc.). | Updates `sessions.ended_at` + `end_reason`. Sweeps any still-pending `tool_calls` and `agent_invocations` for this session to `orphaned`. Also does a final transcript ingestion pass to catch anything `Stop` missed. |
1920

2021
## Tool capture allowlist
2122

@@ -47,6 +48,17 @@ cc-logger resolves the link by matching on `subagent_type`:
4748

4849
In practice, Claude Code emits hook events sequentially even when sub-agents execute in parallel, so the multi-candidate case is rare.
4950

51+
## Transcript-based message capture
52+
53+
Hooks don't include Claude's narration text in their payloads — they only fire at action boundaries. To capture the *decisions* Claude is making mid-process (e.g., "I'll start by exploring the project structure..."), cc-logger reads the Claude Code JSONL transcript file at `transcript_path` (a field present in every hook event).
54+
55+
- On `Stop` / `SubagentStop`: incremental ingest, near-realtime
56+
- On `SessionEnd`: final reconciliation pass
57+
58+
Only `text` blocks are extracted. Claude's `thinking` (extended thinking) blocks are encrypted in the transcript by Anthropic — only a `signature` is present, no plaintext reasoning. This is an API-level choice and not something cc-logger can work around.
59+
60+
Insertion is idempotent (`ON CONFLICT (message_id, block_index) DO NOTHING`), so repeated reads of the same transcript are safe.
61+
5062
## Hook payload notes
5163

5264
Every hook event includes a common envelope:

docs/SCHEMA.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,24 @@ cc-logger writes to 4 tables. The full DDL is in [`migrations/001_initial_schema
5353
| `started_at` | TIMESTAMPTZ | When PreToolUse fired. |
5454
| `received_at` | TIMESTAMPTZ | When the worker actually processed the event. Difference shows async queue lag. |
5555

56+
## `messages` — assistant text blocks (Claude's narration)
57+
58+
Populated by reading the Claude Code transcript JSONL at `Stop` / `SubagentStop` / `SessionEnd`. Only `text` blocks are captured; Claude's `thinking` blocks are encrypted in the transcript (signature only, no plaintext) and we can't extract them.
59+
60+
| column | type | notes |
61+
|---|---|---|
62+
| `message_id` | TEXT | Anthropic message UUID from the transcript. |
63+
| `block_index` | INTEGER | Position of the text block within the message's `content` array. PK is composite (`message_id`, `block_index`). |
64+
| `session_id` | TEXT FK | References `sessions(session_id)`. |
65+
| `invocation_id` | TEXT FK | The agent that produced the message (root or sub-agent). |
66+
| `role` | TEXT | `assistant` (we only capture assistant text). |
67+
| `block_type` | TEXT | `text` (we only capture text blocks). |
68+
| `text` | TEXT | The text Claude said, after redaction. |
69+
| `position` | INTEGER | Line number in the source JSONL — gives a stable in-transcript ordering. |
70+
| `created_at` | TIMESTAMPTZ | When the row was inserted. |
71+
72+
Indexed on `(session_id, position)` and `(invocation_id)`.
73+
5674
## `artifacts` — overflow for any field >50KB
5775

5876
| column | type | notes |

examples/settings-hooks.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@
7171
]
7272
}
7373
],
74+
"Stop": [
75+
{
76+
"hooks": [
77+
{ "type": "http", "url": "http://127.0.0.1:8787/hook", "timeout": 5 }
78+
]
79+
}
80+
],
7481
"SessionEnd": [
7582
{
7683
"hooks": [

migrations/003_messages.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env python3
2+
"""Migration 003: Assistant message capture.
3+
4+
Adds a `messages` table that stores Claude's text/narration blocks
5+
extracted from the Claude Code transcript JSONL files. This is the
6+
"what Claude was thinking out loud" layer — the decisions and framing
7+
between tool calls.
8+
9+
Note: Claude's extended `thinking` blocks are encrypted in the transcript
10+
(only a `signature` is present, no plaintext). We capture only `text`
11+
blocks. See docs/PRIVACY.md.
12+
13+
Usage:
14+
python3 migrations/003_messages.py # dry-run
15+
python3 migrations/003_messages.py --apply
16+
"""
17+
import argparse
18+
import os
19+
import sys
20+
from pathlib import Path
21+
22+
from dotenv import load_dotenv
23+
import psycopg
24+
25+
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
26+
27+
28+
DDL_STATEMENTS = [
29+
"""
30+
CREATE TABLE IF NOT EXISTS messages (
31+
message_id TEXT NOT NULL,
32+
block_index INTEGER NOT NULL,
33+
session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
34+
invocation_id TEXT REFERENCES agent_invocations(invocation_id) ON DELETE SET NULL,
35+
role TEXT NOT NULL DEFAULT 'assistant',
36+
block_type TEXT NOT NULL DEFAULT 'text',
37+
text TEXT NOT NULL,
38+
position INTEGER NOT NULL,
39+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
40+
PRIMARY KEY (message_id, block_index)
41+
)
42+
""",
43+
"CREATE INDEX IF NOT EXISTS idx_messages_session_position ON messages (session_id, position)",
44+
"CREATE INDEX IF NOT EXISTS idx_messages_invocation ON messages (invocation_id)",
45+
"""
46+
CREATE OR REPLACE VIEW vw_session_messages AS
47+
SELECT m.session_id, m.invocation_id, m.position, m.text, m.created_at
48+
FROM messages m
49+
ORDER BY m.session_id, m.position
50+
""",
51+
]
52+
53+
54+
def get_dsn() -> str:
55+
dsn = os.getenv("DATABASE_URL") or os.getenv("NEON_CC_LOGGER_URL")
56+
if not dsn:
57+
sys.exit("DATABASE_URL not set in environment (.env)")
58+
return dsn
59+
60+
61+
def main() -> None:
62+
ap = argparse.ArgumentParser(description=__doc__)
63+
ap.add_argument("--apply", action="store_true")
64+
args = ap.parse_args()
65+
dsn = get_dsn()
66+
67+
if not args.apply:
68+
print("DRY RUN — would execute the following statements:\n")
69+
for stmt in DDL_STATEMENTS:
70+
print(stmt.strip())
71+
print("---")
72+
print(f"\n{len(DDL_STATEMENTS)} DDL statements. Re-run with --apply.")
73+
return
74+
75+
with psycopg.connect(dsn, autocommit=True) as conn, conn.cursor() as cur:
76+
for stmt in DDL_STATEMENTS:
77+
cur.execute(stmt)
78+
print(f"Applied {len(DDL_STATEMENTS)} DDL statements.")
79+
80+
81+
if __name__ == "__main__":
82+
main()

scripts/install-hooks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"UserPromptSubmit",
3131
"SubagentStart",
3232
"SubagentStop",
33+
"Stop",
3334
"SessionEnd",
3435
)
3536
TOOL_MATCHERS = ("Agent|Bash|Edit|Write|WebFetch|WebSearch", "mcp__.*")

src/cc_logger/handlers.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from psycopg import AsyncConnection
1313
from psycopg.types.json import Json
1414

15-
from . import models
15+
from . import models, transcripts
1616
from .artifacts import truncate
1717
from .filters import should_capture
1818
from .linking import resolve_parent
@@ -247,6 +247,17 @@ async def handle_subagent_stop(conn: AsyncConnection, ev: models.SubagentStop) -
247247
""",
248248
(ev.last_message, _now(), ev.agent_id),
249249
)
250+
# Ingest the sub-agent's transcript so its text blocks land in `messages`.
251+
await transcripts.ingest(conn, ev.session_id, ev.transcript_path, ev.agent_id)
252+
253+
254+
async def handle_stop(conn: AsyncConnection, ev: models.Stop) -> None:
255+
"""Root agent finished a turn. Ingest the transcript incrementally so
256+
Claude's narration / decisions land in `messages` near-realtime.
257+
"""
258+
# Root invocation gets the message rows; root_id is synthesized from session_id.
259+
root_id = f"root::{ev.session_id}"
260+
await transcripts.ingest(conn, ev.session_id, ev.transcript_path, root_id)
250261

251262

252263
async def handle_session_end(conn: AsyncConnection, ev: models.SessionEnd) -> None:
@@ -278,6 +289,9 @@ async def handle_session_end(conn: AsyncConnection, ev: models.SessionEnd) -> No
278289
""",
279290
(_now(), ev.session_id),
280291
)
292+
# Final reconciliation pass on the transcript: catch anything Stop missed.
293+
root_id = f"root::{ev.session_id}"
294+
await transcripts.ingest(conn, ev.session_id, ev.transcript_path, root_id)
281295

282296

283297
HANDLERS = {
@@ -288,6 +302,7 @@ async def handle_session_end(conn: AsyncConnection, ev: models.SessionEnd) -> No
288302
"PostToolUseFailure": handle_post_tool_use_failure,
289303
"SubagentStart": handle_subagent_start,
290304
"SubagentStop": handle_subagent_stop,
305+
"Stop": handle_stop,
291306
"SessionEnd": handle_session_end,
292307
}
293308

src/cc_logger/inspect.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,22 @@ async def render(session_id: str, file=None) -> None:
101101
tc_cols = [c.name for c in cur.description]
102102
tool_calls = [dict(zip(tc_cols, r)) for r in await cur.fetchall()]
103103

104+
# Pull assistant text blocks (may be empty if Stop hook hasn't run yet)
105+
try:
106+
await cur.execute(
107+
"""
108+
SELECT invocation_id, text, position, created_at
109+
FROM messages WHERE session_id = %s
110+
ORDER BY position
111+
""",
112+
(session_id,),
113+
)
114+
msg_cols = [c.name for c in cur.description]
115+
messages = [dict(zip(msg_cols, r)) for r in await cur.fetchall()]
116+
except Exception:
117+
# `messages` table may not exist on older installs
118+
messages = []
119+
104120
# Header
105121
print("", file=out)
106122
print(f"SESSION {session['session_id']}", file=out)
@@ -125,9 +141,29 @@ async def render(session_id: str, file=None) -> None:
125141
children: dict[str | None, list[dict]] = {}
126142
for i in invocations:
127143
children.setdefault(i["parent_invocation_id"], []).append(i)
128-
calls_by_inv: dict[str | None, list[dict]] = {}
144+
145+
# Interleave tool_calls and messages per invocation by timestamp.
146+
# tool_calls have started_at; messages have created_at + position (line
147+
# number in transcript). We use created_at as the sort key for messages.
148+
timeline_by_inv: dict[str | None, list[tuple]] = {}
129149
for tc in tool_calls:
130-
calls_by_inv.setdefault(tc["invocation_id"], []).append(tc)
150+
timeline_by_inv.setdefault(tc["invocation_id"], []).append(
151+
(tc["started_at"], "tool", tc)
152+
)
153+
for m in messages:
154+
timeline_by_inv.setdefault(m["invocation_id"], []).append(
155+
(m["created_at"], "msg", m)
156+
)
157+
for inv_id in timeline_by_inv:
158+
timeline_by_inv[inv_id].sort(key=lambda x: x[0])
159+
160+
def _fmt_msg(msg: dict, indent: str) -> str:
161+
text = msg["text"].strip()
162+
# Wrap long lines onto continuation lines for readability
163+
first = text[:120].replace("\n", " ")
164+
if len(text) > 120:
165+
first += "..."
166+
return f'{indent} · {first!r}'
131167

132168
def render_inv(inv: dict, depth: int) -> None:
133169
indent = " " * depth
@@ -136,8 +172,11 @@ def render_inv(inv: dict, depth: int) -> None:
136172
head += f" {inv['agent_id'][:18]}"
137173
head += f" {_STATUS_GLYPH.get(inv['status'], '?')}]"
138174
print(head, file=out)
139-
for tc in calls_by_inv.get(inv["invocation_id"], []):
140-
print(f"{indent} {_fmt_tool_summary(tc)}", file=out)
175+
for _ts, kind, item in timeline_by_inv.get(inv["invocation_id"], []):
176+
if kind == "tool":
177+
print(f"{indent} {_fmt_tool_summary(item)}", file=out)
178+
else:
179+
print(_fmt_msg(item, indent), file=out)
141180
for child in children.get(inv["invocation_id"], []):
142181
render_inv(child, depth + 1)
143182
if inv.get("last_message"):
@@ -151,9 +190,10 @@ def render_inv(inv: dict, depth: int) -> None:
151190
n_fail = sum(1 for t in tool_calls if t["status"] == "failure")
152191
n_pend = sum(1 for t in tool_calls if t["status"] == "pending")
153192
n_subs = sum(1 for i in invocations if i["parent_invocation_id"])
193+
n_msgs = len(messages)
154194
print("", file=out)
155195
print(f" {len(invocations)} invocations ({n_subs} sub-agents), {n_tools} tool calls "
156-
f"({n_fail} failed, {n_pend} pending)", file=out)
196+
f"({n_fail} failed, {n_pend} pending), {n_msgs} text blocks", file=out)
157197

158198

159199
def run(session_id: str) -> None:

src/cc_logger/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ class SubagentStop(HookEnvelope):
7777
last_message: str | None = None
7878

7979

80+
class Stop(HookEnvelope):
81+
"""Fires when the root agent finishes a turn. We use it to incrementally
82+
ingest assistant text blocks from the transcript file."""
83+
hook_event_name: Literal["Stop"]
84+
stop_hook_active: bool | None = None
85+
86+
8087
class SessionEnd(HookEnvelope):
8188
hook_event_name: Literal["SessionEnd"]
8289
reason: str | None = None
@@ -91,6 +98,7 @@ class SessionEnd(HookEnvelope):
9198
"PostToolUseFailure": PostToolUseFailure,
9299
"SubagentStart": SubagentStart,
93100
"SubagentStop": SubagentStop,
101+
"Stop": Stop,
94102
"SessionEnd": SessionEnd,
95103
}
96104

0 commit comments

Comments
 (0)