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
38 changes: 29 additions & 9 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -400,6 +400,7 @@ uv run invoke clean # remove dist/ build/ *.egg-info (no stale artifa
uv run invoke build # clean, then `uv build` -> sdist + wheel in dist/
uv run invoke check # build, `twine check dist/*`, and print sdist + wheel contents
uv run invoke publish-test # check, then upload to TestPyPI (rehearsal — never PyPI)
uv run invoke release --patch # bump + validate + commit + tag (see §12.3) — modifies Git state
```

`check` is the gate to run before cutting a release. It confirms:
Expand All@@ -414,14 +415,33 @@ uv run invoke publish-test # check, then upload to TestPyPI (rehearsal — n

### 12.3 Cutting a release (steps)

1. **Bump the version** in `pyproject.toml`'s `[project] version` (the only place — see §12.1).
2. **Verify locally**: `uv run invoke check` (and `uv run invoke publish-test` for a dry run).
3. **Commit + tag**: `git commit -am "Release X.Y.Z"` then `git tag vX.Y.Z`; push both.
The tag `vX.Y.Z` must equal `pyproject.toml`'s version — the build derives the version from
that file, **not** the tag.
4. **Publish via a GitHub Release** — create/publish a Release for tag `vX.Y.Z`
(`gh release create vX.Y.Z` or the GitHub UI). Publishing the Release is what triggers
the `release` workflow; a plain tag push does not.
> **`uv run invoke release` modifies repository files and CREATES A GIT COMMIT AND TAG.**
> It never pushes and never creates the GitHub Release — that's step 2 below, always manual.

1. **Bump, validate, commit, and tag** — pick exactly one mode:

```bash
uv run invoke release --patch # X.Y.Z -> X.Y.(Z+1)
uv run invoke release --minor # X.Y.Z -> X.(Y+1).0
uv run invoke release --major # X.Y.Z -> (X+1).0.0
uv run invoke release --version X.Y.Z # explicit target instead of incrementing
```

The task (`tasks.py`): rejects zero or more than one of the four flags; requires a clean
working tree, a target version strictly greater than the current one, and no pre-existing
`vX.Y.Z` tag — all checked **before** touching any file. It then bumps `pyproject.toml`'s
`[project] version` (the only place — see §12.1), regenerates `uv.lock` via `uv lock`, and
runs the same lint/type-check/test/build gate as `uv run invoke check`. If any of that
fails, the file changes are rolled back and nothing is committed. On success it creates one
commit (`chore(release): version X.Y.Z`) and one **annotated** tag (`vX.Y.Z`), and prints
the exact next commands — it does not run them for you.
2. **Push, then publish via a GitHub Release** — `git push origin HEAD && git push origin
vX.Y.Z`, then create/publish a Release for that tag (`gh release create vX.Y.Z
--generate-notes` or the GitHub UI). Publishing the Release is what triggers the `release`
workflow; a plain tag push does not.

`tests/test_release.py` covers the task itself (patch/minor/major/explicit-version success,
plus every validation failure) against disposable temp Git repos — never this repository.

`uv run invoke publish` (direct upload to real PyPI) exists as a manual fallback only. The
**preferred** path is the GitHub Release → CI flow below, so no PyPI token lives on a laptop.
Expand All@@ -448,4 +468,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.
25 changes: 24 additions & 1 deletion CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,29 @@ 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`.
- `uv run invoke release --patch|--minor|--major|--version X.Y.Z` — a dev-only
task that bumps `pyproject.toml`, regenerates `uv.lock`, runs the lint/
type-check/test/build gate, then creates one release commit and one
annotated `vX.Y.Z` tag. Never pushes or creates the GitHub Release; prints
the exact next commands instead. See `AGENTS.md` §12.3.

### Changed
- `__version__` is now resolved at runtime from installed package metadata
(`importlib.metadata.version("cominty-sdk")`) instead of the removed
Expand DownExpand Up@@ -43,4 +66,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
72 changes: 65 additions & 7 deletions README.md
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -280,18 +324,32 @@ See [AGENTS.md](AGENTS.md) for coding conventions (typing, versioning, models).
Publishing to PyPI uses **Trusted Publishing (OIDC)** — no tokens stored in
GitHub — and is triggered by publishing a **GitHub Release**
(`.github/workflows/release.yml`). The published version comes from
`pyproject.toml`, so the tag is cosmetic; keep them in sync.
`pyproject.toml`.

**`uv run invoke release` modifies repository files and CREATES A GIT COMMIT
AND TAG.** It bumps `pyproject.toml`, regenerates `uv.lock`, runs the lint /
type-check / test / build gate, then commits and tags — it never pushes and
never creates the GitHub Release itself.

```bash
# 1. bump the version in pyproject.toml
# 2. commit on main and push
# 3. create the release — this tags and triggers the publish
gh release create v0.4.0 --title "v0.4.0" --generate-notes
# pre-release rehearsal: gh release create v0.4.0rc1 --prerelease --generate-notes
# 1. bump, validate, commit, and tag locally — pick exactly one
uv run invoke release --patch # X.Y.Z -> X.Y.(Z+1)
uv run invoke release --minor # X.Y.Z -> X.(Y+1).0
uv run invoke release --major # X.Y.Z -> (X+1).0.0
uv run invoke release --version X.Y.Z # set an explicit version

# 2. push the commit and the tag it just created
git push origin HEAD
git push origin vX.Y.Z

# 3. create the release — this triggers the publish workflow
gh release create vX.Y.Z --title vX.Y.Z --generate-notes
# pre-release rehearsal (skips `invoke release`): tag and push manually,
# then gh release create vX.Y.Zrc1 --prerelease --generate-notes
```

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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).
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,7 @@ dev = [
"invoke>=2.2",
"twine>=5",
"rich>=13", # examples/ pretty terminal output (not a runtime dependency)
"packaging>=23", # tasks.py release: PEP 440 / SemVer version parsing
]

[build-system]
Expand All@@ -64,6 +65,7 @@ only-include = ["src/cominty_sdk", "README.md", "pyproject.toml"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
pythonpath = ["."] # so tests/test_release.py can `import tasks` (repo-root script)
markers = [
"integration: opt-in tests requiring COMINTY_API_KEY",
]
Expand Down
10 changes: 10 additions & 0 deletions src/cominty_sdk/__init__.py
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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",
]
]
Loading
Loading