M5.3: scope memory
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m57s
CI / e2e (pull_request) Successful in 3m49s

A project's notes have their own folder, memory/nisse-dev/projects/<project>/,
with its own index; remember and forget take scope (general, or project:
always the current item's own, so no item writes another project's notes,
and a conversation has none). At an item's start the transcript gets the
general index and its project's, never another's. Index links are pages
from the vault's root, as SilverBullet names them.

The closing pass's memory_done can name repository documents the item left
out of date; the harness says so in the thread and records it. The prompt:
the repository is the authority, and a note that disagrees is corrected.

With the operator: ARCHITECTURE.md isn't put into every item's context (its
size); the infrastructure world model waits for nisse-ops. The walk writes
project notes and checks they come only from that project's items.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-08 23:33:12 +00:00
1 parent 427278ac80
commit fe4548459f
8 files changed
+301 -66

No files matched your search

+79 -4
View File
@@ -72,7 +72,7 @@ def test_the_model_remembers_and_forgets_without_asking(
assert "Remembered: Count words with split" in said(transport)
assert "Forgot: old-style (the operator changed it)" in said(transport)
index = memory.snapshot().text
assert "[[uses-split]]" in index and "old-style" not in index
assert "[[memory/nisse-dev/uses-split]]" in index and "old-style" not in index
def test_the_index_is_shown_once_per_item(
@@ -86,7 +86,7 @@ def test_the_index_is_shown_once_per_item(
shown = [f for f in facts(world) if f.startswith("your memory's index")]
assert len(shown) == 1
assert f"sha {memory.snapshot().sha}" in shown[0]
assert "[[uses-split]] — Count words with split" in shown[0]
assert "[[memory/nisse-dev/uses-split]] — Count words with split" in shown[0]
def test_a_pass_at_the_close_records_what_will_matter(
@@ -109,7 +109,7 @@ def test_a_pass_at_the_close_records_what_will_matter(
say(transport, world, "abandon")
assert the_item(world).status == "closed"
assert "memory pass: recording what this item decided and learned" in facts(world)
assert "[[no-count-words]]" in memory.snapshot().text
assert "[[memory/nisse-dev/no-count-words]]" in memory.snapshot().text
offered = model.offered[-1]
assert offered == {
"read_note",
@@ -156,5 +156,80 @@ def test_a_conversation_can_remember(world: dict[str, Any], memory: Memory) -> N
)
_, transport = agent(world, model, memory)
transport.receive(DON, "I have a 4090 for local models.")
assert "[[has-4090]]" in memory.snapshot().text
assert "[[memory/nisse-dev/has-4090]]" in memory.snapshot().text
assert "Remembered: Has a 4090" in said(transport)
def test_a_project_note_goes_with_the_items_project(
world: dict[str, Any], memory: Memory
) -> None:
memory.remember("other-fact", "Other's", "o", provenance={}, project="other")
memory.remember("old", "Sandbox's", "s", provenance={}, project="sandbox")
model = Spy(
[
call(
"remember",
name="deploys-by-tag",
description="Sandbox deploys by tag",
fact="f",
scope="project",
),
call("remember", name="x", description="d", fact="f", scope="team"),
],
RESTATE,
)
loop, _ = agent(world, model, memory)
loop.intake()
note = memory._vault.read(f"{FOLDER}/projects/sandbox/deploys-by-tag.md")
assert "project: sandbox" in note
[shown] = [f for f in facts(world) if f.startswith("your memory's index")]
assert "projects/sandbox/old]]" in shown # this project's notes
assert "other-fact" not in shown # never another's
results = [
e.event.result
for e in world["store"].transcript(the_item(world).id, "driver").read()
if hasattr(e.event, "result")
]
assert "scope is general or project, not 'team'" in results
def test_a_conversation_has_no_project_scope(
world: dict[str, Any], memory: Memory
) -> None:
world["forge"].issues.clear()
model = Spy(
[call("remember", name="x", description="d", fact="f", scope="project")],
[call("reply", text="Noted.")],
)
_, transport = agent(world, model, memory)
transport.receive(DON, "remember this")
assert not (memory._vault.root / FOLDER / "projects").exists()
log = world["store"].all_events()
assert any(
getattr(e.event, "result", "") == "a conversation has no project: use general"
for e in log
)
def test_the_pass_flags_documents_left_out_of_date(
world: dict[str, Any], memory: Memory
) -> None:
model = Spy(
RESTATE,
[
call(
"memory_done",
summary="nothing new",
docs="ARCHITECTURE.md still describes the old text module",
)
],
)
loop, transport = agent(world, model, memory)
loop.intake()
say(transport, world, "abandon")
line = (
"Docs to update in don/sandbox: ARCHITECTURE.md still describes the old "
"text module"
)
assert line in said(transport)
assert line in facts(world)
+31 -7
View File
@@ -57,7 +57,7 @@ from nisse_dev.memory import Memory
from nisse_dev.oplog import OpLog
from nisse_dev.tools import Workspace
from nisse_dev.transport import InboundMessage, InMemoryTransport
from nisse_dev.vault import Vault
from nisse_dev.vault import Vault, split_frontmatter
from nisse_dev.worker import InlineWorker
# How many walks, and from which seed: all 48 by default; CI runs fewer on a
@@ -87,6 +87,16 @@ class PolicyModel:
self.projects = projects
self.n = 0
def _remember(self) -> Any:
scope = _scope(self.rng)
return call(
"remember", name=f"n{self.n % 4}", description="d", fact="f", scope=scope
)
def _forget(self) -> Any:
scope = _scope(self.rng)
return call("forget", name=f"n{self.n % 4}", why="superseded", scope=scope)
def complete(self, events, tools, model, system=None) -> list[Any]:
assert_renderable(events) # as the real API would
self.n += 1
@@ -101,16 +111,14 @@ class PolicyModel:
# The memory pass at a close (M5.2): a note, a forgetting, done.
roll = self.rng.random()
if roll < 0.3:
return [
call("remember", name=f"n{self.n % 4}", description="d", fact="f")
]
return [self._remember()]
if roll < 0.55:
return [call("forget", name=f"n{self.n % 4}", why="superseded")]
return [self._forget()]
return [call("memory_done", summary="done")]
if "remember" in offered and self.rng.random() < 0.06:
if self.rng.random() < 0.4:
return [call("forget", name=f"n{self.n % 4}", why="superseded")]
return [call("remember", name=f"n{self.n % 4}", description="d", fact="f")]
return [self._forget()]
return [self._remember()]
if "toolchain_ready" in offered:
# A preflight (M4.1): usually reported, sometimes badly or not.
if self.rng.random() < 0.15:
@@ -213,6 +221,10 @@ class PolicyModel:
]
def _scope(rng: random.Random) -> str:
return rng.choice(["general", "project"])
class Watched:
"""The driver's model, watched: a run on a project item (not its
preflight) must find the project's marker on the VM, and no model runs
@@ -715,6 +727,18 @@ class Walk:
and p != root / "notes" / "Ops.md"
]
assert not strays, f"memory_stays_home: {strays}: {where}"
# M5.3: a project's notes come only from that project's items.
projects = root / "memory" / "nisse-dev" / "projects"
for note in projects.rglob("*.md") if projects.exists() else []:
if note.name == "index.md":
continue
project = note.relative_to(projects).parts[0]
front, _ = split_frontmatter(note.read_text())
source = front["provenance"]["source"]
assert front["provenance"]["project"] == project, where
assert source.startswith(f"work item {project}-"), (
f"project_notes_stay_with_their_project: {note}: {source}: {where}"
)
assert (root / "notes" / "Ops.md").read_text() == "the operator's\n", where
# #137: no model works on a project whose build VM is out of reach.
assert not self.while_down, f"no_model_on_a_lost_vm: {self.while_down}: {where}"
+30 -2
View File
@@ -40,7 +40,9 @@ def test_the_same_name_replaces_and_the_index_follows(memory: Memory) -> None:
memory.remember("a-fact", "Ay", "a", provenance={})
assert memory.remember("a-fact", "Ay, again", "a2", provenance={})
index = memory.snapshot().text
assert index.endswith("- [[a-fact]] — Ay, again\n- [[b-fact]] — Bee")
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"):
@@ -60,8 +62,34 @@ def test_a_note_needs_a_description_and_a_fact(memory: Memory) -> None:
def test_the_snapshot_is_hashed_and_made_if_missing(memory: Memory) -> None:
first = memory.snapshot()
assert first.path == f"{FOLDER}/index.md"
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")