From 26cef870ce9d9e8b0fa4fc833f5392369cdec8a6 Mon Sep 17 00:00:00 2001 From: izgorodin Date: Fri, 2 Oct 2026 21:31:26 +0100 Subject: [PATCH] feat: include_history on read() and recent(), fingerprint on read() and write(), the revision chain on items (0.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API has accepted all three since the revision chain shipped, and the SDK had never learned them: a Python caller could not ask for superseded revisions, could not match a memory by an exact episodic fingerprint, and saw items without their `supersedes` / `superseded_by` even when the server sent both. - read(..., include_history=True) and recent(..., include_history=True), sent only when True so a default request is byte-identical to before. - read(..., fingerprint=...) and write(..., fingerprint=...), at most 512 characters, exact episodic match; `episodic_hit` reports the hit. - MemoryItem and RecentItem gain `supersedes` (default []) and `superseded_by` (default None); both are omitted on the wire when they do not apply. New public names, so a MINOR: 0.4.0. Nine tests cover the wire shape in both clients, the by-default omission, and the parsing of both chain fields; the existing suite is unchanged. Co-Authored-By: Σ Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 21 +++++ README.md | 6 +- mnemoverse/__init__.py | 2 +- mnemoverse/_async_client.py | 26 ++++++ mnemoverse/client.py | 17 +++- mnemoverse/types.py | 8 ++ pyproject.toml | 2 +- tests/test_client.py | 175 ++++++++++++++++++++++++++++++++++++ 8 files changed, 250 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13a20ca..1ad1b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,27 @@ keyword argument is a MINOR, even pre-1.0. _Nothing yet._ +## [0.4.0] — 2026-10-02 + +Three keyword arguments the API has accepted since the revision chain +shipped and the SDK had never learned, plus the two response fields that +make the chain visible. New public names, so a MINOR under the rule above; +nothing existing changes shape or wire behaviour. + +### Added + +- **`read(..., include_history=True)` and `recent(..., include_history=True)`** + ask for superseded revisions alongside live ones (`POST /memory/read` and + `/memory/recent`, `include_history`). Sent only when `True`, so a request + against an older core is byte-identical to before. +- **`read(..., fingerprint=...)` and `write(..., fingerprint=...)`**: an exact + episodic match on a fingerprint given at write time (at most 512 + characters); `ReadResponse.episodic_hit` says whether it hit. +- **`MemoryItem.supersedes` / `.superseded_by` and `RecentItem.supersedes` / + `.superseded_by`**: the revision chain as the server reports it. Both are + omitted on the wire when they do not apply, so they default to `[]` and + `None`; a present `superseded_by` means the item is history. + ## [0.3.2] — 2026-10-02 Package metadata and README text only. No public name, signature or request diff --git a/README.md b/README.md index b0afd73..33f5673 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,10 @@ async with AsyncMnemoClient(api_key="mk_live_YOUR_KEY") as client: | Method | Description | |--------|-------------| -| `write(content, concepts, domain, metadata, external_ref, supersedes)` | Store a memory | +| `write(content, concepts, domain, metadata, external_ref, supersedes, fingerprint)` | Store a memory | | `write_batch(items)` | Store up to 500 memories | -| `read(query, top_k, domain, since, until, order_by, exclude_author)` | Natural-language search — "what do I know about X" | -| `recent(domain, since, until, exclude_author, limit, cursor)` | Newest-first feed — "what happened lately" | +| `read(query, top_k, domain, since, until, order_by, exclude_author, include_history, fingerprint)` | Natural-language search — "what do I know about X" | +| `recent(domain, since, until, exclude_author, limit, cursor, include_history)` | Newest-first feed — "what happened lately" | | `graph(seeds, depth, domain, min_weight, limit)` | Bounded read of the concept-association graph around `seeds` | | `feedback(atom_ids, outcome)` | Report success/failure | | `stats()` | Memory statistics | diff --git a/mnemoverse/__init__.py b/mnemoverse/__init__.py index d63d294..a15aae5 100644 --- a/mnemoverse/__init__.py +++ b/mnemoverse/__init__.py @@ -20,7 +20,7 @@ HealthResponse, ) -__version__ = "0.3.2" +__version__ = "0.4.0" __all__ = [ "MnemoClient", diff --git a/mnemoverse/_async_client.py b/mnemoverse/_async_client.py index 2491690..e067fe9 100644 --- a/mnemoverse/_async_client.py +++ b/mnemoverse/_async_client.py @@ -189,6 +189,7 @@ async def write( metadata: dict[str, Any] | None = None, external_ref: str | None = None, supersedes: list[UUID | str] | None = None, + fingerprint: str | None = None, ) -> WriteResponse: """Store a single memory atom. @@ -197,6 +198,9 @@ async def write( stored and every listed one is marked superseded by it, in a single transaction — all of it or none. Rejected by the server (422) on :meth:`write_batch`, and alongside an ``xroom:`` domain. + ``fingerprint`` (at most 512 characters, a task hash for example) lets a + later :meth:`read` with the same fingerprint find this atom by exact + match instead of by similarity. """ body: dict[str, Any] = {"content": content, "domain": domain} if concepts: @@ -207,6 +211,8 @@ async def write( body["external_ref"] = external_ref if supersedes: body["supersedes"] = [str(sid) for sid in supersedes] + if fingerprint is not None: + body["fingerprint"] = fingerprint data = await self._request("POST", "/api/v1/memory/write", json=body) return WriteResponse.model_validate(data) @@ -231,9 +237,19 @@ async def read( until: datetime | str | None = None, order_by: str | None = None, exclude_author: str | None = None, + include_history: bool = False, + fingerprint: str | None = None, ) -> ReadResponse: """Query memory with semantic search + Hebbian expansion. + ``include_history=True`` asks for superseded revisions alongside live + ones: each carries ``superseded_by``, and any item that replaced + something carries ``supersedes``. History competes under the same + ranking and ``top_k`` as everything else, so a read may return part of + a chain; for the whole chain, follow ``superseded_by`` by id. + ``fingerprint`` asks for an exact episodic match on a fingerprint given + at write time (``episodic_hit`` on the response says whether it hit). + ``since`` / ``until`` bound the result by creation time, inclusive at both ends; naive datetimes are read as UTC. ``order_by="recency"`` re-sorts the matched set newest-first without changing which entries @@ -262,6 +278,10 @@ async def read( body["order_by"] = order_by if exclude_author is not None: body["exclude_author"] = exclude_author + if include_history: + body["include_history"] = True + if fingerprint is not None: + body["fingerprint"] = fingerprint data = await self._request("POST", "/api/v1/memory/read", json=body) return ReadResponse.model_validate(data) @@ -274,9 +294,13 @@ async def recent( exclude_author: str | None = None, limit: int = 20, cursor: str | None = None, + include_history: bool = False, ) -> RecentResponse: """List the newest entries first — no query, complete by construction. + ``include_history=True`` includes superseded revisions in the feed, + each carrying ``superseded_by``; by default the feed lists live tips. + The temporal complement of :meth:`read`. Nothing is ranked away, so this is what to use when you need to be sure you are seeing everything: resuming after a break, catching up on a shared room, or auditing. @@ -297,6 +321,8 @@ async def recent( body["exclude_author"] = exclude_author if cursor: body["cursor"] = cursor + if include_history: + body["include_history"] = True data = await self._request("POST", "/api/v1/memory/recent", json=body) return RecentResponse.model_validate(data) diff --git a/mnemoverse/client.py b/mnemoverse/client.py index f982e48..775d6de 100644 --- a/mnemoverse/client.py +++ b/mnemoverse/client.py @@ -348,6 +348,7 @@ def write( metadata: dict[str, Any] | None = None, external_ref: str | None = None, supersedes: list[UUID | str] | None = None, + fingerprint: str | None = None, ) -> WriteResponse: """Store a single memory atom. @@ -356,12 +357,14 @@ def write( stored and every listed one is marked superseded by it, in a single transaction — all of it or none. Rejected by the server (422) on :meth:`write_batch`, and alongside an ``xroom:`` domain. + ``fingerprint`` (at most 512 characters) lets a later :meth:`read` with + the same fingerprint find this atom by exact match. """ return self._run( self._async_client.write( content, concepts=concepts, domain=domain, metadata=metadata, external_ref=external_ref, - supersedes=supersedes, + supersedes=supersedes, fingerprint=fingerprint, ) ) @@ -382,9 +385,15 @@ def read( until: datetime | str | None = None, order_by: str | None = None, exclude_author: str | None = None, + include_history: bool = False, + fingerprint: str | None = None, ) -> ReadResponse: """Query memory with semantic search + Hebbian expansion. + ``include_history=True`` returns superseded revisions alongside live + ones (each carries ``superseded_by``); ``fingerprint`` asks for an exact + episodic match on a fingerprint given at write time. + ``since`` / ``until`` bound the result by creation time, inclusive at both ends. ``order_by="recency"`` re-sorts the matched set newest-first without changing which entries matched. ``exclude_author`` drops one @@ -402,6 +411,7 @@ def read( concepts=concepts, since=since, until=until, order_by=order_by, exclude_author=exclude_author, + include_history=include_history, fingerprint=fingerprint, ) ) @@ -414,18 +424,21 @@ def recent( exclude_author: str | None = None, limit: int = 20, cursor: str | None = None, + include_history: bool = False, ) -> RecentResponse: """List the newest entries first — no query, complete by construction. The temporal complement of :meth:`read`: nothing is ranked away, so use this when you need to be sure you are seeing everything. Paged by ``next_cursor``, which continues the listing with no skips and no - duplicates even while writes are landing. + duplicates even while writes are landing. ``include_history=True`` + includes superseded revisions, each carrying ``superseded_by``. """ return self._run( self._async_client.recent( domain=domain, since=since, until=until, exclude_author=exclude_author, limit=limit, cursor=cursor, + include_history=include_history, ) ) diff --git a/mnemoverse/types.py b/mnemoverse/types.py index a995e26..1ea90ce 100644 --- a/mnemoverse/types.py +++ b/mnemoverse/types.py @@ -73,6 +73,12 @@ class MemoryItem(BaseModel): # timestamp and no author even when the API sent both. created_at: datetime | None = None provenance: Provenance | None = None + # Revision chain, reported as a fact of the data regardless of server + # settings. Both are OMITTED on the wire when they do not apply: an item + # that replaced nothing has no ``supersedes``; the live tip has no + # ``superseded_by``. Present ``superseded_by`` means the item is history. + supersedes: list[UUID] = [] + superseded_by: UUID | None = None class RecentItem(BaseModel): @@ -88,6 +94,8 @@ class RecentItem(BaseModel): created_at: datetime concepts: list[str] = [] provenance: Provenance | None = None + supersedes: list[UUID] = [] + superseded_by: UUID | None = None class RecentResponse(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index 5e07cb0..8949a44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mnemoverse" -version = "0.3.2" +version = "0.4.0" description = "Official Python SDK for Mnemoverse — hosted AI agent memory that learns from outcomes. Sync and async REST client." readme = "README.md" license = "MIT" diff --git a/tests/test_client.py b/tests/test_client.py index e672c92..e7d77b9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -931,3 +931,178 @@ async def test_enriched_validation_summary_is_not_duplicated( await client.write("x" * 10_001) assert str(exc_info.value) == summary + + +# --- 0.4.0: the revision chain and the episodic fingerprint, on the wire --- + +_READ_EMPTY = { + "items": [], + "episodic_hit": False, + "query_concepts": [], + "expanded_concepts": [], + "search_time_ms": 1.0, +} + + +@pytest.mark.parametrize("use_async", [True, False]) +async def test_read_sends_include_history_and_fingerprint_when_given( + use_async: bool, core: MockCore +) -> None: + """The API has accepted both since the revision chain shipped; a Python + caller could reach neither. Both clients must put them on the wire.""" + core.respond(path="/api/v1/memory/read", json=_READ_EMPTY) + + if use_async: + async_client = AsyncMnemoClient(base_url=core.url, api_key="mk_test", max_retries=0) + try: + await async_client.read("x", include_history=True, fingerprint="task:abc") + finally: + await async_client.close() + else: + with MnemoClient(base_url=core.url, api_key="mk_test", max_retries=0) as sync_client: + sync_client.read("x", include_history=True, fingerprint="task:abc") + + sent = core.requests[-1].json() + assert sent["include_history"] is True + assert sent["fingerprint"] == "task:abc" + + +async def test_read_omits_include_history_and_fingerprint_by_default( + client: AsyncMnemoClient, core: MockCore +) -> None: + """A request from a caller that asked for neither must be byte-identical to + before 0.4.0: the server ignores unknown read fields rather than rejecting + them, so an older core would not complain, but the body must not grow.""" + core.respond(path="/api/v1/memory/read", json=_READ_EMPTY) + + await client.read("x") + + sent = core.requests[-1].json() + assert "include_history" not in sent + assert "fingerprint" not in sent + + +@pytest.mark.parametrize("use_async", [True, False]) +async def test_recent_sends_include_history_only_when_true( + use_async: bool, core: MockCore +) -> None: + core.respond(path="/api/v1/memory/recent", json={"items": [], "next_cursor": None}) + + if use_async: + async_client = AsyncMnemoClient(base_url=core.url, api_key="mk_test", max_retries=0) + try: + await async_client.recent(include_history=True) + await async_client.recent() + finally: + await async_client.close() + else: + with MnemoClient(base_url=core.url, api_key="mk_test", max_retries=0) as sync_client: + sync_client.recent(include_history=True) + sync_client.recent() + + assert core.requests[-2].json()["include_history"] is True + assert "include_history" not in core.requests[-1].json() + + +@pytest.mark.parametrize("use_async", [True, False]) +async def test_write_sends_fingerprint_when_given_and_omits_it_otherwise( + use_async: bool, core: MockCore +) -> None: + core.respond( + path="/api/v1/memory/write", + json={"stored": True, "atom_id": ATOM_ID, "importance": 0.5, "reason": "novel insight"}, + ) + + if use_async: + async_client = AsyncMnemoClient(base_url=core.url, api_key="mk_test", max_retries=0) + try: + await async_client.write("m", fingerprint="task:abc") + await async_client.write("m") + finally: + await async_client.close() + else: + with MnemoClient(base_url=core.url, api_key="mk_test", max_retries=0) as sync_client: + sync_client.write("m", fingerprint="task:abc") + sync_client.write("m") + + assert core.requests[-2].json()["fingerprint"] == "task:abc" + assert "fingerprint" not in core.requests[-1].json() + + +_HISTORY_ITEM = { + "atom_id": "550e8400-e29b-41d4-a716-446655440001", + "content": "old", + "relevance": 0.8, + "similarity": 0.8, + "valence": 0.0, + "importance": 0.5, + "source": "semantic", + "concepts": [], + "domain": "general", + "superseded_by": "550e8400-e29b-41d4-a716-446655440002", +} +_TIP_ITEM = { + "atom_id": "550e8400-e29b-41d4-a716-446655440002", + "content": "new", + "relevance": 0.9, + "similarity": 0.9, + "valence": 0.0, + "importance": 0.5, + "source": "semantic", + "concepts": [], + "domain": "general", + "supersedes": ["550e8400-e29b-41d4-a716-446655440001"], +} + + +async def test_read_items_carry_the_revision_chain( + client: AsyncMnemoClient, core: MockCore +) -> None: + """`supersedes` and `superseded_by` are omitted on the wire when they do not + apply, so an item that replaced nothing parses with an empty list and the + live tip parses with `superseded_by` None; a present `superseded_by` is + what marks an item as history.""" + core.respond( + path="/api/v1/memory/read", + json={**_READ_EMPTY, "items": [_TIP_ITEM, _HISTORY_ITEM]}, + ) + + result = await client.read("x", include_history=True) + + tip, old = result.items + assert tip.superseded_by is None + assert [str(i) for i in tip.supersedes] == ["550e8400-e29b-41d4-a716-446655440001"] + assert str(old.superseded_by) == "550e8400-e29b-41d4-a716-446655440002" + assert old.supersedes == [] + + +async def test_recent_items_carry_the_revision_chain( + client: AsyncMnemoClient, core: MockCore +) -> None: + core.respond( + path="/api/v1/memory/recent", + json={ + "items": [ + { + "atom_id": "550e8400-e29b-41d4-a716-446655440001", + "content": "old", + "domain": "general", + "created_at": "2026-09-01T00:00:00Z", + "superseded_by": "550e8400-e29b-41d4-a716-446655440002", + }, + { + "atom_id": "550e8400-e29b-41d4-a716-446655440002", + "content": "new", + "domain": "general", + "created_at": "2026-09-02T00:00:00Z", + }, + ], + "next_cursor": None, + }, + ) + + result = await client.recent(include_history=True) + + old, tip = result.items + assert str(old.superseded_by) == "550e8400-e29b-41d4-a716-446655440002" + assert tip.superseded_by is None and tip.supersedes == []