- 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>
276 lines
10 KiB
Python
276 lines
10 KiB
Python
"""Tests for the memory package (memory/)."""
|
|
import pytest
|
|
from pathlib import Path
|
|
|
|
import memory.store as _store
|
|
from memory.store import (
|
|
MemoryEntry,
|
|
save_memory,
|
|
load_index,
|
|
load_entries,
|
|
delete_memory,
|
|
search_memory,
|
|
_slugify,
|
|
parse_frontmatter,
|
|
get_index_content,
|
|
)
|
|
from memory.context import get_memory_context, truncate_index_content
|
|
from memory.scan import (
|
|
scan_memory_dir,
|
|
format_memory_manifest,
|
|
memory_age_days,
|
|
memory_age_str,
|
|
memory_freshness_text,
|
|
MemoryHeader,
|
|
)
|
|
from memory.types import MEMORY_TYPES
|
|
|
|
|
|
# ── Fixtures ─────────────────────────────────────────────────────────────
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def redirect_memory_dirs(tmp_path, monkeypatch):
|
|
"""Redirect user and project memory dirs to tmp_path for all tests."""
|
|
user_mem = tmp_path / "user_memory"
|
|
user_mem.mkdir()
|
|
proj_mem = tmp_path / "project_memory"
|
|
proj_mem.mkdir()
|
|
|
|
monkeypatch.setattr(_store, "USER_MEMORY_DIR", user_mem)
|
|
|
|
# Patch get_project_memory_dir to return our tmp project dir
|
|
monkeypatch.setattr(_store, "get_project_memory_dir", lambda: proj_mem)
|
|
|
|
|
|
def _make_entry(name="test note", description="a test", type_="user",
|
|
content="hello world", scope="user"):
|
|
return MemoryEntry(
|
|
name=name, description=description, type=type_,
|
|
content=content, created="2026-04-02", scope=scope,
|
|
)
|
|
|
|
|
|
# ── Save and Load ─────────────────────────────────────────────────────────
|
|
|
|
class TestSaveAndLoad:
|
|
def test_roundtrip(self):
|
|
entry = _make_entry()
|
|
save_memory(entry, scope="user")
|
|
loaded = load_entries("user")
|
|
assert len(loaded) == 1
|
|
assert loaded[0].name == "test note"
|
|
assert loaded[0].description == "a test"
|
|
assert loaded[0].type == "user"
|
|
assert loaded[0].content == "hello world"
|
|
|
|
def test_creates_file_on_disk(self):
|
|
entry = _make_entry()
|
|
save_memory(entry, scope="user")
|
|
assert Path(entry.file_path).exists()
|
|
text = Path(entry.file_path).read_text()
|
|
assert "hello world" in text
|
|
|
|
def test_update_existing(self):
|
|
"""Save same name twice → only 1 entry with updated content."""
|
|
save_memory(_make_entry(content="version 1"), scope="user")
|
|
save_memory(_make_entry(content="version 2"), scope="user")
|
|
loaded = load_entries("user")
|
|
assert len(loaded) == 1
|
|
assert loaded[0].content == "version 2"
|
|
|
|
def test_project_scope_stored_separately(self):
|
|
save_memory(_make_entry(name="user note"), scope="user")
|
|
save_memory(_make_entry(name="proj note"), scope="project")
|
|
user_entries = load_entries("user")
|
|
proj_entries = load_entries("project")
|
|
assert len(user_entries) == 1
|
|
assert len(proj_entries) == 1
|
|
assert user_entries[0].name == "user note"
|
|
assert proj_entries[0].name == "proj note"
|
|
|
|
def test_load_index_all_combines_scopes(self):
|
|
save_memory(_make_entry(name="user note"), scope="user")
|
|
save_memory(_make_entry(name="proj note"), scope="project")
|
|
all_entries = load_index("all")
|
|
names = {e.name for e in all_entries}
|
|
assert "user note" in names
|
|
assert "proj note" in names
|
|
|
|
|
|
# ── Delete ────────────────────────────────────────────────────────────────
|
|
|
|
class TestDelete:
|
|
def test_delete_removes_file_and_index(self):
|
|
entry = _make_entry()
|
|
save_memory(entry, scope="user")
|
|
delete_memory("test note", scope="user")
|
|
assert load_entries("user") == []
|
|
assert not Path(entry.file_path).exists()
|
|
|
|
def test_delete_nonexistent_no_error(self):
|
|
delete_memory("nonexistent", scope="user")
|
|
|
|
def test_delete_from_project_scope(self):
|
|
save_memory(_make_entry(name="proj note"), scope="project")
|
|
delete_memory("proj note", scope="project")
|
|
assert load_entries("project") == []
|
|
|
|
|
|
# ── Search ────────────────────────────────────────────────────────────────
|
|
|
|
class TestSearch:
|
|
def test_search_by_keyword(self):
|
|
save_memory(_make_entry(name="python tips", content="use list comprehension"), scope="user")
|
|
save_memory(_make_entry(name="rust tips", content="use iterators"), scope="user")
|
|
results = search_memory("python")
|
|
assert len(results) == 1
|
|
assert results[0].name == "python tips"
|
|
|
|
def test_search_case_insensitive(self):
|
|
save_memory(_make_entry(name="Important Note", content="something"), scope="user")
|
|
results = search_memory("important")
|
|
assert len(results) == 1
|
|
|
|
def test_search_in_content(self):
|
|
save_memory(_make_entry(name="misc", content="the quick brown fox"), scope="user")
|
|
results = search_memory("brown fox")
|
|
assert len(results) == 1
|
|
|
|
def test_search_across_scopes(self):
|
|
save_memory(_make_entry(name="user note", content="alpha"), scope="user")
|
|
save_memory(_make_entry(name="proj note", content="alpha"), scope="project")
|
|
results = search_memory("alpha", scope="all")
|
|
assert len(results) == 2
|
|
|
|
|
|
# ── Memory context ────────────────────────────────────────────────────────
|
|
|
|
class TestGetMemoryContext:
|
|
def test_returns_index_text(self):
|
|
save_memory(_make_entry(name="my note", description="desc here"), scope="user")
|
|
ctx = get_memory_context()
|
|
assert "my note" in ctx
|
|
assert "desc here" in ctx
|
|
|
|
def test_empty_when_no_memories(self):
|
|
ctx = get_memory_context()
|
|
assert ctx == ""
|
|
|
|
def test_project_memories_labelled(self):
|
|
save_memory(_make_entry(name="proj note", description="project context"), scope="project")
|
|
ctx = get_memory_context()
|
|
assert "Project memories" in ctx
|
|
assert "proj note" in ctx
|
|
|
|
|
|
# ── Truncation ────────────────────────────────────────────────────────────
|
|
|
|
class TestTruncation:
|
|
def test_no_truncation_within_limits(self):
|
|
text = "- line\n" * 10
|
|
result = truncate_index_content(text)
|
|
assert "WARNING" not in result
|
|
|
|
def test_line_truncation(self):
|
|
text = "\n".join(f"- line {i}" for i in range(300))
|
|
result = truncate_index_content(text)
|
|
assert "WARNING" in result
|
|
assert "lines" in result
|
|
|
|
def test_byte_truncation(self):
|
|
# 25001 bytes of content
|
|
text = "x" * 25001
|
|
result = truncate_index_content(text)
|
|
assert "WARNING" in result
|
|
|
|
|
|
# ── Slugify ───────────────────────────────────────────────────────────────
|
|
|
|
class TestSlugify:
|
|
def test_basic(self):
|
|
assert _slugify("Hello World") == "hello_world"
|
|
|
|
def test_special_chars(self):
|
|
assert _slugify("foo@bar!baz") == "foobarbaz"
|
|
|
|
def test_max_length(self):
|
|
assert len(_slugify("a" * 100)) == 60
|
|
|
|
|
|
# ── parse_frontmatter ─────────────────────────────────────────────────────
|
|
|
|
class TestParseFrontmatter:
|
|
def test_parse(self):
|
|
text = "---\nname: foo\ntype: user\n---\nbody text"
|
|
meta, body = parse_frontmatter(text)
|
|
assert meta["name"] == "foo"
|
|
assert meta["type"] == "user"
|
|
assert body == "body text"
|
|
|
|
def test_no_frontmatter(self):
|
|
meta, body = parse_frontmatter("just plain text")
|
|
assert meta == {}
|
|
assert body == "just plain text"
|
|
|
|
|
|
# ── scan / age / freshness ────────────────────────────────────────────────
|
|
|
|
class TestScanAndAge:
|
|
def test_scan_memory_dir(self):
|
|
save_memory(_make_entry(name="note a"), scope="user")
|
|
save_memory(_make_entry(name="note b"), scope="user")
|
|
user_dir = _store.USER_MEMORY_DIR
|
|
headers = scan_memory_dir(user_dir, "user")
|
|
assert len(headers) == 2
|
|
assert all(isinstance(h, MemoryHeader) for h in headers)
|
|
|
|
def test_format_manifest(self):
|
|
import time
|
|
headers = [
|
|
MemoryHeader(
|
|
filename="foo.md",
|
|
file_path="/tmp/foo.md",
|
|
mtime_s=time.time(),
|
|
description="test desc",
|
|
type="user",
|
|
scope="user",
|
|
)
|
|
]
|
|
manifest = format_memory_manifest(headers)
|
|
assert "foo.md" in manifest
|
|
assert "test desc" in manifest
|
|
assert "today" in manifest
|
|
|
|
def test_memory_age_days_today(self):
|
|
import time
|
|
assert memory_age_days(time.time()) == 0
|
|
|
|
def test_memory_age_days_old(self):
|
|
import time
|
|
old = time.time() - 5 * 86400 # 5 days ago
|
|
assert memory_age_days(old) == 5
|
|
|
|
def test_memory_age_str(self):
|
|
import time
|
|
assert memory_age_str(time.time()) == "today"
|
|
assert memory_age_str(time.time() - 86400) == "yesterday"
|
|
assert memory_age_str(time.time() - 3 * 86400) == "3 days ago"
|
|
|
|
def test_freshness_text_fresh(self):
|
|
import time
|
|
assert memory_freshness_text(time.time()) == ""
|
|
|
|
def test_freshness_text_stale(self):
|
|
import time
|
|
old = time.time() - 10 * 86400
|
|
text = memory_freshness_text(old)
|
|
assert "10 days old" in text
|
|
assert "stale" in text.lower() or "outdated" in text.lower()
|
|
|
|
|
|
# ── Memory types ──────────────────────────────────────────────────────────
|
|
|
|
class TestMemoryTypes:
|
|
def test_types_list(self):
|
|
assert set(MEMORY_TYPES) == {"user", "feedback", "project", "reference"}
|