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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ cd better-memory
.\scripts\setup.ps1
```

Each script: installs `uv` if it isn't already on PATH (macOS/Linux: the official installer via `curl | sh`; Windows: `winget install --id=astral-sh.uv`, falling back to the `irm https://astral.sh/uv/install.ps1 | iex` installer if winget isn't available), runs `uv sync`, then runs `uv run better-memory setup` — which creates `~/.better-memory/{spool,state,install-backups,knowledge-base/{standards,languages,projects}}`, and installs/repairs the MCP server registration (`~/.claude.json`), all eight managed hooks plus the `BETTER_MEMORY_INJECT_MODE` env var (`~/.claude/settings.json`), the managed CLAUDE.md block, and the three skill symlinks (falling back to Windows directory junctions if symlink privilege is unavailable) — idempotently; anything overwritten is backed up first to `~/.better-memory/install-backups/`.
Each script: installs `uv` if it isn't already on PATH (macOS/Linux: the official installer via `curl | sh`; Windows: `winget install --id=astral-sh.uv`, falling back to the `irm https://astral.sh/uv/install.ps1 | iex` installer if winget isn't available), runs `uv sync`, then runs `uv run better-memory setup` — which creates `~/.better-memory/{spool,state,install-backups,knowledge-base/{standards,languages,projects}}`, and installs/repairs the MCP server registration (`~/.claude.json`), all eight managed hooks plus the `BETTER_MEMORY_INJECT_MODE` env var (`~/.claude/settings.json`), the managed CLAUDE.md block, and a skill symlink for every skill in the repo's `.claude/skills/` (falling back to Windows directory junctions if symlink privilege is unavailable) — idempotently; anything overwritten is backed up first to `~/.better-memory/install-backups/`.

**Scripted / CI** — the same steps, called directly (skips the bootstrap script's `uv`-install step, e.g. when `uv` is already provisioned by the image):

Expand All @@ -88,7 +88,7 @@ That single command:
- Registers the MCP server in `~/.claude.json` (creating the file if needed).
- Installs eight managed hooks plus the `BETTER_MEMORY_INJECT_MODE` env var into `~/.claude/settings.json`: `session_bootstrap` (SessionStart) opens/reuses the session's background episode and injects curated context — project-scoped and general-scope semantic memories plus distilled reflections (`do` / `dont` / `neutral` buckets) — as `additionalContext`, with only the top `BETTER_MEMORY_BOOTSTRAP_TOP_N` project-scoped items (default 5) rendered in full; `observer` (PostToolUse, `Write|Edit|Bash`) captures tool-call snapshots into the spool; `session_close` + `stop_sweep` (Stop) drive the end-of-session rating sweep and a reminder to record non-obvious observations; `contextual_inject` (UserPromptSubmit every prompt, and PreToolUse unscoped but latched to one firing per session) surfaces memories relevant to the current prompt/tool-input — see [Configuration](website/configuration.md) and [Architecture](website/architecture.md#injection-strategies) for how it scores, floors, and dedups candidates; `commit_checkpoint` (PreToolUse, gated to `Bash(git commit*)`) reminds Claude to record an observation before committing; `pre_compact` (PreCompact) reminds Claude to persist state before context compaction.
- Splices the managed CLAUDE.md block (the retrieve/reinforce/synthesize/record protocol) into `~/.claude/CLAUDE.md` between `<!-- BEGIN better-memory (managed) -->` / `<!-- END -->` markers, leaving the rest of your CLAUDE.md untouched.
- Symlinks (or, without Windows symlink privilege, directory-junctions) the three Claude Code skills — see [Claude Code skills](#claude-code-skills) below.
- Symlinks (or, without Windows symlink privilege, directory-junctions) every Claude Code skill found in the repo's `.claude/skills/`, and prunes links whose repo skill was removed — see [Claude Code skills](#claude-code-skills) below.

For the exact JSON shapes it writes — useful if you're hand-editing `~/.claude/settings.json` or `~/.claude.json` — see [docs/hooks-setup.md](docs/hooks-setup.md), which documents each hook's event, matcher, and async/sync-ness as reference material only; `better-memory setup` / `doctor --fix` are the source of truth, not the doc.

Expand Down Expand Up @@ -239,7 +239,7 @@ Plus `CLAUDE.snippet.md` — paste into your project's `CLAUDE.md` to teach the

### Claude Code skills

Three skills live in `.claude/skills/` and are auto-symlinked into `~/.claude/skills/` by `better-memory setup` (run directly, or via either bootstrap script) so Claude triggers them in any repo — falling back to a Windows directory junction automatically when symlink privilege isn't available:
The skills in `.claude/skills/` (every directory with a `SKILL.md`) are enumerated and auto-symlinked into `~/.claude/skills/` by `better-memory setup` (run directly, or via either bootstrap script) so Claude triggers them in any repo — falling back to a Windows directory junction automatically when symlink privilege isn't available. A skill added to the repo is picked up on the next session's autocheck with no code change; a skill removed from the repo has its user-level link pruned. Currently:

- **`better-memory-synthesize`** — walks Claude through the per-episode reflection synthesis loop (`memory.synthesize_next_get_context` → decide → `memory.synthesize_next_apply`). Fires when `memory.start_episode` reports `pending_synthesis.pending > 0` or when the user asks to consolidate.
- **`rate-session-memories`** — classifies exposed reflections / semantic memories at session end (`cited` / `shaped` / `ignored` / `misled` / `overlooked`) via `memory.list_session_exposures` + `memory.apply_session_ratings`. Triggered by a Stop-block directive from the `session_close` hook when unrated exposures remain.
Expand Down
6 changes: 5 additions & 1 deletion better_memory/setup/autocheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@

def _mtimes(paths: engine.TargetPaths) -> dict[str, float]:
result = {}
for p in (paths.claude_json, paths.settings_json, paths.claude_md):
# skills_dir: a directory's mtime changes when entries are added or
# removed, so a manually deleted skill link busts the cache and gets
# relinked next session.
for p in (paths.claude_json, paths.settings_json, paths.claude_md,
paths.skills_dir):
result[str(p)] = p.stat().st_mtime if p.exists() else 0.0
return result

Expand Down
62 changes: 53 additions & 9 deletions better_memory/setup/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
(lines 228-279). The pure merges are generalized to iterate the full
``MANAGED_HOOKS`` table (8 specs, including ``if_filter`` groups) and to
merge ``MANAGED_ENV`` alongside hooks; ``install_skill_symlinks`` is
parameterized by target dir + repo root and extended to loop
``MANAGED_SKILLS``.
parameterized by target dir + repo root and extended to loop every skill
``manifest.managed_skills()`` enumerates from the repo checkout.

``_backup``/``_atomic_write`` are duplicated here rather than imported from
``cli/install_hooks`` — that module becomes a thin shim over this engine in
Expand Down Expand Up @@ -43,9 +43,9 @@
LEGACY_HOOK_MODULES,
MANAGED_ENV,
MANAGED_HOOKS,
MANAGED_SKILLS,
MachineParams,
hook_entry,
managed_skills,
mcp_server_entry,
)

Expand Down Expand Up @@ -106,7 +106,8 @@ def _canonical_hook_groups(params: MachineParams) -> dict[str, list[dict]]:
def render(params: MachineParams) -> dict:
"""Build the desired-state dict for every managed surface."""
skills = tuple(
(name, str(_repo_skill_source(params, name))) for name in MANAGED_SKILLS
(name, str(_repo_skill_source(params, name)))
for name in managed_skills(params.repo_root)
)

return {
Expand Down Expand Up @@ -351,7 +352,7 @@ def diff(params: MachineParams, paths: TargetPaths) -> list[str]:
f"{paths.claude_md}: legacy unmarked better-memory section outside managed block"
)

for skill_name in MANAGED_SKILLS:
for skill_name in managed_skills(params.repo_root):
link = paths.skills_dir / skill_name
source = _repo_skill_source(params, skill_name)
if not (link.is_symlink() or os.path.isjunction(link)):
Expand All @@ -364,6 +365,9 @@ def diff(params: MachineParams, paths: TargetPaths) -> list[str]:
if not resolved_ok:
drifts.append(f"skill {skill_name!r}: link does not resolve to repo skill dir")

for stale in _stale_repo_skill_links(paths, params):
drifts.append(f"skill {stale.name!r}: stale link (source gone from repo)")

return drifts


Expand Down Expand Up @@ -512,14 +516,42 @@ def _apply_claude_md(paths: TargetPaths, backup_dir: Path, repaired: list[str])
repaired.append(f"{paths.claude_md}: managed block")


def _stale_repo_skill_links(paths: TargetPaths, params: MachineParams) -> list[Path]:
"""Links in ``paths.skills_dir`` that better-memory owns but no longer
manages: the link targets ``<repo>/.claude/skills/`` yet its name is not
an enumerated skill (the skill was deleted or renamed in the repo).
Foreign entries — real directories, or links targeting anywhere else —
are never included.
"""
if not paths.skills_dir.is_dir():
return []
managed = set(managed_skills(params.repo_root))
skills_root = (Path(params.repo_root) / ".claude" / "skills").resolve()
stale: list[Path] = []
for entry in paths.skills_dir.iterdir():
if entry.name in managed:
continue
if not (entry.is_symlink() or os.path.isjunction(entry)):
continue
try:
# realpath, not resolve(): non-strict for dangling links, and it
# follows junctions on Windows without the \\?\ prefix.
target = Path(os.path.realpath(entry))
except OSError:
continue
if skills_root in target.parents:
stale.append(entry)
return stale


def install_skills(paths: TargetPaths, params: MachineParams) -> list[str]:
"""Symlink each ``MANAGED_SKILLS`` entry from the repo into
``paths.skills_dir``.
"""Symlink each skill ``manifest.managed_skills()`` enumerates from the
repo checkout into ``paths.skills_dir``.

Port of ``cli/install_hooks.py``'s ``install_skill_symlinks``,
parameterized by target dir (``paths.skills_dir``) and repo root
(``params.repo_root``) instead of hardcoded user-scope paths, and
looping the full ``MANAGED_SKILLS`` tuple.
looping every enumerated repo skill.

Idempotent: if a link already points to the right target, nothing
happens. If a different symlink, file, or directory exists at the
Expand All @@ -534,7 +566,7 @@ def install_skills(paths: TargetPaths, params: MachineParams) -> list[str]:
warnings: list[str] = []
paths.skills_dir.mkdir(parents=True, exist_ok=True)

for skill_name in MANAGED_SKILLS:
for skill_name in managed_skills(params.repo_root):
source = _repo_skill_source(params, skill_name)
if not source.is_dir():
warnings.append(f"skill {skill_name!r}: source not found at {source}")
Expand Down Expand Up @@ -579,6 +611,18 @@ def install_skills(paths: TargetPaths, params: MachineParams) -> list[str]:
"Developer Mode or run as administrator to install skill symlinks."
)

# Prune links whose repo skill is gone (deleted/renamed in the checkout).
for stale in _stale_repo_skill_links(paths, params):
try:
stale.unlink()
except OSError as exc:
try:
os.rmdir(stale)
except OSError:
warnings.append(
f"stale skill link not removed ({stale.name}): {exc}"
)

return warnings


Expand Down
29 changes: 21 additions & 8 deletions better_memory/setup/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

Managed surface (spec table, rows 1-12): eight hook entries and one env key
in ~/.claude/settings.json, the MCP server entry in ~/.claude.json, the
CLAUDE.md managed block, and three user-scope skills. Row 13 (per-repo
post-commit hook) lives in setup/repo_hook.py.
CLAUDE.md managed block, and the user-scope skills enumerated from the repo
checkout's .claude/skills/. Row 13 (per-repo post-commit hook) lives in
setup/repo_hook.py.

Pure data + rendering helpers; no I/O except detect_machine_params().
Pure data + rendering helpers; no I/O except detect_machine_params() and
managed_skills() (a read-only directory listing).
"""
from __future__ import annotations

Expand Down Expand Up @@ -104,11 +106,22 @@ class HookSpec:

MANAGED_ENV: dict[str, str] = {"BETTER_MEMORY_INJECT_MODE": "deferred"}

MANAGED_SKILLS: tuple[str, ...] = (
"better-memory-synthesize",
"rate-session-memories",
"start-better-memory-ui",
)
def managed_skills(repo_root: str | Path) -> tuple[str, ...]:
"""Enumerate the repo's user-scope skills: every directory under
``<repo_root>/.claude/skills/`` that contains a ``SKILL.md``.

Sorted for deterministic ``engine.fingerprint()`` output — adding or
removing a skill directory in the repo changes the fingerprint, which
is what lets the per-session autocheck notice and re-apply. A missing
skills directory yields an empty tuple.
"""
skills_root = Path(repo_root) / ".claude" / "skills"
if not skills_root.is_dir():
return ()
return tuple(sorted(
entry.name for entry in skills_root.iterdir()
if entry.is_dir() and (entry / "SKILL.md").is_file()
))

BEGIN_MARKER = "<!-- BEGIN better-memory (managed) -->"
END_MARKER = "<!-- END better-memory (managed) -->"
Expand Down
15 changes: 15 additions & 0 deletions tests/setup/test_autocheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ def test_kill_switch(tmp_path, monkeypatch):
assert autocheck.maybe_repair(tmp_path / "home", tmp_path) is None


def test_cache_invalidated_when_skills_dir_touched(tmp_path, monkeypatch):
"""Adding or removing an entry in the user-level skills dir changes its
mtime, which must bust the fingerprint cache — otherwise a manually
deleted skill link would never be restored."""
paths, params = _wire(tmp_path, monkeypatch)
autocheck.maybe_repair(tmp_path / "home", tmp_path)
calls = []
monkeypatch.setattr(engine, "diff",
lambda *a, **k: calls.append(1) or [])
paths.skills_dir.mkdir(parents=True)
(paths.skills_dir / "some-skill").mkdir()
autocheck.maybe_repair(tmp_path / "home", tmp_path)
assert calls == [1] # skills_dir mtime change re-ran the diff


def test_cache_invalidated_when_config_touched(tmp_path, monkeypatch):
paths, params = _wire(tmp_path, monkeypatch)
autocheck.maybe_repair(tmp_path / "home", tmp_path)
Expand Down
Loading