Skip to content
Draft
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
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
11 changes: 10 additions & 1 deletion src/memu/hosts/bridging/recall_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/memu/hosts/claude_code/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/memu/hosts/host_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
74 changes: 74 additions & 0 deletions src/memu/hosts/seed.py
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions tests/test_host_seed.py
Original file line number Diff line number Diff line change
@@ -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"