From a4a606f8c557d20903e8a8a9cbfe58386822e9b3 Mon Sep 17 00:00:00 2001 From: Aindrew Tran Date: Thu, 28 May 2026 22:04:00 +0700 Subject: [PATCH 1/2] Add Claude Code skills Memanto bridge example --- .../claudecode-skills-memanto/.env.example | 4 + examples/claudecode-skills-memanto/.gitignore | 1 + examples/claudecode-skills-memanto/README.md | 99 +++++++ .../memanto_skills.py | 244 ++++++++++++++++++ .../requirements.txt | 3 + .../run_session_a.py | 24 ++ .../run_session_b.py | 19 ++ .../tests/conftest.py | 7 + .../tests/test_memanto_skills.py | 49 ++++ 9 files changed, 450 insertions(+) create mode 100644 examples/claudecode-skills-memanto/.env.example create mode 100644 examples/claudecode-skills-memanto/.gitignore create mode 100644 examples/claudecode-skills-memanto/README.md create mode 100644 examples/claudecode-skills-memanto/memanto_skills.py create mode 100644 examples/claudecode-skills-memanto/requirements.txt create mode 100644 examples/claudecode-skills-memanto/run_session_a.py create mode 100644 examples/claudecode-skills-memanto/run_session_b.py create mode 100644 examples/claudecode-skills-memanto/tests/conftest.py create mode 100644 examples/claudecode-skills-memanto/tests/test_memanto_skills.py diff --git a/examples/claudecode-skills-memanto/.env.example b/examples/claudecode-skills-memanto/.env.example new file mode 100644 index 00000000..6f48de62 --- /dev/null +++ b/examples/claudecode-skills-memanto/.env.example @@ -0,0 +1,4 @@ +MOORCHEH_API_KEY=your-moorcheh-api-key +MEMANTO_AGENT_ID=claudecode-skills-memanto-demo +MEMANTO_SKILLS_BACKEND=local +MEMANTO_SKILLS_STORE=.memanto-skills-local.jsonl diff --git a/examples/claudecode-skills-memanto/.gitignore b/examples/claudecode-skills-memanto/.gitignore new file mode 100644 index 00000000..3c0573d9 --- /dev/null +++ b/examples/claudecode-skills-memanto/.gitignore @@ -0,0 +1 @@ +.memanto-skills-local.jsonl diff --git a/examples/claudecode-skills-memanto/README.md b/examples/claudecode-skills-memanto/README.md new file mode 100644 index 00000000..aaca14fb --- /dev/null +++ b/examples/claudecode-skills-memanto/README.md @@ -0,0 +1,99 @@ +# Claude Code Skills + Memanto Memory Bridge + +This example shows how Memanto can act as a global engineering memory companion +for skill-based developer workflows such as `mattpocock/skills`. + +The bridge has two lifecycle hooks: + +- `pre`: recall relevant engineering memory before a skill starts. +- `post`: distill a completed skill session into a durable memory. + +That lets one skill remember an architectural decision and a later skill reuse it +without manual re-prompting. + +## Files + +```text +examples/claudecode-skills-memanto/ +├── README.md +├── requirements.txt +├── .env.example +├── memanto_skills.py +├── run_session_a.py +├── run_session_b.py +└── tests/ + ├── conftest.py + └── test_memanto_skills.py +``` + +## Setup + +```bash +cd examples/claudecode-skills-memanto +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +``` + +The default backend is local JSONL, so reviewers can run the demo without +credentials. To use live Memanto, set `MEMANTO_SKILLS_BACKEND=memanto` and add a +`MOORCHEH_API_KEY` in `.env`. + +## Demo + +Session A simulates `/grill-with-docs` finding and storing an architecture +decision: + +```bash +python run_session_a.py +``` + +Session B simulates `/tdd` starting later in a fresh process and receiving the +stored decision as injected context: + +```bash +python run_session_b.py +``` + +Expected output from Session B includes: + +```text +Relevant engineering memory from previous skill sessions: +- Architecture decision: use a repository layer for Stripe webhook persistence. +``` + +## Hook Commands + +The same flow can be wired into a skill runner: + +```bash +python memanto_skills.py pre \ + --skill /tdd \ + --path services/payments \ + --prompt "Add tests for Stripe webhook retries." + +python memanto_skills.py post \ + --skill /grill-with-docs \ + --path services/payments \ + --prompt "Review the payments service architecture." \ + --transcript "Decision: use a repository layer for Stripe webhook persistence." +``` + +## Validation + +```bash +cd examples/claudecode-skills-memanto +python -m pytest tests -q +python -m py_compile memanto_skills.py run_session_a.py run_session_b.py +``` + +## Why This Fits The Challenge + +- Active extraction: `post` turns skill output into a durable engineering memory. +- Dynamic injection: `pre` recalls path/task-relevant context before another skill + runs. +- Zero repeated instructions: the second skill receives the earlier architectural + decision without the user restating it. +- Review-safe: local JSONL mode proves behavior without secrets; live Memanto mode + uses the same `remember` and `recall` lifecycle against a Moorcheh-backed agent. diff --git a/examples/claudecode-skills-memanto/memanto_skills.py b/examples/claudecode-skills-memanto/memanto_skills.py new file mode 100644 index 00000000..100d601e --- /dev/null +++ b/examples/claudecode-skills-memanto/memanto_skills.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Protocol + +from dotenv import load_dotenv + +load_dotenv() + +DEFAULT_AGENT_ID = "claudecode-skills-memanto-demo" +DEFAULT_STORE = ".memanto-skills-local.jsonl" + + +@dataclass +class SkillMemory: + memory_type: str + title: str + content: str + skill: str + path_hint: str + confidence: float = 0.9 + + +class MemoryBackend(Protocol): + def remember(self, memory: SkillMemory) -> None: ... + + def recall(self, query: str, limit: int = 5) -> list[SkillMemory]: ... + + +class LocalJsonlBackend: + """Credential-free backend used by tests and reviewer demos.""" + + def __init__(self, path: str | Path = DEFAULT_STORE) -> None: + self.path = Path(path) + + def remember(self, memory: SkillMemory) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(asdict(memory), sort_keys=True) + "\n") + + def recall(self, query: str, limit: int = 5) -> list[SkillMemory]: + memories = self._load() + query_terms = _tokens(query) + scored = [] + for memory in memories: + haystack = " ".join( + [memory.title, memory.content, memory.skill, memory.path_hint] + ) + score = len(query_terms & _tokens(haystack)) + if score: + scored.append((score, memory)) + scored.sort(key=lambda item: item[0], reverse=True) + return [memory for _, memory in scored[:limit]] + + def _load(self) -> list[SkillMemory]: + if not self.path.exists(): + return [] + memories = [] + for line in self.path.read_text(encoding="utf-8").splitlines(): + if line.strip(): + memories.append(SkillMemory(**json.loads(line))) + return memories + + +class MemantoBackend: + """Live backend that uses the Memanto SDK when MOORCHEH_API_KEY is available.""" + + def __init__(self, api_key: str, agent_id: str = DEFAULT_AGENT_ID) -> None: + from memanto.app.utils.errors import AgentAlreadyExistsError + from memanto.cli.client.sdk_client import SdkClient + + self.client = SdkClient(api_key=api_key) + self.agent_id = agent_id + try: + self.client.create_agent( + agent_id=agent_id, + pattern="project", + description="Cross-skill developer memory for Claude Code skills.", + ) + except AgentAlreadyExistsError: + pass + self.client.activate_agent(agent_id, duration_hours=6) + + def remember(self, memory: SkillMemory) -> None: + self.client.remember( + agent_id=self.agent_id, + memory_type=memory.memory_type, + title=memory.title, + content=memory.content, + confidence=memory.confidence, + tags=["claudecode-skills", memory.skill, memory.path_hint], + source="claude-code-skill-hook", + provenance="skill_transcript", + ) + + def recall(self, query: str, limit: int = 5) -> list[SkillMemory]: + result = self.client.recall(agent_id=self.agent_id, query=query, limit=limit) + memories = [] + for item in result.get("memories", []): + memories.append( + SkillMemory( + memory_type=item.get("type", "memory"), + title=item.get("title", "Memanto memory"), + content=item.get("content") or item.get("title") or "", + skill="memanto", + path_hint="", + confidence=float(item.get("confidence", 0.9)), + ) + ) + return memories + + +def build_backend() -> MemoryBackend: + backend = os.getenv("MEMANTO_SKILLS_BACKEND", "local").lower() + if backend == "memanto": + api_key = os.getenv("MOORCHEH_API_KEY") + if not api_key: + raise RuntimeError("MOORCHEH_API_KEY is required for memanto backend.") + return MemantoBackend( + api_key=api_key, + agent_id=os.getenv("MEMANTO_AGENT_ID", DEFAULT_AGENT_ID), + ) + return LocalJsonlBackend(os.getenv("MEMANTO_SKILLS_STORE", DEFAULT_STORE)) + + +def pre_skill_context( + backend: MemoryBackend, + skill: str, + prompt: str, + path_hint: str = "", + limit: int = 3, +) -> str: + query = f"{skill} {path_hint} {prompt}" + memories = backend.recall(query, limit=limit) + if not memories: + return "" + lines = [ + "Relevant engineering memory from previous skill sessions:", + *[f"- {memory.title}: {memory.content}" for memory in memories], + ] + return "\n".join(lines) + + +def post_skill_capture( + backend: MemoryBackend, + skill: str, + prompt: str, + transcript: str, + path_hint: str = "", +) -> SkillMemory: + memory = distill_engineering_memory(skill, prompt, transcript, path_hint) + backend.remember(memory) + return memory + + +def distill_engineering_memory( + skill: str, + prompt: str, + transcript: str, + path_hint: str = "", +) -> SkillMemory: + """Extract a compact engineering preference from one skill session.""" + + text = f"{prompt}\n{transcript}" + explicit = _find_explicit_decision(text) + if explicit: + title = "Architecture decision" + content = explicit + memory_type = "decision" + else: + title = f"{skill} session preference" + content = _first_sentence(transcript) or _first_sentence(prompt) + memory_type = "preference" + return SkillMemory( + memory_type=memory_type, + title=title, + content=content, + skill=skill, + path_hint=path_hint, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Memanto memory bridge for skills.") + subparsers = parser.add_subparsers(dest="command", required=True) + + pre = subparsers.add_parser("pre", help="Recall context before a skill starts.") + pre.add_argument("--skill", required=True) + pre.add_argument("--prompt", required=True) + pre.add_argument("--path", default="") + + post = subparsers.add_parser("post", help="Store context after a skill finishes.") + post.add_argument("--skill", required=True) + post.add_argument("--prompt", required=True) + post.add_argument("--transcript", required=True) + post.add_argument("--path", default="") + + args = parser.parse_args(argv) + backend = build_backend() + + if args.command == "pre": + print(pre_skill_context(backend, args.skill, args.prompt, args.path)) + return 0 + + memory = post_skill_capture( + backend, args.skill, args.prompt, args.transcript, args.path + ) + print(f"Stored {memory.memory_type}: {memory.content}") + return 0 + + +def _tokens(value: str) -> set[str]: + return { + token for token in re.findall(r"[a-z0-9_/-]+", value.lower()) if len(token) > 2 + } + + +def _find_explicit_decision(text: str) -> str: + patterns = [ + r"(?:decision|we decided|use|prefer|choose|keep):?\s+(.+)", + r"(?:architecture rule):?\s+(.+)", + ] + for pattern in patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match: + return _first_sentence(match.group(1)) + return "" + + +def _first_sentence(value: str) -> str: + cleaned = " ".join(value.strip().split()) + if not cleaned: + return "" + return re.split(r"(?<=[.!?])\s+", cleaned)[0][:240] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/claudecode-skills-memanto/requirements.txt b/examples/claudecode-skills-memanto/requirements.txt new file mode 100644 index 00000000..081129fe --- /dev/null +++ b/examples/claudecode-skills-memanto/requirements.txt @@ -0,0 +1,3 @@ +memanto>=0.1.0 +python-dotenv>=1.0.0 +pytest>=8.2.0 diff --git a/examples/claudecode-skills-memanto/run_session_a.py b/examples/claudecode-skills-memanto/run_session_a.py new file mode 100644 index 00000000..0b973376 --- /dev/null +++ b/examples/claudecode-skills-memanto/run_session_a.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from memanto_skills import build_backend, post_skill_capture + + +def main() -> None: + backend = build_backend() + memory = post_skill_capture( + backend=backend, + skill="/grill-with-docs", + prompt="Review the payments service architecture.", + transcript=( + "Decision: use a repository layer for Stripe webhook persistence. " + "Keep webhook signature verification at the HTTP boundary and store " + "only normalized event records under services/payments." + ), + path_hint="services/payments", + ) + print("Session A stored memory:") + print(f"- {memory.title}: {memory.content}") + + +if __name__ == "__main__": + main() diff --git a/examples/claudecode-skills-memanto/run_session_b.py b/examples/claudecode-skills-memanto/run_session_b.py new file mode 100644 index 00000000..29758f78 --- /dev/null +++ b/examples/claudecode-skills-memanto/run_session_b.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from memanto_skills import build_backend, pre_skill_context + + +def main() -> None: + backend = build_backend() + context = pre_skill_context( + backend=backend, + skill="/tdd", + prompt="Add tests for Stripe webhook retries.", + path_hint="services/payments", + ) + print("Session B injected context:") + print(context or "No relevant engineering memory found.") + + +if __name__ == "__main__": + main() diff --git a/examples/claudecode-skills-memanto/tests/conftest.py b/examples/claudecode-skills-memanto/tests/conftest.py new file mode 100644 index 00000000..e253190e --- /dev/null +++ b/examples/claudecode-skills-memanto/tests/conftest.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_ROOT)) diff --git a/examples/claudecode-skills-memanto/tests/test_memanto_skills.py b/examples/claudecode-skills-memanto/tests/test_memanto_skills.py new file mode 100644 index 00000000..6f611277 --- /dev/null +++ b/examples/claudecode-skills-memanto/tests/test_memanto_skills.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from memanto_skills import LocalJsonlBackend, post_skill_capture, pre_skill_context + + +def test_cross_skill_memory_round_trip(tmp_path): + backend = LocalJsonlBackend(tmp_path / "memory.jsonl") + + post_skill_capture( + backend=backend, + skill="/grill-with-docs", + prompt="Review the payments service architecture.", + transcript=( + "Decision: keep Stripe webhook signature verification at the " + "HTTP boundary and store normalized events through a repository." + ), + path_hint="services/payments", + ) + + context = pre_skill_context( + backend=backend, + skill="/tdd", + prompt="Add webhook retry tests.", + path_hint="services/payments", + ) + + assert "Stripe webhook signature verification" in context + assert "repository" in context + + +def test_unrelated_query_does_not_inject_context(tmp_path): + backend = LocalJsonlBackend(tmp_path / "memory.jsonl") + + post_skill_capture( + backend=backend, + skill="/handoff", + prompt="Document frontend routing.", + transcript="Decision: keep route objects in web/router.", + path_hint="web/router", + ) + + context = pre_skill_context( + backend=backend, + skill="/tdd", + prompt="Add database migration tests.", + path_hint="db/migrations", + ) + + assert context == "" From 0a9f9092f50df5d6b0e4edceef02c4cd62a33027 Mon Sep 17 00:00:00 2001 From: Aindrew Tran Date: Fri, 29 May 2026 11:28:32 +0700 Subject: [PATCH 2/2] Address Claude Code skills review comments --- examples/claudecode-skills-memanto/README.md | 3 + .../memanto_skills.py | 13 ++- .../run_session_a.py | 30 ++++--- .../run_session_b.py | 20 +++-- .../tests/test_memanto_skills.py | 80 ++++++++++++++++++- 5 files changed, 122 insertions(+), 24 deletions(-) diff --git a/examples/claudecode-skills-memanto/README.md b/examples/claudecode-skills-memanto/README.md index aaca14fb..a7b33659 100644 --- a/examples/claudecode-skills-memanto/README.md +++ b/examples/claudecode-skills-memanto/README.md @@ -18,6 +18,7 @@ examples/claudecode-skills-memanto/ ├── README.md ├── requirements.txt ├── .env.example +├── .gitignore ├── memanto_skills.py ├── run_session_a.py ├── run_session_b.py @@ -86,6 +87,8 @@ python memanto_skills.py post \ cd examples/claudecode-skills-memanto python -m pytest tests -q python -m py_compile memanto_skills.py run_session_a.py run_session_b.py +ruff check . +ruff format --check . ``` ## Why This Fits The Challenge diff --git a/examples/claudecode-skills-memanto/memanto_skills.py b/examples/claudecode-skills-memanto/memanto_skills.py index 100d601e..d0dc8dd5 100644 --- a/examples/claudecode-skills-memanto/memanto_skills.py +++ b/examples/claudecode-skills-memanto/memanto_skills.py @@ -47,13 +47,16 @@ def remember(self, memory: SkillMemory) -> None: def recall(self, query: str, limit: int = 5) -> list[SkillMemory]: memories = self._load() query_terms = _tokens(query) + if not query_terms: + return [] + minimum_score = min(2, len(query_terms)) scored = [] for memory in memories: haystack = " ".join( [memory.title, memory.content, memory.skill, memory.path_hint] ) score = len(query_terms & _tokens(haystack)) - if score: + if score >= minimum_score: scored.append((score, memory)) scored.sort(key=lambda item: item[0], reverse=True) return [memory for _, memory in scored[:limit]] @@ -64,7 +67,10 @@ def _load(self) -> list[SkillMemory]: memories = [] for line in self.path.read_text(encoding="utf-8").splitlines(): if line.strip(): - memories.append(SkillMemory(**json.loads(line))) + try: + memories.append(SkillMemory(**json.loads(line))) + except (json.JSONDecodeError, TypeError, ValueError): + continue return memories @@ -167,8 +173,7 @@ def distill_engineering_memory( ) -> SkillMemory: """Extract a compact engineering preference from one skill session.""" - text = f"{prompt}\n{transcript}" - explicit = _find_explicit_decision(text) + explicit = _find_explicit_decision(transcript) if explicit: title = "Architecture decision" content = explicit diff --git a/examples/claudecode-skills-memanto/run_session_a.py b/examples/claudecode-skills-memanto/run_session_a.py index 0b973376..33f6f111 100644 --- a/examples/claudecode-skills-memanto/run_session_a.py +++ b/examples/claudecode-skills-memanto/run_session_a.py @@ -4,18 +4,24 @@ def main() -> None: - backend = build_backend() - memory = post_skill_capture( - backend=backend, - skill="/grill-with-docs", - prompt="Review the payments service architecture.", - transcript=( - "Decision: use a repository layer for Stripe webhook persistence. " - "Keep webhook signature verification at the HTTP boundary and store " - "only normalized event records under services/payments." - ), - path_hint="services/payments", - ) + """Demonstrate post-skill memory capture for Session A.""" + + try: + backend = build_backend() + memory = post_skill_capture( + backend=backend, + skill="/grill-with-docs", + prompt="Review the payments service architecture.", + transcript=( + "Decision: use a repository layer for Stripe webhook persistence. " + "Keep webhook signature verification at the HTTP boundary and store " + "only normalized event records under services/payments." + ), + path_hint="services/payments", + ) + except Exception as error: + raise RuntimeError(f"Session A failed to store memory: {error}") from error + print("Session A stored memory:") print(f"- {memory.title}: {memory.content}") diff --git a/examples/claudecode-skills-memanto/run_session_b.py b/examples/claudecode-skills-memanto/run_session_b.py index 29758f78..3eca4a47 100644 --- a/examples/claudecode-skills-memanto/run_session_b.py +++ b/examples/claudecode-skills-memanto/run_session_b.py @@ -4,13 +4,19 @@ def main() -> None: - backend = build_backend() - context = pre_skill_context( - backend=backend, - skill="/tdd", - prompt="Add tests for Stripe webhook retries.", - path_hint="services/payments", - ) + """Demonstrate pre-skill context retrieval for Session B.""" + + try: + backend = build_backend() + context = pre_skill_context( + backend=backend, + skill="/tdd", + prompt="Add tests for Stripe webhook retries.", + path_hint="services/payments", + ) + except Exception as error: + raise RuntimeError(f"Session B failed to retrieve context: {error}") from error + print("Session B injected context:") print(context or "No relevant engineering memory found.") diff --git a/examples/claudecode-skills-memanto/tests/test_memanto_skills.py b/examples/claudecode-skills-memanto/tests/test_memanto_skills.py index 6f611277..f05d8b97 100644 --- a/examples/claudecode-skills-memanto/tests/test_memanto_skills.py +++ b/examples/claudecode-skills-memanto/tests/test_memanto_skills.py @@ -1,6 +1,11 @@ from __future__ import annotations -from memanto_skills import LocalJsonlBackend, post_skill_capture, pre_skill_context +from memanto_skills import ( + LocalJsonlBackend, + SkillMemory, + post_skill_capture, + pre_skill_context, +) def test_cross_skill_memory_round_trip(tmp_path): @@ -47,3 +52,76 @@ def test_unrelated_query_does_not_inject_context(tmp_path): ) assert context == "" + + +def test_single_shared_token_does_not_inject_context(tmp_path): + backend = LocalJsonlBackend(tmp_path / "memory.jsonl") + backend.remember( + SkillMemory( + memory_type="decision", + title="Webhook note", + content="Document webhook retries in the API guide.", + skill="/handoff", + path_hint="docs/api", + ) + ) + + context = pre_skill_context( + backend=backend, + skill="/tdd", + prompt="Add webhook retry tests.", + path_hint="services/payments", + ) + + assert context == "" + + +def test_empty_query_does_not_inject_context(tmp_path): + backend = LocalJsonlBackend(tmp_path / "memory.jsonl") + backend.remember( + SkillMemory( + memory_type="decision", + title="Payments backend", + content="Use repository storage for Stripe webhooks.", + skill="/grill-with-docs", + path_hint="services/payments", + ) + ) + + assert backend.recall("") == [] + + +def test_corrupt_jsonl_line_is_skipped(tmp_path): + store = tmp_path / "memory.jsonl" + store.write_text( + '{"memory_type":"decision","title":"Valid","content":"Use repository ' + 'storage for Stripe webhooks.","skill":"/grill-with-docs",' + '"path_hint":"services/payments","confidence":0.9}\n' + "{not-json}\n", + encoding="utf-8", + ) + backend = LocalJsonlBackend(store) + + context = pre_skill_context( + backend=backend, + skill="/tdd", + prompt="Add Stripe webhook storage tests.", + path_hint="services/payments", + ) + + assert "Use repository storage" in context + + +def test_prompt_decision_text_is_not_treated_as_session_output(tmp_path): + backend = LocalJsonlBackend(tmp_path / "memory.jsonl") + + memory = post_skill_capture( + backend=backend, + skill="/handoff", + prompt="Decision: use Redis for webhook storage?", + transcript="We explored storage tradeoffs but did not finalize a backend.", + path_hint="services/payments", + ) + + assert memory.memory_type == "preference" + assert "Redis" not in memory.content