session-end transcript back-fill extracts no prompts from a Claude Code transcript

Author: dmazhukovCreated Sep 12, 2026Updated Sep 12, 2026

extractTranscriptPrompts in src/hooks/session-end.ts returns an empty array for every Claude Code transcript, so the prompt back-fill added for the SessionEnd hook silently posts nothing. Sessions then reach the daemon carrying only {endedAt, status}, and mem::summarize logs No observations to summarize for them indefinitely.

Cause

The extractor matches one transcript dialect:

typescript
if (msg.role !== "user") continue;
for (const block of msg.message?.content ?? []) {
  if (block.type !== "text" || typeof block.text !== "string") continue;

That is the Cursor shape — role at the top level, content always an array of typed blocks, which is what test/session-end-transcript.test.ts builds. A Claude Code transcript line is shaped differently:

json
{"type":"user","message":{"role":"user","content":"the prompt text"},"sessionId":"...","cwd":"..."}

The role is nested under message and the top-level field is type, so msg.role is undefined and every line is skipped. Past that first guard there is a second mismatch: a plain user turn carries content as a bare string, so for (const block of content) would iterate the string's characters and block.type would be undefined on each.

Reproduction

javascript
// transcript.jsonl
{"type":"user","message":{"role":"user","content":"first prompt"}}
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"second prompt"}]}}
bash
echo '{"session_id":"s1","hook_event_name":"sessionEnd","reason":"completed","transcript_path":"transcript.jsonl"}' \
  | node plugin/scripts/session-end.mjs

Expected two POSTs to /agentmemory/observe; observed none, followed by the usual /agentmemory/session/end.

Measured on a real 1187-line Claude Code transcript holding 109 user records: 0 prompts extracted. Five of those records are genuine user turns; the other 107 are tool_result blocks that should stay excluded.

Effect

On a store where this is the only capture path, nothing has been summarized since the last manual import: 9201 session records, of which only 574 carry startedAt / observationCount / summary, and those 574 all came from mem::import. Consolidation pipeline complete keeps reporting success against that frozen corpus (totalSummaries constant), so nothing in the logs indicates capture has stopped.

Fix

Accept type: "user" and message.role === "user" alongside the existing top-level role, and read a string content as a single text. Skipping isSidechain turns keeps subagent traffic out. PR follows.