Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,9 @@ Thumbs.db
*.log
.env
.env.local

# Internal planning hub (specs/plans) — not published
localdocs/

# Local review/feature worktrees
.worktrees/
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

## [1.1.0] - 2026-06-09

### Added
- `atomicmemory.contract.v1`: a wire codec for the v1 provider contract's deliberately mixed-case encoding (`Memory.createdAt`/`updatedAt` and `SearchResult.rankingScore` are camelCase on the wire; `version_id`, `observed_at`, and retrieval-receipt fields are snake_case). Encode/decode helpers cover `Memory`, `Provenance`, `SearchResult`, `SearchResultPage`, `SearchRequest`, and ingest payloads (`IngestInput`, `IngestResult`). Dates follow the contract's ISO-8601 UTC millisecond `Z` form (`_to_iso_z`, equivalent to TS `toISOString()`). Naive datetimes in encode paths are assumed UTC. `encode_ingest_input` fails closed on the Python-ahead `content_class` field (no place in the v1 `additionalProperties: false` schemas; TS contract alignment is a recorded follow-up). Explicit-null `version_id` in `SearchResult` normalizes to absent on re-encode, matching the TS optional declaration. `encode_search_request` uses `by_alias=True` so Python-keyword-safe combinator field names (`and_`/`or_`/`not_`) emit their wire aliases; a recursive `_jsonify` walk converts any `datetime` operands in filter trees to the toISOString form. In-process models and provider mappers are unchanged.
- Vendored the TS SDK's versioned v1 wire contract (JSON Schemas, cross-provider conformance corpus, and CONTRACT.md) under `contract/`, with explicit provenance in `contract/VENDORED.json` and a documented refresh script (`scripts/refresh_contract.py`, never run in CI). A pytest conformance harness proves corpus fixtures decode into the Python models (directly for snake-on-wire types, through the codec for the mixed-case search response) and that SDK emissions validate against the vendored draft-2020-12 schemas, with the TS suite's negative cases mirrored against both schemas and Pydantic. The `capabilities-descriptor` case is schema-only (no Python model in this release — recorded follow-up).
- `atomicmemory.contract` re-exports `v1` as a specialty import surface; deliberately not re-exported from the package root to keep the root namespace focused on the core provider API.
- `AsyncProviderFactory` now accepts factories that return an `Awaitable[AsyncProviderRegistration]`, enabling lazy or async provider construction during `AsyncMemoryService.initialize()`.
- `MemoryService.initialize()` and `AsyncMemoryService.initialize()` raise `ConfigError` when the configured default provider has no registered factory, making a misconfigured default an immediate, explicit error rather than a silent no-op.

### Changed
- `content_class` is now accepted on **every** ingest mode (`text`, `messages`, and `verbatim`), not just `verbatim`, and is forwarded to core for all modes. Extraction-based ingests (`text`/`messages`) can now satisfy a core running the default `RAW_CONTENT_POLICY=reject`. Still never defaulted — omitting it leaves the field off the wire and a reject-policy core fails closed.
- Both clients' `initialize()` is now concurrency-safe and idempotent: concurrent callers share a single initialization run (the first caller's registry wins), and the completed outcome — success or failure — is captured in loop-independent state for `AsyncMemoryClient`.
- A failed `initialize()` is sticky: retrying re-raises the original error from any caller; resolve the cause and construct a new client rather than retrying on the same instance.
- `AsyncMemoryClient.initialize()` shields each waiter from cancellation so that one waiter's timeout or cancellation never cancels the shared run for other concurrent callers.
- `AsyncMemoryClient.close()` during a pending initialization cancels the shared run; staged providers are torn down by the service's atomic-initialize cleanup, any concurrent `initialize()` waiter receives `CancelledError`, and the client ends in the not-initialized state without recording a sticky error.
- Both `MemoryService` and `AsyncMemoryService` stage provider registrations atomically: factories and provider `initialize()` calls run against a local staging area, and the maps are replaced only after every provider succeeds; on any failure, already-staged providers are torn down best-effort before the original error re-raises.
- `MemoryService.close()` and `AsyncMemoryService.close()` are best-effort: every provider gets a chance to close regardless of earlier failures, maps are cleared in a `finally` block, and the first failure is re-raised after all providers have been given the chance to close.

### Fixed
- `atomicmemory.__version__` reported `1.0.0` while package metadata said `1.0.1`; all version sources now agree at `1.1.0`, guarded by a regression test that will fail if they drift again.

## [1.0.1] - 2026-05-14

### Changed
Expand Down
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ pip install 'atomicmemory[embeddings]' # + sentence-transformers for local

## Quick start

Prerequisite: start `atomicmemory-core` first. Follow the [Core Quickstart](https://docs.atomicstrata.ai/quickstart) if you do not already have a backend at `http://localhost:3050`.
Prerequisite: start `atomicmemory-core` first. Follow the [Core Quickstart](https://docs.atomicstrata.ai/quickstart) if you do not already have a backend at `http://localhost:17350`.

```python
from atomicmemory import AtomicMemoryClient

with AtomicMemoryClient({
"apiUrl": "http://localhost:3050",
"apiUrl": "http://localhost:17350",
"apiKey": "server-api-key",
"userId": "demo",
}) as client:
Expand Down Expand Up @@ -72,7 +72,7 @@ from atomicmemory import AsyncAtomicMemoryClient

async def main() -> None:
async with AsyncAtomicMemoryClient({
"apiUrl": "http://localhost:3050",
"apiUrl": "http://localhost:17350",
"apiKey": "server-api-key",
"userId": "demo",
}) as client:
Expand Down Expand Up @@ -131,6 +131,50 @@ The `client.storage` namespace mirrors the TypeScript SDK's direct storage API:

Every storage request sends `Authorization: Bearer <apiKey>` and `X-AtomicMemory-User-Id`. The SDK never sends the legacy `?user_id=` URL parameter.

## v1 wire contract

`atomicmemory.contract.v1` is the wire codec for the v1 provider-contract encoding. The wire form is deliberately mixed-case — `Memory.createdAt`/`updatedAt` and `SearchResult.rankingScore` are camelCase; `version_id`, `observed_at`, and retrieval-receipt fields are snake_case — as pinned by the vendored `contract/CONTRACT.md`. This module is the only place that mapping lives; in-process models and provider mappers are unchanged.

```python
from atomicmemory.contract import v1

# decode a wire search response (e.g. from a cross-SDK provider call)
wire_page = {
"results": [
{
"memory": {
"id": "mem_1",
"content": "I prefer aisle seats on flights.",
"scope": {"user": "demo"},
"kind": "fact",
"createdAt": "2026-05-30T12:00:00.000Z",
},
"score": 0.91,
"rankingScore": 0.87,
}
],
"retrieval": {
"embedding_model": "text-embedding-x",
"embedding_model_version": "1",
"embedding_dimensions": 1536,
"query_text": "deploy gate",
"candidate_ids": ["mem_1"],
"trace_id": "trace-1",
},
}

page = v1.decode_search_result_page(wire_page)
for hit in page.results:
print(hit.memory.content, hit.score) # snake_case in-process models

# re-encode to the exact v1 wire form (millisecond-precision UTC datetimes)
wire_out = v1.encode_search_result_page(page)
```

Two behaviors to know: naive datetimes passed to encode functions are assumed UTC (bare `astimezone()` would shift by the host's UTC offset); `encode_ingest_input` rejects models carrying `content_class` with a clear error because the v1 schemas have `additionalProperties: false` and no such field — this is a Python-ahead field pending TS contract alignment.

This is NOT the AtomicMemory core HTTP API. That boundary stays in the provider mappers. The import path is `atomicmemory.contract` — deliberately not re-exported from the package root to keep the root namespace focused on the core provider API.

## Development

```bash
Expand Down
26 changes: 26 additions & 0 deletions atomicmemory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,25 @@
RateLimitError,
ValidationError,
)
from atomicmemory.memory.capability_profiles import (
CapabilityGap,
CapabilityProfile,
capability_gaps,
satisfies_profile,
)
from atomicmemory.memory.filters import FieldFilter, FieldFilterOp, FilterExpr
from atomicmemory.memory.meta_fact_filter import (
DEFAULT_META_FACT_PATTERNS,
MetaFactFilterConfig,
filter_meta_facts,
is_meta_fact,
resolve_meta_fact_patterns,
)
from atomicmemory.memory.types import (
Capabilities,
CapabilitiesExtensions,
CapabilitiesRequiredScope,
ContentClass,
ContextPackage,
GraphEdge,
GraphNode,
Expand All @@ -52,6 +66,7 @@
PackageRequest,
Profile,
Provenance,
RetrievalReceipt,
Scope,
SearchRequest,
SearchResult,
Expand Down Expand Up @@ -87,6 +102,7 @@
)

__all__ = [
"DEFAULT_META_FACT_PATTERNS",
"ArtifactHead",
"ArtifactInUseError",
"ArtifactMetadata",
Expand All @@ -103,7 +119,10 @@
"Capabilities",
"CapabilitiesExtensions",
"CapabilitiesRequiredScope",
"CapabilityGap",
"CapabilityProfile",
"ConfigError",
"ContentClass",
"ContextPackage",
"DeleteArtifactOptions",
"DeleteArtifactPolicy",
Expand Down Expand Up @@ -133,6 +152,7 @@
"Message",
"MessageIngest",
"MessageRole",
"MetaFactFilterConfig",
"NetworkError",
"NotInitializedError",
"PackageFormat",
Expand All @@ -146,6 +166,7 @@
"PutManagedInput",
"PutPointerInput",
"RateLimitError",
"RetrievalReceipt",
"Scope",
"SearchRequest",
"SearchResult",
Expand All @@ -163,4 +184,9 @@
"VerificationResult",
"VerifyArtifactOptions",
"__version__",
"capability_gaps",
"filter_meta_facts",
"is_meta_fact",
"resolve_meta_fact_patterns",
"satisfies_profile",
]
8 changes: 6 additions & 2 deletions atomicmemory/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""Version metadata for the atomicmemory Python SDK."""
"""Version metadata for the atomicmemory Python SDK.

__version__ = "1.0.0"
Exports:
__version__: The current package version string (PEP 440).
"""

__version__ = "1.1.0"
102 changes: 91 additions & 11 deletions atomicmemory/client/async_memory_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""AsyncMemoryClient — async facade for the V3 memory layer.

Port of `atomicmemory-sdk/src/client/memory-client.ts` (async variant).
Mirrors :class:`atomicmemory.client.memory_client.MemoryClient` with
``async def`` for every I/O method and a ``__aenter__`` / ``__aexit__``
context manager. Dict coercion + Pydantic-error wrapping is identical
Expand All @@ -8,6 +9,8 @@

from __future__ import annotations

import asyncio
import contextlib
from dataclasses import dataclass
from types import TracebackType
from typing import Any
Expand All @@ -17,11 +20,13 @@
import atomicmemory.providers.hindsight
import atomicmemory.providers.mem0 # noqa: F401
from atomicmemory.client.memory_client import (
MemoryProviderConfigs,
_coerce_ingest,
_coerce_list_request,
_coerce_package,
_coerce_ref,
_coerce_search,
_pick_first_provider_key,
)
from atomicmemory.core.errors import ConfigError, NotInitializedError
from atomicmemory.memory.provider import BaseAsyncMemoryProvider
Expand All @@ -42,8 +47,6 @@
)
from atomicmemory.providers.atomicmemory.async_handle_impl import AsyncAtomicMemoryHandle

MemoryProviderConfigs = dict[str, Any]


@dataclass
class AsyncProviderStatus:
Expand All @@ -62,7 +65,7 @@ class AsyncMemoryClient:

Example:
>>> async with AsyncMemoryClient(
... providers={"atomicmemory": {"api_url": "http://localhost:3050"}}
... providers={"atomicmemory": {"api_url": "http://localhost:17350"}}
... ) as memory:
... await memory.initialize()
... await memory.ingest({"mode": "text", "content": "hi", "scope": {"user": "u1"}})
Expand All @@ -88,18 +91,82 @@ def __init__(
)
)
self._initialized = False
self._init_error: Exception | None = None
self._init_task: asyncio.Task[None] | None = None

async def initialize(self, registry: AsyncProviderRegistry | None = None) -> None:
"""Initialize all configured providers. Idempotent and concurrency-safe.

Concurrent calls on one event loop share a single initialization run
(the first call's ``registry`` wins). The COMPLETED outcome — success
or the original failure — is captured into loop-independent state, so
a failed initialization is sticky from any loop: retrying re-raises
the original error; construct a new client after resolving the cause.
An instance is bound to the event loop of its first ``initialize()``
while initialization is still PENDING — awaiting a pending run from a
different loop is unsupported. ``close()`` after a SUCCESSFUL
lifecycle returns the client to the uninitialized state.
"""
if self._initialized:
return
await self._service.initialize(registry if registry is not None else default_async_registry)
if self._init_error is not None:
raise self._init_error
if self._init_task is None:
self._init_task = asyncio.ensure_future(self._run_initialize(registry))
self._init_task.add_done_callback(_mark_retrieved)
task = self._init_task
try:
# shield: cancelling ONE waiter (e.g. wait_for timeout) must not
# cancel the shared run for everyone — promises aren't cancellable
# in TS, so unshielded awaiting would NOT be lifecycle parity.
await asyncio.shield(task)
finally:
if task.done():
self._init_task = None

async def _run_initialize(self, registry: AsyncProviderRegistry | None) -> None:
"""Execute the shared initialization run; capture errors into sticky state.

CancelledError is BaseException and never caught here, so cancellation
never becomes sticky. A cancelled task's ``_init_task`` slot is cleared
by a surviving waiter's ``finally`` once the task is done, or by
``close()``; either path lets a later call start fresh.
"""
try:
await self._service.initialize(registry if registry is not None else default_async_registry)
except Exception as exc:
self._init_error = exc
raise
self._initialized = True

async def close(self) -> None:
"""Close providers; safe to call multiple times.

Closing while an initialization is PENDING cancels that run: staged
providers are torn down by the service's atomic-initialize cleanup,
any concurrent initialize() waiter receives CancelledError, and the
client ends not-initialized (no sticky error is recorded for
cancellation). After a SUCCESSFUL lifecycle, close() returns the
client to the uninitialized state. A FAILED initialization remains
sticky — close() does not reset it.
"""
task = self._init_task
if task is not None:
if not task.done():
task.cancel()
with contextlib.suppress(Exception, asyncio.CancelledError):
await task
# Always clear, even when already done: a run whose waiters were
# all cancelled leaves a stale DONE task behind, and a later
# initialize() awaiting it would resolve instantly WITHOUT
# re-running — silently leaving the client uninitialized.
self._init_task = None
if not self._initialized:
return
await self._service.close()
self._initialized = False
try:
await self._service.close()
finally:
self._initialized = False

async def __aenter__(self) -> AsyncMemoryClient:
return self
Expand All @@ -117,6 +184,7 @@ async def ingest(self, input: IngestInput | dict[str, Any]) -> IngestResult:
return await self._service.ingest(_coerce_ingest(input))

async def ingest_direct(self, input: IngestInput | dict[str, Any]) -> IngestResult:
"""Identical to :meth:`ingest`; preserved for wrapper-subclass parity with TS."""
self._assert_initialized()
return await self._service.ingest(_coerce_ingest(input))

Expand All @@ -125,6 +193,7 @@ async def search(self, request: SearchRequest | dict[str, Any]) -> SearchResultP
return await self._service.search(_coerce_search(request))

async def search_direct(self, request: SearchRequest | dict[str, Any]) -> SearchResultPage:
"""Identical to :meth:`search`; preserved for wrapper-subclass parity with TS."""
self._assert_initialized()
return await self._service.search(_coerce_search(request))

Expand All @@ -133,6 +202,7 @@ async def package(self, request: PackageRequest | dict[str, Any]) -> ContextPack
return await self._service.package(_coerce_package(request))

async def package_direct(self, request: PackageRequest | dict[str, Any]) -> ContextPackage:
"""Identical to :meth:`package`; preserved for wrapper-subclass parity with TS."""
self._assert_initialized()
return await self._service.package(_coerce_package(request))

Expand Down Expand Up @@ -181,6 +251,11 @@ def get_provider(self, name: str | None = None) -> BaseAsyncMemoryProvider:

@property
def atomicmemory(self) -> AsyncAtomicMemoryHandle | None:
"""Typed access to AtomicMemory-specific routes.

Returns ``None`` when the client is not yet initialized or the
``atomicmemory`` provider was not configured.
"""
if not self._initialized:
return None
if "atomicmemory" not in self._service.get_configured_providers():
Expand All @@ -196,8 +271,13 @@ def _assert_initialized(self) -> None:
raise NotInitializedError("AsyncMemoryClient is not initialized. Call await client.initialize() first.")


def _pick_first_provider_key(providers: MemoryProviderConfigs) -> str | None:
for key, value in providers.items():
if value is not None and key != "default":
return key
return None
def _mark_retrieved(task: asyncio.Task[None]) -> None:
"""Retrieve the task's exception so asyncio never logs 'never retrieved'.

A run whose waiters were all cancelled fails unobserved; without this
callback asyncio would log "Task exception was never retrieved" at GC.
Correctness is unchanged: waiters still see errors through the shield,
and stickiness is recorded by ``_run_initialize`` itself.
"""
if not task.cancelled():
task.exception()
Loading
Loading