session-end transcript back-fill extracts no prompts from a Claude Code transcript
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:
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:
{"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
// transcript.jsonl
{"type":"user","message":{"role":"user","content":"first prompt"}}
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"second prompt"}]}}echo '{"session_id":"s1","hook_event_name":"sessionEnd","reason":"completed","transcript_path":"transcript.jsonl"}' \
| node plugin/scripts/session-end.mjsExpected 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.
Source: rohitg00/agentmemory