Spaces:
Running
Running
Release portable agent memory client 0.1.0
Browse files- .gitignore +8 -0
- README.md +2 -0
- scripts/deploy_space.py +2 -2
- tests/live_smoke.py +53 -0
- tests/test_memory.py +388 -0
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.agent-memory/
|
| 5 |
+
.test-work/
|
| 6 |
+
*.pyc
|
| 7 |
+
dist/
|
| 8 |
+
|
README.md
CHANGED
|
@@ -25,6 +25,8 @@ python3 -m venv .venv
|
|
| 25 |
.venv/bin/python memory-use/scripts/memory.py --help
|
| 26 |
```
|
| 27 |
|
|
|
|
|
|
|
| 28 |
With an existing local HF login or `HF_TOKEN`, run from the project you want to map:
|
| 29 |
|
| 30 |
```bash
|
|
|
|
| 25 |
.venv/bin/python memory-use/scripts/memory.py --help
|
| 26 |
```
|
| 27 |
|
| 28 |
+
The automated suite uses local fixtures and never contacts HF. An explicit live test is available with `.venv/bin/python tests/live_smoke.py --bucket namespace/name`; it writes synthetic notes under `memory-client-validation` and publishes a clearly labelled test skill revision to that existing private bucket.
|
| 29 |
+
|
| 30 |
With an existing local HF login or `HF_TOKEN`, run from the project you want to map:
|
| 31 |
|
| 32 |
```bash
|
scripts/deploy_space.py
CHANGED
|
@@ -8,8 +8,8 @@ from huggingface_hub import CommitOperationAdd, HfApi
|
|
| 8 |
|
| 9 |
def deploy(space):
|
| 10 |
root = Path(__file__).resolve().parents[1]
|
| 11 |
-
names = ["README.md", "index.html", "style.css", "bootstrap.md", "requirements.txt",
|
| 12 |
-
"docs/agent-memory-design.md", "scripts/deploy_space.py"]
|
| 13 |
names += [p.relative_to(root).as_posix() for p in (root / "memory-use").rglob("*")
|
| 14 |
if p.is_file() and "__pycache__" not in p.parts and p.suffix != ".pyc"]
|
| 15 |
api = HfApi()
|
|
|
|
| 8 |
|
| 9 |
def deploy(space):
|
| 10 |
root = Path(__file__).resolve().parents[1]
|
| 11 |
+
names = ["README.md", "index.html", "style.css", "bootstrap.md", "requirements.txt", ".gitignore",
|
| 12 |
+
"docs/agent-memory-design.md", "scripts/deploy_space.py", "tests/test_memory.py", "tests/live_smoke.py"]
|
| 13 |
names += [p.relative_to(root).as_posix() for p in (root / "memory-use").rglob("*")
|
| 14 |
if p.is_file() and "__pycache__" not in p.parts and p.suffix != ".pyc"]
|
| 15 |
api = HfApi()
|
tests/live_smoke.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Explicit live test: writes synthetic notes and one skill revision to an existing private bucket."""
|
| 2 |
+
import argparse
|
| 3 |
+
import importlib.util
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import uuid
|
| 7 |
+
|
| 8 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 9 |
+
spec = importlib.util.spec_from_file_location("memory", ROOT / "memory-use/scripts/memory.py")
|
| 10 |
+
m = importlib.util.module_from_spec(spec)
|
| 11 |
+
spec.loader.exec_module(m)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def run(bucket, state):
|
| 15 |
+
state.mkdir(parents=True)
|
| 16 |
+
first = m.Memory(state / "machine-one")
|
| 17 |
+
second = m.Memory(state / "machine-two")
|
| 18 |
+
work_a, work_b = state / "checkout-a", state / "different-checkout-b"
|
| 19 |
+
work_a.mkdir()
|
| 20 |
+
work_b.mkdir()
|
| 21 |
+
for client, work, runtime in ((first, work_a, "codex"), (second, work_b, "claude")):
|
| 22 |
+
instructions = client.home / ("AGENTS.md" if runtime == "codex" else "CLAUDE.md")
|
| 23 |
+
instructions.write_text("Preserve this unrelated instruction.\n")
|
| 24 |
+
result = client.bootstrap("memory-client-validation", work, bucket=bucket, runtime=runtime,
|
| 25 |
+
skills_dir=client.home / "skills", instructions=instructions)
|
| 26 |
+
assert result["status"] == "ready", result
|
| 27 |
+
assert instructions.read_text().startswith("Preserve this unrelated instruction.")
|
| 28 |
+
note = first.save("Synthetic portability check: independent clients retrieve the same immutable checkpoint.",
|
| 29 |
+
"Client portability validation", kind="checkpoint", cwd=work_a)
|
| 30 |
+
assert note["status"] == "saved", note
|
| 31 |
+
read = second.read(note["id"], cwd=work_b, fresh=True)
|
| 32 |
+
assert "Synthetic portability check" in read["text"]
|
| 33 |
+
assert second.search("portability", cwd=work_b)["sync"] == "fresh"
|
| 34 |
+
skill = state / "memory-client-smoke"
|
| 35 |
+
skill.mkdir()
|
| 36 |
+
(skill / "SKILL.md").write_text("---\nname: memory-client-smoke\ndescription: Synthetic skill used to verify memory client portability.\n---\nRead references/check.md when validating a memory installation. This skill is test data.\n")
|
| 37 |
+
(skill / "references").mkdir()
|
| 38 |
+
(skill / "references/check.md").write_text("Check that a note written by one client can be read by another.\n")
|
| 39 |
+
published = first.publish(skill, cwd=work_a)
|
| 40 |
+
assert published["status"] == "saved", published
|
| 41 |
+
second.install_shared("memory-client-smoke", published["revision"], "claude", cwd=work_b)
|
| 42 |
+
assert (second.home / "skills/memory-client-smoke/references/check.md").read_text().startswith("Check that")
|
| 43 |
+
return {"status": "passed", "bucket": bucket, "checkpoint_id": note["id"],
|
| 44 |
+
"skill_revision": published["revision"], "isolated_clients": 2,
|
| 45 |
+
"physical_machines": 1, "space_dependency": False, "state": str(state)}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
if __name__ == "__main__":
|
| 49 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 50 |
+
p.add_argument("--bucket", required=True)
|
| 51 |
+
p.add_argument("--state", type=Path, default=ROOT / ".test-work" / ("live-" + uuid.uuid4().hex[:8]))
|
| 52 |
+
args = p.parse_args()
|
| 53 |
+
print(json.dumps(run(args.bucket, args.state), indent=2))
|
tests/test_memory.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import importlib.util
|
| 2 |
+
import json
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import subprocess
|
| 5 |
+
import sys
|
| 6 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 7 |
+
from types import SimpleNamespace
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 12 |
+
spec = importlib.util.spec_from_file_location("memory", ROOT / "memory-use/scripts/memory.py")
|
| 13 |
+
m = importlib.util.module_from_spec(spec)
|
| 14 |
+
spec.loader.exec_module(m)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Bucket:
|
| 18 |
+
"""An in-memory fake of bucket operations; never used by the production CLI."""
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.buckets = {}
|
| 21 |
+
self.calls = []
|
| 22 |
+
self.fail = None
|
| 23 |
+
self.fail_marker = False
|
| 24 |
+
self.unknown_put = False
|
| 25 |
+
|
| 26 |
+
def call(self, op, **kw):
|
| 27 |
+
self.calls.append((op, kw))
|
| 28 |
+
if self.fail:
|
| 29 |
+
raise m.MemoryError("Simulated " + self.fail, self.fail)
|
| 30 |
+
if op == "whoami":
|
| 31 |
+
return {"name": "owner", "orgs": ["team"]}
|
| 32 |
+
if op == "create":
|
| 33 |
+
if kw["bucket"] in self.buckets:
|
| 34 |
+
raise m.MemoryError("Already exists", "conflict")
|
| 35 |
+
self.buckets[kw["bucket"]] = {"private": True, "files": {}}
|
| 36 |
+
return {}
|
| 37 |
+
if op == "core":
|
| 38 |
+
return {"path": str(ROOT / "memory-use")}
|
| 39 |
+
if kw["bucket"] not in self.buckets:
|
| 40 |
+
raise m.MemoryError("Missing", "not-found")
|
| 41 |
+
bucket = self.buckets[kw["bucket"]]
|
| 42 |
+
files = bucket["files"]
|
| 43 |
+
if op == "info":
|
| 44 |
+
return {"id": kw["bucket"], "private": bucket["private"]}
|
| 45 |
+
if op == "put":
|
| 46 |
+
if self.fail_marker and kw["path"].endswith("/SKILL.md"):
|
| 47 |
+
raise m.MemoryError("Interrupted publication", "offline")
|
| 48 |
+
files[kw["path"]] = kw["text"]
|
| 49 |
+
if self.unknown_put:
|
| 50 |
+
raise m.MemoryError("Lost acknowledgment", "offline")
|
| 51 |
+
return {}
|
| 52 |
+
if op == "get":
|
| 53 |
+
if kw["path"] not in files:
|
| 54 |
+
raise m.MemoryError("Missing", "not-found")
|
| 55 |
+
return {"text": files[kw["path"]]}
|
| 56 |
+
if op == "list":
|
| 57 |
+
return [{"path": p, "size": len(t.encode())} for p, t in files.items() if p.startswith(kw["prefix"])]
|
| 58 |
+
raise AssertionError(op)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@pytest.fixture
|
| 62 |
+
def setup(tmp_path):
|
| 63 |
+
store = Bucket()
|
| 64 |
+
client = m.Memory(tmp_path / "state", store)
|
| 65 |
+
work = tmp_path / "project"
|
| 66 |
+
work.mkdir()
|
| 67 |
+
client.provision("project", work)
|
| 68 |
+
return client, store, work
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def example_skill(tmp_path, body="Use a release checklist."):
|
| 72 |
+
folder = tmp_path / "release-check"
|
| 73 |
+
folder.mkdir(exist_ok=True)
|
| 74 |
+
(folder / "SKILL.md").write_text("---\nname: release-check\ndescription: Check releases.\n---\n\n" + body)
|
| 75 |
+
(folder / "references").mkdir(exist_ok=True)
|
| 76 |
+
(folder / "references/check.md").write_text("Verify the release artifact.")
|
| 77 |
+
return folder
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def setup_runtime(client, tmp_path, store, work):
|
| 81 |
+
instructions = tmp_path / "AGENTS.md"
|
| 82 |
+
instructions.write_text("Keep existing conventions.\n")
|
| 83 |
+
return client.bootstrap("project", work, bucket="owner/agent-memory", skills_dir=tmp_path / "skills", instructions=instructions)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_two_machines_share_memories_and_keep_corrections(setup, tmp_path):
|
| 87 |
+
one, store, work = setup
|
| 88 |
+
saved = one.save("Use uv for this project.", "Package tooling", cwd=work)
|
| 89 |
+
two = m.Memory(tmp_path / "second-machine", store)
|
| 90 |
+
other_checkout = tmp_path / "other-checkout"
|
| 91 |
+
other_checkout.mkdir()
|
| 92 |
+
two.provision("project", other_checkout, "owner/agent-memory")
|
| 93 |
+
results = two.search("UV", cwd=other_checkout)
|
| 94 |
+
assert results["sync"] == "fresh" and len(results["results"]) == 1
|
| 95 |
+
assert "Use uv" in two.read(saved["id"], cwd=other_checkout)["text"]
|
| 96 |
+
correction = two.save("Correction: use pip. Supersedes " + saved["id"], "Tooling correction", cwd=other_checkout)
|
| 97 |
+
assert saved["id"] != correction["id"]
|
| 98 |
+
assert len(one.search(cwd=work)["results"]) == 2
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_unknown_upload_retries_identical_bytes_after_configuration_change(setup):
|
| 102 |
+
client, store, work = setup
|
| 103 |
+
store.unknown_put = True
|
| 104 |
+
result = client.save("Verified result", "Checkpoint", kind="checkpoint", cwd=work)
|
| 105 |
+
assert result["status"] == "pending"
|
| 106 |
+
original = dict(store.buckets["owner/agent-memory"]["files"])
|
| 107 |
+
store.unknown_put = False
|
| 108 |
+
client.provision("other", work, create_bucket="owner/other")
|
| 109 |
+
assert client.retry(result["id"])["status"] == "saved"
|
| 110 |
+
assert store.buckets["owner/agent-memory"]["files"] == original
|
| 111 |
+
assert not store.buckets["owner/other"]["files"]
|
| 112 |
+
assert not client.pending()
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_save_offline_is_durable_and_retryable(setup):
|
| 116 |
+
client, store, work = setup
|
| 117 |
+
store.fail = "offline"
|
| 118 |
+
result = client.save("Keep this finding", "Finding", cwd=work)
|
| 119 |
+
assert result["status"] == "pending"
|
| 120 |
+
assert client.pending()[0]["text"].endswith("Keep this finding\n")
|
| 121 |
+
restarted = m.Memory(client.home, store)
|
| 122 |
+
store.fail = None
|
| 123 |
+
assert restarted.retry(result["id"])["status"] == "saved"
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_no_cleanup_before_remote_confirmation(setup, monkeypatch):
|
| 127 |
+
client, store, work = setup
|
| 128 |
+
original = m.write_json
|
| 129 |
+
def crash_on_receipt(path, value):
|
| 130 |
+
if Path(path).name == "last-upload.json":
|
| 131 |
+
raise OSError("Simulated disk failure after upload")
|
| 132 |
+
return original(path, value)
|
| 133 |
+
monkeypatch.setattr(m, "write_json", crash_on_receipt)
|
| 134 |
+
with pytest.raises(OSError):
|
| 135 |
+
client.save("Retain until acknowledged", "Crash case", cwd=work)
|
| 136 |
+
job = client.pending()[0]
|
| 137 |
+
assert len(store.buckets["owner/agent-memory"]["files"]) == 1
|
| 138 |
+
monkeypatch.setattr(m, "write_json", original)
|
| 139 |
+
client.retry(job["id"])
|
| 140 |
+
assert len(store.buckets["owner/agent-memory"]["files"]) == 1
|
| 141 |
+
assert not client.pending()
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@pytest.mark.parametrize("failure", ["offline", "auth"])
|
| 145 |
+
def test_cached_search_distinguishes_failed_sync_and_does_not_prune(setup, failure):
|
| 146 |
+
client, store, work = setup
|
| 147 |
+
client.save("Cached evidence", "Evidence", cwd=work)
|
| 148 |
+
client.search(cwd=work)
|
| 149 |
+
store.fail = failure
|
| 150 |
+
result = client.search("evidence", cwd=work)
|
| 151 |
+
assert result["stale"] and len(result["results"]) == 1
|
| 152 |
+
assert result["sync"] == ("auth-error" if failure == "auth" else "offline")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def test_incomplete_listing_and_download_preserve_cache(setup, monkeypatch):
|
| 156 |
+
client, store, work = setup
|
| 157 |
+
old = client.save("Keep cached note", "Old", cwd=work)
|
| 158 |
+
client.search(cwd=work)
|
| 159 |
+
original = store.call
|
| 160 |
+
def interrupted(op, **kw):
|
| 161 |
+
if op == "list":
|
| 162 |
+
raise m.MemoryError("Pagination failed after page one", "offline")
|
| 163 |
+
return original(op, **kw)
|
| 164 |
+
monkeypatch.setattr(store, "call", interrupted)
|
| 165 |
+
assert len(client.search(cwd=work)["results"]) == 1
|
| 166 |
+
monkeypatch.setattr(store, "call", original)
|
| 167 |
+
store.buckets["owner/agent-memory"]["files"].pop(old["path"])
|
| 168 |
+
new = client.save("New note", "New", cwd=work)
|
| 169 |
+
def broken_download(op, **kw):
|
| 170 |
+
if op == "get" and kw["path"] == new["path"]:
|
| 171 |
+
raise m.MemoryError("Download interrupted", "offline")
|
| 172 |
+
return original(op, **kw)
|
| 173 |
+
monkeypatch.setattr(store, "call", broken_download)
|
| 174 |
+
assert client.search(cwd=work)["stale"]
|
| 175 |
+
assert (client.cache("owner/agent-memory") / old["path"]).exists()
|
| 176 |
+
assert not (client.cache("owner/agent-memory") / new["path"]).exists()
|
| 177 |
+
monkeypatch.setattr(store, "call", original)
|
| 178 |
+
assert not client.search(cwd=work)["stale"]
|
| 179 |
+
assert not (client.cache("owner/agent-memory") / old["path"]).exists()
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_explicit_missing_bucket_never_creates_replacement(tmp_path):
|
| 183 |
+
store = Bucket()
|
| 184 |
+
client = m.Memory(tmp_path / "state", store)
|
| 185 |
+
with pytest.raises(m.MemoryError):
|
| 186 |
+
client.provision("project", tmp_path, "owner/missing")
|
| 187 |
+
assert not any(op == "create" for op, _ in store.calls)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_configured_missing_bucket_is_not_replaced(setup):
|
| 191 |
+
client, store, work = setup
|
| 192 |
+
store.buckets.clear()
|
| 193 |
+
store.calls.clear()
|
| 194 |
+
with pytest.raises(m.MemoryError):
|
| 195 |
+
client.provision("project", work)
|
| 196 |
+
assert not any(op == "create" for op, _ in store.calls)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def test_public_default_bucket_is_not_reconfigured(tmp_path):
|
| 200 |
+
store = Bucket()
|
| 201 |
+
store.buckets["owner/agent-memory"] = {"private": False, "files": {}}
|
| 202 |
+
with pytest.raises(m.MemoryError, match="private"):
|
| 203 |
+
m.Memory(tmp_path / "state", store).provision("project", tmp_path)
|
| 204 |
+
assert store.buckets["owner/agent-memory"]["private"] is False
|
| 205 |
+
assert not any(op == "create" for op, _ in store.calls)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_longest_project_ancestor_and_directory_boundaries(setup, tmp_path):
|
| 209 |
+
client, _, work = setup
|
| 210 |
+
nested = work / "nested"
|
| 211 |
+
nested.mkdir()
|
| 212 |
+
client.provision("nested", nested, "owner/agent-memory")
|
| 213 |
+
assert client.project(nested / "src")["project"] == "nested"
|
| 214 |
+
assert client.project(work / "other")["project"] == "project"
|
| 215 |
+
with pytest.raises(m.MemoryError, match="no memory project"):
|
| 216 |
+
client.save("Do not route incorrectly", "Unmapped", cwd=tmp_path / "project-other")
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def test_bootstrap_repeat_preserves_instructions_and_verification(setup, tmp_path):
|
| 220 |
+
client, store, work = setup
|
| 221 |
+
first = setup_runtime(client, tmp_path, store, work)
|
| 222 |
+
instructions = tmp_path / "AGENTS.md"
|
| 223 |
+
instructions.write_text(instructions.read_text() + "\nMore unrelated instructions.\n")
|
| 224 |
+
second = client.bootstrap("project", work, bucket="owner/agent-memory", skills_dir=tmp_path / "skills", instructions=instructions)
|
| 225 |
+
assert first["verification_id"] == second["verification_id"]
|
| 226 |
+
assert len(store.buckets["owner/agent-memory"]["files"]) == 1
|
| 227 |
+
text = instructions.read_text()
|
| 228 |
+
assert text.startswith("Keep existing conventions.") and text.endswith("More unrelated instructions.\n")
|
| 229 |
+
assert text.count(m.BEGIN) == 1
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def test_skill_revisions_partial_publication_and_frozen_retry(setup, tmp_path):
|
| 233 |
+
client, store, work = setup
|
| 234 |
+
folder = example_skill(tmp_path)
|
| 235 |
+
store.fail_marker = True
|
| 236 |
+
first = client.publish(folder, cwd=work)
|
| 237 |
+
assert first["status"] == "pending"
|
| 238 |
+
assert not client.search(skills=True, cwd=work)["results"]
|
| 239 |
+
original_text = (folder / "SKILL.md").read_text()
|
| 240 |
+
(folder / "SKILL.md").write_text(original_text + "\nA subsequent local edit.")
|
| 241 |
+
store.fail_marker = False
|
| 242 |
+
client.retry(first["id"])
|
| 243 |
+
assert store.buckets["owner/agent-memory"]["files"][first["path"] + "SKILL.md"] == original_text
|
| 244 |
+
second = client.publish(folder, cwd=work)
|
| 245 |
+
assert second["revision"] != first["revision"]
|
| 246 |
+
assert len(client.search(skills=True, cwd=work)["results"]) == 2
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def test_install_preserves_modified_skill_and_explicit_replacement_backs_up(setup, tmp_path):
|
| 250 |
+
client, store, work = setup
|
| 251 |
+
setup_runtime(client, tmp_path, store, work)
|
| 252 |
+
folder = example_skill(tmp_path)
|
| 253 |
+
published = client.publish(folder, cwd=work)
|
| 254 |
+
client.install_shared("release-check", published["revision"], cwd=work)
|
| 255 |
+
installed = tmp_path / "skills/release-check/references/check.md"
|
| 256 |
+
installed.write_text("Local modifications")
|
| 257 |
+
with pytest.raises(m.MemoryError, match="Existing skill differs"):
|
| 258 |
+
client.install_shared("release-check", published["revision"], cwd=work)
|
| 259 |
+
assert installed.read_text() == "Local modifications"
|
| 260 |
+
result = client.install_shared("release-check", published["revision"], cwd=work, replace=True)
|
| 261 |
+
assert (Path(result["backup"]) / "references/check.md").read_text() == "Local modifications"
|
| 262 |
+
assert "release-check" in (tmp_path / "AGENTS.md").read_text()
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
@pytest.mark.parametrize("bad", ["../escape", "/absolute", "a/../../escape", "a\\b", "a//b", "./file", "C:/file"])
|
| 266 |
+
def test_unsafe_paths_rejected(bad):
|
| 267 |
+
with pytest.raises(m.MemoryError):
|
| 268 |
+
m.validate_skill({"SKILL.md": "---\nname: example\ndescription: Example\n---\nBody", bad: "bad"})
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def test_symlink_publish_and_install_destination_rejected(setup, tmp_path):
|
| 272 |
+
client, store, work = setup
|
| 273 |
+
folder = example_skill(tmp_path)
|
| 274 |
+
(folder / "link").symlink_to(tmp_path / "outside")
|
| 275 |
+
with pytest.raises(m.MemoryError, match="symlink"):
|
| 276 |
+
client.publish(folder, cwd=work)
|
| 277 |
+
(folder / "link").unlink()
|
| 278 |
+
root = tmp_path / "linked-skills"
|
| 279 |
+
root.symlink_to(folder, target_is_directory=True)
|
| 280 |
+
with pytest.raises(m.MemoryError, match="Symlinks"):
|
| 281 |
+
client.install_files(m.skill_files(folder), "release-check", root, {})
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def test_core_name_cannot_be_published_or_installed_from_bucket(setup):
|
| 285 |
+
client, _, work = setup
|
| 286 |
+
with pytest.raises(m.MemoryError, match="pinned Space"):
|
| 287 |
+
client.publish(ROOT / "memory-use", cwd=work)
|
| 288 |
+
with pytest.raises(m.MemoryError, match="cannot replace"):
|
| 289 |
+
client.install_shared("memory-use", "abc", cwd=work)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def test_reminder_has_no_network_or_transcript_reads(setup, tmp_path):
|
| 293 |
+
client, store, work = setup
|
| 294 |
+
store.calls.clear()
|
| 295 |
+
payload = {"hook_event_name": "UserPromptSubmit", "cwd": str(work), "transcript_path": "/must/not/read", "prompt": "Do not echo my message"}
|
| 296 |
+
result = m.hook_output(client, payload)
|
| 297 |
+
assert result["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit"
|
| 298 |
+
assert "Do not echo" not in json.dumps(result)
|
| 299 |
+
assert not store.calls
|
| 300 |
+
assert m.hook_output(client, {**payload, "cwd": str(tmp_path / "unmapped")}) == {}
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def test_hook_merge_remove_preserves_unrelated_handlers(setup, tmp_path, monkeypatch):
|
| 304 |
+
client, store, work = setup
|
| 305 |
+
setup_runtime(client, tmp_path, store, work)
|
| 306 |
+
config = tmp_path / "hooks.json"
|
| 307 |
+
unrelated = {"type": "command", "command": "echo unrelated"}
|
| 308 |
+
initial = {"description": "Existing hooks", "hooks": {"SessionStart": [{"matcher": "resume", "hooks": [unrelated]}], "Stop": [{"hooks": [unrelated]}]}}
|
| 309 |
+
config.write_text(json.dumps(initial))
|
| 310 |
+
monkeypatch.setattr(m.subprocess, "run", lambda *a, **k: SimpleNamespace(stdout="codex-cli 0.162.1"))
|
| 311 |
+
m.hooks(client, "install", config)
|
| 312 |
+
m.hooks(client, "install", config)
|
| 313 |
+
value = json.loads(config.read_text())
|
| 314 |
+
assert len(value["hooks"]["SessionStart"]) == 2
|
| 315 |
+
m.hooks(client, "remove", config)
|
| 316 |
+
assert json.loads(config.read_text()) == initial
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def test_credentials_rejected_and_errors_do_not_echo_sdk_secrets(setup, monkeypatch):
|
| 320 |
+
client, _, work = setup
|
| 321 |
+
token = "hf_" + "x" * 30
|
| 322 |
+
monkeypatch.setenv("HF_TOKEN", token)
|
| 323 |
+
with pytest.raises(m.MemoryError, match="credential"):
|
| 324 |
+
client.save(token, "Bad note", cwd=work)
|
| 325 |
+
exc = Exception("Authorization: " + token)
|
| 326 |
+
assert token not in str(m.error_from_exception(exc))
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def test_cli_json_errors_and_hook_fail_open(tmp_path):
|
| 330 |
+
script = ROOT / "memory-use/scripts/memory.py"
|
| 331 |
+
command = [sys.executable, str(script), "--home", str(tmp_path / "state")]
|
| 332 |
+
result = subprocess.run(command + ["search", "query", "--json"], capture_output=True, text=True, cwd=tmp_path)
|
| 333 |
+
assert result.returncode == 1
|
| 334 |
+
assert json.loads(result.stdout)["code"] == "unmapped"
|
| 335 |
+
hook = subprocess.run(command + ["hook"], input="invalid JSON", capture_output=True, text=True)
|
| 336 |
+
assert hook.returncode == 0
|
| 337 |
+
assert "systemMessage" in json.loads(hook.stdout)
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def test_native_network_child_has_hard_deadline(tmp_path, monkeypatch):
|
| 341 |
+
hanging = tmp_path / "hanging.py"
|
| 342 |
+
hanging.write_text("import time\ntime.sleep(10)\n")
|
| 343 |
+
monkeypatch.setattr(m, "__file__", str(hanging))
|
| 344 |
+
with pytest.raises(m.MemoryError, match="deadline"):
|
| 345 |
+
m.HFStore(timeout=0.05).call("whoami")
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def test_concurrent_saves_survive_with_distinct_objects(setup):
|
| 349 |
+
client, store, work = setup
|
| 350 |
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
| 351 |
+
futures = [pool.submit(client.save, f"Finding {i}", f"Concurrent {i}", cwd=work) for i in range(12)]
|
| 352 |
+
results = [f.result() for f in futures]
|
| 353 |
+
assert len({r["id"] for r in results}) == 12
|
| 354 |
+
assert all(r["status"] == "saved" for r in results)
|
| 355 |
+
assert len(store.buckets["owner/agent-memory"]["files"]) == 12
|
| 356 |
+
assert not client.pending()
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def test_remote_traversal_skill_never_changes_installation(setup, tmp_path):
|
| 360 |
+
client, store, work = setup
|
| 361 |
+
setup_runtime(client, tmp_path, store, work)
|
| 362 |
+
prefix = "skills/hostile/revision/"
|
| 363 |
+
store.buckets["owner/agent-memory"]["files"].update({
|
| 364 |
+
prefix + "SKILL.md": "---\nname: hostile\ndescription: Test\n---\nInstructions",
|
| 365 |
+
prefix + "../../../escaped": "Bad path",
|
| 366 |
+
})
|
| 367 |
+
with pytest.raises(m.MemoryError, match="Unsafe"):
|
| 368 |
+
client.install_shared("hostile", "revision", cwd=work)
|
| 369 |
+
assert not (tmp_path / "skills/hostile").exists()
|
| 370 |
+
assert not (tmp_path / "escaped").exists()
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def test_missing_authentication_has_clear_local_message(monkeypatch):
|
| 374 |
+
import huggingface_hub
|
| 375 |
+
monkeypatch.setattr(huggingface_hub, "get_token", lambda: None)
|
| 376 |
+
with pytest.raises(m.MemoryError, match="No HF credentials") as result:
|
| 377 |
+
m.hf_operation({"op": "whoami"})
|
| 378 |
+
assert result.value.code == "auth"
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def test_fresh_read_never_silently_falls_back_to_cache(setup):
|
| 382 |
+
client, store, work = setup
|
| 383 |
+
saved = client.save("Cached", "Fresh check", cwd=work)
|
| 384 |
+
client.read(saved["id"], cwd=work)
|
| 385 |
+
store.fail = "offline"
|
| 386 |
+
assert client.read(saved["id"], cwd=work)["stale"]
|
| 387 |
+
with pytest.raises(m.MemoryError):
|
| 388 |
+
client.read(saved["id"], cwd=work, fresh=True)
|