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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
{
"name": "mnemo",
"description": "7 memory skills for Claude Code and Codex via Obsidian — vault audit, knowledge search, bounded cross-runtime recall, connection discovery, session handoff, memory routing, review orchestration, and setup",
"version": "1.2.10",
"version": "1.2.12",
"source": "./plugins/mnemo",
"category": "productivity",
"keywords": [
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ __pycache__/
# working goal-state files — local, not part of the public plugin
docs/plans/
.claude/loop-gate

# personal permission grants — never ship these in a public plugin: they would
# hand every cloner a pre-approved path to scripts that rewrite a vault
.claude/settings.local.json
30 changes: 29 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

## [1.2.12] - 2026-07-25

### Changed

- **An unfinished tail now belongs to its own session note; the handoff gets a pointer.** `session` Step 5 and `depth-contract.md` previously routed open threads *into* the shared handoff. Measured on a live vault, that left only **9%** of fresh open items present in their own session note (34% for older ones) — the handoff had become the sole home of forward state, which is precisely how it reached 805 KiB and then could be neither read nor shrunk. A pointer line is bounded by the *number of sessions*; a copied tail is bounded by nothing. `hot-scan` (v1.2.11) collects the tails from where they now live, so the digest keeps working unchanged.

### Added

- **`handoff-index-upsert` action in `vault-write.py`** — writes one idempotent pointer line per session (`- 2026-07-25 · mnemo · open 3 · [[Session — …]]`), keyed on the session link so a mid-task checkpoint refreshes its own line instead of appending a twin. This also repairs a contract that was already broken: Step 5 demanded "the exact old section copied from read", but a large handoff read comes back truncated to a preview in which no complete section appears. Bounds — `handoff.maxLines` (180), `handoff.maxLineBytes` (200), `handoff.hardCapBytes` (38912) — are enforced in **bytes**, and trimming sacrifices the project label, never the `[[Session — …]]` link (a cut wikilink is a dead link). The block path (`archive-handoff`, `mode=blocks`) is untouched, along with its post-v1.1.11 regression suite.
- **`scripts/migrate-handoff-to-index.py`** — one-shot migration, `--dry-run` by default. Moves blocks **verbatim and whole** into the archive (never checkbox-extracted: 90 flat bullets and ~200 prose lines on the live file carry live state with no `- [ ]`), never edits a session note, never deletes, writes `.bak` first, and verifies afterwards that every migrated block is byte-present in the archive. Measured on the live handoff: **826 916 B → 35 910 B (−95.7%)**, 186 pointer lines.
- **`scripts/restore-handoff-from-bak.py`** — the rehearsed undo. The vault has no version control, so the migration's rollback is written and exercised *before* the migration may run; a restore itself saves a `.pre-restore` copy. Verified end-to-end on a copy of the live file: migrate → restore returns it byte-for-byte (md5 match).
- **`scripts/split-handoff-archive.py`** — splits the cold archive into per-month notes plus a small hub. A cold file is not harmless: at 717 KiB it is past the 256 KB read limit, so "it's still in the archive" was a promise nothing could keep, and it matched nearly every content search while being unopenable. A month is not automatically a readable unit either — June alone was 425 KB — so oversized months split into numbered parts under `--max-part-bytes` (default 200 KB).
- **43 new tests** across `test-handoff-index.py`, `test-migrate-handoff.py`, and `test-split-archive.py`, pinning the safety contract itself: dry-run writes nothing, blocks move verbatim, nothing is lost, backups precede writes, restore is byte-exact. Mutation-tested: counting characters instead of bytes, or dropping the hard cap, each fails the suite.

## [1.2.11] - 2026-07-25

### Added

- **Open-tails digest at SessionStart (`hot-scan.py` + `hooks.hotDigest`, default true).** Continuity finally has an automatic *reader*. `hot-scan.py` collects still-open tails from the pending sections of recent session notes and `mnemo-context.sh` injects a byte-capped, project-scoped summary next to the memory nudge — so a new session opens knowing where the last one stopped, instead of relying on someone remembering to open a note. Config: `hot.scope` (`project`/`all`, default project), `hot.windowDays` (7), `hot.maxKB` (8). Two measured properties are designed in: inside a pending section a bare `- bullet` counts as a tail (the v1.1.10 class — "live" is not always a `- [ ]`; 90 such bullets exist on the live vault), and every cap is measured in **bytes**, because `len(str)` under-counts a Cyrillic vault by ~34%. Any failure (no Obsidian, slow CLI, missing plugin root) degrades to nudge-only. The injected digest always carries the stale-premise warning — a handoff is a snapshot, not current state.
- **`handoff-resolver.py` + `health` Step 7.6 — report-only handoff triage.** A handoff does not shrink by archiving harder: the archiver may never move a block holding an open `- [ ]`, so it shrinks only when someone *decides* what those items still mean. The resolver reports the ceiling (what a complete resolution would free), blocks by payoff class (`fully-resolvable` / `partial` / `no-anchor` / `prose-pinned` / `fresh`), and the items an external arbiter could actually settle. Offline, read-only, no credentials — and permanently report-only, because closing someone's unfinished work is a human decision. It imports the keep-hot invariant from `vault-write.py` rather than copying it, so the report can never drift from the archiver it predicts. 🛑 It separates an item's **own** anchors from ones **inherited** from the block header: counting inherited anchors inflates the resolvable share from 27.6% to 60.6%.

### Fixed

- **`docs/session.md` promised a startup read that never existed.** It stated "when the next session starts, it reads `Meta — Session Handoff`" — no hook or skill ever did, and past ~256 KB no file read can. Corrected to describe the digest above, which is the reader that claim always assumed.
- **"Token bomb read every session" was the wrong reason to bound the handoff** (`config-schema.md`, `session/SKILL.md`). A host runtime truncates a large read to a preview, so the real cost of an un-rotated handoff is **unreadability**, not tokens. `session/SKILL.md` now also states the guard's own limit plainly: because a single open `- [ ]` pins a block, the archiver can legitimately be a no-op while the file sits 20× over `handoff.maxKB` (measured: 805 KiB, 863 open items, 0 eligible blocks). Size is bounded by the format of what gets written, not by the rotation guard.

## [1.2.10] - 2026-07-23

### Added
Expand Down Expand Up @@ -939,7 +965,9 @@ Frontmatter now includes `session_id: {CLAUDE_SESSION_ID}` — disambiguates sam
- `config.example.json`
- MIT License

[Unreleased]: https://github.com/jojoprison/mnemo/compare/v1.2.10...HEAD
[Unreleased]: https://github.com/jojoprison/mnemo/compare/v1.2.12...HEAD
[1.2.12]: https://github.com/jojoprison/mnemo/compare/v1.2.11...v1.2.12
[1.2.11]: https://github.com/jojoprison/mnemo/compare/v1.2.10...v1.2.11
[1.2.10]: https://github.com/jojoprison/mnemo/compare/v1.2.9...v1.2.10
[1.2.9]: https://github.com/jojoprison/mnemo/compare/v1.2.8...v1.2.9
[1.2.8]: https://github.com/jojoprison/mnemo/compare/v1.2.7...v1.2.8
Expand Down
5 changes: 4 additions & 1 deletion docs/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@ Handoff contents:

## Cross-Session Continuity

When the next session starts, it reads `Meta — Session Handoff`:
The next session starts with the **open-tails digest**: `hooks/mnemo-context.sh` runs `hot-scan.py` over the pending sections of recent session notes and injects a byte-capped summary (`hot.windowDays`, `hot.maxKB`) next to the memory nudge, scoped to the repo you are in (`hot.scope`).

- Picks up pending items
- Has context about what happened
- No more "what was I doing yesterday?"

**The handoff note itself is not read at startup, and never was.** That expectation was documented here for a long time, but no such path existed in any hook or skill — and a grown handoff cannot be read even on request (a file read caps at 256 KB / 25,000 tokens). Measured on a live vault in July 2026, an 805 KiB handoff was opened for continuity in 6% of sessions. The digest above is the automatic reader that claim always assumed; `Meta — Session Handoff` remains the durable index that `session` writes and `health` Step 7.6 triages.

## When to Use

- ✅ After completing a feature / PR / fix
Expand Down
2 changes: 1 addition & 1 deletion plugins/mnemo/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "mn",
"displayName": "mnemo",
"version": "1.2.10",
"version": "1.2.12",
"description": "Persistent memory layer for Claude Code and Codex via Obsidian — 7 skills for vault health, knowledge search, verified cross-runtime project recall, connection discovery, session continuity, memory routing, skill-aware session review, and interactive setup. Bundled JSON-stdin atomic vault writer, argv-safe indexed reads, dual-runtime helpers, hybrid model routing, parallel CLI calls, SessionStart prewarm, incremental JSONL parsing, shared references, and semantic taxonomy roles.",
"author": {
"name": "jojoprison",
Expand Down
2 changes: 1 addition & 1 deletion plugins/mnemo/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mnemo",
"version": "1.2.10",
"version": "1.2.12",
"description": "Persistent memory layer for Codex and Claude Code via Obsidian: vault health, knowledge search, verified cross-runtime project recall, link discovery, session notes, memory routing, review orchestration, and setup.",
"author": {
"name": "jojoprison",
Expand Down
61 changes: 59 additions & 2 deletions plugins/mnemo/hooks/mnemo-context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,19 @@ else
RUNTIME="claude"
fi

OUT=$(python3 - "$CONFIG" "$RUNTIME" <<'PY' 2>/dev/null
import json, sys
# Project label for the open-tails digest. Use the git COMMON dir, not the
# worktree root: in a worktree `basename $(git rev-parse --show-toplevel)` is the
# worktree's name (floating-frolicking-goose), not the project's (mnemo).
PROJECT=""
if command -v git >/dev/null 2>&1; then
COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
[ -n "$COMMON_DIR" ] && PROJECT=$(basename "$(dirname "$COMMON_DIR")")
fi

PLUGIN_DIR="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-}}"

OUT=$(python3 - "$CONFIG" "$RUNTIME" "$PLUGIN_DIR" "$PROJECT" <<'PY' 2>/dev/null
import json, os, subprocess, sys
try:
cfg = json.load(open(sys.argv[1]))
except Exception:
Expand All @@ -52,6 +63,52 @@ msg = (
f"capture findings, decisions, and gotchas as they happen with {save} so a future "
"session doesn't relearn them."
)


def hot_digest() -> str:
"""Open tails from recent session notes — the forward-state half of memory.

Best-effort and byte-capped: any failure (no Obsidian, no plugin root, slow
CLI) yields an empty string, so the nudge above is never lost to it.
Gate: hooks.hotDigest, defaults to true.
"""
if cfg.get("hooks", {}).get("hotDigest", True) is False:
return ""
plugin_root = sys.argv[3] if len(sys.argv) > 3 else ""
script = os.path.join(plugin_root, "scripts", "hot-scan.py")
if not plugin_root or not os.path.isfile(script):
return ""
try:
probe = subprocess.run(
["obsidian", "vault", f"vault={vault}"],
capture_output=True, text=True, timeout=3, check=False)
except Exception:
return ""
vault_path = ""
for line in probe.stdout.splitlines():
field, separator, value = line.partition("\t")
if field == "path" and separator and value:
vault_path = value
break
if not vault_path or not os.path.isdir(vault_path):
return ""
command = [sys.executable, script, vault_path]
project = sys.argv[4] if len(sys.argv) > 4 else ""
# Project scope keeps the digest about the repo in front of you; "all" is
# for people who want the cross-project view every session.
if cfg.get("hot", {}).get("scope", "project") == "project" and project:
command += ["--project", project]
try:
result = subprocess.run(
command, capture_output=True, text=True, timeout=5, check=False)
except Exception:
return ""
return result.stdout.strip() if result.returncode == 0 else ""


digest = hot_digest()
if digest:
msg = f"{msg}\n\n{digest}"
payload = {
"hookSpecificOutput": {
"hookEventName": "SessionStart",
Expand Down
9 changes: 8 additions & 1 deletion plugins/mnemo/references/config-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,15 @@ The `recall` section is optional and ships off. `recall.codeGraph` (default `nul
| `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.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.maxKB` | Size ceiling (KB) before `vault-write.py archive-handoff` rotates CLOSED old blocks into `<handoff_note> Archive` (cold). The handoff is a live index, not a store; un-rotated it becomes a token bomb read every session. Default **40** | session |
| `handoff.maxKB` | Size ceiling (KB) before `vault-write.py archive-handoff` rotates CLOSED old blocks into `<handoff_note> Archive` (cold). The handoff is a live index, not a store. What an un-rotated one costs is **unreadability, not tokens** — a host runtime truncates a large read to a preview, and a file read refuses outright past 256 KB / 25,000 tokens, so past a point nothing can consult it and live items drown under months of closed history. Default **40** | session |
| `handoff.keepDays` | Blocks newer than this stay hot regardless of status; older **and** closed (no open `- [ ]`) move to the archive. Default **14** | session |
| `handoff.maxLines` | Pointer lines kept in the handoff index before the oldest rotate out. Default **180** — at the maintainer's measured pace (5.9 sessions/day lifetime, 12.4 over the last week) that is a 14-31 day window that never needs retuning as the pace changes | session |
| `handoff.maxLineBytes` | Per-line ceiling for an index line, in **bytes**. Default **200** (measured: the longest real session-note link is 177 B, so the pointer always fits). Trimming sacrifices the project label — never the `[[Session — …]]` link, because a cut wikilink is a dead link | session |
| `handoff.hardCapBytes` | Whole-file ceiling for the index, in **bytes**; oldest pointers drop until it fits. Default **38912** (38 KiB, under the 40 KB `maxKB` guard) | session |
| `hooks.hotDigest` | Inject the open-tails digest alongside the SessionStart nudge — the forward-state half of memory ("where did I stop"), collected from recent session notes' pending sections. This is the automatic reader continuity never had: a full handoff cannot be read at all (a file read caps at 256 KB / 25,000 tokens), so before this the digest existed only if someone opened the note by hand. Degrades to nudge-only on any failure. Default **true**; set false to silence | hooks/mnemo-context.sh |
| `hot.scope` | `"project"` limits the digest to the repo you are in (project label = the basename of the git **common** dir, so a worktree reports its project, not the worktree name); `"all"` gives the cross-project view. Default **project** | hooks/mnemo-context.sh |
| `hot.windowDays` | How far back the digest looks for open tails. Default **7** | hot-scan |
| `hot.maxKB` | Hard **byte** cap on the rendered digest; oldest tails drop first until it fits. Measured in bytes, never characters — `len(str)` under-counts a Cyrillic vault by ~34%. Default **8** | hot-scan |
| `review.full.autoConnect` | In `review --full`, the internal `connect` step **applies** its link suggestions without a per-suggestion prompt instead of only showing them (connect Step 5.5, review Step 9A). The user typing `--full` **and** setting this flag is the consent — mirroring `review.lint.autoStampReviewed`, the opt-in that lets a skill write. A standalone `/mn:connect` is unaffected and never auto-applies. Default **false** (non-destructive default; connect stays suggest-only for everyone who doesn't opt in) | review, connect |
| `review.staleDays.default` | Days before a note becomes a review candidate when its type has no specific entry. Default **30**. (`review.staleDays` may also be a bare integer — a single uniform threshold for every type.) | health |
| `review.staleDays.{type}` | Per-type staleness cadence (key = a taxonomy `type` you actually use: `atom`/`molecule`/`source`/`session`/`moc`). A fast-moving fact ages quicker than an architectural decision | health |
Expand Down
2 changes: 1 addition & 1 deletion plugins/mnemo/references/depth-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Step 0b, applied across the whole close-out.
| An actionable never-X / always-Y rule tied to code | `save` Step 3.5 → `.claude/rules/` | path-scoped auto-inject, not recall |
| The session narrative (what happened, the arc, decisions-in-context) | `session` | one note + handoff, never a duplicate of today's |
| Connections between the new notes (incl. non-obvious) | `connect` | suggest-only links (auto-applied only under `review.full.autoConnect`), no orphans |
| A future-useful thread not finished | `session` handoff `- [ ]` pending | thin live index |
| A future-useful thread not finished | the session note's own `## Next steps / pending` | the handoff gets a pointer line, never a copy |

The business-logic / pains / mental-model layer is the crown material — it routes to `save`'s typed
`principle` / `pain` / `stance` atoms, **not** the session narrative. That is how "capture the way I
Expand Down
Loading
Loading