diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b093150..8719566 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,8 @@ jobs: - run: python eval/run_tasks.py --verify-fixtures - run: python examples/quickstart.py - run: python examples/handoff_demo.py - - run: ruff check --select E9,F63,F7,F82 src tests eval scripts examples + - run: ruff check src tests eval scripts examples + - run: ruff format --check src mcp-versions: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a84ca5..1d27038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 0.4.0rc2 — independent snapshots and a simpler core + +- Return detached entries from every public operation, including nested source, + metadata and history. Caller mutations cannot bypass revisions or re-embedding. +- Serialize operations on each store object with a reentrant thread lock. Readers + cannot observe a partial write or a change that later rolls back. +- Build startup context from one snapshot so another writer's correction cannot + mix a retired handoff with its replacement in the same response. +- Count literal tokenizer markers as ordinary text, preserving recall, statistics + and rendered budgets for memories that quote those markers. +- Separate record rules (`models.py`) and JSON persistence (`persistence.py`) + from memory operations. Preserve format 3, old IDs and migration support. +- Replace `dedup_threshold` with explicit `deduplicate=False`; make the writing + agent keyword-only and document the Python compatibility changes. +- Default new stores to offline hashing. Preserve existing backend configuration, + require semantic opt-in, and reject unknown configuration values. +- Share display tags, avoid copying full histories just to count candidates, + and make evaluation output directories work on first use. +- Add API ownership and concurrent-read regressions, an architecture walkthrough, + and project-local lint/format checks in CI. + +See [migration](docs/migration-v0.4.md) and [verification](docs/verification-rc2.md). +The optional semantic-model and live-agent comparisons remain outside this +candidate's verified scope. + ## 0.4.0rc1 — release candidate - Generate UUID4 memory IDs and reject duplicate explicit IDs; retain legacy and caller-supplied identities. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6af6cd3..fe285a2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,8 @@ AGENT_MEMORY_EMBEDDER=hashing python -m pytest -q -ra python eval/run_eval.py python eval/run_tasks.py --verify-fixtures python examples/handoff_demo.py +ruff check src tests eval scripts examples +ruff format --check src python -m build ``` @@ -21,4 +23,10 @@ Keep generated retrieval results current if their underlying behavior changes. K Core dependencies should stay small. MCP and model integrations remain optional. For persistence changes, cover multiple instances, malformed data, failed writes, legacy IDs and restart behavior. For tool changes, test the real MCP schema and stdio boundary. +Keep client adapters thin, record rules in `models.py`, and file encoding in +`persistence.py`. Store operations own revisions, retrieval and transactions. +Return detached snapshots from public APIs; never expose live internal entries. +Include same-object threaded readers as well as separate writer processes when +changing transaction behavior. See [architecture](docs/architecture.md). + Feature requests should include a concrete workflow and what currently fails. A benchmark showing where the current store stops working is more useful than a new backend in search of a workload. diff --git a/README.md b/README.md index a6a2650..0014d10 100644 --- a/README.md +++ b/README.md @@ -8,22 +8,46 @@ Keep architectural decisions, bug findings and session handoffs in one project store. Retrieve relevant notes within a text token budget. When a decision changes, retain its history and keep stale agents from overwriting the correction. -For developers switching between coding agents or returning to a project after a break. It provides a Python library, CLI and local MCP server. No model API key is required; the lightweight installation uses offline lexical retrieval. +Designed for developers switching between coding agents or returning to a project after a break, the engine provides a Python library, CLI and local MCP server. No model API key or database service is required. The default retriever matches words and character fragments; an optional local model adds semantic retrieval. -**v0.4 release candidate:** new correction APIs and store format. Existing IDs remain intact. Read the [migration notes](docs/migration-v0.4.md) before upgrading a shared store. +The workflow is simple: **recall context → work → save durable facts and a handoff**. Memories are explicitly written by you or your agent. This is not automatic learning from every conversation, and stored claims still need verification. + +**Status: v0.4.0rc2, a release candidate.** The instructions below install this checkout, not a published stable release. Existing format-3 stores need no conversion; Python callers should read the [migration notes](docs/migration-v0.4.md). + +Use this when several agents need selective retrieval and traceable corrections. If a few short, maintained Markdown files already solve your problem, keep them. + +## Architecture at a glance + +```mermaid +flowchart LR + Clients[CLI / MCP / hooks] --> Context[Rendering and diagnostics] + Clients --> Store[MemoryStore] + Context --> Store + Store --> Records[Records and validation] + Store --> Embeddings[Embedding backend] + Store --> Disk[Atomic JSON persistence] +``` + +One Python package, one project store, one implementation of memory operations. All client adapters share the same core. The only required dependency is NumPy. + +The CLI, MCP server and hooks translate requests. Rendering builds the returned context. [`store.py`](src/agent_memory/store.py) coordinates memory operations, [`models.py`](src/agent_memory/models.py) defines records and validation, and [`persistence.py`](src/agent_memory/persistence.py) reads and atomically replaces the JSON file. These are modules, not separate services. + +The design prioritizes safe corrections and predictable behavior: stale edits are rejected when a revision is supplied, failed writes roll back, and returned Python records cannot mutate the store. The [architecture guide](docs/architecture.md) explains the write flow, locking and tradeoffs. ## Try it in five minutes -Python 3.10+ and Git are required. From this checkout: +You need Python 3.10+ and Git. On macOS/Linux: ```bash +git clone https://github.com/Ninadnj/agent-memory-engine.git +cd agent-memory-engine python -m venv .venv source .venv/bin/activate python -m pip install -e ".[mcp]" python examples/handoff_demo.py ``` -On Windows PowerShell, activate with `.venv\Scripts\Activate.ps1` instead. +On Windows PowerShell, use `.venv\Scripts\Activate.ps1` instead of the `source` line. If your system uses `python3`, substitute it for `python`. The demo starts **two real MCP server processes** against a temporary store. Its output: @@ -34,9 +58,11 @@ The demo starts **two real MCP server processes** against a temporary store. Its All checks passed. Two server processes; one temporary store; no model calls. ``` -This is a scripted protocol demonstration. It does not measure an LLM's ability to use memory. +An expected stale-revision error may also appear in the logs: the demo deliberately attempts an outdated deletion and verifies that it is rejected. It uses a temporary store and makes no model calls. This tests the protocol, not an LLM's ability to use memory. + +### Use it in your own project -To use your project's store: +With the environment still active, **change into the Git project you want to remember**, then run: ```bash agent-memory --agent developer write "Bookings are stored in UTC; the UI converts to local time." --type decision @@ -45,7 +71,9 @@ agent-memory doctor agent-memory recall "booking timezone UTC" --budget 200 --explain ``` -Inside a Git project, the default is `.agent_memory/store.json`. Outside one, it is `~/.agent_memory/store.json`. `--path` or `AGENT_MEMORY_PATH` selects an explicit store; use the same absolute path for clients that should share it. Keep memory files out of Git. +The store is created on the first write. Inside a Git project, its default path is `.agent_memory/store.json` at the repository root. Outside Git, the default is `~/.agent_memory/store.json`. + +`--path` or `AGENT_MEMORY_PATH` selects an explicit store. Give every client the **same absolute path** when they should share memory. Add `.agent_memory/` to that project's `.gitignore`; memories can contain private project details. ## Correct a memory without losing the explanation @@ -53,14 +81,24 @@ Inside a Git project, the default is `.agent_memory/store.json`. Outside one, it from agent_memory import HashingEmbedder, MemoryStore store = MemoryStore(".agent_memory/store.json", embedder=HashingEmbedder()) -old = store.write("Queue retries are limited to three attempts.", type="decision", - source={"path": "queue/policy.py"}, agent="reviewer") -new = store.supersede(old.id, "Queue retries are limited to five attempts.", - expected_revision=old.revision, agent="implementer") +old = store.write( + "Queue retries are limited to three attempts.", + type="decision", + source={"path": "queue/policy.py"}, + agent="reviewer", +) +new = store.supersede( + old.id, + "Queue retries are limited to five attempts.", + expected_revision=old.revision, + agent="implementer", +) assert store.get(old.id).superseded_by == new.id ``` -Use `update` for a correction to the same memory, `supersede` for a replacement decision, and `forget` for permanent deletion. MCP and CLI edits require the revision you read. Python accepts `expected_revision` for compatibility; **pass it when multiple callers can edit**. Updates retain the last 20 prior revisions. Forgetting removes the entry and its history from the current store. +Use `update` to correct the same memory, `supersede` to replace it while retaining the old record, and `forget` to permanently delete it. MCP and CLI edits require the revision you read. In Python, `update` and `forget` allow the revision to be omitted; **always pass it when multiple callers can edit**. `supersede` requires it. The last 20 prior revisions are retained; forgetting removes the entry and its history from the current store. + +Python methods return independent snapshots. Editing their fields changes only that local copy; calling `save()` afterward does not persist those edits. Use `store.update(...)` or `store.supersede(...)`. An earlier snapshot keeps its original revision even after another caller updates the store. CLI equivalents, replacing `MEMORY_ID` and revision with values from `list`: @@ -76,7 +114,7 @@ Sources can include a project-relative `path`, Git `commit`, `event`, or caller- ## Connect a coding agent -See [client setup](docs/clients.md) for Claude Code, Codex CLI and Cursor. All use the same stdio command, `agent-memory-mcp`, and can share a store on the same machine. +See [client setup](docs/clients.md) for Claude Code, Codex CLI and Cursor. Configure the absolute path to the installed `agent-memory-mcp` executable and the project store. The client launches the server over stdio; no HTTP service or port is needed. Agent use of the tools depends on the client and model—it is not guaranteed to happen automatically. | Task | MCP tool | | --- | --- | @@ -91,33 +129,49 @@ See [client setup](docs/clients.md) for Claude Code, Codex CLI and Cursor. All u Optional Claude Code hooks inject fresh startup notes and record observed Git changes. Install with `agent-memory install-hooks`; enable per-prompt recall with `--with-prompt-recall`. Hooks are fallible observations and do not establish who authored a change. -## What is guaranteed, and what is measured? +## Correctness and limits - New IDs use `mem_` plus UUID4. Deletion cannot reset an ID counter. Unique explicit IDs and legacy `mem_0001` IDs remain usable; duplicate explicit IDs are rejected. - Mutations reload under an OS lock and replace the JSON file atomically. Failed writes roll back the local snapshot. This contract covers cooperating v0.4 processes on a local filesystem. +- A per-store thread lock prevents concurrent calls from seeing partially applied changes. Returned records include independent copies of nested metadata and history. +- Startup selects its handoff and relevant memories from one snapshot. A concurrent correction appears on the next call, rather than mixing old and replacement notes in one response. - Correction revisions reject stale edits. Superseded notes leave normal recall; old handoffs and worklogs leave startup context. Exact duplicates are skipped only within the same type and source; similar wording never silently merges a contradiction. - MCP/CLI context budgets include the returned text's labels, IDs and source references. `cl100k_base` is used when tiktoken is installed, otherwise counting is approximate. Client wrappers, tool schemas and full session usage are outside this budget. The Python store API budgets memory bodies only. -The [retrieval benchmark](eval/results.md) is a small diagnostic: 14 memories and seven queries. Its paraphrase results expose the limits of lexical matching. It is not evidence of improved coding outcomes. +The intended workload is **hundreds of memories per project**. Writes replace the whole JSON file; recall scores all stored vectors. There is no cloud sync, authorization layer or distributed-storage guarantee. Memory content may reach your coding agent's provider: treat it as fallible data, never executable instructions. + +## Verification and evidence + +[CI](https://github.com/Ninadnj/agent-memory-engine/actions/workflows/ci.yml) tests Linux, macOS and Windows, Python 3.10/3.12, both supported MCP SDK majors, packaging and clean installation. Tests cover revisions, concurrent access, rollback, legacy stores, token budgets and real MCP calls. See the [verification record](docs/verification-rc2.md) for results and scope. -The [coding evaluation](docs/evaluation.md) adds **30 executable tasks across three synthetic projects**, with six calibration tasks and 24 test tasks. It compares no memory, curated Markdown and engine recall with the same external agent. A [bundled Codex CLI adapter](docs/evaluation.md#run-with-codex-cli) provides setup checks and a direct evaluation command. [Fixture validation](eval/fixture-validation.json) verifies all 30 graders. **The live-agent comparison is deferred for v0.4.0rc1; no live-agent performance result is published.** You can install, use and test the engine without running that comparison or supplying model credentials. +- **Retrieval diagnostic:** [14 memories and seven queries](eval/results.md). Literal-query recall is 0.93; paraphrase recall is 0.43. This small benchmark exposes the offline retriever's limitations, not improved coding performance. +- **Evaluation infrastructure:** [30 executable tasks](docs/evaluation.md) across three synthetic projects. All [task graders are validated](eval/fixture-validation.json); this is not an agent-success result. +- **Still unverified in this candidate:** the optional semantic model, live coding-client sessions, and the live-agent comparison. No whole-session token-savings or coding-performance claim is made. ## Install options +Run these from the cloned repository, with its environment active: + | Installation | Contents | | --- | --- | -| `pip install -e .` | Python library and CLI; NumPy only | -| `pip install -e ".[mcp]"` | Adds the MCP server | -| `pip install -e ".[real,mcp]"` | Adds optional sentence-transformers embeddings and tiktoken; first model use can download weights | -| `pip install -e ".[dev]"` | Tests, MCP, exact tokenizer and build tools | +| `python -m pip install -e .` | Python library and CLI; NumPy only | +| `python -m pip install -e ".[mcp]"` | Adds the MCP server | +| `python -m pip install -e ".[real,mcp]"` | Adds optional sentence-transformers embeddings and tiktoken; first model use can download weights | +| `python -m pip install -e ".[dev]"` | Tests, MCP, exact tokenizer and build tools | + +New stores use offline hashing by default, even if semantic packages are installed. Set `AGENT_MEMORY_EMBEDDER=sentence-transformers` to opt into semantic retrieval, or pass an explicit embedder in Python. An unset value (or `auto`) reuses an existing v0.4 store's saved backend and model configuration. Unknown values fail with an actionable error. Configuration changes trigger re-embedding; immutable model revisions are recommended for reproducibility. -Set `AGENT_MEMORY_EMBEDDER=hashing` for offline behavior. A new store otherwise prefers the semantic backend when installed. Existing v0.4 stores retain their embedding configuration unless explicitly overridden. Configuration changes trigger re-embedding; immutable model revisions are recommended for reproducibility. +## Development and existing notes -## Scope +After installing the `dev` extra, run: -This is a local JSON + NumPy store for small project memories. It has no network server, authorization layer, cloud sync or automatic truth checker. Memory content may reach your coding agent's provider. Treat it as fallible data and verify operational claims against code. +```bash +python -m pytest -q +ruff check src tests eval scripts examples +ruff format --check src +``` -Keep maintained Markdown if a few short files already solve your problem. Use this engine when selective retrieval, cross-session handoffs and inspectable corrections justify the extra component. Vector database migrations and automatic LLM compaction are deferred until measurements justify them. +See [Contributing](CONTRIBUTING.md) for the full checks. Keep changes small and backed by regression tests; a database migration or automatic summarizer needs a measured reason. The original Markdown convention remains in [scaffold/](scaffold/). Import existing notes with `python scripts/ingest_markdown.py path/to/notes --path .agent_memory/store.json`; oversized sections are split into bounded chunks. diff --git a/docs/architecture.md b/docs/architecture.md index 26ea99c..a619c31 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,29 +1,132 @@ # Architecture -The engine keeps a small in-memory list of entries and a NumPy embedding matrix, persisted as JSON with base64 vectors. CLI, MCP and hooks share the same store implementation. There is no separate database service. +Agent Memory Engine is a local Python library with CLI, MCP and hook adapters. +Its job is to preserve useful project knowledge, retrieve a small relevant +context, and make corrections safe when several agents share the store. -## Identity and writes +## Responsibilities -Every generated identity is `mem_` plus `uuid.uuid4().hex`. The store checks current IDs as a collision guard; explicit caller IDs are preserved and duplicates rejected before mutation. IDs are never recomputed from the remaining records. +These are module boundaries inside one package, not separate services. -A mutation acquires an OS advisory lock, reloads a changed file, validates the caller's revision, computes embeddings, applies the change, then atomically replaces the JSON. Failed embedding or persistence operations restore the local entry and vector snapshots. The file stamp includes mtime, size and inode. Reads observe replacement on their next call. Deleting the backing file clears the next snapshot rather than resurrecting it. +| Role | Modules | Responsibility | +| --- | --- | --- | +| Interfaces | `cli.py`, `mcp_server.py`, `hooks.py` | Translate commands, tool calls and client events into memory operations. | +| Presentation | `rendering.py`, `diagnostics.py` | Pack complete context blocks, explain selection and inspect source references. | +| Engine | `store.py` | Own entries and vectors; coordinate writes, revisions, supersession, retrieval and transactions. | +| Records | `models.py` | Define memory records, input validation, history limits and freshness rules. No disk access or model loading. | +| Infrastructure | `persistence.py`, `_locking.py`, `embeddings.py`, `tokens.py` | Encode/decode JSON, replace files safely, lock writers, embed text and count tokens. | -The JSON contains an embedding configuration: backend, dimensions, model/revision when applicable, and normalization or feature-version information. Mismatches cause re-embedding. A floating remote model name cannot establish that downloaded weights stayed identical; use an immutable revision when that matters. +The engine never imports a client adapter. Persistence never decides which +memory should change. All clients use the same core operations. Public imports +such as `from agent_memory import MemoryStore, MemoryEntry` stay stable. -## Corrections and evidence +```mermaid +flowchart TD + Clients[CLI / MCP / hooks] --> Context[Rendering and diagnostics] + Clients --> Store[MemoryStore] + Context --> Store + Store --> Models[Records and validation] + Store --> Embeddings[Embedding backend] + Store --> Persistence[JSON snapshots] + Persistence --> Models + Persistence --> Embeddings + Store --> Locks[OS writer lock] + Persistence --> File[(Project store.json)] +``` -An entry has original creation provenance, current revision, last update provenance, optional source references, and up to 20 prior snapshots. A replacement marks the old record superseded and records the replacement ID. Source checks only compare a project-relative file against a Git commit; they do not evaluate text or execute stored instructions. +`eval/`, `tests/`, `scripts/` and `examples/` are development and demonstration +tools. They do not become runtime dependencies of the package. -Expected revisions provide optimistic concurrency control for one record. They are required at the agent-facing edit boundary and optional for legacy Python calls. This is not a multi-record transaction API or tamper-proof audit system. Python entry objects remain mutable for backward compatibility; use the mutation methods when revision tracking matters. +## A write, step by step -## Retrieval and rendering +1. Acquire the store's thread lock and validate input. +2. Acquire the OS writer lock and reload any change made by another process. +3. Check the expected revision, if supplied. Reject stale edits before mutation. +4. Compute the embedding and apply the change to the in-memory snapshot. +5. Validate and serialize the complete snapshot, flush and fsync a temporary + file, then atomically replace the JSON file. +6. Return an independent copy of the result. If a mutation or save fails, + restore the prior entries, vectors and file stamp before releasing the lock. -Recall ranks active records by embedding dot product, with age decay for state, handoff and worklog types. Durable facts and decisions do not decay automatically. Startup also imposes hard age limits on handoffs and worklogs so a low relevance floor cannot revive months-old startup instructions. +There are two locks because they protect different things. The reentrant thread +lock protects one store object, including readers and in-memory stores. The OS +lock serializes writers in separate processes using the same file. A concurrent +reader of the same object cannot see a write that later rolls back. Separate +processes read either the previous complete file or the new complete file. -The store-level budget is the sum of memory body tokens. The presentation layer packs complete rendered blocks and counts labels, IDs, dates, source references and separators. It skips an oversized block and tries the next candidate. Protocol envelopes, tool descriptions and client-added formatting remain outside that text budget. +Startup selects its handoff and recall results from one in-memory snapshot. +It refreshes once when selecting the handoff, then scores that same snapshot +without another disk reload. A concurrent correction appears on the next call, +not mixed with its retired predecessor in the current response. This does not +require blocking other processes' writers while building context. -Hashing is lexical: synonyms and paraphrases can be missed. Optional sentence-transformers provides a semantic backend, but still needs evaluation for the target workload. Similarity is never used to merge memories; a changed number or negation must not silently disappear. +The OS releases its lock when a process exits. A persistent `.guard` file names +the lock; its existence does not mean a writer is active. Windows replacement +retries cover brief file-sharing conflicts. This contract is for cooperating +v0.4 processes on a local filesystem, not network shares or mixed-version writers. -## Tradeoffs +## Identity, snapshots and corrections -Writes serialize through one lock and rewrite the whole file. Recall is a linear matrix scan. These choices keep deployment and recovery understandable for small local stores. Benchmark actual workload size and contention before introducing a vector database, distributed locks, background workers or automatic summarization. +Generated IDs use UUID4, independent of the number of remaining records. Legacy +and explicit unique IDs remain usable. `write` skips only exact text after +whitespace normalization with the same type and source; `deduplicate=False` +explicitly stores another occurrence. Similar wording cannot silently merge +a contradiction. + +Every public method returns detached records, including nested metadata, sources +and history. A local edit cannot leave the stored text and embedding out of sync. +An earlier read also retains its earlier revision: + +```python +earlier = store.get(memory_id) # revision 1 +store.update(memory_id, "Corrected fact.", expected_revision=1) +assert earlier.revision == 1 # the earlier read has not changed +# Deleting with expected_revision=earlier.revision now raises MemoryConflictError. +``` + +`update` preserves identity and records the prior revision. `supersede` creates +a replacement identity and retires the old record in the same transaction. +Normal recall excludes retired records; inspection still exposes their history. +The last 20 prior revisions are retained. `forget` removes a record and its +history from the current store; backups are separate copies. + +Revision checks are required in CLI/MCP edits and optional in Python for +compatibility. Use them for all Python edits involving multiple callers. +Source checks compare a project-relative file with a recorded Git commit; +they are evidence for review, not proof that the memory's claim is true. + +## Retrieval and context + +Recall embeds the query, scores the matrix by dot product, applies age decay to +state/handoff/worklog records, filters retired or weak matches, and returns the +best candidates. Durable decisions and facts do not decay. Startup additionally +excludes handoffs older than 14 days and worklogs older than 42 days. + +The Python store budget counts memory bodies. CLI/MCP rendering counts complete +blocks, including IDs, dates, source references and separators. If a block is +too large, it tries the next candidate instead of truncating a warning or fact. +`cl100k_base` is used when tiktoken is available; the fallback is approximate. +Tokenizer markers quoted in memories are counted as ordinary text, not control +tokens, so valid memory content cannot trip special-token validation. +Tool schemas, protocol envelopes and client wrappers are outside this text budget. + +New stores default to offline feature hashing. Semantic embeddings are explicit +and optional. Existing stores retain their recorded backend unless overridden; +a changed configuration causes re-embedding. A floating model name cannot +detect changed remote weights, so pin an immutable revision when reproducing results. + +## Why this stays small + +| Decision | Benefit | Cost / limit | +| --- | --- | --- | +| JSON + NumPy | Easy installation, inspection, backup and recovery. | Every write rewrites the file; scoring scans all vectors and ranking sorts them. | +| One concrete store | One place to understand memory behavior. | A different backend would need a measured reason and a migration. | +| Detached snapshots | Clear ownership and reliable revision checks. | Returned records and nested history must be copied. | +| Explicit semantic opt-in | Predictable offline startup and dependencies. | Hashing misses synonyms and paraphrases. | +| Thin adapters | Consistent behavior across clients. | Client-specific protocol and event handling still need tests. | + +The intended workload is hundreds of project memories. The file limit is +128 MiB; it is a validation guard, not a performance promise. Measure actual +latency and contention before adding a database, vector index, background worker +or model-driven summarizer. The [evaluation guide](evaluation.md) separates +retrieval diagnostics, grader validation and live-agent evidence. diff --git a/docs/evaluation.md b/docs/evaluation.md index db30578..d50a0c1 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -2,7 +2,7 @@ There are two separate checks. Neither establishes production performance by itself. -**v0.4.0rc1 scope:** the live-agent comparison is deferred. The retrieval diagnostic, fixture validation and automated tests run without model calls or model credentials. The live-run commands below remain available for a future evaluation; they are optional and are not part of the candidate's release checks. +**v0.4 candidate scope:** the live-agent comparison remains deferred. The retrieval diagnostic, fixture validation and automated tests run without model calls or model credentials. The live-run commands below remain available for a future evaluation; they are optional and are not part of the candidate's release checks. ## Retrieval diagnostic diff --git a/docs/migration-v0.4.md b/docs/migration-v0.4.md index b3a8dd7..43531c1 100644 --- a/docs/migration-v0.4.md +++ b/docs/migration-v0.4.md @@ -1,5 +1,27 @@ # Upgrading to v0.4 +## From rc1 to rc2 + +The on-disk format remains **3**. Existing IDs, vectors, sources and histories +are preserved; this cleanup does not require data conversion. Restart clients +after upgrading so they use the same version. + +- Python results are now **detached snapshots**. Assigning `entry.text`, changing + nested dictionaries, or calling `save()` afterward no longer edits stored + memory. Use `update` or `supersede` with the revision you read. +- `dedup_threshold` is removed. Exact deduplication remains the default; replace + `dedup_threshold=2` with `deduplicate=False`. Semantic deduplication is not + supported. `agent` is now keyword-only in `write` and `write_with_status`, so + an old positional threshold fails instead of being mistaken for an agent name. +- New stores default to hashing, even when sentence-transformers is installed. + Select `AGENT_MEMORY_EMBEDDER=sentence-transformers` for semantic retrieval. + An unset value or `auto` still restores an existing store's saved backend. + Unknown configuration values now fail instead of silently choosing a backend. +- Public imports from `agent_memory` remain available. Internal record and + persistence helpers moved into `models.py` and `persistence.py`. + +## From v0.3 or earlier + v0.4 reads existing format 1 and 2 stores and writes format 3 on the next mutation. Reading alone does not rewrite the file. All existing IDs, texts, creation dates, authors and metadata are retained. New UUID IDs are opaque: never infer sequence or ordering from them. 1. Stop **all** processes writing the shared store. v0.3 uses a different lock protocol and does not understand revisions. Mixed-version writers are unsupported. @@ -19,7 +41,7 @@ New fields default to revision 1, active status, no update timestamp, empty sour | Python save on an attached store | Rejects a stale snapshot instead of overwriting another writer | | Python export | Explicit snapshot to a different path; existing target rejected unless `overwrite=True` | | Deduplication | Whitespace-normalized, case-sensitive exact text, same type and source; explicit IDs bypass deduplication | -| `dedup_threshold` | Accepted for compatibility; values above 1 disable exact deduplication. Semantic merging is removed | +| Deduplication option | rc2 uses `deduplicate=False` to retain repeats. The old `dedup_threshold` argument is removed | | Recall and boot in MCP/CLI | Budgets count complete rendered text; fewer memories may fit than before | | Startup | Expired handoffs/worklogs are omitted; corrections refresh the effective age | diff --git a/docs/release.md b/docs/release.md index 897c361..32a04cd 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,12 +1,18 @@ # Releasing v0.4 -The package version is `0.4.0rc1`. It is a reviewable release candidate, with an explicit format/API migration. A local build does not imply a GitHub or PyPI release exists. +The current package version is `0.4.0rc2`. It remains a release candidate. +The [migration notes](migration-v0.4.md) cover format-3 stores and the rc2 +Python API changes. A local build is not a published release. ## Candidate scope -The live-agent comparison is deferred for this candidate. Release preparation uses the offline tests, fixture checks, MCP demo and package checks below; none requires model credentials or a live model session. The evaluation adapter remains available for future measurements. Do not describe the deferred comparison as passed or use the retrieval diagnostic to claim better coding outcomes or lower whole-session costs. +rc2 protects returned snapshots, serializes operations within one store object, +separates records and JSON persistence, and defaults new stores to offline +hashing. The local JSON/NumPy backend and existing file format remain unchanged. -The candidate is available for review in [PR #6](https://github.com/Ninadnj/agent-memory-engine/pull/6). Stable `0.4.0` remains subject to migration feedback and verified coding-client sessions. +The live-agent comparison remains deferred. The tests, fixture checks and MCP +demo below require no model credentials. Verified client sessions and migration +feedback are still required before a stable release. ## Reproduce the checks @@ -18,36 +24,31 @@ git diff --exit-code -- eval/results.md eval/results.json python eval/run_tasks.py --verify-fixtures python examples/handoff_demo.py python examples/quickstart.py +ruff check src tests eval scripts examples +ruff format --check src python -m build +python scripts/check_wheel.py ``` -Install the generated wheel in a clean environment and run `agent-memory doctor` against a temporary path before publishing. CI also checks Python/OS combinations and both MCP SDK majors. The optional semantic job downloads a model and is run manually; its absence must not be presented as a pass. +CI checks Linux, macOS, Windows, Python 3.10/3.12 and MCP 1.x/2.x. +The optional semantic job downloads a model and is run manually. +See the [rc2 verification record](verification-rc2.md) for actual results; +a job's presence in CI configuration alone is not evidence of a pass. -## Candidate review +## Review before publishing -- Inspect the legacy migration, stale-revision errors, file-lock protocol and rollback behavior. -- Test the documented connection in at least one actual coding client, then record application/SDK/OS versions. -- Run the external-agent evaluation with a frozen test split before making coding-performance claims. A release without those results must keep the limitation visible. -- Check that no project memory files, secrets, private logs or local backup files entered the release. - -## Prerelease notes - -Use the following scope when preparing the GitHub prerelease, with artifacts built from the reviewed tag: - -**Agent Memory Engine v0.4.0rc1** adds durable local memory identity and traceable corrections for coding agents. UUID4 IDs prevent deletion from resetting the generated ID sequence; legacy and unique caller-supplied IDs stay usable. Revision checks reject stale edits, while bounded history and explicit supersession make changed decisions inspectable. Shared writes use OS locks, atomic replacement and rollback. The storage backend remains local JSON and NumPy. - -The candidate also includes a five-minute MCP demo, CLI diagnostics, migration guidance and an optional coding-evaluation harness. The demo uses two real MCP server processes and makes no model calls. - -**Validation:** the full hashing suite passed with **297 passed, 0 failed and 1 skipped** on Linux Python 3.10/3.12, Windows Python 3.12 and macOS Python 3.12. Both MCP SDK majors, package build and fresh installation passed. All 30 task graders reject the known broken implementation and accept the reference fix. The [verification record](verification-codex-adapter.md) links the completed CI and distinguishes these checks from agent performance. - -**Upgrade:** stop all shared writers and save a byte-for-byte backup of the original store before upgrading every client. v0.4 reads formats 1/2 and writes format 3; mixed old/new writers are unsupported. MCP/CLI edits now require the inspected revision. For rollback, stop writers and restore the original backup with its matching old software; a v0.4 export is not a downgrade converter. Read the full [migration notes](migration-v0.4.md). - -**Limitations:** the live-agent comparison is deferred, actual coding-client MCP use remains unverified, and the optional semantic-model check was skipped. This prerelease makes no claim of improved agent coding performance or whole-session token savings. Its concurrency contract covers cooperating v0.4 processes on a local filesystem. +- Check snapshot ownership, revision conflicts, rollback and both locking scopes. +- Read the Python compatibility changes; format 3 itself needs no conversion. +- Verify the documented connection in an actual coding client and record versions. +- Keep retrieval diagnostics distinct from real agent performance evidence. ## Publish -After review and successful required CI, use the reviewed commit for the tag and release. Build wheel and sdist from that tag, attach them and the changelog to a GitHub prerelease, and label it `v0.4.0rc1`. Publish to PyPI only with the project's configured publisher credentials/trusted publisher; no credentials are embedded here. Keep the original-store backup and rollback instructions in the release notes. - -A stable `0.4.0` release should follow migration feedback and verified client sessions. Do not silently relabel an existing candidate artifact; build again with the stable version from its reviewed commit. +Build wheel and sdist from the reviewed commit after required CI passes. +Tag that commit `v0.4.0rc2` and attach the artifacts and changelog to a GitHub +prerelease. Publish to PyPI only through the project's configured publisher. +Do not silently relabel candidate artifacts as stable builds. -The implementation record and exact local results are in [verification](verification-v0.4.md). +The earlier candidate's evidence remains in the +[v0.4 verification](verification-v0.4.md) and +[adapter verification](verification-codex-adapter.md) records. diff --git a/docs/verification-rc2.md b/docs/verification-rc2.md new file mode 100644 index 0000000..6a43088 --- /dev/null +++ b/docs/verification-rc2.md @@ -0,0 +1,64 @@ +# v0.4.0rc2 verification + +Review started from `6ffb103` (`v0.4.0rc1`). The implementation keeps format 3 +and adds no runtime dependencies. + +## Regressions demonstrated before implementation + +The existing local suite passed: **278 passed, 3 skipped**, without the optional +MCP package or semantic backend. + +The first API regression selection then produced **19 failures and 2 passes**. +It covered mutation through every entry-returning API, nested metadata/history, +stale snapshots acquiring newer revisions, explicit deduplication, deterministic +offline defaults and invalid backend configuration. + +Two further tests demonstrated concurrent `get` and `recall` observing a change +while its save was pending, even though that save subsequently failed. Both +failed before the per-store thread lock was added. They now verify that readers +wait and receive the original, successfully stored state after rollback. + +Historical test fixtures now create entries with a controlled write clock. +They exercise the real write path instead of relying on mutable returned objects. +The original freshness, conflict and persistence assertions are retained. + +The pre-merge review added ten regression cases: **9 failed and 1 passed** before +the fixes. They cover another store superseding a handoff between startup +selection and recall, literal tokenizer markers, persisted-memory budgets, and +an MCP roundtrip. All ten now pass. The startup cases also verify that the next +call observes the correction rather than retaining an indefinitely stale snapshot. + +## Local results (including the September 16 pre-merge fixes) + +macOS, Python 3.12.1. The development environment uses NumPy 2.5.3, +pytest 9.1.1, MCP 2.2.0 and tiktoken 0.14.0. + +| Check | Result | +| --- | --- | +| Full development suite | 330 passed; only the optional semantic-model module skipped | +| Minimal environment, NumPy 1.26.3, approximate token counting | 304 passed, 9 optional checks skipped (MCP, semantic model and exact-tokenizer cases) | +| Retrieval diagnostic with exact token counting | JSON and Markdown results match the committed baseline byte-for-byte | +| Executable task fixtures | All 30 broken implementations rejected and all 30 reference fixes accepted | +| MCP 2.2.0 demo | Two real stdio server processes share a store, reject a stale deletion and recall a replacement decision | +| MCP 1.30.0 compatibility | 89 focused API, MCP, task-runner and token tests passed, including the two-process demo | +| Python quickstart | Passed | +| Lint and source formatting | Passed using the repository's Ruff configuration | +| Wheel and source distribution | Built successfully as 0.4.0rc2 | +| Fresh wheel installation outside the checkout | Import, revision/reopen, MCP construction and CLI doctor passed | + +The [release guide](release.md) contains the commands. The current platform CI +results are available in the pull request's checks; these local results describe +macOS only. + +## Scope + +The hashing retriever's measured relevance did not change. This work improves +state integrity, concurrency, maintainability and configuration predictability; +it does not establish improved LLM coding outcomes. The semantic model and actual +coding-client sessions remain unverified in this run. The 30 fixture checks +validate graders, not agent success rates. + +The file-size limit remains a guard rather than a scale benchmark. File locks +cover cooperating processes on a local filesystem; thread locks cover concurrent +public operations on a single store instance. No network-share or distributed +storage guarantee is added. diff --git a/eval/codex_adapter.py b/eval/codex_adapter.py index e14d530..b290498 100644 --- a/eval/codex_adapter.py +++ b/eval/codex_adapter.py @@ -9,11 +9,11 @@ import argparse import hashlib import json -from pathlib import Path import shutil import subprocess import sys import tempfile +from pathlib import Path REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh") REQUIRED_FLAGS = ( diff --git a/eval/run_eval.py b/eval/run_eval.py index 5390957..1bbc40e 100644 --- a/eval/run_eval.py +++ b/eval/run_eval.py @@ -446,6 +446,7 @@ def main() -> None: # silently overwrite it with numbers CI can never reproduce. stem = "results" if args.embedder == "hashing" else "results_sentence_transformers" md = render_markdown(results) + args.out_dir.mkdir(parents=True, exist_ok=True) (args.out_dir / f"{stem}.md").write_text(md + "\n") (args.out_dir / f"{stem}.json").write_text(json.dumps(results, indent=2) + "\n") print(md) diff --git a/eval/run_tasks.py b/eval/run_tasks.py index c04975d..caa29fa 100644 --- a/eval/run_tasks.py +++ b/eval/run_tasks.py @@ -7,27 +7,28 @@ from __future__ import annotations import argparse -from dataclasses import asdict import hashlib import json import math import os -from pathlib import Path import random import signal import subprocess import sys import tempfile import time +from dataclasses import asdict +from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) +from task_cases import TASKS, Task, project_source + from agent_memory import HashingEmbedder, MemoryStore, count_tokens, default_min_score from agent_memory.embeddings import embedding_config from agent_memory.rendering import recall_context from agent_memory.tokens import using_exact_tokenizer -from task_cases import TASKS, Task, project_source ARMS = ("no_memory", "curated_markdown", "engine") diff --git a/examples/handoff_demo.py b/examples/handoff_demo.py index 11afd51..fd56e15 100644 --- a/examples/handoff_demo.py +++ b/examples/handoff_demo.py @@ -7,12 +7,12 @@ """ import asyncio -from contextlib import AsyncExitStack import json import os -from pathlib import Path import sys import tempfile +from contextlib import AsyncExitStack +from pathlib import Path from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client diff --git a/pyproject.toml b/pyproject.toml index 8ee3663..753de5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agent-memory-engine" -version = "0.4.0rc1" +version = "0.4.0rc2" description = "Local project memory for coding agents, with traceable corrections, bounded recall and MCP." readme = "README.md" requires-python = ">=3.10" @@ -37,3 +37,16 @@ packages = ["src/agent_memory"] [tool.pytest.ini_options] pythonpath = ["src", "eval"] testpaths = ["tests"] + +[tool.ruff] +target-version = "py310" + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] + +[tool.ruff.lint.isort] +combine-as-imports = true + +[tool.ruff.lint.per-file-ignores] +# This executable runner adds src/ to sys.path before importing the package. +"eval/run_tasks.py" = ["E402"] diff --git a/scripts/check_wheel.py b/scripts/check_wheel.py index 3d8fc0b..be507ad 100644 --- a/scripts/check_wheel.py +++ b/scripts/check_wheel.py @@ -1,10 +1,10 @@ """Build-independent wheel installation smoke test; downloads wheel dependencies.""" -from pathlib import Path import os import subprocess import tempfile import venv +from pathlib import Path def main(): diff --git a/src/agent_memory/__init__.py b/src/agent_memory/__init__.py index 362f5b8..f3930a7 100644 --- a/src/agent_memory/__init__.py +++ b/src/agent_memory/__init__.py @@ -7,24 +7,25 @@ default_embedder, default_min_score, ) -from .store import ( - GLOBAL_STORE, +from .models import ( HALF_LIFE_DAYS, MEMORY_TYPES, - STORE_FORMAT, - MemoryEntry, MemoryConflictError, - StoreFormatError, - MemoryStore, + MemoryEntry, RecallHit, age_in_days, decay_factor, +) +from .persistence import STORE_FORMAT, StoreFormatError +from .store import ( + GLOBAL_STORE, + MemoryStore, default_store_path, find_project_root, ) from .tokens import count_tokens -__version__ = "0.4.0rc1" +__version__ = "0.4.0rc2" __all__ = [ "MemoryStore", diff --git a/src/agent_memory/_locking.py b/src/agent_memory/_locking.py index 7a78968..129b34f 100644 --- a/src/agent_memory/_locking.py +++ b/src/agent_memory/_locking.py @@ -1,9 +1,9 @@ """OS-owned local file locks. Process exit releases the lock automatically.""" -from contextlib import contextmanager import os -from pathlib import Path import time +from contextlib import contextmanager +from pathlib import Path def _replace_file(source: Path, target: Path, timeout: float = 1.0) -> None: diff --git a/src/agent_memory/cli.py b/src/agent_memory/cli.py index 300f549..6c55937 100644 --- a/src/agent_memory/cli.py +++ b/src/agent_memory/cli.py @@ -23,9 +23,9 @@ import sys from pathlib import Path -from .embeddings import default_min_score -from .rendering import boot_context, recall_context, empty_message from .diagnostics import doctor, explain_recall, inspect_memory +from .embeddings import default_min_score +from .rendering import boot_context, empty_message, recall_context, tag from .store import ( GLOBAL_STORE, MEMORY_TYPES, @@ -64,10 +64,6 @@ def _min_score(args, store: MemoryStore) -> float: return default_min_score(store.embedder) -def _tag(entry) -> str: - return f"{entry.type} · {entry.agent}" if entry.agent else entry.type - - def cmd_write(args) -> None: entry, stored = _store(args).write_with_status( args.text, type=args.type, agent=args.agent, source=_source(args) @@ -146,7 +142,7 @@ def cmd_list(args) -> None: # correct or forget. faded = decay_factor(e) note = f"{age:.0f}d" + (f", faded to {faded:.0%}" if faded < 0.95 else "") - print(f"{e.id} [{_tag(e)} · r{e.revision} · {e.status} · {note}] {e.text}") + print(f"{e.id} [{tag(e)} · r{e.revision} · {e.status} · {note}] {e.text}") def cmd_update(args) -> None: @@ -209,7 +205,7 @@ def cmd_install_hooks(args) -> None: from . import hooks root = Path.home() if args.user else (find_project_root() or Path.cwd()) - settings = root / ".claude" / ("settings.json" if args.user else "settings.json") + settings = root / ".claude" / "settings.json" if args.uninstall: hooks.uninstall(settings) @@ -225,7 +221,7 @@ def cmd_install_hooks(args) -> None: if not args.with_prompt_recall: print( "Add --with-prompt-recall to also surface memories relevant to each " - "prompt (costs a model load per message)." + "prompt (the semantic backend loads its model per message)." ) diff --git a/src/agent_memory/diagnostics.py b/src/agent_memory/diagnostics.py index ddf0bc1..44ae57b 100644 --- a/src/agent_memory/diagnostics.py +++ b/src/agent_memory/diagnostics.py @@ -1,14 +1,15 @@ """Local diagnostics and evidence inspection; never evaluates memory text.""" -from dataclasses import asdict import os -from pathlib import Path import re import subprocess +from dataclasses import asdict +from pathlib import Path from .embeddings import embedding_config +from .models import _validate_limits, startup_fresh from .rendering import render_entry -from .store import MemoryStore, _validate_limits, find_project_root, startup_fresh +from .store import MemoryStore, find_project_root from .tokens import count_tokens, using_exact_tokenizer @@ -96,7 +97,7 @@ def explain_recall( store: MemoryStore, query: str, *, k=5, budget=300, min_score=0.0, decay=True ) -> dict: _validate_limits(k, budget, min_score) - hits = store.recall(query, k=max(1, len(store.all())), min_score=-1, decay=decay) + hits = store.recall(query, k=max(1, len(store)), min_score=-1, decay=decay) rows, parts = [], [] for hit in hits: block = render_entry(hit.entry) diff --git a/src/agent_memory/embeddings.py b/src/agent_memory/embeddings.py index 2669869..b67c092 100644 --- a/src/agent_memory/embeddings.py +++ b/src/agent_memory/embeddings.py @@ -11,8 +11,8 @@ ``sentence-transformers`` (optional dependency). Drops in unchanged and improves recall on paraphrases/synonyms. -Select one with ``default_embedder()``, which prefers the real model when it -is installed and not disabled via ``AGENT_MEMORY_EMBEDDER=hashing``. +New stores use hashing unless ``AGENT_MEMORY_EMBEDDER=sentence-transformers`` +is explicitly selected. Installing optional packages does not change defaults. """ from __future__ import annotations @@ -20,7 +20,6 @@ import hashlib import os import re -import sys from typing import Protocol, runtime_checkable import numpy as np @@ -51,6 +50,14 @@ def embed(self, texts: list[str]) -> np.ndarray: # (n, dim) float32 ... +def validated_embed(embedder: Embedder, texts: list[str]) -> np.ndarray: + """Reject invalid backend output before it can reach live or persisted state.""" + vectors = np.asarray(embedder.embed(texts), dtype=np.float32) + if vectors.shape != (len(texts), embedder.dim) or not np.isfinite(vectors).all(): + raise ValueError("embedder returned invalid vectors") + return vectors + + def _features(text: str) -> list[str]: """Word unigrams + bigrams + 3-char n-grams. @@ -196,30 +203,23 @@ def embedding_config(embedder: Embedder) -> dict: def default_embedder() -> Embedder: - """Prefer the real model when available; fall back to hashing. - - ``AGENT_MEMORY_EMBEDDER`` selects explicitly: ``hashing`` forces the offline - embedder (CI does this so results are byte-stable), ``sentence-transformers`` - demands the real one and raises if it cannot be loaded. The default is - ``auto``, which tries the real model and warns — loudly, on stderr — before - falling back, because the two produce incompatible vectors and a silent - switch is how a store ends up half-embedded by each. + """Offline by default; semantic retrieval is an explicit choice. + + MemoryStore resolves ``auto`` from a saved store's configuration first. + For new stores (or standalone use here), ``auto`` means hashing. """ - choice = os.environ.get("AGENT_MEMORY_EMBEDDER", "auto").lower() - if choice == "hashing": + choice = os.environ.get("AGENT_MEMORY_EMBEDDER", "auto").strip().lower() + if choice in {"auto", "hashing"}: return HashingEmbedder() + if choice not in {"sentence-transformers", "sentence_transformers", "real"}: + raise ValueError( + f"unknown AGENT_MEMORY_EMBEDDER={choice!r}; " + "use hashing, sentence-transformers or auto" + ) try: return SentenceTransformerEmbedder() except Exception as exc: - if choice in {"sentence-transformers", "sentence_transformers", "real"}: - raise RuntimeError( - f"AGENT_MEMORY_EMBEDDER={choice} but sentence-transformers could " - f'not be loaded: {exc}. Install it with: pip install "agent-memory-engine[real]"' - ) from exc - print( - f"[agent-memory] sentence-transformers unavailable ({exc.__class__.__name__}); " - "using the offline HashingEmbedder. Set AGENT_MEMORY_EMBEDDER=hashing to " - "silence this.", - file=sys.stderr, - ) - return HashingEmbedder() + raise RuntimeError( + f"AGENT_MEMORY_EMBEDDER={choice} but sentence-transformers could " + f'not be loaded: {exc}. Install it with: pip install "agent-memory-engine[real]"' + ) from exc diff --git a/src/agent_memory/hooks.py b/src/agent_memory/hooks.py index 4f841c9..87a73ff 100644 --- a/src/agent_memory/hooks.py +++ b/src/agent_memory/hooks.py @@ -24,8 +24,8 @@ from __future__ import annotations -import json import hashlib +import json import os import shlex import subprocess diff --git a/src/agent_memory/mcp_server.py b/src/agent_memory/mcp_server.py index 5e15bf1..ac389eb 100644 --- a/src/agent_memory/mcp_server.py +++ b/src/agent_memory/mcp_server.py @@ -18,15 +18,15 @@ from __future__ import annotations import json -from functools import wraps import os import sys +from functools import wraps from pathlib import Path from typing import Optional -from .embeddings import default_min_score from .diagnostics import inspect_memory -from .rendering import boot_context, recall_context, empty_message +from .embeddings import default_min_score +from .rendering import boot_context, empty_message, recall_context, tag from .store import MEMORY_TYPES, MemoryStore, default_store_path, relocation_notice # Who is talking to the store — "claude-code", "codex", "cursor", ... @@ -89,10 +89,6 @@ def _load_server_class(): """ -def _tag(entry) -> str: - return f"{entry.type} · {entry.agent}" if entry.agent else entry.type - - def build_server( store_path: Optional[Path] = None, agent: str = DEFAULT_AGENT, @@ -255,7 +251,7 @@ def memory_list(type: str = "", limit: int = 20) -> str: return "No memories stored." shown = entries[:limit] lines = [ - f"- {e.id} [{_tag(e)}; r{e.revision}; {e.status}] {e.text}" for e in shown + f"- {e.id} [{tag(e)}; r{e.revision}; {e.status}] {e.text}" for e in shown ] if len(entries) > len(shown): lines.append(f"... and {len(entries) - len(shown)} more.") diff --git a/src/agent_memory/models.py b/src/agent_memory/models.py new file mode 100644 index 0000000..352645a --- /dev/null +++ b/src/agent_memory/models.py @@ -0,0 +1,199 @@ +"""Memory records, validation and freshness rules; no filesystem or model loading.""" + +from __future__ import annotations + +import json +import math +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional + +from .tokens import count_tokens + +# Memory categories mirror the original Markdown scaffold (PROJECT, DECISIONS, +# KNOWN_ISSUES, STATE, HANDOFF, WORKLOG) so migration is one-to-one. +MEMORY_TYPES = { + "project", + "decision", + "issue", + "state", + "handoff", + "worklog", + "fact", +} + +MAX_TEXT_CHARS = 20_000 +MAX_METADATA_BYTES = 16_384 +MAX_HISTORY = 20 + + +class MemoryConflictError(ValueError): + """The caller's revision or store snapshot is no longer current.""" + + +def _validate_text(text: str) -> None: + if not isinstance(text, str) or not text.strip() or len(text) > MAX_TEXT_CHARS: + raise ValueError(f"memory text must contain 1..{MAX_TEXT_CHARS} characters") + + +def _validate_mapping(value: dict, name: str, *, limit: bool = True) -> None: + if not isinstance(value, dict) or any(not isinstance(key, str) for key in value): + raise ValueError(f"{name} must be an object with string keys") + try: + encoded = json.dumps(value, allow_nan=False).encode("utf-8") + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must contain finite JSON values") from exc + if limit and len(encoded) > MAX_METADATA_BYTES: + raise ValueError(f"{name} exceeds {MAX_METADATA_BYTES} bytes") + + +def _validate_limits(k: int, budget: Optional[int], min_score: float) -> None: + if isinstance(k, bool) or not isinstance(k, int) or k < 0: + raise ValueError("k must be a nonnegative integer") + if budget is not None and ( + isinstance(budget, bool) or not isinstance(budget, int) or budget < 0 + ): + raise ValueError("budget_tokens must be a nonnegative integer or None") + if ( + not isinstance(min_score, (int, float)) + or not math.isfinite(min_score) + or not -1 <= min_score <= 1 + ): + raise ValueError("min_score must be finite and between -1 and 1") + + +# How fast a memory's relevance fades, in days, per type. A memory's similarity +# score is multiplied by 0.5 ** (age / half_life), so an entry at its half-life +# needs to be twice as good a match to rank where it did when fresh. +# +# Not everything should fade. "Bookings are stored in UTC" is as true in a year +# as it was on the day it was written, and decaying it would quietly lose the +# facts most worth keeping. What goes stale is the record of a moment: +# "currently implementing X" is usually false a fortnight later, and recalling +# it with full confidence actively misleads. Correct a decision with +# memory_update; let a status note fade on its own. +HALF_LIFE_DAYS: dict[str, Optional[float]] = { + "state": 7.0, # "currently working on…" — stale fastest + "handoff": 7.0, # next steps are usually done or abandoned by then + "worklog": 21.0, # what happened still orients, but fades + "decision": None, # durable until explicitly superseded + "project": None, + "issue": None, # true until someone fixes it; forget it then + "fact": None, +} + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +@dataclass +class MemoryEntry: + id: str + type: str + text: str + metadata: dict = field(default_factory=dict) + created_at: str = field(default_factory=_now_iso) + # Which agent wrote this (e.g. "claude-code", "codex", "cursor"). Lets one + # store be shared between agents while keeping provenance visible. + agent: str = "" + updated_at: Optional[str] = None + updated_by: str = "" + revision: int = 1 + status: str = "active" + superseded_by: Optional[str] = None + source: dict = field(default_factory=dict) + history: list[dict] = field(default_factory=list) + + @property + def tokens(self) -> int: + return count_tokens(self.text) + + +@dataclass +class RecallHit: + entry: MemoryEntry + score: float + + +def _entry_from_raw(raw: dict, *, history: bool = True) -> MemoryEntry: + if not isinstance(raw, dict): + raise ValueError("each memory must be an object") + known = MemoryEntry.__dataclass_fields__ + entry = MemoryEntry(**{key: value for key, value in raw.items() if key in known}) + # New writes are bounded at the API boundary. Existing records may predate + # those limits (including empty text); retain them so they can be corrected + # or deleted without an unrelated migration changing their identity/data. + if not isinstance(entry.text, str): + raise ValueError("stored memory text must be a string") + if entry.type not in MEMORY_TYPES: + raise ValueError(f"unknown memory type {entry.type!r}") + if not isinstance(entry.id, str): + raise ValueError("invalid memory id") + for name in ("created_at", "agent", "updated_by"): + if not isinstance(getattr(entry, name), str): + raise ValueError(f"{name} must be a string") + if entry.updated_at is not None and not isinstance(entry.updated_at, str): + raise ValueError("updated_at must be a string or null") + if ( + isinstance(entry.revision, bool) + or not isinstance(entry.revision, int) + or entry.revision < 1 + ): + raise ValueError("revision must be a positive integer") + if entry.status not in ("active", "superseded"): + raise ValueError("invalid memory status") + if entry.superseded_by is not None and not isinstance(entry.superseded_by, str): + raise ValueError("superseded_by must be an id or null") + if entry.status == "superseded" and entry.superseded_by is None: + raise ValueError("superseded memory must name its replacement") + _validate_mapping(entry.metadata, "metadata", limit=False) + _validate_mapping(entry.source, "source", limit=False) + if not isinstance(entry.history, list) or len(entry.history) > MAX_HISTORY: + raise ValueError(f"history must contain at most {MAX_HISTORY} revisions") + if history: + for snapshot in entry.history: + if not isinstance(snapshot, dict) or "history" in snapshot: + raise ValueError("invalid revision snapshot") + previous = _entry_from_raw(snapshot, history=False) + if previous.id != entry.id or previous.revision >= entry.revision: + raise ValueError("invalid revision history identity or order") + return entry + + +def age_in_days(entry: MemoryEntry, now: Optional[datetime] = None) -> float: + """How old a memory is. 0.0 when the timestamp is unreadable or in the future.""" + try: + written = datetime.fromisoformat(entry.updated_at or entry.created_at) + except (TypeError, ValueError): + return 0.0 # an unparseable timestamp must not silently bury the memory + if written.tzinfo is None: + written = written.replace(tzinfo=timezone.utc) + delta = (now or datetime.now(timezone.utc)) - written + return max(0.0, delta.total_seconds() / 86400.0) # clock skew must not boost + + +def decay_factor(entry: MemoryEntry, now: Optional[datetime] = None) -> float: + """Multiplier applied to a memory's similarity score, in (0, 1].""" + half_life = HALF_LIFE_DAYS.get(entry.type) + if not half_life: + return 1.0 + return float(0.5 ** (age_in_days(entry, now) / half_life)) + + +def startup_fresh(entry: MemoryEntry) -> bool: + """Startup notes expire after two half-lives (handoff 14d, worklog 42d). + + Ordinary recall still supports explicit inspection of aged content. + Unparseable startup timestamps are omitted rather than treated as current. + """ + if entry.status != "active": + return False + half_life = HALF_LIFE_DAYS.get(entry.type) + if half_life is None: + return True + try: + datetime.fromisoformat(entry.updated_at or entry.created_at) + except (TypeError, ValueError): + return False + return age_in_days(entry) <= 2 * half_life diff --git a/src/agent_memory/persistence.py b/src/agent_memory/persistence.py new file mode 100644 index 0000000..c47c842 --- /dev/null +++ b/src/agent_memory/persistence.py @@ -0,0 +1,164 @@ +"""The format-3 JSON boundary: validated reads and atomic snapshot replacement. + +The store owns transaction locks and rollback. These functions know only how +to read and write a snapshot; they never decide which memories to change. +""" + +from __future__ import annotations + +import base64 +import json +import os +import tempfile +from dataclasses import asdict +from pathlib import Path + +import numpy as np + +from ._locking import _replace_file +from .embeddings import Embedder, embedding_config, validated_embed +from .models import MemoryEntry, _entry_from_raw + +STORE_FORMAT = 3 +MAX_STORE_BYTES = 128 * 1024 * 1024 + + +class StoreFormatError(ValueError): + """An unreadable or invalid store was left untouched.""" + + +def file_stamp(path: Path) -> tuple[int, int, int] | None: + try: + stat = path.stat() + except FileNotFoundError: + return None + return stat.st_mtime_ns, stat.st_size, stat.st_ino + + +def read_payload(path: Path) -> dict: + """Read supported JSON without repairing or rewriting the source file.""" + try: + if path.stat().st_size > MAX_STORE_BYTES: + raise ValueError("store exceeds the supported local file size") + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict) or not isinstance( + payload.get("entries"), list + ): + raise ValueError("store must contain an entries array") + version = payload.get("format", 1) + if ( + isinstance(version, bool) + or not isinstance(version, int) + or not 1 <= version <= STORE_FORMAT + ): + raise ValueError(f"unsupported store format {version!r}") + return payload + except (ValueError, TypeError, UnicodeError) as exc: + raise StoreFormatError( + f"cannot load {path}: {exc}; original file left untouched" + ) from exc + + +def _encode_vector(vector: np.ndarray) -> str: + """Float16 + base64 keeps vectors compact inside an inspectable JSON store.""" + return base64.b64encode(np.asarray(vector, dtype=np.float16).tobytes()).decode( + "ascii" + ) + + +def _decode_vector(raw: str | list[float]) -> np.ndarray: + if isinstance(raw, str): + vector = np.frombuffer( + base64.b64decode(raw, validate=True), dtype=np.float16 + ).astype(np.float32) + else: # Format 1 stored plain JSON float lists. + vector = np.asarray(raw, dtype=np.float32) + norm = float(np.linalg.norm(vector)) + return vector / norm if norm else vector + + +def read_snapshot( + path: Path, embedder: Embedder +) -> tuple[list[MemoryEntry], np.ndarray, tuple[int, int, int] | None]: + # A concurrent replacement during the read must trigger another reload. + stamp = file_stamp(path) + payload = read_payload(path) + try: + reembed = ( + payload.get("dim") != embedder.dim + or payload.get("embedder") != type(embedder).__name__ + or payload.get("embedding_config") != embedding_config(embedder) + ) + entries, vectors, seen = [], [], set() + for raw in payload["entries"]: + entry = _entry_from_raw(raw) + if entry.id in seen: + raise ValueError(f"duplicate stored memory id {entry.id!r}") + seen.add(entry.id) + entries.append(entry) + embedding = raw.get("embedding") + # Validate old vectors even when changing the embedding backend. + vector = None if embedding is None else _decode_vector(embedding) + if vector is not None and ( + vector.ndim != 1 + or not np.isfinite(vector).all() + or len(vector) != payload.get("dim") + ): + raise ValueError(f"invalid embedding for {entry.id}") + vectors.append(None if reembed else vector) + missing = [i for i, vector in enumerate(vectors) if vector is None] + if missing: + fresh = validated_embed(embedder, [entries[i].text for i in missing]) + for slot, i in enumerate(missing): + vectors[i] = fresh[slot] + matrix = ( + np.array(vectors, dtype=np.float32) + if vectors + else np.zeros((0, embedder.dim), dtype=np.float32) + ) + except (ValueError, TypeError, KeyError, UnicodeError) as exc: + raise StoreFormatError( + f"cannot load {path}: {exc}; original file left untouched" + ) from exc + return entries, matrix, stamp + + +def write_snapshot( + path: Path, entries: list[MemoryEntry], matrix: np.ndarray, embedder: Embedder +) -> None: + """Write under the caller's OS lock; never truncate the existing file.""" + path.parent.mkdir(parents=True, exist_ok=True) + records = [] + for i, entry in enumerate(entries): + raw = asdict(entry) + _entry_from_raw(raw) + raw["embedding"] = _encode_vector(matrix[i]) + records.append(raw) + payload = { + "format": STORE_FORMAT, + "embedder": type(embedder).__name__, + "embedding_config": embedding_config(embedder), + "dim": embedder.dim, + "entries": records, + } + content = json.dumps(payload, indent=2, ensure_ascii=False, allow_nan=False) + if len(content.encode("utf-8")) > MAX_STORE_BYTES: + raise ValueError("store exceeds the supported local file size") + temporary = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=path.name + ".", + suffix=".tmp", + delete=False, + ) as stream: + temporary = Path(stream.name) + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + _replace_file(temporary, path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) diff --git a/src/agent_memory/rendering.py b/src/agent_memory/rendering.py index 32afc09..e8e5f49 100644 --- a/src/agent_memory/rendering.py +++ b/src/agent_memory/rendering.py @@ -4,7 +4,8 @@ approximation. It excludes protocol/tool schemas and client-added wrappers. """ -from .store import MemoryStore, _validate_limits +from .models import _validate_limits +from .store import MemoryStore from .tokens import count_tokens @@ -56,9 +57,7 @@ def recall_context( _validate_limits(k, budget, min_score) if k == 0: return "" - hits = store.recall( - query, k=max(1, len(store.all())), min_score=min_score, decay=decay - ) + hits = store.recall(query, k=max(1, len(store)), min_score=min_score, decay=decay) return pack_blocks( (render_entry(hit.entry, identity=identity) for hit in hits), budget, limit=k ) @@ -66,7 +65,7 @@ def recall_context( def boot_context(store: MemoryStore, task: str, *, budget=300, min_score=0.0) -> str: handoff, hits = store.boot( - task, k=max(1, len(store.all())), budget_tokens=None, min_score=min_score + task, k=max(1, len(store)), budget_tokens=None, min_score=min_score ) blocks = [] if handoff: diff --git a/src/agent_memory/store.py b/src/agent_memory/store.py index fc74343..4f585f7 100644 --- a/src/agent_memory/store.py +++ b/src/agent_memory/store.py @@ -13,109 +13,51 @@ from __future__ import annotations -import base64 -from copy import deepcopy import json import os -import tempfile import uuid from contextlib import contextmanager, nullcontext -from dataclasses import asdict, dataclass, field -from datetime import datetime, timezone +from copy import deepcopy +from dataclasses import asdict +from functools import wraps from pathlib import Path +from threading import RLock from typing import Optional import numpy as np +from ._locking import _file_lock from .embeddings import ( Embedder, HashingEmbedder, SentenceTransformerEmbedder, default_embedder, - embedding_config, + validated_embed, +) +from .models import ( + HALF_LIFE_DAYS as HALF_LIFE_DAYS, + MAX_HISTORY, + MAX_TEXT_CHARS, + MEMORY_TYPES, + MemoryConflictError, + MemoryEntry, + RecallHit, + _now_iso, + _validate_limits, + _validate_mapping, + _validate_text, + age_in_days as age_in_days, + decay_factor, + startup_fresh, +) +from .persistence import ( + STORE_FORMAT as STORE_FORMAT, + StoreFormatError, + file_stamp, + read_payload, + read_snapshot, + write_snapshot, ) -from ._locking import _file_lock, _replace_file -from .tokens import count_tokens - -# Memory categories mirror the original Markdown scaffold (PROJECT, DECISIONS, -# KNOWN_ISSUES, STATE, HANDOFF, WORKLOG) so migration is one-to-one. -MEMORY_TYPES = { - "project", - "decision", - "issue", - "state", - "handoff", - "worklog", - "fact", -} - -# Bumped when the on-disk layout changes. v2 stores embeddings as base64 -# float16 instead of JSON float lists (~5x smaller, same ranking). -STORE_FORMAT = 3 -MAX_TEXT_CHARS = 20_000 -MAX_METADATA_BYTES = 16_384 -MAX_HISTORY = 20 -MAX_STORE_BYTES = 128 * 1024 * 1024 - - -class MemoryConflictError(ValueError): - """The caller's revision or store snapshot is no longer current.""" - - -class StoreFormatError(ValueError): - """An unreadable or invalid store was left untouched.""" - - -def _validate_text(text: str) -> None: - if not isinstance(text, str) or not text.strip() or len(text) > MAX_TEXT_CHARS: - raise ValueError(f"memory text must contain 1..{MAX_TEXT_CHARS} characters") - - -def _validate_mapping(value: dict, name: str, *, limit: bool = True) -> None: - if not isinstance(value, dict) or any(not isinstance(key, str) for key in value): - raise ValueError(f"{name} must be an object with string keys") - try: - encoded = json.dumps(value, allow_nan=False).encode("utf-8") - except (TypeError, ValueError) as exc: - raise ValueError(f"{name} must contain finite JSON values") from exc - if limit and len(encoded) > MAX_METADATA_BYTES: - raise ValueError(f"{name} exceeds {MAX_METADATA_BYTES} bytes") - - -def _validate_limits(k: int, budget: Optional[int], min_score: float) -> None: - if isinstance(k, bool) or not isinstance(k, int) or k < 0: - raise ValueError("k must be a nonnegative integer") - if budget is not None and ( - isinstance(budget, bool) or not isinstance(budget, int) or budget < 0 - ): - raise ValueError("budget_tokens must be a nonnegative integer or None") - if ( - not isinstance(min_score, (int, float)) - or not np.isfinite(min_score) - or not -1 <= min_score <= 1 - ): - raise ValueError("min_score must be finite and between -1 and 1") - - -# How fast a memory's relevance fades, in days, per type. A memory's similarity -# score is multiplied by 0.5 ** (age / half_life), so an entry at its half-life -# needs to be twice as good a match to rank where it did when fresh. -# -# Not everything should fade. "Bookings are stored in UTC" is as true in a year -# as it was on the day it was written, and decaying it would quietly lose the -# facts most worth keeping. What goes stale is the record of a moment: -# "currently implementing X" is usually false a fortnight later, and recalling -# it with full confidence actively misleads. Correct a decision with -# memory_update; let a status note fade on its own. -HALF_LIFE_DAYS: dict[str, Optional[float]] = { - "state": 7.0, # "currently working on…" — stale fastest - "handoff": 7.0, # next steps are usually done or abandoned by then - "worklog": 21.0, # what happened still orients, but fades - "decision": None, # durable until explicitly superseded - "project": None, - "issue": None, # true until someone fixes it; forget it then - "fact": None, -} # Where memories live when nothing is configured. One store per project, not one # store for everything you have ever worked on: recall matches on similarity @@ -126,10 +68,6 @@ def _validate_limits(k: int, budget: Optional[int], min_score: float) -> None: GLOBAL_STORE = Path.home() / PROJECT_STORE_DIR / STORE_FILENAME -def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat(timespec="seconds") - - def find_project_root(start: Optional[str | Path] = None) -> Optional[Path]: """Nearest ancestor directory containing `.git`, or None outside a repo.""" current = Path(start).expanduser().resolve() if start else Path.cwd().resolve() @@ -179,132 +117,15 @@ def relocation_notice(path: Path) -> Optional[str]: ) -@dataclass -class MemoryEntry: - id: str - type: str - text: str - metadata: dict = field(default_factory=dict) - created_at: str = field(default_factory=_now_iso) - # Which agent wrote this (e.g. "claude-code", "codex", "cursor"). Lets one - # store be shared between agents while keeping provenance visible. - agent: str = "" - updated_at: Optional[str] = None - updated_by: str = "" - revision: int = 1 - status: str = "active" - superseded_by: Optional[str] = None - source: dict = field(default_factory=dict) - history: list[dict] = field(default_factory=list) - - @property - def tokens(self) -> int: - return count_tokens(self.text) - - -@dataclass -class RecallHit: - entry: MemoryEntry - score: float - - -def _entry_from_raw(raw: dict, *, history: bool = True) -> MemoryEntry: - if not isinstance(raw, dict): - raise ValueError("each memory must be an object") - known = MemoryEntry.__dataclass_fields__ - entry = MemoryEntry(**{key: value for key, value in raw.items() if key in known}) - # New writes are bounded at the API boundary. Existing records may predate - # those limits (including empty text); retain them so they can be corrected - # or deleted without an unrelated migration changing their identity/data. - if not isinstance(entry.text, str): - raise ValueError("stored memory text must be a string") - if entry.type not in MEMORY_TYPES: - raise ValueError(f"unknown memory type {entry.type!r}") - if not isinstance(entry.id, str): - raise ValueError("invalid memory id") - for name in ("created_at", "agent", "updated_by"): - if not isinstance(getattr(entry, name), str): - raise ValueError(f"{name} must be a string") - if entry.updated_at is not None and not isinstance(entry.updated_at, str): - raise ValueError("updated_at must be a string or null") - if ( - isinstance(entry.revision, bool) - or not isinstance(entry.revision, int) - or entry.revision < 1 - ): - raise ValueError("revision must be a positive integer") - if entry.status not in ("active", "superseded"): - raise ValueError("invalid memory status") - if entry.superseded_by is not None and not isinstance(entry.superseded_by, str): - raise ValueError("superseded_by must be an id or null") - if entry.status == "superseded" and entry.superseded_by is None: - raise ValueError("superseded memory must name its replacement") - _validate_mapping(entry.metadata, "metadata", limit=False) - _validate_mapping(entry.source, "source", limit=False) - if not isinstance(entry.history, list) or len(entry.history) > MAX_HISTORY: - raise ValueError(f"history must contain at most {MAX_HISTORY} revisions") - if history: - for snapshot in entry.history: - if not isinstance(snapshot, dict) or "history" in snapshot: - raise ValueError("invalid revision snapshot") - previous = _entry_from_raw(snapshot, history=False) - if previous.id != entry.id or previous.revision >= entry.revision: - raise ValueError("invalid revision history identity or order") - return entry - - -def age_in_days(entry: MemoryEntry, now: Optional[datetime] = None) -> float: - """How old a memory is. 0.0 when the timestamp is unreadable or in the future.""" - try: - written = datetime.fromisoformat(entry.updated_at or entry.created_at) - except (TypeError, ValueError): - return 0.0 # an unparseable timestamp must not silently bury the memory - if written.tzinfo is None: - written = written.replace(tzinfo=timezone.utc) - delta = (now or datetime.now(timezone.utc)) - written - return max(0.0, delta.total_seconds() / 86400.0) # clock skew must not boost - - -def decay_factor(entry: MemoryEntry, now: Optional[datetime] = None) -> float: - """Multiplier applied to a memory's similarity score, in (0, 1].""" - half_life = HALF_LIFE_DAYS.get(entry.type) - if not half_life: - return 1.0 - return float(0.5 ** (age_in_days(entry, now) / half_life)) - - -def startup_fresh(entry: MemoryEntry) -> bool: - """Startup notes expire after two half-lives (handoff 14d, worklog 42d). - - Ordinary recall still supports explicit inspection of aged content. - Unparseable startup timestamps are omitted rather than treated as current. - """ - if entry.status != "active": - return False - half_life = HALF_LIFE_DAYS.get(entry.type) - if half_life is None: - return True - try: - datetime.fromisoformat(entry.updated_at or entry.created_at) - except (TypeError, ValueError): - return False - return age_in_days(entry) <= 2 * half_life +def _synchronized(method): + """Keep each public operation consistent across threads sharing one store.""" + @wraps(method) + def call(self, *args, **kwargs): + with self._mutex: + return method(self, *args, **kwargs) -def _encode_vector(vec: np.ndarray) -> str: - """float16 + base64. Precision loss is ~1e-3 — far below what ranking needs.""" - return base64.b64encode(np.asarray(vec, dtype=np.float16).tobytes()).decode("ascii") - - -def _decode_vector(raw: str | list[float]) -> np.ndarray: - if isinstance(raw, str): - vec = np.frombuffer( - base64.b64decode(raw, validate=True), dtype=np.float16 - ).astype(np.float32) - else: # v1 stores kept a plain JSON list of floats - vec = np.asarray(raw, dtype=np.float32) - norm = float(np.linalg.norm(vec)) - return vec / norm if norm else vec # re-normalise after the float16 round-trip + return call class MemoryStore: @@ -319,6 +140,7 @@ class MemoryStore: def __init__( self, path: Optional[str | Path] = None, embedder: Optional[Embedder] = None ) -> None: + self._mutex = RLock() self.path = Path(path).expanduser() if path else None self.embedder = ( embedder if embedder is not None else self._configured_embedder() @@ -334,12 +156,11 @@ def _configured_embedder(self) -> Embedder: if ( self.path and self.path.exists() - and os.environ.get("AGENT_MEMORY_EMBEDDER", "auto") == "auto" + and os.environ.get("AGENT_MEMORY_EMBEDDER", "auto").strip().lower() + == "auto" ): try: - if self.path.stat().st_size > MAX_STORE_BYTES: - raise ValueError("store exceeds the supported local file size") - payload = json.loads(self.path.read_text(encoding="utf-8")) + payload = read_payload(self.path) config = payload.get("embedding_config", {}) if config.get("backend") == "hashing": return HashingEmbedder(dim=config["dim"]) @@ -370,46 +191,45 @@ def _transaction(self): self._entries, self._matrix, self._stamp = entries, matrix, stamp raise - def _embed(self, texts: list[str]) -> np.ndarray: - vectors = np.asarray(self.embedder.embed(texts), dtype=np.float32) - if ( - vectors.shape != (len(texts), self.embedder.dim) - or not np.isfinite(vectors).all() - ): - raise ValueError("embedder returned invalid vectors") - return vectors - + @_synchronized def write( self, text: str, type: str = "fact", metadata: Optional[dict] = None, id: Optional[str] = None, - dedup_threshold: float = 0.97, - agent: str = "", *, + agent: str = "", source: Optional[dict] = None, + deduplicate: bool = True, ) -> MemoryEntry: """Save a memory, or return its exact duplicate of the same type/source. Caller-supplied IDs are preserved; a duplicate ID raises ValueError. - Semantic similarity never establishes identity. ``dedup_threshold`` is - retained for compatibility: values above 1 disable exact deduplication. + Semantic similarity never establishes identity. Set ``deduplicate=False`` + to store a separate occurrence. The returned entry is a detached snapshot. """ return self.write_with_status( - text, type, metadata, id, dedup_threshold, agent, source=source + text, + type, + metadata, + id, + agent=agent, + source=source, + deduplicate=deduplicate, )[0] + @_synchronized def write_with_status( self, text: str, type: str = "fact", metadata: Optional[dict] = None, id: Optional[str] = None, - dedup_threshold: float = 0.97, - agent: str = "", *, + agent: str = "", source: Optional[dict] = None, + deduplicate: bool = True, ) -> tuple[MemoryEntry, bool]: """Like write; stored=False means an exact duplicate was found.""" _validate_text(text) @@ -421,25 +241,23 @@ def write_with_status( raise ValueError("id must be a string") if not isinstance(agent, str) or len(agent) > 200: raise ValueError("agent must be a string of at most 200 characters") - if not isinstance(dedup_threshold, (int, float)) or not np.isfinite( - dedup_threshold - ): - raise ValueError("dedup_threshold must be finite") + if not isinstance(deduplicate, bool): + raise ValueError("deduplicate must be a boolean") with self._transaction(): entry, stored = self._append( - text, type, metadata, id, dedup_threshold, agent, source + text, type, metadata, id, deduplicate, agent, source ) if stored and self.path: self._save_unlocked() - return entry, stored + return deepcopy(entry), stored - def _append(self, text, type, metadata, id, dedup_threshold, agent, source=None): + def _append(self, text, type, metadata, id, deduplicate, agent, source=None): if id is not None and any(entry.id == id for entry in self._entries): raise ValueError(f"duplicate memory id {id!r}; use update to revise it") # Whitespace-only differences can be ignored; case, numbers and # negation can change a fact or identifier and must be preserved. normalized = " ".join(text.split()) - if id is None and dedup_threshold <= 1: + if id is None and deduplicate: for entry in self._entries: if ( entry.status == "active" @@ -448,11 +266,12 @@ def _append(self, text, type, metadata, id, dedup_threshold, agent, source=None) and " ".join(entry.text.split()) == normalized ): return entry, False - vec = self._embed([text])[0] + vec = validated_embed(self.embedder, [text])[0] entry = MemoryEntry( id=id if id is not None else self._next_id(), type=type, text=text, + created_at=_now_iso(), metadata=deepcopy(metadata or {}), agent=agent, source=deepcopy(source or {}), @@ -483,6 +302,7 @@ def _check_revision(entry: MemoryEntry, expected: Optional[int]) -> None: f"memory {entry.id} changed: expected revision {expected}, current {entry.revision}; reload before editing" ) + @_synchronized def forget(self, entry_id: str, *, expected_revision: Optional[int] = None) -> bool: """Delete one memory; optionally reject a stale caller revision.""" with self._transaction(): @@ -496,6 +316,7 @@ def forget(self, entry_id: str, *, expected_revision: Optional[int] = None) -> b return True return False + @_synchronized def update( self, entry_id: str, @@ -536,9 +357,9 @@ def update( entry.type, entry.source, ): - return entry + return deepcopy(entry) vec = ( - self._embed([new_text])[0] + validated_embed(self.embedder, [new_text])[0] if new_text != entry.text else self._matrix[i] ) @@ -551,7 +372,7 @@ def update( self._matrix[i] = vec if self.path: self._save_unlocked() - return entry + return deepcopy(entry) return None @staticmethod @@ -563,6 +384,7 @@ def _record_revision(entry: MemoryEntry, agent: str) -> None: entry.updated_at = _now_iso() entry.updated_by = agent + @_synchronized def supersede( self, entry_id: str, @@ -590,20 +412,24 @@ def supersede( if old.status != "active": raise MemoryConflictError(f"memory {entry_id} is already superseded") replacement, _ = self._append( - text, old.type, old.metadata, None, 2, agent, source + text, old.type, old.metadata, None, False, agent, source ) self._record_revision(old, agent) old.status, old.superseded_by = "superseded", replacement.id if self.path: self._save_unlocked() - return replacement + return deepcopy(replacement) + @_synchronized def get(self, entry_id: str) -> Optional[MemoryEntry]: - """Inspect an entry, including superseded entries and recent history.""" + """Return a detached snapshot, including superseded entries and history.""" self._reload_if_changed() - return next((entry for entry in self._entries if entry.id == entry_id), None) + return deepcopy( + next((entry for entry in self._entries if entry.id == entry_id), None) + ) # ---- reading ------------------------------------------------------- + @_synchronized def recall( self, query: str, @@ -630,6 +456,29 @@ def recall( alongside it. Durable types are unaffected. Combined with `min_score`, stale status notes eventually drop out of recall on their own. """ + self._reload_if_changed() + return self._recall_snapshot( + query, + k=k, + type_filter=type_filter, + budget_tokens=budget_tokens, + exclude_ids=exclude_ids, + min_score=min_score, + decay=decay, + ) + + def _recall_snapshot( + self, + query: str, + *, + k: int, + type_filter: Optional[str] = None, + budget_tokens: Optional[int] = None, + exclude_ids: Optional[set[str]] = None, + min_score: float = 0.0, + decay: bool = True, + ) -> list[RecallHit]: + """Score the current snapshot without reloading; caller holds the mutex.""" _validate_limits(k, budget_tokens, min_score) if not isinstance(query, str) or len(query) > MAX_TEXT_CHARS: raise ValueError( @@ -637,10 +486,9 @@ def recall( ) if type_filter is not None and type_filter not in MEMORY_TYPES: raise ValueError(f"unknown memory type {type_filter!r}") - self._reload_if_changed() if not self._entries or k == 0 or budget_tokens == 0 or not query.strip(): return [] - qvec = self._embed([query])[0] + qvec = validated_embed(self.embedder, [query])[0] sims = self._matrix @ qvec # cosine: both sides are unit-norm if decay: factors = np.array( @@ -668,11 +516,12 @@ def recall( if cost > remaining: continue # doesn't fit; a smaller lower-ranked one may remaining -= cost - hits.append(RecallHit(entry=entry, score=score)) + hits.append(RecallHit(entry=deepcopy(entry), score=score)) if len(hits) >= k: break return hits + @_synchronized def boot( self, task: str, @@ -685,7 +534,8 @@ def boot( The budget applies to memory content across both parts. If the latest handoff is too large to fit, it is skipped and the full budget remains - available for relevant memories. + available for relevant memories. Both parts use one snapshot, even if + another process commits a correction while this call is running. """ _validate_limits(k, budget_tokens, min_score) remaining = budget_tokens @@ -693,7 +543,7 @@ def boot( included_handoff: Optional[MemoryEntry] = None excluded_ids = { entry.id - for entry in self.all() + for entry in self._entries if entry.type in ("handoff", "worklog") and not startup_fresh(entry) } @@ -704,7 +554,9 @@ def boot( if remaining is not None: remaining -= latest_handoff.tokens - hits = self.recall( + # latest() already refreshed the snapshot. Reloading again here could + # combine a retired handoff with its replacement from another writer. + hits = self._recall_snapshot( task, k=k, budget_tokens=remaining, @@ -714,6 +566,7 @@ def boot( ) return included_handoff, hits + @_synchronized def latest(self, type: str, *, fresh: bool = False) -> Optional[MemoryEntry]: """Most recently written entry of a type (e.g. the last handoff).""" self._reload_if_changed() @@ -723,13 +576,22 @@ def latest(self, type: str, *, fresh: bool = False) -> Optional[MemoryEntry]: and entry.status == "active" and (not fresh or startup_fresh(entry)) ): - return entry + return deepcopy(entry) return None + @_synchronized + def __len__(self) -> int: + """Count entries without copying their text, metadata and histories.""" + self._reload_if_changed() + return len(self._entries) + + @_synchronized def all(self) -> list[MemoryEntry]: + """Return detached snapshots; use update/supersede to persist changes.""" self._reload_if_changed() - return list(self._entries) + return deepcopy(self._entries) + @_synchronized def stats(self) -> dict: self._reload_if_changed() by_type: dict[str, int] = {} @@ -746,6 +608,7 @@ def stats(self) -> dict: } # ---- persistence --------------------------------------------------- + @_synchronized def save(self, path: Optional[str | Path] = None) -> None: """Save only a current snapshot; export to a new path for a backup.""" target = Path(path).expanduser() if path else self.path @@ -761,6 +624,7 @@ def save(self, path: Optional[str | Path] = None) -> None: ) self._save_unlocked(target) + @_synchronized def export(self, path: str | Path, *, overwrite: bool = False) -> None: """Write an explicit snapshot to a different file, without rebinding.""" target = Path(path).expanduser() @@ -773,119 +637,26 @@ def export(self, path: str | Path, *, overwrite: bool = False) -> None: self._save_unlocked(target) def _save_unlocked(self, path: Optional[Path] = None) -> None: - """Flush a unique temporary file before atomically replacing the store.""" target = path or self.path assert target is not None - target.parent.mkdir(parents=True, exist_ok=True) - records = [] - for i, entry in enumerate(self._entries): - raw = asdict(entry) - _entry_from_raw(raw) - raw["embedding"] = _encode_vector(self._matrix[i]) - records.append(raw) - payload = { - "format": STORE_FORMAT, - "embedder": type(self.embedder).__name__, - "embedding_config": embedding_config(self.embedder), - "dim": self.embedder.dim, - "entries": records, - } - content = json.dumps(payload, indent=2, ensure_ascii=False, allow_nan=False) - if len(content.encode("utf-8")) > MAX_STORE_BYTES: - raise ValueError("store exceeds the supported local file size") - temporary = None - try: - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=target.parent, - prefix=target.name + ".", - suffix=".tmp", - delete=False, - ) as stream: - temporary = Path(stream.name) - stream.write(content) - stream.flush() - os.fsync(stream.fileno()) - _replace_file(temporary, target) - finally: - if temporary is not None: - temporary.unlink(missing_ok=True) + write_snapshot(target, self._entries, self._matrix, self.embedder) if self.path and target.resolve() == self.path.resolve(): self._stamp = self._read_stamp() + @_synchronized def load(self, path: Optional[str | Path] = None) -> None: target = Path(path).expanduser() if path else self.path if target is None or not target.exists(): return if self.path and target.resolve() != self.path.resolve(): raise ValueError("open a separate MemoryStore to load a different file") - # Stamp before reading: a concurrent replace must trigger another load. - stamp = self._read_stamp() if target == self.path else None - try: - if target.stat().st_size > MAX_STORE_BYTES: - raise ValueError("store exceeds the supported local file size") - payload = json.loads(target.read_text(encoding="utf-8")) - if not isinstance(payload, dict) or not isinstance( - payload.get("entries"), list - ): - raise ValueError("store must contain an entries array") - version = payload.get("format", 1) - if ( - isinstance(version, bool) - or not isinstance(version, int) - or not 1 <= version <= STORE_FORMAT - ): - raise ValueError(f"unsupported store format {version!r}") - reembed = ( - payload.get("dim") != self.embedder.dim - or payload.get("embedder") != type(self.embedder).__name__ - or payload.get("embedding_config") != embedding_config(self.embedder) - ) - entries, vectors, seen = [], [], set() - for raw in payload["entries"]: - entry = _entry_from_raw(raw) - if entry.id in seen: - raise ValueError(f"duplicate stored memory id {entry.id!r}") - seen.add(entry.id) - entries.append(entry) - embedding = raw.get("embedding") - # Validate stored vectors even when changing models. Invalid - # files must not be silently repaired and overwritten. - vector = None if embedding is None else _decode_vector(embedding) - if vector is not None and ( - vector.ndim != 1 - or not np.isfinite(vector).all() - or len(vector) != payload.get("dim") - ): - raise ValueError(f"invalid embedding for {entry.id}") - vectors.append(None if reembed else vector) - missing = [i for i, vector in enumerate(vectors) if vector is None] - if missing: - fresh = self._embed([entries[i].text for i in missing]) - for slot, i in enumerate(missing): - vectors[i] = fresh[slot] - matrix = ( - np.array(vectors, dtype=np.float32) - if vectors - else np.zeros((0, self.embedder.dim), dtype=np.float32) - ) - except (ValueError, TypeError, KeyError, UnicodeError) as exc: - raise StoreFormatError( - f"cannot load {target}: {exc}; original file left untouched" - ) from exc + entries, matrix, stamp = read_snapshot(target, self.embedder) self._entries, self._matrix = entries, matrix if target == self.path: self._stamp = stamp def _read_stamp(self) -> Optional[tuple[int, int, int]]: - try: - st = self.path.stat() - except FileNotFoundError: - return None - except AttributeError: - return None - return (st.st_mtime_ns, st.st_size, st.st_ino) + return file_stamp(self.path) if self.path else None def _reload_if_changed(self) -> None: if self.path is None: diff --git a/src/agent_memory/tokens.py b/src/agent_memory/tokens.py index 8d45147..619f404 100644 --- a/src/agent_memory/tokens.py +++ b/src/agent_memory/tokens.py @@ -27,7 +27,8 @@ def _encoder(): def count_tokens(text: str) -> int: enc = _encoder() if enc is not None: - return len(enc.encode(text)) + # Memories may quote tokenizer markers; they are data, not control tokens. + return len(enc.encode_ordinary(text)) # Approximation: count word and punctuation chunks, then nudge up ~30% # because BPE typically splits long words into multiple tokens. return round(len(_APPROX_RE.findall(text)) * 1.3) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..854fe51 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,41 @@ +"""Fixtures that create historical memories through the actual write path.""" + +import subprocess +from datetime import datetime, timedelta, timezone + +import pytest + + +@pytest.fixture +def offline(monkeypatch): + monkeypatch.setenv("AGENT_MEMORY_EMBEDDER", "hashing") + monkeypatch.setenv("AGENT_MEMORY_AGENT", "claude-code") + + +@pytest.fixture +def repo(tmp_path): + """A real Git repository with one commit, shared by the hook tests.""" + root = tmp_path / "project" + root.mkdir() + + def run(*args): + return subprocess.run(args, cwd=root, capture_output=True, check=True) + + run("git", "init", "-q") + run("git", "config", "user.email", "t@example.com") + run("git", "config", "user.name", "Test") + (root / "app.py").write_text("v1\n") + run("git", "add", "-A") + run("git", "commit", "-qm", "initial commit") + return root + + +@pytest.fixture +def write_aged(monkeypatch): + def write(store, text, *, days, **kwargs): + timestamp = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + with monkeypatch.context() as clock: + clock.setattr("agent_memory.store._now_iso", lambda: timestamp) + return store.write(text, **kwargs) + + return write diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py new file mode 100644 index 0000000..7fbc8a8 --- /dev/null +++ b/tests/test_api_contract.py @@ -0,0 +1,222 @@ +"""Public API contracts: snapshots cannot bypass tracked writes.""" + +import json +from copy import deepcopy +from dataclasses import asdict + +import pytest + +from agent_memory import HashingEmbedder, MemoryConflictError, MemoryStore, embeddings +from agent_memory.rendering import boot_context + + +@pytest.mark.parametrize( + "operation", + [ + "write", + "write_with_status", + "duplicate", + "get", + "all", + "latest", + "recall", + "boot_handoff", + "boot_hit", + "update", + "noop", + "supersede", + ], +) +def test_returned_memories_are_detached_snapshots(tmp_path, operation): + path = tmp_path / "memory.json" + store = MemoryStore(path, HashingEmbedder()) + entry = store.write( + "Bookings use UTC.", + type="handoff" if operation == "boot_handoff" else "fact", + metadata={"labels": ["calendar"]}, + source={"path": "calendar.py", "evidence": ["review"]}, + ) + if operation == "write_with_status": + entry, stored = store.write_with_status("Bookings use Paris time.") + assert stored + elif operation == "duplicate": + entry, stored = store.write_with_status( + entry.text, source=entry.source, metadata=entry.metadata + ) + assert not stored + elif operation == "get": + entry = store.get(entry.id) + elif operation == "all": + entry = store.all()[0] + elif operation == "latest": + entry = store.latest("fact") + elif operation == "recall": + entry = store.recall("Bookings UTC")[0].entry + elif operation == "boot_handoff": + entry = store.boot("Bookings UTC")[0] + elif operation == "boot_hit": + entry = store.boot("Bookings UTC")[1][0].entry + elif operation in ("update", "noop"): + entry = store.update( + entry.id, + "Bookings use Paris time." if operation == "update" else entry.text, + expected_revision=1, + ) + elif operation == "supersede": + entry = store.supersede( + entry.id, "Bookings use Paris time.", expected_revision=1 + ) + + original = deepcopy(asdict(entry)) + entry.text = "Untracked replacement." + entry.revision = 999 + entry.metadata.setdefault("labels", []).append("tampered") + entry.source.setdefault("evidence", []).append("tampered") + if entry.history: + entry.history[0]["text"] = "Tampered history." + entry.history.append({"injected": True}) + + assert asdict(store.get(original["id"])) == original + # A later, legitimate write must not accidentally commit the local edits. + store.write("Invoices are archived monthly.") + reopened = MemoryStore(path, HashingEmbedder()) + assert asdict(reopened.get(original["id"])) == original + hit = reopened.recall(original["text"], k=1)[0] + assert hit.entry.id == original["id"] and hit.score > 0.99 + + +def test_saved_snapshot_keeps_the_revision_that_was_actually_read(): + store = MemoryStore(embedder=HashingEmbedder()) + earlier = store.write("Retry three times.") + store.update(earlier.id, "Retry five times.", expected_revision=earlier.revision) + assert earlier.revision == 1 and earlier.text == "Retry three times." + with pytest.raises(MemoryConflictError, match="current 2"): + store.forget(earlier.id, expected_revision=earlier.revision) + + +def test_duplicate_behavior_has_an_explicit_boolean_option(): + store = MemoryStore(embedder=HashingEmbedder()) + first = store.write("Bookings use UTC.") + duplicate, stored = store.write_with_status(first.text) + assert duplicate.id == first.id and not stored + repeated, stored = store.write_with_status(first.text, deduplicate=False) + assert stored and repeated.id != first.id + assert store.write(first.text, deduplicate=False).id != first.id + with pytest.raises(ValueError, match="deduplicate"): + store.write(first.text, deduplicate=0.97) + + +@pytest.mark.parametrize("choice", [None, "auto", "hashing", " HASHING "]) +def test_default_backend_does_not_attempt_a_model_load(monkeypatch, choice): + if choice is None: + monkeypatch.delenv("AGENT_MEMORY_EMBEDDER", raising=False) + else: + monkeypatch.setenv("AGENT_MEMORY_EMBEDDER", choice) + + def unexpected_model_load(*args, **kwargs): + pytest.fail("offline defaults attempted to load a model") + + monkeypatch.setattr( + embeddings, "SentenceTransformerEmbedder", unexpected_model_load + ) + assert isinstance(embeddings.default_embedder(), HashingEmbedder) + + +def test_unknown_backend_is_reported_instead_of_silently_falling_back(monkeypatch): + monkeypatch.setenv("AGENT_MEMORY_EMBEDDER", "typo") + with pytest.raises(ValueError, match="AGENT_MEMORY_EMBEDDER"): + embeddings.default_embedder() + + +def test_explicit_semantic_backend_reports_load_failure(monkeypatch): + monkeypatch.setenv("AGENT_MEMORY_EMBEDDER", "sentence-transformers") + + def unavailable(*args, **kwargs): + raise ImportError("model dependency unavailable") + + monkeypatch.setattr(embeddings, "SentenceTransformerEmbedder", unavailable) + with pytest.raises(RuntimeError, match="model dependency unavailable"): + embeddings.default_embedder() + + +def test_reopening_keeps_existing_embedding_configuration(tmp_path, monkeypatch): + path = tmp_path / "memory.json" + MemoryStore(path, HashingEmbedder(dim=128)).write("Bookings use UTC.") + monkeypatch.setenv("AGENT_MEMORY_EMBEDDER", " AUTO ") + reopened = MemoryStore(path) + assert reopened.embedder.dim == 128 + assert json.loads(path.read_text())["embedding_config"]["dim"] == 128 + + +@pytest.mark.parametrize("read_method", ["get", "recall"]) +def test_concurrent_reader_cannot_observe_a_write_that_rolls_back( + tmp_path, monkeypatch, read_method +): + from concurrent.futures import ThreadPoolExecutor, TimeoutError + from threading import Event + + store = MemoryStore(tmp_path / "memory.json", HashingEmbedder()) + entry = store.write("Bookings use UTC.") + saving, release = Event(), Event() + + def fail_save(*args): + saving.set() + assert release.wait(timeout=5), "test did not release the pending write" + raise OSError("disk unavailable") + + def read(): + if read_method == "get": + return store.get(entry.id).text + return store.recall("Bookings", k=1)[0].entry.text + + monkeypatch.setattr(store, "_save_unlocked", fail_save) + with ThreadPoolExecutor(max_workers=2) as pool: + writer = pool.submit(store.update, entry.id, "Uncommitted booking policy.") + try: + assert saving.wait(timeout=5) + reader = pool.submit(read) + with pytest.raises(TimeoutError): + reader.result(timeout=0.05) + finally: + release.set() + with pytest.raises(OSError, match="disk unavailable"): + writer.result(timeout=5) + assert reader.result(timeout=5) == "Bookings use UTC." + + +@pytest.mark.parametrize("rendered", [False, True], ids=["library", "rendered"]) +def test_boot_uses_one_snapshot_when_another_store_replaces_the_handoff( + tmp_path, monkeypatch, rendered +): + path = tmp_path / "memory.json" + writer = MemoryStore(path, HashingEmbedder()) + old = writer.write("Deployment is approved. Next: deploy now.", type="handoff") + fact = writer.write("Deployment requires a verified backup.") + reader = MemoryStore(path, HashingEmbedder()) + replacement_text = "Deployment is NOT approved. Next: wait for review." + latest = reader.latest + + def latest_then_replace(*args, **kwargs): + handoff = latest(*args, **kwargs) + # Deterministically interleave a separate store's committed write between + # handoff selection and recall; no scheduling sleeps are necessary. + writer.supersede(old.id, replacement_text, expected_revision=1) + return handoff + + def context(): + if rendered: + return boot_context(reader, "Deployment", budget=1000, min_score=-1) + handoff, hits = reader.boot("Deployment", budget_tokens=1000, min_score=-1) + return "\n".join([handoff.text, *(hit.entry.text for hit in hits)]) + + with monkeypatch.context() as race: + race.setattr(reader, "latest", latest_then_replace) + before = context() + + assert writer.get(old.id).status == "superseded" + assert old.text in before and fact.text in before + assert replacement_text not in before + # Consistency must not become permanent staleness: the next call refreshes. + after = context() + assert replacement_text in after and fact.text in after + assert old.text not in after diff --git a/tests/test_codex_adapter.py b/tests/test_codex_adapter.py index 3fa52ab..571e72c 100644 --- a/tests/test_codex_adapter.py +++ b/tests/test_codex_adapter.py @@ -2,13 +2,12 @@ import io import json -from pathlib import Path import subprocess import sys - -import pytest +from pathlib import Path import codex_adapter +import pytest import run_tasks from task_cases import TASKS diff --git a/tests/test_decay.py b/tests/test_decay.py index 0426e9c..f2a9314 100644 --- a/tests/test_decay.py +++ b/tests/test_decay.py @@ -24,16 +24,6 @@ def aged(entry_type: str, text: str, days: float) -> MemoryEntry: ) -def backdate(store: MemoryStore, entry_id: str, days: float) -> None: - """Rewrite an entry's timestamp, as if it had been written `days` ago.""" - written = datetime.now(timezone.utc) - timedelta(days=days) - for entry in store.all(): - if entry.id == entry_id: - entry.created_at = written.isoformat(timespec="seconds") - return - raise AssertionError(f"no entry {entry_id}") - - # ---- the decay curve ------------------------------------------------------- def test_a_fresh_memory_is_not_faded(): # Timestamps are stored to the second, so "now" can already be a second old. @@ -44,11 +34,17 @@ def test_a_fresh_memory_is_not_faded(): @pytest.mark.parametrize("entry_type", [t for t, hl in HALF_LIFE_DAYS.items() if hl]) def test_a_memory_at_its_half_life_is_worth_half(entry_type): half_life = HALF_LIFE_DAYS[entry_type] - assert decay_factor(aged(entry_type, "x", half_life)) == pytest.approx(0.5, abs=0.01) - assert decay_factor(aged(entry_type, "x", half_life * 2)) == pytest.approx(0.25, abs=0.01) + assert decay_factor(aged(entry_type, "x", half_life)) == pytest.approx( + 0.5, abs=0.01 + ) + assert decay_factor(aged(entry_type, "x", half_life * 2)) == pytest.approx( + 0.25, abs=0.01 + ) -@pytest.mark.parametrize("entry_type", [t for t, hl in HALF_LIFE_DAYS.items() if not hl]) +@pytest.mark.parametrize( + "entry_type", [t for t, hl in HALF_LIFE_DAYS.items() if not hl] +) def test_durable_types_never_fade(entry_type): assert decay_factor(aged(entry_type, "x", 3650)) == 1.0 @@ -82,60 +78,94 @@ def store(): return MemoryStore(embedder=HashingEmbedder()) -def test_a_stale_status_note_ranks_below_a_fresh_one(store): - old = store.write("Currently implementing multilingual chatbot support.", type="state") - backdate(store, old.id, 60) - store.write("Currently implementing the rate limiter for the chat endpoint.", type="state") +def test_a_stale_status_note_ranks_below_a_fresh_one(store, write_aged): + write_aged( + store, + "Currently implementing multilingual chatbot support.", + type="state", + days=60, + ) + store.write( + "Currently implementing the rate limiter for the chat endpoint.", type="state" + ) top = store.recall("what are we currently implementing", k=1)[0] assert "rate limiter" in top.entry.text, "the 60-day-old note should not win" -def test_an_old_decision_still_outranks_a_stale_note(store): - decision = store.write( - "Bookings are stored in UTC and converted in the UI layer.", type="decision" +def test_an_old_decision_still_outranks_a_stale_note(store, write_aged): + write_aged( + store, + "Bookings are stored in UTC and converted in the UI layer.", + type="decision", + days=400, + ) + write_aged( + store, + "Currently looking at how bookings store UTC timezones.", + type="state", + days=90, ) - backdate(store, decision.id, 400) - note = store.write("Currently looking at how bookings store UTC timezones.", type="state") - backdate(store, note.id, 90) top = store.recall("how are booking timezones handled", k=1)[0] assert top.entry.type == "decision" -def test_decay_can_be_switched_off(store): - old = store.write("Currently implementing multilingual chatbot support.", type="state") - backdate(store, old.id, 365) +def test_decay_can_be_switched_off(store, write_aged): + write_aged( + store, + "Currently implementing multilingual chatbot support.", + type="state", + days=365, + ) faded = store.recall("multilingual chatbot support", k=1)[0].score raw = store.recall("multilingual chatbot support", k=1, decay=False)[0].score assert raw > faded - assert raw == pytest.approx(store.recall("multilingual chatbot support", k=1, decay=False)[0].score) + assert raw == pytest.approx( + store.recall("multilingual chatbot support", k=1, decay=False)[0].score + ) -def test_a_long_stale_note_falls_below_the_relevance_floor(store): +def test_a_long_stale_note_falls_below_the_relevance_floor(store, write_aged): """Combined with the floor, stale status notes leave recall on their own.""" - old = store.write("Currently implementing multilingual chatbot support.", type="state") - backdate(store, old.id, 180) + write_aged( + store, + "Currently implementing multilingual chatbot support.", + type="state", + days=180, + ) floor = HashingEmbedder.recommended_min_score - assert store.recall("multilingual chatbot support", k=3, min_score=floor, decay=False) + assert store.recall( + "multilingual chatbot support", k=3, min_score=floor, decay=False + ) assert store.recall("multilingual chatbot support", k=3, min_score=floor) == [] -def test_decay_does_not_promote_unrelated_old_memories(store): +def test_decay_does_not_promote_unrelated_old_memories(store, write_aged): """Scaling a negative similarity moves it toward zero — it must not rank up.""" - old = store.write("Deployment runs from GitHub Actions on every push.", type="worklog") - backdate(store, old.id, 300) - store.write("The chatbot uses Google Gemini for customer questions.", type="decision") + write_aged( + store, + "Deployment runs from GitHub Actions on every push.", + type="worklog", + days=300, + ) + store.write( + "The chatbot uses Google Gemini for customer questions.", type="decision" + ) hits = store.recall("which model answers customer questions", k=2) assert hits[0].entry.type == "decision" -def test_boot_applies_decay_to_its_recall(store): - old = store.write("Currently implementing multilingual chatbot support.", type="state") - backdate(store, old.id, 365) +def test_boot_applies_decay_to_its_recall(store, write_aged): + write_aged( + store, + "Currently implementing multilingual chatbot support.", + type="state", + days=365, + ) store.write("Currently implementing the chatbot rate limiter.", type="state") _, hits = store.boot("what are we currently implementing", k=1, budget_tokens=None) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index d2f00cf..b6f6f99 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -124,6 +124,7 @@ def test_existing_embedding_configuration_is_sticky(tmp_path, monkeypatch): def test_long_markdown_paragraphs_are_bounded_without_losing_content(): import importlib.util from pathlib import Path + from agent_memory import count_tokens spec = importlib.util.spec_from_file_location( diff --git a/tests/test_hook_contract.py b/tests/test_hook_contract.py index d218d35..24f5781 100644 --- a/tests/test_hook_contract.py +++ b/tests/test_hook_contract.py @@ -1,11 +1,13 @@ """Real Git state and client payload contracts, separate from handler internals.""" -from datetime import datetime, timedelta, timezone +import pytest +from test_hooks import payload, store_for from agent_memory import hooks -from test_hooks import repo, payload, store_for, offline # noqa: F401 from agent_memory.tokens import count_tokens +pytestmark = pytest.mark.usefixtures("offline") + def test_documented_field_takes_precedence_over_legacy_alias(repo): store_for(repo).write("Admin routes use requireAdmin.") @@ -20,12 +22,10 @@ def test_documented_field_takes_precedence_over_legacy_alias(repo): assert "requireAdmin" in result["additionalContext"] -def test_startup_drops_old_handoff_and_worklog(repo): +def test_startup_drops_old_handoff_and_worklog(repo, write_aged): store = store_for(repo) for kind in ("handoff", "worklog"): - entry = store.write("Retired staging server deployment.", type=kind) - entry.created_at = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat() - store.save() + write_aged(store, "Retired staging server deployment.", type=kind, days=90) assert hooks.session_start(payload(repo, "SessionStart")) == {} diff --git a/tests/test_hooks.py b/tests/test_hooks.py index e3c8bec..6135b06 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -7,34 +7,15 @@ """ import io -from pathlib import Path import json import subprocess +from pathlib import Path import pytest from agent_memory import hooks - -@pytest.fixture(autouse=True) -def offline(monkeypatch): - monkeypatch.setenv("AGENT_MEMORY_EMBEDDER", "hashing") - monkeypatch.setenv("AGENT_MEMORY_AGENT", "claude-code") - - -@pytest.fixture -def repo(tmp_path): - """A real git repository with one commit.""" - root = tmp_path / "project" - root.mkdir() - run = lambda *a: subprocess.run(a, cwd=root, capture_output=True, check=True) - run("git", "init", "-q") - run("git", "config", "user.email", "t@example.com") - run("git", "config", "user.name", "Test") - (root / "app.py").write_text("v1\n") - run("git", "add", "-A") - run("git", "commit", "-qm", "initial commit") - return root +pytestmark = pytest.mark.usefixtures("offline") def payload(root, event, **extra): diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 77ccd7d..6f60e4c 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -167,6 +167,14 @@ def test_stats_reports_the_store(server): assert "1 memories" in out and "decision=1" in out +def test_literal_tokenizer_marker_survives_mcp_roundtrip(server): + text = "The parser must preserve the literal <|endoftext|> marker." + assert "Saved" in call(server, "memory_write", text=text) + assert "1 memories" in call(server, "memory_stats") + assert text in call(server, "memory_recall", query="parser literal marker") + assert text in call(server, "memory_boot", task="parser literal marker") + + @pytest.mark.parametrize("tool", ["memory_update", "memory_forget", "memory_supersede"]) def test_correction_tools_require_revision_and_reject_stale_read(server, tool): saved = call(server, "memory_write", text="Retry three times.") diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 3e7a054..2e74f2d 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -7,11 +7,11 @@ """ import base64 -from dataclasses import asdict import json import subprocess import sys import textwrap +from dataclasses import asdict import numpy as np import pytest diff --git a/tests/test_reliability.py b/tests/test_reliability.py index e714dc6..a608e21 100644 --- a/tests/test_reliability.py +++ b/tests/test_reliability.py @@ -1,11 +1,10 @@ """Regression contracts from the September engineering review.""" -from datetime import datetime, timedelta, timezone import json import os -from pathlib import Path import subprocess import sys +from pathlib import Path import numpy as np import pytest @@ -54,24 +53,27 @@ def test_documented_prompt_event_reaches_the_hook_process(tmp_path): ) -def test_startup_does_not_reintroduce_an_expired_handoff(): +def test_startup_does_not_reintroduce_an_expired_handoff(write_aged): store = open_store() - entry = store.write("Next: deploy the retired staging server.", type="handoff") - entry.created_at = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat() + entry = write_aged( + store, "Next: deploy the retired staging server.", type="handoff", days=90 + ) handoff, hits = store.boot("fix invoice rounding", min_score=0.15) assert handoff is None assert all(hit.entry.id != entry.id for hit in hits) -def test_correction_refreshes_an_old_state_without_erasing_creation_time(): +def test_correction_refreshes_an_old_state_without_erasing_creation_time(write_aged): store = open_store() - entry = store.write("Currently updating the staging server.", type="state") - entry.created_at = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat() + entry = write_aged( + store, "Currently updating the staging server.", type="state", days=90 + ) created = entry.created_at store.update(entry.id, text="Currently fixing invoice rounding.") hits = store.recall("Currently fixing invoice rounding.", min_score=0.15) assert hits and hits[0].entry.id == entry.id - assert entry.created_at == created + assert store.get(entry.id).created_at == created + assert store.get(entry.id).updated_at > created def test_negation_is_not_a_duplicate(): diff --git a/tests/test_revisions.py b/tests/test_revisions.py index f02d930..ca57c14 100644 --- a/tests/test_revisions.py +++ b/tests/test_revisions.py @@ -1,15 +1,15 @@ """Traceable corrections and failure atomicity through public store operations.""" -from dataclasses import asdict import json +from dataclasses import asdict import numpy as np import pytest from agent_memory import ( HashingEmbedder, - MemoryStore, MemoryConflictError, + MemoryStore, StoreFormatError, ) from agent_memory.rendering import recall_context @@ -143,7 +143,7 @@ def test_failed_disk_replace_rolls_back_data_and_cleans_tempfile( def fail(*args): raise OSError("disk unavailable") - monkeypatch.setattr("agent_memory.store.os.replace", fail) + monkeypatch.setattr("agent_memory._locking.os.replace", fail) with pytest.raises(OSError): if operation == "write": store.write("Images are private.") diff --git a/tests/test_store.py b/tests/test_store.py index 512a994..4bb8036 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1,5 +1,5 @@ -from dataclasses import asdict import uuid +from dataclasses import asdict import numpy as np import pytest diff --git a/tests/test_task_runner.py b/tests/test_task_runner.py index 6127ede..a482955 100644 --- a/tests/test_task_runner.py +++ b/tests/test_task_runner.py @@ -1,17 +1,17 @@ import json import os -from pathlib import Path import signal import subprocess import sys import time +from pathlib import Path import pytest - -from agent_memory import count_tokens from run_tasks import context_for, evaluate_tasks, grade, prepare, run_agent, summarize from task_cases import TASKS +from agent_memory import count_tokens + @pytest.mark.parametrize("task", TASKS, ids=lambda t: t.id) def test_task_grader_rejects_bug_and_accepts_reference(tmp_path, task): diff --git a/tests/test_tokens.py b/tests/test_tokens.py new file mode 100644 index 0000000..dd9fa00 --- /dev/null +++ b/tests/test_tokens.py @@ -0,0 +1,65 @@ +"""Memory content is ordinary text, even when it contains tokenizer markers.""" + +import pytest + +from agent_memory import HashingEmbedder, MemoryStore, tokens +from agent_memory.rendering import boot_context, recall_context + + +@pytest.fixture +def exact_encoder(monkeypatch): + tiktoken = pytest.importorskip("tiktoken", reason="needs the exact tokenizer") + encoder = tiktoken.get_encoding("cl100k_base") + monkeypatch.setattr(tokens, "_encoder", lambda: encoder) + return encoder + + +@pytest.mark.parametrize( + "marker", + [ + "<|endoftext|>", + "<|fim_prefix|>", + "<|fim_middle|>", + "<|fim_suffix|>", + "<|endofprompt|>", + ], +) +def test_special_markers_are_counted_as_literal_text(exact_encoder, marker): + text = f"The parser must preserve the literal {marker} marker." + assert tokens.count_tokens(text) == len(exact_encoder.encode_ordinary(text)) + + +def test_literal_markers_work_in_persisted_memory_and_budgeted_context( + tmp_path, exact_encoder +): + path = tmp_path / "memory.json" + store = MemoryStore(path, HashingEmbedder()) + handoff = store.write( + "Next: verify the parser preserves <|endoftext|>.", type="handoff" + ) + fact = store.write( + "The parser preserves <|fim_prefix|> and <|fim_suffix|> as literal text.", + source={"path": "fixtures/<|endofprompt|>.txt"}, + ) + reopened = MemoryStore(path, HashingEmbedder()) + expected = sum( + len(exact_encoder.encode_ordinary(entry.text)) for entry in (handoff, fact) + ) + assert reopened.stats()["total_tokens"] == expected + hits = reopened.recall("parser", budget_tokens=expected) + assert {hit.entry.id for hit in hits} == {handoff.id, fact.id} + for render in (boot_context, recall_context): + full = render(reopened, "parser", budget=1000) + assert handoff.text in full and fact.text in full + size = len(exact_encoder.encode_ordinary(full)) + assert render(reopened, "parser", budget=size) == full + smaller = render(reopened, "parser", budget=size - 1) + assert len(exact_encoder.encode_ordinary(smaller)) <= size - 1 + assert render(reopened, "parser", budget=0) == "" + + +def test_approximate_counting_also_accepts_literal_markers(monkeypatch): + monkeypatch.setattr(tokens, "_encoder", lambda: None) + text = "The parser preserves <|endoftext|>." + assert tokens.count_tokens(text) > 0 + assert tokens.count_tokens("") == 0 diff --git a/tests/test_windows_contract.py b/tests/test_windows_contract.py index a931551..49fd764 100644 --- a/tests/test_windows_contract.py +++ b/tests/test_windows_contract.py @@ -4,8 +4,7 @@ import pytest -from agent_memory import HashingEmbedder, MemoryStore -from agent_memory import hooks +from agent_memory import HashingEmbedder, MemoryStore, hooks @pytest.mark.parametrize( @@ -70,6 +69,7 @@ def test_permanent_windows_replace_failure_is_bounded_and_keeps_original( ): import os import time + from agent_memory._locking import _replace_file source, target = tmp_path / "new.json", tmp_path / "store.json"