Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **A compaction no longer re-injects the whole memory recap** ([#339](https://github.com/Digital-Process-Tools/claude-remember/issues/339)) — `SessionStart` fires at every auto-compaction with `source=compact`, and the hook read `session_id` out of that payload ([#206](https://github.com/Digital-Process-Tools/claude-remember/issues/206)/[#270](https://github.com/Digital-Process-Tools/claude-remember/issues/270)) while discarding `source`. Every memory file was therefore `cat`'d again, into a context that had just been replaced by a summary of the conversation those same bytes were already in. The reporter measured `compact` firing about as often as `startup` over 40 days.

**A compaction is not a new session.** The store has not changed since this session started and the recap is not news, so at `source=compact` the bodies are not repeated — with one exception.

**Identity still is.** `identity.md` works by *presence*: a path to it does not make the agent behave as that persona, and no other line of the hook's output even names the file. Everything else is recall-on-demand and stays addressable — the unconditional `=== REMEMBER ===` hint names the store's files on every fire, and the `=== MEMORY ===` block now names the withheld ones again with their sizes. That is [#124](https://github.com/Digital-Process-Tools/claude-remember/issues/124)'s vocabulary for "kept but not injected": a recap that shrinks in silence is indistinguishable from a store that emptied.

**The default runs one way only.** A payload with no `source`, an empty value, a spelling from a future release, or no stdin at all is left unrecognised and gets today's output unchanged. An absence read as `compact` would silently stop injecting memory for anyone whose payload shape differs from the one this heuristic was written against — the failure this plugin exists to prevent, not to cause.

**Nothing else narrows.** `startup`, `resume`, `clear` and `fork` are untouched, and so are `=== HANDOFF ===`, `=== LAST HANDOFF ===`, the history hint, the consolidation trigger and the `hooks.d/` dispatches, at every source. `fork` in particular is left at the full recap deliberately: what a fork inherits from its parent's context was not established, and an unverified belief is not grounds for withholding memory. Neither the recovery block nor the capture-gap check branches on `source` — #206 settled that by changing the shape of the evidence store, precisely because a source filter answers the wrong half of that question.

### Fixed

- **A cooldown marker AHEAD of now sticks the throttle ON, permanently and mutely** ([#326](https://github.com/Digital-Process-Tools/claude-remember/issues/326)) — `case "$X" in ''|*[!0-9]*) X=0 ;; esac` validates a marker's *syntax*. It does not bound its *range*. A digits-only value ahead of the current clock is accepted, makes `ELAPSED` negative, and a negative `ELAPSED` is `-lt` any cooldown — so the gate takes its `exit 0`. That exit sits **above** the line that rewrites the marker, so the self-heal the previous three releases rest on is unreachable on exactly the path that needs it. Four sites, four different outages: no session is ever saved again; `now.md` is never compressed again and grows unbounded; the git backup stops, which is [#258](https://github.com/Digital-Process-Tools/claude-remember/issues/258)'s original outage reached through a value the guard *accepts*; and the per-tool-call fork throttle refuses to fork the save that would have healed the marker, so the two throttles hold each other shut.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ On session start, the `SessionStart` hook automatically injects into Claude's co

No manual prompting, no "read this file" instructions. The agent begins every session with its memory already loaded. It just remembers.

**Except after a compaction.** `SessionStart` fires again with `source=compact`, and a compaction is not a new session: the store has not changed and the same bytes were already delivered, once, to the context the compaction has just replaced ([#339](https://github.com/Digital-Process-Tools/claude-remember/issues/339)). There the hook still injects `identity.md` — a path to it does not make the agent behave as that persona — and names the rest with their sizes instead of injecting them, so they stay greppable. `startup`, `resume`, `clear` and `fork` are unchanged, and so is any payload whose `source` this hook does not recognise.

### How memory files are written

Writers of `now.md` take `save.lock`. **Readers do not, by design** — the `SessionStart` hook that injects memory into a new session sources only what it needs (`resolve-paths.sh`, `detect-tools.sh`, `bootstrap-dirs.sh`, `log.sh`, `lib-env-cache.sh`) and never `lib-lock.sh`, so it *cannot* lock even if it wanted to. That is deliberate: it runs before your first prompt, and `save.lock` is held for the whole of a save including its `claude -p` call ([#227](https://github.com/Digital-Process-Tools/claude-remember/issues/227), [#230](https://github.com/Digital-Process-Tools/claude-remember/issues/230), [#204](https://github.com/Digital-Process-Tools/claude-remember/issues/204)). A hook that blocks your prompt behind a model call is a worse outcome than anything it would be protecting you from.
Expand Down Expand Up @@ -214,7 +216,7 @@ The plugin registers three Claude Code hooks:

| Hook | Script | Purpose |
| ------------------ | ----------------------- | --------------------------------------------------------- |
| `SessionStart` | `session-start-hook.sh` | Loads memory files into context, recovers missed sessions |
| `SessionStart` | `session-start-hook.sh` | Loads memory files into context (identity only at `source=compact`), recovers missed sessions |
| `UserPromptSubmit` | `user-prompt-hook.sh` | Injects current timestamp so the agent knows the time |
| `PostToolUse` | `post-tool-hook.sh` | Auto-saves session when tool call delta exceeds threshold |

Expand Down
93 changes: 81 additions & 12 deletions scripts/session-start-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
# DESCRIPTION
# Runs at the beginning of every Claude Code session. Performs three jobs:
# 1. Injects memory files (identity, core memories, today, now, recent,
# archive) into the session context via stdout.
# archive) into the session context via stdout. At source=compact only
# identity is injected and the rest are named — see #339, below.
# 2. Recovers the most recent missed session by launching save-session.sh
# with --force in the background.
# 3. Triggers background maintenance: consolidation of past-day staging
Expand Down Expand Up @@ -108,9 +109,11 @@ _remember_env_cache_publish
#
# #206 named the enabler and shipped the other half of the fix: this hook never
# read its stdin, so it had neither `source` nor `session_id` and could not
# exclude itself. Only `session_id` is needed. Excluding our own transcript by
# id is correct at EVERY source, so there is no source list to enumerate and
# nothing that was being reported stops being reported.
# exclude itself. Only `session_id` is needed for THAT job: excluding our own
# transcript by id is correct at EVERY source, so there is no source list to
# enumerate and nothing that was being reported stops being reported. `source`
# is read too, since #339, but for a different job entirely — how much of the
# memory recap to print — and nothing on this path consults it.
#
# Reading stdin is only safe if it cannot wait forever, so this takes both
# guards post-tool-hook.sh documents: a tty stdin (hand invocation from a
Expand All @@ -131,12 +134,17 @@ fi
# same reason: the key must be followed by nothing but whitespace and a colon
# before the value's opening quote, so a `session_id` appearing inside some
# other field is not mistaken for the field. It is a heuristic and is treated
# as one — the result is validated as a path component below before anything
# is done with it.
_stdin_session_id() {
local raw="$1" rest prefix value
case "$raw" in *'"session_id"'*) ;; *) return 1 ;; esac
rest=${raw#*\"session_id\"}
# as one — every result is validated below before anything is done with it.
#
# Taken over the key rather than hard-coded, because #339 needs a second field
# — `source` — out of the same payload, and one heuristic is easier to reason
# about than two copies of it. post-tool-hook.sh keeps its own single-key
# copy: it reads one field, and sourcing a shared library from a hook that has
# to survive a broken install is a worse trade than ten duplicated lines.
_stdin_json_string() {
local key="$1" raw="$2" rest prefix value
case "$raw" in *"\"$key\""*) ;; *) return 1 ;; esac
rest=${raw#*\"$key\"}
prefix=${rest%%\"*}
case "$prefix" in *[!:[:space:]]*) return 1 ;; esac
value=${rest#*\"}
Expand All @@ -145,6 +153,10 @@ _stdin_session_id() {
printf '%s' "$value"
}

_stdin_session_id() {
_stdin_json_string session_id "$1"
}

CURRENT_SESSION_ID=$(_stdin_session_id "$HOOK_STDIN" 2>/dev/null) || CURRENT_SESSION_ID=""
# stdin is not more trustworthy than a basename. This is compared against
# names taken off the transcript directory, and `..` would match nothing
Expand All @@ -154,6 +166,26 @@ case "$CURRENT_SESSION_ID" in
''|.|..|*[!A-Za-z0-9._-]*) CURRENT_SESSION_ID="" ;;
esac

# ── Which KIND of SessionStart is this? (#339) ────────────────────────────
# `source` is one of startup | resume | clear | compact | fork. It is read for
# exactly one decision — how much of the memory recap to print — and nothing
# else in this script branches on it. In particular the recovery block and the
# capture-gap check below deliberately do NOT: #206 settled that question by
# changing the shape of the evidence store, precisely because a source filter
# answers the wrong half of it.
#
# Read strictly, and in one direction only. A payload with no `source`, an
# empty value, a spelling from a future release, or no stdin at all leaves
# this empty and takes the unchanged path. An absence must never be read as
# `compact`: that would silently stop injecting memory for anyone whose
# payload shape differs from the one this heuristic was written against —
# the failure this plugin exists to prevent, not to cause.
SESSION_START_SOURCE=$(_stdin_json_string source "$HOOK_STDIN" 2>/dev/null) || SESSION_START_SOURCE=""
case "$SESSION_START_SOURCE" in
startup|resume|clear|compact|fork) ;;
*) SESSION_START_SOURCE="" ;;
esac

# ── Publish the consumed payload to hooks.d/ ──────────────────────────────
# This hook now reads stdin, so a listener that wanted the payload would find
# EOF where one used to be. It travels by the route #266 settled on: a file for
Expand Down Expand Up @@ -898,8 +930,13 @@ cat "$PLUGIN_ROOT/prompts/session-history-hint.txt" 2>/dev/null
echo ""

# ── Inject memory into context ────────────────────────────────────────────
# One list, read three times below — the membership test, the injection loop
# and the named-only loop. Kept in a single place so a seventh memory file
# cannot be added to one of them and forgotten by the others.
MEMORY_FILES=("$IDENTITY_FILE" "$CORE_MEMORIES" "$REMEMBER_TODAY_FILE" "$REMEMBER_NOW" "$REMEMBER_RECENT" "$REMEMBER_ARCHIVE")

HAS_MEMORY=""
for MFILE in "$IDENTITY_FILE" "$CORE_MEMORIES" "$REMEMBER_TODAY_FILE" "$REMEMBER_NOW" "$REMEMBER_RECENT" "$REMEMBER_ARCHIVE"; do
for MFILE in "${MEMORY_FILES[@]}"; do
if [ -f "$MFILE" ]; then
HAS_MEMORY="true"
fi
Expand All @@ -915,14 +952,46 @@ fi

if [ -n "$HAS_MEMORY" ]; then
echo "=== MEMORY ==="
for MFILE in "$IDENTITY_FILE" "$CORE_MEMORIES" "$REMEMBER_TODAY_FILE" "$REMEMBER_NOW" "$REMEMBER_RECENT" "$REMEMBER_ARCHIVE"; do
# At source=compact these bodies were already delivered — in this same
# session, to the context the compaction has just replaced with a summary
# of it. SessionStart fires again there, but the store has not changed and
# nothing about the recap is news.
#
# Identity is the exception and is still printed in full, because it works
# by PRESENCE: a path to identity.md does not make the agent behave as
# that persona, and no other line of this hook's output even names the
# file. Everything else is recall-on-demand and stays addressable — the
# === REMEMBER === hint above names the store's files on every single
# fire, and the block below names these ones again with their sizes.
#
# Named rather than dropped, which is the #124 vocabulary for "kept but
# not injected": a file nobody names is a file nobody greps, and a recap
# that shrinks in silence is indistinguishable from a store that emptied.
for MFILE in "${MEMORY_FILES[@]}"; do
if [ -f "$MFILE" ] && [ -s "$MFILE" ]; then
if [ "$SESSION_START_SOURCE" = "compact" ] && [ "$MFILE" != "$IDENTITY_FILE" ]; then
continue
fi
BASENAME=$(basename "$MFILE")
echo "--- $BASENAME ---"
cat "$MFILE"
echo ""
fi
done
if [ "$SESSION_START_SOURCE" = "compact" ]; then
# Built before the header is printed, so the header is never printed
# over an empty list — a store can hold identity.md and nothing else.
DEFERRED_MEMORY=$(for MFILE in "${MEMORY_FILES[@]}"; do
[ "$MFILE" != "$IDENTITY_FILE" ] || continue
[ -f "$MFILE" ] && [ -s "$MFILE" ] || continue
printf '%s (%s bytes)\n' "$MFILE" "$(wc -c < "$MFILE" | tr -d ' ')"
done)
if [ -n "$DEFERRED_MEMORY" ]; then
echo "--- not re-injected at compact (delivered at session start); read or grep on request ---"
printf '%s\n' "$DEFERRED_MEMORY"
echo ""
fi
fi
# ── Rotated archives: named, not injected (#124) ──────────────────────
# An oversized archive.md is rotated to archive-YYYY-MM-DD.md and a fresh
# one started (#123). The bytes are kept, but nothing in the read path
Expand Down
Loading