"""Agent memory (L6.4, M5.2): one note per fact in the agent's folder, an index the harness keeps, and a snapshot of it for the transcript.""" from pathlib import Path import pytest from nisse_core.memory import Memory from nisse_core.vault import Vault, VaultError, split_frontmatter FOLDER = "memory/nisse-dev" @pytest.fixture def memory(tmp_path: Path) -> Memory: return Memory( Vault(tmp_path / "v", {FOLDER: "rw", "notes": "ro"}, agent="nisse-dev"), FOLDER ) def note(memory: Memory, name: str) -> tuple[dict, str]: path = memory._vault.root / FOLDER / f"{name}.md" return split_frontmatter(path.read_text()) def test_a_fact_is_a_note_with_its_description_and_provenance(memory: Memory) -> None: replaced = memory.remember( "prefers-small-prs", "The operator prefers small PRs", "Don said PRs over 400 lines are hard to review.\n", provenance={"source": "work item x"}, ) assert not replaced front, body = note(memory, "prefers-small-prs") assert front["description"] == "The operator prefers small PRs" assert front["provenance"]["source"] == "work item x" assert body == "Don said PRs over 400 lines are hard to review.\n" def test_the_same_name_replaces_and_the_index_follows(memory: Memory) -> None: memory.remember("b-fact", "Bee", "b", provenance={}) memory.remember("a-fact", "Ay", "a", provenance={}) assert memory.remember("a-fact", "Ay, again", "a2", provenance={}) index = memory.snapshot().text assert index.endswith( "- [[memory/nisse-dev/a-fact]] — Ay, again\n- [[memory/nisse-dev/b-fact]] — Bee" ) memory.forget("a-fact") assert "a-fact" not in memory.snapshot().text with pytest.raises(VaultError, match="no such note"): memory.forget("a-fact") @pytest.mark.parametrize("name", ["index", "Caps", "../x", "a/b", "", "-x"]) def test_names_are_plain(memory: Memory, name: str) -> None: with pytest.raises(VaultError, match="a note's name"): memory.remember(name, "d", "f", provenance={}) def test_a_note_needs_a_description_and_a_fact(memory: Memory) -> None: with pytest.raises(VaultError, match="description and the fact"): memory.remember("x", " ", "f", provenance={}) def test_the_snapshot_is_hashed_and_made_if_missing(memory: Memory) -> None: first = memory.snapshot() assert first.paths == (f"{FOLDER}/index.md",) assert "Nothing yet." in first.text assert memory.snapshot().sha == first.sha # same index, same hash memory.remember("x", "Ex", "f", provenance={}) assert memory.snapshot().sha != first.sha def test_a_projects_notes_have_their_own_folder_and_index(memory: Memory) -> None: memory.remember("general-one", "General", "g", provenance={}) memory.remember("deploy", "Deploys by tag", "d", provenance={}, project="sandbox") memory.remember("other", "Other's", "o", provenance={}, project="other") root = memory._vault.root / FOLDER assert (root / "projects" / "sandbox" / "deploy.md").exists() top = memory.snapshot().text assert "- [[memory/nisse-dev/general-one]] — General" in top assert "deploy" not in top.split("Projects:")[0] assert "- [[memory/nisse-dev/projects/sandbox/index]] — notes about sandbox" in top seen = memory.snapshot("sandbox") assert seen.paths == ( f"{FOLDER}/index.md", f"{FOLDER}/projects/sandbox/index.md", ) assert ( "- [[memory/nisse-dev/projects/sandbox/deploy]] — Deploys by tag" in seen.text ) assert "projects/other/other]]" not in seen.text memory.forget("deploy", "sandbox") assert memory.snapshot("sandbox").paths == (f"{FOLDER}/index.md",) assert "sandbox/index" not in memory.snapshot().text with pytest.raises(VaultError, match="not a project's name"): memory.remember("x", "d", "f", provenance={}, project="../x")