- README.MD: add original-source-code and nano-claude-code sections, update overview table (4 subprojects), add v3.0 news entry, expand comparison table with memory/multi-agent/skills dimensions - nano-claude-code v3.0: multi-agent package (multi_agent/), memory package (memory/), skill package (skill/) with built-in /commit and /review skills, context compression (compaction.py), tool registry plugin system, diff view, 17 slash commands, 18 built-in tools, 101 tests (~5000 lines total) - original-source-code/src: add raw TypeScript source tree (1884 files) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
224 lines
7.0 KiB
Python
224 lines
7.0 KiB
Python
"""File-based memory storage with user-level and project-level scopes.
|
|
|
|
Storage layout:
|
|
user scope : ~/.nano_claude/memory/<slug>.md
|
|
project scope : .nano_claude/memory/<slug>.md (relative to cwd)
|
|
|
|
MEMORY.md in each directory is the index file — rebuilt automatically after
|
|
every save/delete. It is loaded into the system prompt to give Claude an
|
|
overview of available memories.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
|
|
# ── Paths ──────────────────────────────────────────────────────────────────
|
|
|
|
USER_MEMORY_DIR = Path.home() / ".nano_claude" / "memory"
|
|
INDEX_FILENAME = "MEMORY.md"
|
|
|
|
# Maximum lines/bytes for the index file (mirrors Claude Code limits)
|
|
MAX_INDEX_LINES = 200
|
|
MAX_INDEX_BYTES = 25_000
|
|
|
|
|
|
def get_project_memory_dir() -> Path:
|
|
"""Return the project-local memory directory (relative to cwd)."""
|
|
return Path.cwd() / ".nano_claude" / "memory"
|
|
|
|
|
|
def get_memory_dir(scope: str = "user") -> Path:
|
|
"""Return the memory directory for the given scope.
|
|
|
|
Args:
|
|
scope: "user" (global ~/.nano_claude/memory) or
|
|
"project" (.nano_claude/memory relative to cwd)
|
|
"""
|
|
if scope == "project":
|
|
return get_project_memory_dir()
|
|
return USER_MEMORY_DIR
|
|
|
|
|
|
# ── Data model ─────────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class MemoryEntry:
|
|
"""A single memory entry loaded from a .md file.
|
|
|
|
Attributes:
|
|
name: human-readable name (also the display title in the index)
|
|
description: short one-line description (used for relevance decisions)
|
|
type: "user" | "feedback" | "project" | "reference"
|
|
content: body text of the memory
|
|
file_path: absolute path to the .md file on disk
|
|
created: date string, e.g. "2026-04-02"
|
|
scope: "user" | "project" — which directory this was loaded from
|
|
"""
|
|
name: str
|
|
description: str
|
|
type: str
|
|
content: str
|
|
file_path: str = ""
|
|
created: str = ""
|
|
scope: str = "user"
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
def _slugify(name: str) -> str:
|
|
"""Convert name to a filesystem-safe slug (max 60 chars)."""
|
|
s = name.lower().strip().replace(" ", "_")
|
|
s = re.sub(r"[^a-z0-9_]", "", s)
|
|
return s[:60]
|
|
|
|
|
|
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
"""Parse ---\\nkey: value\\n---\\nbody format.
|
|
|
|
Returns:
|
|
(meta_dict, body_str)
|
|
"""
|
|
if not text.startswith("---"):
|
|
return {}, text
|
|
parts = text.split("---", 2)
|
|
if len(parts) < 3:
|
|
return {}, text
|
|
meta: dict = {}
|
|
for line in parts[1].strip().splitlines():
|
|
if ":" in line:
|
|
key, _, val = line.partition(":")
|
|
meta[key.strip()] = val.strip()
|
|
return meta, parts[2].strip()
|
|
|
|
|
|
def _format_entry_md(entry: MemoryEntry) -> str:
|
|
"""Render a MemoryEntry as a markdown file with YAML frontmatter."""
|
|
return (
|
|
f"---\n"
|
|
f"name: {entry.name}\n"
|
|
f"description: {entry.description}\n"
|
|
f"type: {entry.type}\n"
|
|
f"created: {entry.created}\n"
|
|
f"---\n"
|
|
f"{entry.content}\n"
|
|
)
|
|
|
|
|
|
# ── Core storage operations ────────────────────────────────────────────────
|
|
|
|
def save_memory(entry: MemoryEntry, scope: str = "user") -> None:
|
|
"""Write/update a memory file and rebuild the index for that scope.
|
|
|
|
If a memory with the same name (slug) already exists, it is overwritten.
|
|
|
|
Args:
|
|
entry: MemoryEntry to persist
|
|
scope: "user" or "project"
|
|
"""
|
|
mem_dir = get_memory_dir(scope)
|
|
mem_dir.mkdir(parents=True, exist_ok=True)
|
|
slug = _slugify(entry.name)
|
|
fp = mem_dir / f"{slug}.md"
|
|
fp.write_text(_format_entry_md(entry))
|
|
entry.file_path = str(fp)
|
|
entry.scope = scope
|
|
_rewrite_index(scope)
|
|
|
|
|
|
def delete_memory(name: str, scope: str = "user") -> None:
|
|
"""Remove the memory file matching name and rebuild the index.
|
|
|
|
No error if not found.
|
|
"""
|
|
mem_dir = get_memory_dir(scope)
|
|
slug = _slugify(name)
|
|
fp = mem_dir / f"{slug}.md"
|
|
if fp.exists():
|
|
fp.unlink()
|
|
_rewrite_index(scope)
|
|
|
|
|
|
def load_entries(scope: str = "user") -> list[MemoryEntry]:
|
|
"""Scan all .md files (except MEMORY.md) in a scope and return entries.
|
|
|
|
Returns:
|
|
List of MemoryEntry sorted alphabetically by name.
|
|
"""
|
|
mem_dir = get_memory_dir(scope)
|
|
if not mem_dir.exists():
|
|
return []
|
|
entries: list[MemoryEntry] = []
|
|
for fp in sorted(mem_dir.glob("*.md")):
|
|
if fp.name == INDEX_FILENAME:
|
|
continue
|
|
try:
|
|
text = fp.read_text()
|
|
except Exception:
|
|
continue
|
|
meta, body = parse_frontmatter(text)
|
|
entries.append(MemoryEntry(
|
|
name=meta.get("name", fp.stem),
|
|
description=meta.get("description", ""),
|
|
type=meta.get("type", "user"),
|
|
content=body,
|
|
file_path=str(fp),
|
|
created=meta.get("created", ""),
|
|
scope=scope,
|
|
))
|
|
return entries
|
|
|
|
|
|
def load_index(scope: str = "all") -> list[MemoryEntry]:
|
|
"""Load memory entries from one or both scopes.
|
|
|
|
Args:
|
|
scope: "user", "project", or "all" (both combined)
|
|
|
|
Returns:
|
|
List of MemoryEntry (user entries first, then project).
|
|
"""
|
|
if scope == "all":
|
|
return load_entries("user") + load_entries("project")
|
|
return load_entries(scope)
|
|
|
|
|
|
def search_memory(query: str, scope: str = "all") -> list[MemoryEntry]:
|
|
"""Case-insensitive keyword match on name + description + content.
|
|
|
|
Returns:
|
|
List of matching MemoryEntry objects.
|
|
"""
|
|
q = query.lower()
|
|
results = []
|
|
for entry in load_index(scope):
|
|
haystack = f"{entry.name} {entry.description} {entry.content}".lower()
|
|
if q in haystack:
|
|
results.append(entry)
|
|
return results
|
|
|
|
|
|
def _rewrite_index(scope: str) -> None:
|
|
"""Rebuild MEMORY.md for the given scope from all .md files in that dir."""
|
|
mem_dir = get_memory_dir(scope)
|
|
if not mem_dir.exists():
|
|
return
|
|
index_path = mem_dir / INDEX_FILENAME
|
|
entries = load_entries(scope)
|
|
lines = [
|
|
f"- [{e.name}]({Path(e.file_path).name}) — {e.description}"
|
|
for e in entries
|
|
]
|
|
index_path.write_text("\n".join(lines) + ("\n" if lines else ""))
|
|
|
|
|
|
def get_index_content(scope: str = "user") -> str:
|
|
"""Return raw MEMORY.md content for the given scope, or '' if absent."""
|
|
mem_dir = get_memory_dir(scope)
|
|
index_path = mem_dir / INDEX_FILENAME
|
|
if not index_path.exists():
|
|
return ""
|
|
return index_path.read_text().strip()
|