Merge pull request 'M5.3: scope memory' (#142) from scope-memory into main
This commit was merged in pull request #142.
This commit is contained in:
commit
4bbcb09c8e
8 files changed
+301
-66
No files matched your search
+14
-8
@@ -341,24 +341,30 @@ only as code-rendered summaries.
|
|||||||
expert sees only its question. **No role sees another's turns as its own:**
|
expert sees only its question. **No role sees another's turns as its own:**
|
||||||
whatever isn't the viewer's own arrives labelled with its origin.
|
whatever isn't the viewer's own arrives labelled with its origin.
|
||||||
|
|
||||||
**3. Scope memory** outlives work items: a repository's own documents (D2), read at
|
**3. Scope memory** outlives work items: a repository's own documents (D2), with
|
||||||
work-item start, with the repository as the authority; for infrastructure, the
|
the repository as the authority, read by the driver as it needs them (not put into
|
||||||
world model in the vault (L10).
|
every item's context); for infrastructure, the world model in the vault (L10).
|
||||||
|
What the agent knows about a project that doesn't belong in its repository is
|
||||||
|
agent memory kept with that project (below). A memory note that disagrees with
|
||||||
|
the repository is corrected.
|
||||||
|
|
||||||
**4. Agent memory:** the agent's own notes (the operator's preferences, lessons,
|
**4. Agent memory:** the agent's own notes (the operator's preferences, lessons,
|
||||||
recurring decisions and their reasons), one note per fact with an index the
|
recurring decisions and their reasons), one note per fact with an index the
|
||||||
harness keeps, in its folder under `memory/` in the vault (L10). The model
|
harness keeps, in its folder under `memory/` in the vault (L10): general notes at
|
||||||
|
its top, a project's under `projects/<project>/`, written only from that project's
|
||||||
|
items. The model
|
||||||
writes, updates and forgets them itself, without asking; each change is said in
|
writes, updates and forgets them itself, without asking; each change is said in
|
||||||
the thread. The index is put into the transcript at a work item's start; a note
|
the thread. The general index and the item's project's are put into the
|
||||||
is read before decisions it covers. Visible to the operator, who corrects it
|
transcript at a work item's start; a note is read before decisions it covers. Visible to the operator, who corrects it
|
||||||
through conversation, not by editing it.
|
through conversation, not by editing it.
|
||||||
|
|
||||||
**5. Never memory:** Matrix history, provider-side session state, build artifacts,
|
**5. Never memory:** Matrix history, provider-side session state, build artifacts,
|
||||||
a previous task's workspace.
|
a previous task's workspace.
|
||||||
|
|
||||||
**Writing memory:** when a fact is learned, with its source and time; at work-item
|
**Writing memory:** when a fact is learned, with its source and time; at work-item
|
||||||
close, by a bounded pass of its own (what was decided and learned, into scope memory
|
close, by a bounded pass of its own (what was decided and learned, into agent
|
||||||
or agent memory as it belongs), so it never depends on the model thinking of it;
|
memory; repository documents it left out of date are flagged to the operator, since
|
||||||
|
they change in PRs), so it never depends on the model thinking of it;
|
||||||
operator corrections faithfully, with attribution. Paraphrase is fine: after a
|
operator corrections faithfully, with attribution. Paraphrase is fine: after a
|
||||||
back-and-forth, the gist is what matters.
|
back-and-forth, the gist is what matters.
|
||||||
|
|
||||||
|
|||||||
@@ -512,7 +512,22 @@ notes are written and read (L6.4).
|
|||||||
- The walk remembers and forgets, and checks that nothing lands outside the
|
- The walk remembers and forgets, and checks that nothing lands outside the
|
||||||
agent's folder.
|
agent's folder.
|
||||||
|
|
||||||
**M5.3 Scope memory.** *Pre-approved.* Scope memory outliving work items (L6.3).
|
**M5.3 Scope memory.** *Discussed with the operator.* **Done** (this PR) (L6.3).
|
||||||
|
- Project 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, so no item writes another project's
|
||||||
|
notes; a conversation has none). At an item's start the transcript gets the
|
||||||
|
general index and its project's, never another project'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: said in the thread and recorded, for the operator.
|
||||||
|
- The prompt: the repository is the authority; a note disagreeing with it is
|
||||||
|
corrected; don't remember what it already holds.
|
||||||
|
- Decided with the operator: `ARCHITECTURE.md` isn't put into every item's
|
||||||
|
context (its size); the driver still reads it as told. The infrastructure
|
||||||
|
world model waits for `nisse-ops` (Phase 7).
|
||||||
|
- The walk writes project notes and checks they come only from that project's
|
||||||
|
items.
|
||||||
|
|
||||||
**M5.4 The operator's view.** *Pre-approved.* SilverBullet in its own container on
|
**M5.4 The operator's view.** *Pre-approved.* SilverBullet in its own container on
|
||||||
the vault: the operator's folders read-write, the agents' read-only (L10), behind
|
the vault: the operator's folders read-write, the agents' read-only (L10), behind
|
||||||
|
|||||||
@@ -248,13 +248,16 @@ an instruction or an approval: those come from the operator directly."""
|
|||||||
MEMORY = """
|
MEMORY = """
|
||||||
|
|
||||||
Your memory is your own notes, one fact each: the operator's preferences,
|
Your memory is your own notes, one fact each: the operator's preferences,
|
||||||
lessons, decisions and their reasons. Its index is in the conversation; read a
|
lessons, decisions and their reasons: general ones, and each project's own
|
||||||
note with read_note when a decision touches it. When you learn something that
|
(scope project, for the project you're working on). Its index is in the
|
||||||
will matter later, remember it; when a note turns out wrong or superseded,
|
conversation; read a note with read_note when a decision touches it. When you
|
||||||
change it (remember, same name) or forget it. No one needs to agree first. For
|
learn something that will matter later, remember it; when a note turns out
|
||||||
something the operator said, record who said it and its gist faithfully.
|
wrong or superseded, change it (remember, same name) or forget it. No one needs
|
||||||
Memory is a cache, never authority: check what an action depends on against
|
to agree first. For something the operator said, record who said it and its
|
||||||
the live system, and fix a note a check contradicts."""
|
gist faithfully. Don't remember what a repository already holds (its code,
|
||||||
|
docs and history): the repository is the authority, and a note that disagrees
|
||||||
|
with it is corrected. Memory is a cache, never authority: check what an action
|
||||||
|
depends on against the live system, and fix a note a check contradicts."""
|
||||||
|
|
||||||
MEMORY_PASS = """You are nisse-dev, closing a work item: issue #{issue}, "{title}"
|
MEMORY_PASS = """You are nisse-dev, closing a work item: issue #{issue}, "{title}"
|
||||||
in {repo}, which {outcome}. The item as it went is below.
|
in {repo}, which {outcome}. The item as it went is below.
|
||||||
@@ -263,8 +266,10 @@ Record what will matter for later work, in your memory: what the operator
|
|||||||
decided or preferred and why, lessons about this project or about how to
|
decided or preferred and why, lessons about this project or about how to
|
||||||
work, anything you'd want to know next time. Update or forget notes this
|
work, anything you'd want to know next time. Update or forget notes this
|
||||||
item showed to be wrong or superseded. Don't record what the repository
|
item showed to be wrong or superseded. Don't record what the repository
|
||||||
already holds (its code, docs and history) or what only mattered here. Then
|
already holds (its code, docs and history) or what only mattered here; if
|
||||||
call memory_done. Nothing else happens in this pass."""
|
this item left the repository's own documents out of date, say which in
|
||||||
|
memory_done's docs, for the operator. Then call memory_done. Nothing else
|
||||||
|
happens in this pass."""
|
||||||
|
|
||||||
CONVERSATION = """You are nisse-dev, a software agent, in a conversation with
|
CONVERSATION = """You are nisse-dev, a software agent, in a conversation with
|
||||||
the operator in a Matrix thread. It isn't about a project yet: it may be a
|
the operator in a Matrix thread. It isn't about a project yet: it may be a
|
||||||
@@ -2470,14 +2475,15 @@ class LoopAgent:
|
|||||||
if self._memory is None or loop.memory_shown:
|
if self._memory is None or loop.memory_shown:
|
||||||
return loop
|
return loop
|
||||||
try:
|
try:
|
||||||
snap = self._memory.snapshot()
|
snap = self._memory.snapshot(None if item.scope == GENERAL else item.scope)
|
||||||
except VaultError as exc:
|
except VaultError as exc:
|
||||||
self._oplog.emit("memory.unreadable", cause=str(exc), work_item=item.id)
|
self._oplog.emit("memory.unreadable", cause=str(exc), work_item=item.id)
|
||||||
return loop # never blocks work (L10); tried again next run
|
return loop # never blocks work (L10); tried again next run
|
||||||
self._fact(
|
self._fact(
|
||||||
item,
|
item,
|
||||||
f"your memory's index ({snap.path}, sha {snap.sha}); read a note "
|
f"your memory's index ({', '.join(snap.paths)}, sha {snap.sha}); "
|
||||||
f"with read_note when a decision touches it:\n{snap.text}",
|
"read a note with read_note (its page, with .md) when a decision "
|
||||||
|
f"touches it:\n{snap.text}",
|
||||||
)
|
)
|
||||||
loop = replace(loop, memory_shown=True)
|
loop = replace(loop, memory_shown=True)
|
||||||
save(self._store, item.id, item.position, loop, cause="memory shown")
|
save(self._store, item.id, item.position, loop, cause="memory shown")
|
||||||
@@ -2514,6 +2520,14 @@ class LoopAgent:
|
|||||||
self._cancel.clear()
|
self._cancel.clear()
|
||||||
if ran.kind != "wait":
|
if ran.kind != "wait":
|
||||||
self._fact(item, f"memory pass didn't finish ({ran.note or ran.kind})")
|
self._fact(item, f"memory pass didn't finish ({ran.note or ran.kind})")
|
||||||
|
return
|
||||||
|
docs = " ".join(str((ran.arguments or {}).get("docs", "")).split())
|
||||||
|
if docs:
|
||||||
|
# L6.3: the repository is scope memory; its documents change
|
||||||
|
# in PRs, so the pass flags them for the operator instead.
|
||||||
|
line = f"Docs to update in {self._at(item).repo}: {docs}"
|
||||||
|
self._fact(item, line)
|
||||||
|
self._transport.post(line, thread=loop.thread)
|
||||||
|
|
||||||
def _note_handlers(
|
def _note_handlers(
|
||||||
self, item_id: str
|
self, item_id: str
|
||||||
@@ -2574,9 +2588,22 @@ class LoopAgent:
|
|||||||
if item is not None:
|
if item is not None:
|
||||||
self._transport.post(line, thread=loop_of(item).thread)
|
self._transport.post(line, thread=loop_of(item).thread)
|
||||||
|
|
||||||
|
def scoped(args: dict[str, Any]) -> str | None:
|
||||||
|
"""L6.3: project means this item's own project, never another."""
|
||||||
|
scope = str(args.get("scope", "") or "general").strip().lower()
|
||||||
|
if scope == "general":
|
||||||
|
return None
|
||||||
|
item = self._store.work_item(item_id)
|
||||||
|
if scope != "project":
|
||||||
|
raise VaultError(f"scope is general or project, not {scope!r}")
|
||||||
|
if item is None or item.scope in (None, GENERAL):
|
||||||
|
raise VaultError("a conversation has no project: use general")
|
||||||
|
return item.scope
|
||||||
|
|
||||||
def remember(args: dict[str, Any]) -> tuple[str, bool]:
|
def remember(args: dict[str, Any]) -> tuple[str, bool]:
|
||||||
name = str(args.get("name", "")).strip()
|
name = str(args.get("name", "")).strip()
|
||||||
about = str(args.get("description", ""))
|
about = str(args.get("description", ""))
|
||||||
|
project = scoped(args)
|
||||||
item = self._store.work_item(item_id)
|
item = self._store.work_item(item_id)
|
||||||
replaced = memory.remember(
|
replaced = memory.remember(
|
||||||
name,
|
name,
|
||||||
@@ -2585,7 +2612,9 @@ class LoopAgent:
|
|||||||
provenance={
|
provenance={
|
||||||
"source": f"work item {item_id}",
|
"source": f"work item {item_id}",
|
||||||
"thread": loop_of(item).thread if item else "",
|
"thread": loop_of(item).thread if item else "",
|
||||||
|
**({"project": project} if project else {}),
|
||||||
},
|
},
|
||||||
|
project=project,
|
||||||
)
|
)
|
||||||
said(
|
said(
|
||||||
f"{'Updated' if replaced else 'Remembered'}: {' '.join(about.split())}"
|
f"{'Updated' if replaced else 'Remembered'}: {' '.join(about.split())}"
|
||||||
@@ -2595,7 +2624,7 @@ class LoopAgent:
|
|||||||
def forget(args: dict[str, Any]) -> tuple[str, bool]:
|
def forget(args: dict[str, Any]) -> tuple[str, bool]:
|
||||||
name = str(args.get("name", "")).strip()
|
name = str(args.get("name", "")).strip()
|
||||||
why = " ".join(str(args.get("why", "")).split())
|
why = " ".join(str(args.get("why", "")).split())
|
||||||
memory.forget(name)
|
memory.forget(name, scoped(args))
|
||||||
said(f"Forgot: {name}" + (f" ({why})" if why else ""))
|
said(f"Forgot: {name}" + (f" ({why})" if why else ""))
|
||||||
return f"forgot {name}", True
|
return f"forgot {name}", True
|
||||||
|
|
||||||
|
|||||||
@@ -117,18 +117,23 @@ REMEMBER = _tool(
|
|||||||
description="One line saying what the note is, for your memory's index.",
|
description="One line saying what the note is, for your memory's index.",
|
||||||
fact="The fact and why it matters; for something the operator said, who "
|
fact="The fact and why it matters; for something the operator said, who "
|
||||||
"said it and its gist.",
|
"said it and its gist.",
|
||||||
|
scope="general, or project for a note about this work item's project "
|
||||||
|
"(kept with that project's notes).",
|
||||||
)
|
)
|
||||||
FORGET = _tool(
|
FORGET = _tool(
|
||||||
"forget",
|
"forget",
|
||||||
"Forget a note of yours that is wrong or superseded.",
|
"Forget a note of yours that is wrong or superseded.",
|
||||||
name="The note's name.",
|
name="The note's name.",
|
||||||
why="Why it's no longer true or needed.",
|
why="Why it's no longer true or needed.",
|
||||||
|
scope="general or project, as it was remembered.",
|
||||||
)
|
)
|
||||||
MEMORY_TOOLS = [REMEMBER, FORGET]
|
MEMORY_TOOLS = [REMEMBER, FORGET]
|
||||||
MEMORY_DONE = _tool(
|
MEMORY_DONE = _tool(
|
||||||
"memory_done",
|
"memory_done",
|
||||||
"You've recorded what will matter later: end the pass.",
|
"You've recorded what will matter later: end the pass.",
|
||||||
summary="What you remembered or forgot, in a line.",
|
summary="What you remembered or forgot, in a line.",
|
||||||
|
docs="Documents in the repository this item left out of date (like "
|
||||||
|
"ARCHITECTURE.md), and how; empty if none.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# A conversation's tools (L1, L5): it talks, and may propose work.
|
# A conversation's tools (L1, L5): it talks, and may propose work.
|
||||||
|
|||||||
@@ -22,79 +22,132 @@ _NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,63}")
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Snapshot:
|
class Snapshot:
|
||||||
"""The index as read, for the transcript (L6: with its provenance)."""
|
"""The indexes as read, for the transcript (L6: with their provenance)."""
|
||||||
|
|
||||||
path: str
|
paths: tuple[str, ...]
|
||||||
text: str
|
text: str
|
||||||
sha: str
|
sha: str
|
||||||
|
|
||||||
|
|
||||||
class Memory:
|
class Memory:
|
||||||
"""``folder``: the agent's read-write folder in the vault."""
|
"""``folder``: the agent's read-write folder in the vault. General
|
||||||
|
notes live there; a project's under ``projects/<project>/`` (L6.3), each
|
||||||
|
folder with an index the harness keeps."""
|
||||||
|
|
||||||
def __init__(self, vault: Vault, folder: str) -> None:
|
def __init__(self, vault: Vault, folder: str) -> None:
|
||||||
self._vault = vault
|
self._vault = vault
|
||||||
self.folder = folder.strip("/")
|
self.folder = folder.strip("/")
|
||||||
|
|
||||||
def _path(self, name: str) -> str:
|
def _where(self, project: str | None) -> str:
|
||||||
|
if project is None:
|
||||||
|
return self.folder
|
||||||
|
if not _NAME.fullmatch(project):
|
||||||
|
raise VaultError(f"{project!r}: not a project's name")
|
||||||
|
return f"{self.folder}/projects/{project}"
|
||||||
|
|
||||||
|
def _path(self, name: str, project: str | None) -> str:
|
||||||
if not _NAME.fullmatch(name) or name == INDEX:
|
if not _NAME.fullmatch(name) or name == INDEX:
|
||||||
raise VaultError(
|
raise VaultError(
|
||||||
f"{name!r}: a note's name is lowercase letters, digits and -, "
|
f"{name!r}: a note's name is lowercase letters, digits and -, "
|
||||||
f"up to 64, and not {INDEX}"
|
f"up to 64, and not {INDEX}"
|
||||||
)
|
)
|
||||||
return f"{self.folder}/{name}.md"
|
return f"{self._where(project)}/{name}.md"
|
||||||
|
|
||||||
def remember(
|
def remember(
|
||||||
self, name: str, description: str, fact: str, provenance: Mapping[str, str]
|
self,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
fact: str,
|
||||||
|
provenance: Mapping[str, str],
|
||||||
|
project: str | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Write the note, or replace it; True if it existed."""
|
"""Write the note, or replace it; True if it existed. ``project``:
|
||||||
|
a note about that project, in its folder; None: a general one."""
|
||||||
description = " ".join(description.split())
|
description = " ".join(description.split())
|
||||||
if not description or not fact.strip():
|
if not description or not fact.strip():
|
||||||
raise VaultError("a note needs a one-line description and the fact")
|
raise VaultError("a note needs a one-line description and the fact")
|
||||||
path = self._path(name)
|
path = self._path(name, project)
|
||||||
existed = path in self._vault.notes(self.folder)
|
existed = path in self._vault.notes(self.folder)
|
||||||
front = yaml.safe_dump({"description": description}, allow_unicode=True)
|
front = yaml.safe_dump({"description": description}, allow_unicode=True)
|
||||||
text = f"---\n{front}---\n{fact.strip()}\n"
|
text = f"---\n{front}---\n{fact.strip()}\n"
|
||||||
self._vault.write(path, text, provenance=provenance)
|
self._vault.write(path, text, provenance=provenance)
|
||||||
self._rebuild()
|
self._rebuild(project)
|
||||||
return existed
|
return existed
|
||||||
|
|
||||||
def forget(self, name: str) -> None:
|
def forget(self, name: str, project: str | None = None) -> None:
|
||||||
self._vault.delete(self._path(name))
|
self._vault.delete(self._path(name, project))
|
||||||
self._rebuild()
|
self._rebuild(project)
|
||||||
|
|
||||||
def _entries(self) -> list[tuple[str, str]]:
|
def _entries(self, where: str) -> list[tuple[str, str]]:
|
||||||
|
"""The notes directly in ``where``: their pages and descriptions."""
|
||||||
entries = []
|
entries = []
|
||||||
for path in self._vault.notes(self.folder):
|
for path in self._vault.notes(self.folder):
|
||||||
name = path.rsplit("/", 1)[-1].removesuffix(".md")
|
page = path.removesuffix(".md")
|
||||||
if name == INDEX or "/" in path.removeprefix(f"{self.folder}/"):
|
if page.rsplit("/", 1)[0] != where or page.endswith(f"/{INDEX}"):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
front, _ = split_frontmatter(self._vault.read(path))
|
front, _ = split_frontmatter(self._vault.read(path))
|
||||||
except VaultError:
|
except VaultError:
|
||||||
front = {}
|
front = {}
|
||||||
entries.append((name, str(front.get("description", "")).strip()))
|
entries.append((page, str(front.get("description", "")).strip()))
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
def _rebuild(self) -> None:
|
def _projects(self) -> list[str]:
|
||||||
lines = [f"- [[{name}]] — {about}" for name, about in self._entries()]
|
prefix = f"{self.folder}/projects/"
|
||||||
body = "# Memory\n\nOne note per fact; kept by the harness.\n\n" + (
|
return sorted(
|
||||||
|
{
|
||||||
|
p.removeprefix(prefix).split("/", 1)[0]
|
||||||
|
for p in self._vault.notes(self.folder)
|
||||||
|
if p.startswith(prefix) and not p.endswith(f"/{INDEX}.md")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _rebuild(self, project: str | None = None) -> None:
|
||||||
|
"""The changed folder's index, and the top one, which links each
|
||||||
|
project's. Links are pages from the vault's root, as SilverBullet
|
||||||
|
names them."""
|
||||||
|
if project is not None:
|
||||||
|
self._write_index(
|
||||||
|
self._where(project), f"# Memory: {project}", self._lines(project)
|
||||||
|
)
|
||||||
|
lines = self._lines(None)
|
||||||
|
projects = self._projects()
|
||||||
|
if projects:
|
||||||
|
lines += ["", "Projects:"] + [
|
||||||
|
f"- [[{self._where(p)}/{INDEX}]] — notes about {p}" for p in projects
|
||||||
|
]
|
||||||
|
self._write_index(self.folder, "# Memory", lines)
|
||||||
|
|
||||||
|
def _lines(self, project: str | None) -> list[str]:
|
||||||
|
return [
|
||||||
|
f"- [[{page}]] — {about}"
|
||||||
|
for page, about in self._entries(self._where(project))
|
||||||
|
]
|
||||||
|
|
||||||
|
def _write_index(self, where: str, title: str, lines: list[str]) -> None:
|
||||||
|
body = f"{title}\n\nOne note per fact; kept by the harness.\n\n" + (
|
||||||
"\n".join(lines) or "Nothing yet."
|
"\n".join(lines) or "Nothing yet."
|
||||||
)
|
)
|
||||||
self._vault.write(
|
self._vault.write(
|
||||||
f"{self.folder}/{INDEX}.md",
|
f"{where}/{INDEX}.md",
|
||||||
body + "\n",
|
body + "\n",
|
||||||
provenance={"source": "the harness, after a change"},
|
provenance={"source": "the harness, after a change"},
|
||||||
)
|
)
|
||||||
|
|
||||||
def snapshot(self) -> Snapshot:
|
def snapshot(self, project: str | None = None) -> Snapshot:
|
||||||
"""The index for the transcript; rebuilt first if it's missing."""
|
"""The general index, and ``project``'s when it has notes, for the
|
||||||
path = f"{self.folder}/{INDEX}.md"
|
transcript; the general one is made first if it's missing."""
|
||||||
try:
|
paths = [f"{self.folder}/{INDEX}.md"]
|
||||||
text = self._vault.read(path)
|
if project is not None and project in self._projects():
|
||||||
except VaultError:
|
paths.append(f"{self._where(project)}/{INDEX}.md")
|
||||||
self._rebuild()
|
bodies = []
|
||||||
text = self._vault.read(path)
|
for path in paths:
|
||||||
_, body = split_frontmatter(text)
|
try:
|
||||||
sha = hashlib.sha256(body.encode()).hexdigest()[:12]
|
text = self._vault.read(path)
|
||||||
return Snapshot(path, body.strip(), sha)
|
except VaultError:
|
||||||
|
self._rebuild(None if path == paths[0] else project)
|
||||||
|
text = self._vault.read(path)
|
||||||
|
bodies.append(split_frontmatter(text)[1].strip())
|
||||||
|
joined = "\n\n".join(bodies)
|
||||||
|
sha = hashlib.sha256(joined.encode()).hexdigest()[:12]
|
||||||
|
return Snapshot(tuple(paths), joined, sha)
|
||||||
@@ -72,7 +72,7 @@ def test_the_model_remembers_and_forgets_without_asking(
|
|||||||
assert "Remembered: Count words with split" in said(transport)
|
assert "Remembered: Count words with split" in said(transport)
|
||||||
assert "Forgot: old-style (the operator changed it)" in said(transport)
|
assert "Forgot: old-style (the operator changed it)" in said(transport)
|
||||||
index = memory.snapshot().text
|
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(
|
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")]
|
shown = [f for f in facts(world) if f.startswith("your memory's index")]
|
||||||
assert len(shown) == 1
|
assert len(shown) == 1
|
||||||
assert f"sha {memory.snapshot().sha}" in shown[0]
|
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(
|
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")
|
say(transport, world, "abandon")
|
||||||
assert the_item(world).status == "closed"
|
assert the_item(world).status == "closed"
|
||||||
assert "memory pass: recording what this item decided and learned" in facts(world)
|
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]
|
offered = model.offered[-1]
|
||||||
assert offered == {
|
assert offered == {
|
||||||
"read_note",
|
"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 = agent(world, model, memory)
|
||||||
transport.receive(DON, "I have a 4090 for local models.")
|
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)
|
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)
|
||||||
@@ -57,7 +57,7 @@ from nisse_dev.memory import Memory
|
|||||||
from nisse_dev.oplog import OpLog
|
from nisse_dev.oplog import OpLog
|
||||||
from nisse_dev.tools import Workspace
|
from nisse_dev.tools import Workspace
|
||||||
from nisse_dev.transport import InboundMessage, InMemoryTransport
|
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
|
from nisse_dev.worker import InlineWorker
|
||||||
|
|
||||||
# How many walks, and from which seed: all 48 by default; CI runs fewer on a
|
# 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.projects = projects
|
||||||
self.n = 0
|
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]:
|
def complete(self, events, tools, model, system=None) -> list[Any]:
|
||||||
assert_renderable(events) # as the real API would
|
assert_renderable(events) # as the real API would
|
||||||
self.n += 1
|
self.n += 1
|
||||||
@@ -101,16 +111,14 @@ class PolicyModel:
|
|||||||
# The memory pass at a close (M5.2): a note, a forgetting, done.
|
# The memory pass at a close (M5.2): a note, a forgetting, done.
|
||||||
roll = self.rng.random()
|
roll = self.rng.random()
|
||||||
if roll < 0.3:
|
if roll < 0.3:
|
||||||
return [
|
return [self._remember()]
|
||||||
call("remember", name=f"n{self.n % 4}", description="d", fact="f")
|
|
||||||
]
|
|
||||||
if roll < 0.55:
|
if roll < 0.55:
|
||||||
return [call("forget", name=f"n{self.n % 4}", why="superseded")]
|
return [self._forget()]
|
||||||
return [call("memory_done", summary="done")]
|
return [call("memory_done", summary="done")]
|
||||||
if "remember" in offered and self.rng.random() < 0.06:
|
if "remember" in offered and self.rng.random() < 0.06:
|
||||||
if self.rng.random() < 0.4:
|
if self.rng.random() < 0.4:
|
||||||
return [call("forget", name=f"n{self.n % 4}", why="superseded")]
|
return [self._forget()]
|
||||||
return [call("remember", name=f"n{self.n % 4}", description="d", fact="f")]
|
return [self._remember()]
|
||||||
if "toolchain_ready" in offered:
|
if "toolchain_ready" in offered:
|
||||||
# A preflight (M4.1): usually reported, sometimes badly or not.
|
# A preflight (M4.1): usually reported, sometimes badly or not.
|
||||||
if self.rng.random() < 0.15:
|
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:
|
class Watched:
|
||||||
"""The driver's model, watched: a run on a project item (not its
|
"""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
|
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"
|
and p != root / "notes" / "Ops.md"
|
||||||
]
|
]
|
||||||
assert not strays, f"memory_stays_home: {strays}: {where}"
|
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
|
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.
|
# #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}"
|
assert not self.while_down, f"no_model_on_a_lost_vm: {self.while_down}: {where}"
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ def test_the_same_name_replaces_and_the_index_follows(memory: Memory) -> None:
|
|||||||
memory.remember("a-fact", "Ay", "a", provenance={})
|
memory.remember("a-fact", "Ay", "a", provenance={})
|
||||||
assert memory.remember("a-fact", "Ay, again", "a2", provenance={})
|
assert memory.remember("a-fact", "Ay, again", "a2", provenance={})
|
||||||
index = memory.snapshot().text
|
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")
|
memory.forget("a-fact")
|
||||||
assert "a-fact" not in memory.snapshot().text
|
assert "a-fact" not in memory.snapshot().text
|
||||||
with pytest.raises(VaultError, match="no such note"):
|
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:
|
def test_the_snapshot_is_hashed_and_made_if_missing(memory: Memory) -> None:
|
||||||
first = memory.snapshot()
|
first = memory.snapshot()
|
||||||
assert first.path == f"{FOLDER}/index.md"
|
assert first.paths == (f"{FOLDER}/index.md",)
|
||||||
assert "Nothing yet." in first.text
|
assert "Nothing yet." in first.text
|
||||||
assert memory.snapshot().sha == first.sha # same index, same hash
|
assert memory.snapshot().sha == first.sha # same index, same hash
|
||||||
memory.remember("x", "Ex", "f", provenance={})
|
memory.remember("x", "Ex", "f", provenance={})
|
||||||
assert memory.snapshot().sha != first.sha
|
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")
|
||||||
Reference in new issue
Block a user