diff --git a/pyproject.toml b/pyproject.toml index c6fd36d7..e4c7bb29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,11 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/memu"] +# The root SKILL.md doubles as the seed skill (memu.hosts.seed): setup plants it +# in the user's store so a bare "uninstall memU" can retrieve the packaged guide. +[tool.hatch.build.targets.wheel.force-include] +"SKILL.md" = "memu/SKILL.md" + [dependency-groups] dev = [ {include-group = "lint"}, diff --git a/src/memu/hosts/bridging/recall_files.py b/src/memu/hosts/bridging/recall_files.py index 9a6af584..807e4a3a 100644 --- a/src/memu/hosts/bridging/recall_files.py +++ b/src/memu/hosts/bridging/recall_files.py @@ -69,7 +69,16 @@ def read_recall_file(path: Path, track: str) -> dict[str, Any]: tagging it with the ``track`` its directory represents (the file itself does not store the track). Mirrors the fields ``list_all_recall_files`` returns. """ - text = path.read_text(encoding="utf-8") + return parse_recall_text(path.read_text(encoding="utf-8"), track) + + +def parse_recall_text(text: str, track: str) -> dict[str, Any]: + """The parsing half of :func:`read_recall_file`, for text not yet on disk. + + Split out so a caller holding a front-mattered document from elsewhere (a + packaged skill being seeded, a server-fetched template) can parse it with the + same convention the mirror uses, rather than growing a second parser. + """ name = "" description = "" content = text diff --git a/src/memu/hosts/claude_code/INSTALL.md b/src/memu/hosts/claude_code/INSTALL.md index 2bc064f2..f2f44de1 100644 --- a/src/memu/hosts/claude_code/INSTALL.md +++ b/src/memu/hosts/claude_code/INSTALL.md @@ -171,6 +171,18 @@ memu-claude-code doctor It prints the resolved mode plus its endpoint or local store/provider, and runs a smoke-test retrieval. It must exit cleanly. **Zero hits is the expected result** on a new store. +With `doctor` green, plant the packaged install/uninstall skill into the store: + +``` +memu-claude-code seed-skills +``` + +This stores `SKILL.md` as a `skill`-track recall file, so a later bare +"uninstall memU" — said without pointing at any guide — can surface the packaged +instructions through retrieval, on any host sharing this store. Rerunning is an +upsert, never a duplicate; if the store already carries it from another host's +install, this refreshes it to this package's copy. + --- ## Part 2 — Register the bridging (record) task diff --git a/src/memu/hosts/host_cli.py b/src/memu/hosts/host_cli.py index 09853c81..1ba15112 100644 --- a/src/memu/hosts/host_cli.py +++ b/src/memu/hosts/host_cli.py @@ -301,6 +301,22 @@ async def _cmd_doctor(spec: HostSpec, args: argparse.Namespace) -> int: return 0 +async def _cmd_seed_skills(spec: HostSpec, args: argparse.Namespace) -> int: + """Plant the packaged install/uninstall skill into the store (see :mod:`memu.hosts.seed`). + + A setup-time step: run once after ``doctor`` is green, so a later bare + "uninstall memU" can surface the packaged guide through retrieval on any + host sharing this store. Rerunning is an upsert, never a duplicate. + """ + from memu.hosts import seed as seed_module + + mirror, result = await seed_module.seed(_layout(spec, args).base) + committed = result.get("recall_files", []) + print(f"mirror {mirror}") + print(f"store upserted {len(committed)} skill file(s)") + return 0 + + async def _cmd_docs(spec: HostSpec, args: argparse.Namespace) -> int: # Server-first, then last-good cache, then the embedded floor — the same # self-updating shape ADR 0013 gives the instruction templates, applied to the @@ -398,6 +414,14 @@ async def bound(args: argparse.Namespace) -> int: p = sub.add_parser("doctor", help="Verify MEMU_* config resolves and the selected memory backend is reachable") p.set_defaults(handler=bind(_cmd_doctor)) + p = with_base( + sub.add_parser( + "seed-skills", + help="Plant the packaged install/uninstall skill into the store (setup-time; rerun-safe upsert)", + ) + ) + p.set_defaults(handler=bind(_cmd_seed_skills)) + p = sub.add_parser("docs", help="Print a packaged agent-facing guide") p.add_argument( "doc", diff --git a/src/memu/hosts/seed.py b/src/memu/hosts/seed.py new file mode 100644 index 00000000..cc0024e7 --- /dev/null +++ b/src/memu/hosts/seed.py @@ -0,0 +1,74 @@ +"""Seed the packaged install/uninstall skill into the user's store at setup time. + +A user who wants memU gone says "uninstall memU" — not "read SKILL.md and follow +its uninstall section". An agent whose context holds no route to the packaged +guide improvises, and an improvised teardown deletes too much (the store) or too +little (the bridging task). Retrieval is a channel that can carry that route: if +the root ``SKILL.md`` sits in the store as a ``skill``-track recall file, a bare +"uninstall memU" can surface it on *any* host sharing the store — including hosts +whose instruction file was never patched, or was hand-edited since. + +So setup plants it once. The seed is the packaged ``SKILL.md`` verbatim: its +frontmatter is already the recall-file convention (``name``/``description``), its +description already names the triggers ("install, set up, integrate, remove, or +uninstall memU"), and that description is exactly what gets embedded — the +retrieval surface. Committing through ``commit_results`` makes reseeding an +upsert: a later install refreshes the stored copy instead of stacking a twin. + +The mirror under ``~/.memu/skill/`` is written too, for the same reason bridging +mirrors everything: the agent's working medium is markdown on disk, and a seeded +file the self-evolve loop cannot see is a file it would recreate or contradict. +""" + +from __future__ import annotations + +from importlib.resources import files +from pathlib import Path +from typing import Any + +from memu.hosts.bridging.layout import TRACK_DIRS +from memu.hosts.bridging.recall_files import parse_recall_text, write_recall_file + +SEED_TRACK = "skill" + + +def seed_document() -> str: + """The packaged root ``SKILL.md``, falling back to the repo checkout. + + The wheel force-includes the repo-root ``SKILL.md`` as ``memu/SKILL.md`` (see + ``pyproject.toml``); an editable/dev checkout has no such copy, so fall back + to the file three levels up from this module. One source document either way + — never a second copy that can drift. + """ + packaged = files("memu") / "SKILL.md" + if packaged.is_file(): + return packaged.read_text(encoding="utf-8") + return (Path(__file__).resolve().parents[3] / "SKILL.md").read_text(encoding="utf-8") + + +def seed_recall_file() -> dict[str, Any]: + """``SKILL.md`` parsed into the shape ``commit_results`` expects. + + The document's own frontmatter supplies ``name`` and ``description`` — the + description is what gets embedded, and it already carries the uninstall + trigger words. No rewriting here: the skill's author owns the retrieval + surface, not the seeder. + """ + return parse_recall_text(seed_document(), track=SEED_TRACK) + + +async def seed(base_dir: Path) -> tuple[Path, dict[str, Any]]: + """Plant the skill: mirror it under ``base_dir`` and upsert it into the store. + + Returns ``(mirror_path, commit result)``. Deliberately store-first in spirit + but mirror-first in order: the mirror write is local and cannot half-fail, + while the commit needs the backend — if that raises, the caller reruns this + whole command and the mirror write is an idempotent overwrite. + """ + from memu.env import build_agentic_memory_backend_from_env + + recall_file = seed_recall_file() + mirror = write_recall_file(base_dir, TRACK_DIRS[SEED_TRACK], recall_file) + backend = build_agentic_memory_backend_from_env() + result: dict[str, Any] = await backend.commit_results(recall_files=[recall_file]) + return mirror, result diff --git a/tests/test_host_seed.py b/tests/test_host_seed.py new file mode 100644 index 00000000..045cef99 --- /dev/null +++ b/tests/test_host_seed.py @@ -0,0 +1,76 @@ +"""The seed skill: does setup plant the uninstall route where retrieval can find it? + +A bare "uninstall memU" carries no pointer to the packaged guide; the seeded +``SKILL.md`` in the skill track is what retrieval surfaces instead. These pin the +seam's three properties: the seed is the packaged document verbatim (its own +frontmatter is the retrieval surface), planting writes both the mirror and the +store, and replanting is an upsert rather than a twin. +""" + +from __future__ import annotations + +import pathlib +from typing import Any + +import pytest + +from memu.hosts import seed +from memu.hosts.bridging.recall_files import read_recall_file +from memu.hosts.codex.cli import build_parser + + +def test_seed_is_the_packaged_skill_with_its_own_frontmatter() -> None: + """The document's author owns the retrieval surface — the seeder rewrites nothing.""" + recall_file = seed.seed_recall_file() + + assert recall_file["name"] == "install-memu" + assert recall_file["track"] == "skill" + assert "uninstall" in recall_file["description"].lower(), ( + "the description is what gets embedded — without the trigger word, a bare 'uninstall memU' cannot match" + ) + assert "## Uninstall" in recall_file["content"] + + +class _FakeBackend: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + async def commit_results(self, **kwargs: Any) -> dict[str, Any]: + self.calls.append(kwargs) + return {"recall_files": kwargs.get("recall_files") or []} + + +@pytest.fixture +def backend(monkeypatch: pytest.MonkeyPatch) -> _FakeBackend: + import memu.env + + fake = _FakeBackend() + monkeypatch.setattr(memu.env, "build_agentic_memory_backend_from_env", lambda **_: fake) + return fake + + +async def test_seed_writes_the_mirror_and_commits_to_the_store(backend: _FakeBackend, tmp_path: pathlib.Path) -> None: + mirror, result = await seed.seed(tmp_path) + + assert mirror == tmp_path / "skill" / "install-memu.md" + assert read_recall_file(mirror, "skill") == seed.seed_recall_file(), ( + "the mirror must round-trip to the exact recall file the store received" + ) + assert backend.calls == [{"recall_files": [seed.seed_recall_file()]}] + assert result["recall_files"], "the caller reports what the store accepted" + + +async def test_reseeding_overwrites_in_place(backend: _FakeBackend, tmp_path: pathlib.Path) -> None: + """A second install (same host or another sharing the store) refreshes, never stacks.""" + first, _ = await seed.seed(tmp_path) + second, _ = await seed.seed(tmp_path) + + assert first == second + assert [p.name for p in (tmp_path / "skill").iterdir()] == ["install-memu.md"] + assert len(backend.calls) == 2, "each run re-commits — commit_results upserts by (track, name)" + + +def test_cli_registers_seed_skills() -> None: + args = build_parser().parse_args(["seed-skills"]) + assert callable(args.handler) + assert args.base_dir, "seed needs the working tree to place the mirror"