Repository navigation
Same turn summarized repeatedly: every stop after a background-task wakeup inserts another session summary #5225
Description
Version: claude-mem 15.7.0 (worker mode), Claude Code desktop app
Symptom
One session ends up with several session summaries under the same prompt number. They are nearly identical and differ only in the last bit of wrap-up.
Before I cleaned it up, my local database (1,220 summaries with a prompt number) had:
| Summaries for the same turn | Turns |
|---|---|
| 2 | 42 |
| 3 | 5 |
| 4 | 3 |
| 10 | 1 |
| 34 | 1 |
That is 52 turns and 103 extra rows in total.
Reproduction
- In Claude Code, have the agent start a background command (
run_in_background) and end the turn. - The Stop hook fires and the first summary is written.
- The background command finishes, and Claude Code sends a
<task-notification>that wakes the agent up. This is not a user message, so UserPromptSubmit does not fire and the prompt number stays the same. - The agent reads the result and stops again. The Stop hook fires again and inserts another summary with the same prompt number.
Examples:
- Turn 2 of one session: the agent stopped at 12:47:59, a background task woke it up, and it stopped again at 12:50:09. The worker log has one
Stop: Requesting summaryline for each stop, and two summaries were stored, both with prompt number 2. - The 34-summary turn: the user sent 3 messages in that session, recorded as prompts 1, 2 and 3.
- Prompts 1 and 2 ran as one turn: prompt 2 was sent while the agent was still working on prompt 1. Claude Code queued it (
queued_commandin the transcript) and fed it into the same run. The agent never stopped in between, so prompt 1 got no Stop and no summary of its own. Its work is covered by prompt 2's summary. This is expected and not part of this issue. - Prompt 2's turn produced 34 summaries: 1 from the agent's first stop, then 33
<task-notification>wakeups between 12:17 and 14:42, each followed by a stop and a new summary: 1 + 33 = 34. - Prompt 3 has 1 summary of its own and is not among the 34.
- Prompts 1 and 2 ran as one turn: prompt 2 was sent while the agent was still working on prompt 1. Claude Code queued it (
Cause
src/cli/handlers/summarize.ts: every Stop requests a summary, with no check for whether the turn already has one.ingestSummarizeinsrc/services/worker/http/shared.tsderives the prompt number from the time of the Stop. This part is correct: a stop after a wakeup is attributed to the same turn.SessionStore.storeObservations/storeSummaryalwaysINSERT INTO session_summaries, with no dedupe or update on(memory_session_id, prompt_number). The UNIQUE constraint onmemory_session_idwas removed long ago to allow multiple turns, but it was never replaced with "one row per turn".- The summary prompt does not know the turn already has a summary, so the model rewrites it from scratch instead of adding what is new. If the observer conversation has just been retired for exceeding its length budget (
trigger=budget), the new conversation has no earlier summary to go on either.
Impact
- Context injected at session start:
ObservationCompilertakes the newest N summaries. Duplicates use up those slots and push out older summaries that say something different. - Search: full-text and vector search both return several nearly identical results.
- Cost: every rewrite is another observer model call.
Proposed fix
Two layers: point 1 stops one turn from storing several rows; point 2 handles the risk that overwriting introduces, namely that the new summary may say less than the old one.
1. Storage: one row per turn
When storing a summary, if (memory_session_id, prompt_number) already has one, update that row in place instead of inserting. The id stays the same.
- Full-text index: the existing
session_summaries_autrigger already handles updates. - Chroma: call
syncSummary(..., replaceExisting = true). - Cloud sync: as with observation updates, bump
sync_revand resetsynced_atto NULL.
This only controls how many rows are stored, not whether the new summary is any good. On its own, the new summary simply replaces the old one.
2. Content: don't let a summary shrink when it is overwritten
In 3 of my turns, the last summary had empty completed / learned while the one before it was complete. With point 1 alone, that content would be lost on overwrite. I haven't verified why; one possibility is that the observer conversation had just been retired for length, so the model could not see what happened earlier in the turn.
Two options; one is enough:
- A. Merge field by field on write: if a field in the new summary is empty, keep the old summary's value for that field. Plain code, no extra model calls. The downside is that it only catches a field that is entirely empty, not a field that is filled in but says less than before.
- B. Give the model the old summary when generating: when the turn already has a summary, include it in the summary prompt and ask the model to update it with what happened since. This keeps the old content at the source and also helps where A falls short. The downside is a longer prompt, and the result still depends on the model following the instruction.
I recommend A: it is plain code, adds no prompt length or model cost, behaves predictably, and is easy to test.
Minor: the logged prompt number is misleading
ClaudeProvider logs Response received (...) {promptNumber=N} using session.lastPromptNumber (the latest turn at that moment), not the prompt number the response is actually stored under. When a turn-1 summary arrives late, the log shows promptNumber=2 while the database correctly stores 1. While debugging this, it is easy to conclude that the summary was stored under the wrong turn. I suggest logging activeResponseContext.current.promptNumber instead.
Cleaning up existing duplicates (for reference)
This is how I cleaned mine up: in each group, keep the newest summary whose request and completed are both non-empty (or the newest one if none are complete), and delete the rest one by one with DELETE /api/summary/:id, so the worker also removes their full-text index entries and Chroma documents. Afterwards I confirmed that nothing was left in either the database or Chroma.
I can submit a PR
If the maintainers agree with the direction above, I'm happy to submit a PR covering the in-place update for a turn, whichever of A or B the maintainers choose, and the matching tests. The logging fix can go in the same PR or a separate one, whichever the maintainers prefer.
Activity