Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added

- **`hooks.autocompactNudge` — warn before autocompact silently drops raw context.** New opt-in (default **false**) Stop hook `hooks/mnemo-autocompact-nudge.sh` blocks once per severity level when the transcript's token usage closes in on Claude Code's resolved autocompact window (warn ~50k / critical ~10k tokens remaining, margins clamped down on a small window) and recommends `/mn:review --full`. Claude Code's effective threshold is `min(settings.autoCompactWindow, the model's context window)`, which ranges 200k-1M by model — mnemo ships no per-model table, so the window is resolved only from sources Claude Code itself already resolved (env, settings, its own `~/.claude.json` cache); unknown window means silence, never a guessed default. Claude Code only — no-op on Codex. See `docs/design-decisions.md`, "Proactive nudges via hooks".

## [1.2.13] - 2026-07-27

### Changed
Expand Down
1 change: 1 addition & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"hooks": {
"sessionStartNudge": true,
"stopNudge": false,
"autocompactNudge": false,
"invocationEcho": true
}
}
1 change: 1 addition & 0 deletions docs/design-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Descriptions get an agent to *consider* mnemo, but Opus 4.8 / Fable 5 under-trig

- **SessionStart nudge** (`hooks/mnemo-context.sh`, `hooks.sessionStartNudge` default **true**) — one line: mnemo memory exists, recall with `/mn:ask` before non-trivial work, save with `/mn:save` as you go. Gated on a configured vault (silent otherwise). Cost: a few dozen tokens every session — an accepted always-on price for keeping memory top-of-mind. **One channel only:** the nudge lives in the hook, *not* duplicated into a committed `CLAUDE.md` line (that would be a second always-on copy and a committed→private cross-link leak).
- **Stop nudge** (`hooks/mnemo-stop-nudge.sh`, `hooks.stopNudge` default **false**) — if a session looks worth-saving (fix/decision signals) but `/mn:save` and/or `/mn:session` never ran, it blocks the stop **once** and recommends the one-command close-out `/mn:review --full` (v1.2.9 — it audits, then chains save → session → connect + verify), instead of listing save and session separately. Blocking is powerful but can loop for arbitrary users of a public plugin, so it's **opt-in**, and an anti-loop governor blocks at most once per session — keyed on `session_id`, falling back to `CODEX_THREAD_ID` when a Codex Stop payload omits it, so it dedups instead of re-nudging every Stop. The default install never blocks.
- **Autocompact nudge** (`hooks/mnemo-autocompact-nudge.sh`, `hooks.autocompactNudge` default **false**) — Claude Code's effective autocompact threshold is `min(settings.autoCompactWindow, the model's context window)` — see [[Atom — Окно автокомпакта Claude Code равно min настройки и контекста модели]] — and it ranges 200k-1M depending on the model, so mnemo ships no per-model table; a hardcoded fraction or default would be wrong for someone. Instead the window comes only from sources Claude Code itself already resolved — `CLAUDE_CODE_AUTO_COMPACT_WINDOW`, then `settings.autoCompactWindow`, then its own `~/.claude.json` cache — and if none apply, the hook stays silent rather than guess. Token usage comes from the transcript, not hook stdin, which carries no token count (a Stop hook gotcha the same as `stopNudge`'s signal scan). The threshold is a fixed margin from the window (warn ~50k / critical ~10k remaining), clamped down on a small window so the bands can't eat the whole thing, and it blocks at most once per severity level per session — same anti-loop posture as `stopNudge`, recommending the same `/mn:review --full` close-out. Claude Code only: Codex has no comparable window to protect against, so the hook no-ops there. **Opt-in, default false** — same loop-risk reasoning as `stopNudge`. **Rejected:** clamping the resolved window to the model's real context ceiling. There is no per-model table to clamp against (that's the whole reason for resolving W from Claude Code's own sources instead of guessing), so an explicit `settings.autoCompactWindow` larger than the active model's actual limit is trusted as-is — if a user sets it above what their model supports, the nudge fires later than the real autocompact and that's on their own misconfiguration, not a case mnemo defends against.
- **Invocation echo** (`hooks/mnemo-skill-echo.sh`, `hooks.invocationEcho` default **true**, v1.2.2) — unifying skills removed the command-router layer, and with it the visible Skill-tool call users relied on to *see* that a `/mn:*` command loaded its skill. Visibility comes back in two layers: an **in-body invocation marker** (each `SKILL.md` opens its reply with `🧠 mn:<skill> (mnemo) → running` — both runtimes, probabilistic like every body instruction) and this **deterministic** Claude Code hook on `UserPromptExpansion` (fires on every `/mn:*` expansion; live-verified on CC 2.1.215 that hook output never alters the expansion). The event fires only on **user-typed** `/mn:*` — a model-initiated (self-invoked) skill run never triggers it and is covered by the in-body marker alone. Codex does not support this event, and Codex UI has no native invocation indicator, which is exactly why the in-body marker exists.
- **Runtime-safe hook composition** (v1.2.3; Claude loader fix v1.2.4) — `hooks/hooks.json` is the auto-discovered Codex-safe baseline (`SessionStart` + `Stop`). Claude's manifest lists only the additive `hooks/claude-hooks.json`, which contains `UserPromptExpansion`; explicitly listing the standard file as well makes current Claude Code reject the plugin as a duplicate. Each event still has one definition: Claude keeps all three behaviors, while Codex never has to ignore an undocumented event. The Codex manifest deliberately relies on default discovery because the bundled validator rejects an explicit `hooks` field even though the current manual documents it.
- **Rejected:** `PreToolUse(Read)` auto-recall (needs an index/daemon — mnemo has neither), `UserPromptSubmit` nudges (a cost on every prompt), and a **default-on** blocking Stop (loop risk for others). The nudge measures its own worth: ship it only if a trigger-eval shows lift over the bare description — otherwise it's paying an always-on price for nothing.
Expand Down
5 changes: 5 additions & 0 deletions plugins/mnemo/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
"type": "command",
"command": "\"${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/hooks/mnemo-stop-nudge.sh\"",
"timeout": 10
},
{
"type": "command",
"command": "\"${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}/hooks/mnemo-autocompact-nudge.sh\"",
"timeout": 10
}
]
}
Expand Down
86 changes: 86 additions & 0 deletions plugins/mnemo/hooks/mnemo-autocompact-nudge.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Autocompact nudge — warn before Claude Code silently compacts the context
# window, so the agent saves the session (/mn:review --full) while it can
# still remember what happened.
#
# Claude Code only: the autocompact window this hook protects against is a
# Claude Code mechanism, so it no-ops on Codex (see docs/design-decisions.md,
# "Proactive nudges via hooks").
#
# Safety design (mirrors hooks/mnemo-stop-nudge.sh):
# - Config-gated: hooks.autocompactNudge defaults to FALSE. A blocking Stop
# hook only runs when the user opts in. The default install never blocks.
# - Threshold is a FIXED margin from the window (W - N), not a fraction of
# it — any hardcoded per-model window table would be wrong for someone
# (windows range 200k-1M), so W is resolved only from sources Claude Code
# itself already resolved (env / settings / its own cache). Unknown W
# means silence, never a guess.
# - Anti-loop governor: a private hashed marker stores the highest severity
# already nudged this session, so it blocks at most once entering "warn"
# and once entering "critical" — never once per Stop.
set -u

CONFIG="${HOME}/.mnemo/config.json"
is_codex_runtime() {
[ -n "${PLUGIN_ROOT:-}${CODEX_THREAD_ID:-}${CODEX_SESSION_ID:-}" ]
}
pass() {
if is_codex_runtime; then
echo '{"continue":true}'
else
echo '{"continue":true,"suppressOutput":true}'
fi
exit 0
}

is_codex_runtime && pass

INPUT=$(cat 2>/dev/null || true)
[ -f "$CONFIG" ] || pass

# Gate: opt-in only (default false), same posture as hooks.stopNudge.
EN=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('hooks',{}).get('autocompactNudge',False))" "$CONFIG" 2>/dev/null || echo False)
[ "$EN" = "True" ] || pass

IFS=$'\t' read -r SESSION TRANSCRIPT STOP_ACTIVE < <(
printf '%s' "$INPUT" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('session_id','unknown'),d.get('transcript_path',''),int(bool(d.get('stop_hook_active'))),sep='\t')" 2>/dev/null \
|| printf 'unknown\t\t0\n'
)
[ "${STOP_ACTIVE:-0}" = 1 ] && pass
[ -f "$TRANSCRIPT" ] || pass

MNEMO_ROOT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}}"

IFS=$'\t' read -r LEVEL USED WINDOW < <(
python3 "$MNEMO_ROOT/scripts/context-window.py" "$TRANSCRIPT" 2>/dev/null
)
LEVEL="${LEVEL:-unknown}"
[ "$LEVEL" = "unknown" ] && pass
[ "$LEVEL" = "none" ] && pass

# Anti-loop marker lives in the same private, hashed cache namespace as
# stop-nudge's, keyed on session_id (falls back to CODEX_THREAD_ID — moot
# here since Codex already exited above, but keeps the key derivation
# identical to the template this hook is modeled on).
if [ -z "${SESSION:-}" ] || [ "$SESSION" = "unknown" ]; then
SESSION="${CODEX_THREAD_ID:-${CODEX_SESSION_ID:-unknown}}"
fi
MARKER=$(python3 -c 'import sys;sys.path.insert(0,sys.argv[1]);from cache_utils import cache_path;p=cache_path("autocompact-nudged",sys.argv[2],"marker");print(p or "")' "$MNEMO_ROOT/scripts" "$SESSION" 2>/dev/null || true)
[ -n "$MARKER" ] || pass
PRIOR=$(python3 -c 'import sys;sys.path.insert(0,sys.argv[1]);from cache_utils import read_text;t=read_text(__import__("pathlib").Path(sys.argv[2]));print(t or "none")' "$MNEMO_ROOT/scripts" "$MARKER" 2>/dev/null || echo none)

rank() { case "$1" in critical) echo 2 ;; warn) echo 1 ;; *) echo 0 ;; esac; }
[ "$(rank "$LEVEL")" -le "$(rank "$PRIOR")" ] && pass

MARKER_WRITTEN=$(python3 -c 'import sys;sys.path.insert(0,sys.argv[1]);from cache_utils import atomic_write_text;from pathlib import Path;print(atomic_write_text(Path(sys.argv[2]),sys.argv[3]))' "$MNEMO_ROOT/scripts" "$MARKER" "$LEVEL" 2>/dev/null || echo False)
# Fail open if the anti-loop governor cannot be persisted. Blocking without a
# marker could trap the user in a repeated Stop cycle.
[ "$MARKER_WRITTEN" = "True" ] || pass

if [ "$LEVEL" = "critical" ]; then
URGENCY="This session is very close to Claude Code's autocompact window"
else
URGENCY="This session is approaching Claude Code's autocompact window"
fi
MSG="${URGENCY} (~${USED} of ${WINDOW} tokens used). Autocompact will summarize the session and drop raw context. Close it out now with /mn:review --full — it audits, saves the facts, writes the session note + handoff, and links them — before that happens. Then stop again to proceed."
python3 -c "import json,sys; print(json.dumps({'decision':'block','reason':sys.argv[1]}, ensure_ascii=False))" "$MSG"
4 changes: 3 additions & 1 deletion plugins/mnemo/references/config-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ Path: `~/.mnemo/config.json`. Created by `setup` skill on first install. All oth
"hooks": {
"sessionStartNudge": true,
"stopNudge": false,
"autocompactNudge": false,
"invocationEcho": true
}
}
Expand Down Expand Up @@ -131,6 +132,7 @@ The `recall` section is optional and ships off. `recall.codeGraph` (default `nul
| `recall.runtimeMemory.maxExcerptBytes` | Total UTF-8 excerpt budget for counterpart results, clamped to 256-12288 bytes. Default **12288** | ask |
| `hooks.sessionStartNudge` | Inject a one-line memory reminder at SessionStart, rendered as `/mn:ask` + `/mn:save` in Claude Code or `$mnemo:ask` + `$mnemo:save` in Codex. Gated on a configured `vault`. Default **true**; set false to silence | hooks/mnemo-context.sh |
| `hooks.stopNudge` | At session end, if the session looks worth-saving but the save and/or session skill never ran, block once with the current runtime's native commands. This is **opt-in, default false**; an anti-loop governor prevents repeated blocking | hooks/mnemo-stop-nudge.sh |
| `hooks.autocompactNudge` | Claude Code only (no-op on Codex): at session end, if the transcript's token usage is within a fixed margin of the resolved autocompact window (warn ~50k, critical ~10k remaining, clamped down on a small window), block once per severity level and recommend `/mn:review --full` before autocompact drops raw context. W is resolved only from sources Claude Code itself already resolved (env / settings / its own cache) — unknown W means silence, never a guessed default. **Opt-in, default false**, same posture as `stopNudge` | hooks/mnemo-autocompact-nudge.sh |
| `hooks.invocationEcho` | Claude Code only: on a `/mn:*` slash command, emit a `systemMessage` line (`🧠 mnemo: /mn:save → skill body loaded`) via the Claude-only `UserPromptExpansion` hook — a **deterministic** invocation confirmation that, unlike the in-body marker, does not depend on model compliance. Codex does not load this unsupported event. Default **true**; set false to silence | hooks/mnemo-skill-echo.sh |
| `memory.indexWarnKB` | Early loaded-content byte warning for Claude `MEMORY.md`. Current loader limits are 200 lines or 25,000 bytes after stripping leading YAML frontmatter and block-level HTML comments; health always reports either hard-limit breach independently of this threshold. Default **22** | health |
| `handoff.keepDays` | **The rotation rule.** The index keeps pointers from the last N days; older ones are dropped, because a pointer is derived from a session note that keeps its own dated file. Default **31** — one calendar month, the unit a reader actually asks for. (In a handoff still in legacy block format the same knob keeps blocks newer than N days hot regardless of status.) | session |
Expand Down Expand Up @@ -176,7 +178,7 @@ If `recall.runtimeMemory` is absent, cross-runtime recall is disabled. Active-ru

If `taxonomy_roles` is absent, a legacy Zettelkasten config is migrated deterministically in memory as `fact → atom`, `insight → molecule`, `source → source`, `session → session`, `moc → moc`; the next `setup` run persists that map. For any other legacy/custom taxonomy, `setup` must show the existing taxonomy keys and ask once where `fact`, `insight`, and `source` belong instead of guessing from prefixes or tags. A configured map is valid only when its key set is exactly those five roles, every target names an existing `taxonomy` key, and the functional roles self-map as `session → session` and `moc → moc`. Only `fact`, `insight`, and `source` may intentionally coalesce onto one type.

The `hooks` section is optional; defaults are: sessionStartNudge=true, stopNudge=false, invocationEcho=true. If absent, the SessionStart nudge still fires (when a vault is configured), the Stop nudge stays off, and the invocation echo stays on (it does not require a vault).
The `hooks` section is optional; defaults are: sessionStartNudge=true, stopNudge=false, autocompactNudge=false, invocationEcho=true. If absent, the SessionStart nudge still fires (when a vault is configured), both Stop nudges stay off, and the invocation echo stays on (it does not require a vault).

If `vault` or `taxonomy` is missing, the skill that needs them asks the user and offers to run `/mn:setup` in Claude Code or `$mnemo:setup` in Codex.

Expand Down
Loading