Skip to content
Open
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,4 +448,4 @@ One-time PyPI setup (must match the workflow exactly, or PyPI rejects the token)

Note: `release.yml` builds + `twine check`s but does **not** run the test suite — tests run
in `ci.yml` on push/PR to `main`/`dev`. Only merge to `main` through green CI so a Release
never ships untested code.
never ships untested code.
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `client.memory` — full async CRUD for per-user memory files: `list()`,
`create()`, `get()`, `update()`, `delete()` (`GET/POST /memory`,
`GET/PUT/DELETE /memory/file`). New models `MemoryFileCreate`,
`MemoryFileUpdate`, `MemoryFileOut`, `MemoryFileSummaryOut`. `update()` is a
partial update — pass only the fields you want to change; the API does not
support clearing `content`/`purpose` once set (a `null` is silently ignored
server-side), so passing `content=None`/`purpose=None` raises
`InvalidParams` locally instead of sending a request that looks like it
succeeded but did nothing. `path` may have at most one folder segment
(`"folder/file.md"`, not `"a/b/file.md"`) — checked locally, also raising
`InvalidParams`, since the API only enforces this after a round trip.
`content` may be an empty string (no minimum length). Guards against
concurrent writes via an opaque `version` token, raising `ConflictError`
(409) on a stale value — a malformed `version` raises `APIError` (422)
instead. `delete()` is not idempotent: deleting an already-deleted path
raises `NotFoundError` (404). See `examples/09_memory.py`.

### Changed
- `__version__` is now resolved at runtime from installed package metadata
(`importlib.metadata.version("cominty-sdk")`) instead of the removed
Expand Down Expand Up @@ -43,4 +61,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

[Unreleased]: https://github.com/cominty/python-sdk/compare/v0.1.1...HEAD
[0.1.1]: https://github.com/cominty/python-sdk/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/cominty/python-sdk/releases/tag/v0.1.0
[0.1.0]: https://github.com/cominty/python-sdk/releases/tag/v0.1.0
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,50 @@ await client.threads.update(thread_id, name="Renamed", starred=True)
await client.threads.archive(thread_id)
```

### Memory files

`client.memory` stores per-user files an agent can read back later — scoped to
the client's `user_id` automatically.

```python
# Create a file
file = await client.memory.create(
path="preferences/tone.md", purpose="writing style", content="Keep it casual."
)

# List files (summaries — no content)
for f in await client.memory.list():
print(f.path, f.purpose, f.version)

# Read one file's content
file = await client.memory.get("preferences/tone.md")

# Partial update — only the fields you pass change. `version` guards against
# overwriting a concurrent change: pass back the value from your last read,
# and a stale one raises ConflictError (409).
file = await client.memory.update(
"preferences/tone.md", version=file.version, content="Keep it upbeat."
)

# Delete
await client.memory.delete("preferences/tone.md")
```

`version` is an opaque token — never parse or compare it, just round-trip
whatever the API last gave you.

There's currently no way to clear `content` or `purpose` once set — the API
ignores an explicit `null` (leaves the existing value untouched), so
`memory.update(..., content=None)` raises `InvalidParams` locally rather than
sending a request that looks like it succeeded but did nothing.

A few other things worth knowing:
- `path` may have at most one folder segment — `"preferences/tone.md"` is
fine, `"a/b/tone.md"` isn't (raises `InvalidParams` locally).
- `content` may be an empty string; there's no minimum length.
- `memory.delete()` is not idempotent — deleting an already-deleted path
raises `NotFoundError`, not a repeated success.

## Examples

Runnable scripts for each scenario live in [`examples/`](examples/):
Expand Down Expand Up @@ -294,4 +338,4 @@ A local rehearsal to TestPyPI is available via `uv run invoke publish-test`.

## License

MIT
MIT
66 changes: 66 additions & 0 deletions examples/09_memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Create, list, read, update, and delete a memory file.

python examples/09_memory.py

Demonstrates the full memory resource lifecycle. ``update`` is partial: only
the fields you pass are changed, and ``version`` (an opaque token from the
previous read) guards against overwriting a concurrent change — a stale
``version`` raises ``ConflictError``. The file created here is always deleted
before the script exits, even on error.
"""

from __future__ import annotations

import asyncio
from uuid import uuid4

import _pretty as pretty
from _shared import make_client

from cominty_sdk import ConflictError


async def main() -> None:
async with make_client() as client:
path = f"sdk-examples/{uuid4()}.md"

# create() -> the new file, with its initial version token.
created = await client.memory.create(
path=path,
purpose="scratch note for the memory example",
content="Remember to buy milk.",
)
pretty.console.print(f" [bold]create[/] path={created.path!r}")

try:
# list() -> lightweight summaries (no content) for every file.
summaries = await client.memory.list()
pretty.console.print(f" [bold]list[/] {len(summaries)} file(s)")

# get() -> the full file, including content.
fetched = await client.memory.get(path)
pretty.console.print(f" [bold]get[/] content={fetched.content!r}")

# update() is partial: only content changes here, purpose is untouched.
# version must match the file's current version or this raises
# ConflictError (409) — the API's optimistic-concurrency guard.
updated = await client.memory.update(
path, version=fetched.version, content="Buy oat milk instead."
)
pretty.console.print(f" [bold]update[/] content={updated.content!r}")

# Reusing the now-stale version demonstrates the 409 guard.
try:
await client.memory.update(
path, version=fetched.version, content="stale write"
)
except ConflictError:
pretty.console.print(" [bold]conflict[/] [yellow]stale version rejected[/]")
finally:
# Always clean up the file this example created.
await client.memory.delete(path)
pretty.console.print(" [bold]delete[/] [green]done[/]")


if __name__ == "__main__":
asyncio.run(main())
3 changes: 2 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ python examples/01_stream_events.py
| [`06_manage_thread.py`](06_manage_thread.py) | Get, rename/star, and archive a thread |
| [`07_custom_agent.py`](07_custom_agent.py) | Call a custom managed agent (needs `COMINTY_CUSTOM_AGENT_ID`) |
| [`08_mcp_linear.py`](08_mcp_linear.py) | Custom agent pulls live context from the Linear MCP server |
| [`09_memory.py`](09_memory.py) | Create, list, read, update, and delete a memory file |

> Shared client setup lives in [`_shared.py`](_shared.py).
> Shared client setup lives in [`_shared.py`](_shared.py).
10 changes: 10 additions & 0 deletions src/cominty_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
ThreadSummary,
UpdateThreadParams,
)
from .models.memory import (
MemoryFileCreate,
MemoryFileOut,
MemoryFileSummaryOut,
MemoryFileUpdate,
)
from .streaming import AssistantRun, StartedChat

try:
Expand Down Expand Up @@ -81,4 +87,8 @@
"Thread",
"ThreadSummary",
"UpdateThreadParams",
"MemoryFileCreate",
"MemoryFileOut",
"MemoryFileSummaryOut",
"MemoryFileUpdate",
]
4 changes: 3 additions & 1 deletion src/cominty_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from ._transport import AsyncTransport
from .models.chat import validate_user_id
from .resources.chat import ChatResource
from .resources.memory import MemoryResource
from .resources.threads import ThreadsResource

__all__ = ["AsyncCominty"]
Expand Down Expand Up @@ -53,6 +54,7 @@ def __init__(
self._transport = AsyncTransport(self._config)
self.chat = ChatResource(self._transport, user_id=self._config.user_id)
self.threads = ThreadsResource(self._transport, user_id=self._config.user_id)
self.memory = MemoryResource(self._transport, user_id=self._config.user_id)

@property
def user_id(self) -> str:
Expand All @@ -76,4 +78,4 @@ async def __aexit__(
await self.close()

async def close(self) -> None:
await self._transport.aclose()
await self._transport.aclose()
7 changes: 6 additions & 1 deletion src/cominty_sdk/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
Thread,
ThreadSummary,
)
from .memory import MemoryFileCreate, MemoryFileOut, MemoryFileSummaryOut, MemoryFileUpdate

__all__ = [
"Agent",
Expand All @@ -28,10 +29,14 @@
"Message",
"MessageRole",
"MessageStatus",
"MemoryFileCreate",
"MemoryFileOut",
"MemoryFileSummaryOut",
"MemoryFileUpdate",
"Question",
"ShareLink",
"StartChatOptions",
"StartChatParams",
"Thread",
"ThreadSummary",
]
]
117 changes: 117 additions & 0 deletions src/cominty_sdk/models/memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
from __future__ import annotations

from datetime import datetime
from typing import Annotated

from pydantic import AfterValidator, BaseModel, ConfigDict, model_validator
from typing_extensions import TypeAlias

from .chat import UserId

__all__ = [
"MemoryFileCreate",
"MemoryFileUpdate",
"MemoryFileOut",
"MemoryFileSummaryOut",
"MemoryPath",
"MemoryPathParam",
"validate_memory_path",
]


# A path with more than one folder segment (e.g. "a/b/file.md") is rejected with a
# 422 "Maximum folder depth is 1".
_MAX_PATH_DEPTH = 1


def validate_memory_path(value: str) -> str:
"""Return ``value`` if it's within the API's folder-depth limit, else raise."""
depth = value.count("/")
if depth > _MAX_PATH_DEPTH:
raise ValueError(
f"path {value!r} has {depth} folder levels; the API allows at most "
f"{_MAX_PATH_DEPTH} (e.g. 'folder/file.md' is fine, 'a/b/file.md' isn't)"
)
return value


MemoryPath: TypeAlias = Annotated[str, AfterValidator(validate_memory_path)]
"""A memory file path, folder-depth-checked before any request is sent."""


class MemoryPathParam(BaseModel):
"""Validates a bare ``path`` argument (``get``/``update``/``delete``,
which don't otherwise go through a request-body model)."""

model_config = ConfigDict(strict=True)

path: MemoryPath


class MemoryFileCreate(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")

path: MemoryPath
purpose: str
content: str
user_id: UserId
"""Unlike the other memory endpoints, ``POST /memory`` takes ``user_id`` in
the request body rather than as a query parameter."""


class MemoryFileUpdate(BaseModel):
"""Partial update body for ``PUT /memory/file``.

Dumped with ``exclude_unset=True`` so only explicitly-passed fields are
sent. The API does not currently support clearing ``content``/``purpose``
once set — a ``null`` is silently ignored server-side (200, value
unchanged) rather than clearing the field. To avoid that confusing
silent-no-op, this model rejects an explicit ``None`` locally instead of
forwarding it.
"""

model_config = ConfigDict(strict=True, extra="forbid")

content: str | None = None
purpose: str | None = None

@model_validator(mode="after")
def _require_at_least_one_field(self) -> MemoryFileUpdate:
if not self.model_fields_set:
raise ValueError("at least one of `content` or `purpose` must be provided")
return self

@model_validator(mode="after")
def _reject_explicit_none(self) -> MemoryFileUpdate:
nulled = sorted(name for name in self.model_fields_set if getattr(self, name) is None)
if nulled:
fields = " and ".join(nulled)
raise ValueError(
f"{fields} cannot be set to None: the API does not support "
"clearing a field once set (it's currently a silent no-op) — "
"omit the argument instead of passing None"
)
return self


class MemoryFileOut(BaseModel):
model_config = ConfigDict(extra="ignore")

path: str
purpose: str
content: str
created_at: datetime
updated_at: datetime
version: str
"""Opaque concurrency token — pass it back unchanged to
:meth:`~.resources.memory.MemoryResource.update`."""


class MemoryFileSummaryOut(BaseModel):
model_config = ConfigDict(extra="ignore")

path: str
purpose: str
created_at: datetime
updated_at: datetime
version: str
3 changes: 2 additions & 1 deletion src/cominty_sdk/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from .chat import ChatResource
from .memory import MemoryResource
from .threads import ThreadsResource

__all__ = ["ChatResource", "ThreadsResource"]
__all__ = ["ChatResource", "ThreadsResource", "MemoryResource"]
Loading
Loading