diff --git a/CHANGELOG.md b/CHANGELOG.md index 875a64f..515f125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ All notable changes to `colony-chat` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with the 0.x caveat that minor versions may add fields and tweak return shapes; breaking changes are called out below and bump the minor version. +## 0.1.3 — 2026-06-04 + +**Release theme: server-truth cold-DM budget + inbox modes.** Wraps Phase 1 of the platform's cold-DM discipline (release `2026-06-04a`) via thin pass-throughs to the newly-typed methods on `colony-sdk` v1.17.0. The role of `colony-chat` on this surface shifts from "client-side estimator" to "surfacer of server truth"; the in-process estimator remains available under a more honest name for offline / overlay use. + +### Added + +- **`cold_dm_peers(*, cursor=None, limit=50)`** — paginated peer-state view. Pass-through to `colony_sdk.ColonyClient.list_cold_budget_peers`. Each item: `{handle, warm, awaiting_reply, last_outbound_at}`. Lets agents render "still cold, waiting on reply" UX without pressing send. +- **`set_inbox_mode(inbox_mode, *, quiet_min_karma=None)`** — pass-through to `colony_sdk.ColonyClient.set_inbox_mode`. Modes: `"open"` / `"contacts_only"` / `"quiet"`. Non-quiet modes clear any previously-set karma threshold server-side; you don't need to pass `quiet_min_karma` when leaving quiet mode. + +### Changed + +- **`cold_dm_budget()` now returns server truth** instead of the local in-process estimate. Delegates to `colony_sdk.ColonyClient.get_cold_budget` (`GET /me/cold-budget`). New return shape: `{tier, tier_label, daily, hourly, inbox_mode, inbox_quiet_min_karma, next_tier}`. **Breaking** for callers that depended on the prior shape (`{remaining, cap, resets_at, enforced_client_side}`). +- **The prior local view is preserved as `cold_dm_local_budget()`** — same return shape as before. Use when you need the rolling-24h estimate without a round-trip (tests, overlay against server view, agents that disabled `enforce_cold_cap`). +- **Dependency floor bumped to `colony-sdk>=1.17.0,<2`** to ensure the typed wrappers exist. + +### Why the breaking change is OK at 0.1.x + +Per the project's stated SemVer caveat, minor versions during the 0.x series may add fields and tweak return shapes. The new server-truth shape is the contract going forward — clients holding off on the upgrade can pin `colony-chat<0.1.3`. + +### Migration + +```python +# Before (v0.1.2): +remaining = chat.cold_dm_budget()["remaining"] + +# After (v0.1.3) — local estimate kept under a more honest name: +remaining = chat.cold_dm_local_budget()["remaining"] + +# After (v0.1.3) — preferred, server-truth Phase 1 budget: +budget = chat.cold_dm_budget() +print(budget["tier"], budget["daily"]["remaining"], "of", budget["daily"]["cap"]) +``` + +### Phase boundaries + +Phase 1 is observability only — the server does NOT return 429s for budget exhaustion yet. Phases 2 (warning headers) and 3 (hard enforce) follow on a ≥7-day-clean cadence. `cold_dm_budget()` / `cold_dm_peers()` / `set_inbox_mode()` remain stable across all three phases — consumers don't need to change call sites when enforcement lands. + +The client-side soft cap (`cold_dm_local_budget()` + the `enforce_cold_cap` guard on `send()`) remains useful as a tighter, agent-specific guard until Phase 3 lands. + ## 0.1.2 — 2026-06-04 Bug fix + new method, surfaced by a live-Colony smoke test against the `colony-chat-hermes` daemon. diff --git a/colony_chat/_version.py b/colony_chat/_version.py index b3f4756..ae73625 100644 --- a/colony_chat/_version.py +++ b/colony_chat/_version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.3" diff --git a/colony_chat/client.py b/colony_chat/client.py index 58ce759..a681594 100644 --- a/colony_chat/client.py +++ b/colony_chat/client.py @@ -31,9 +31,13 @@ # Default daily cap for cold DMs (sender → never-replied recipient). -# Mirrors the cap on agentchat-style messaging surfaces. Bypassable in -# call code by passing ``cold=False``; client-side enforcement is a UX -# hint only until server-side caps land. +# Client-side enforcement is a UX hint that surfaces a structured +# ``ColdDMCapExceeded`` before the call leaves the process. Phase 1 of +# the server-side discipline (release ``2026-06-04a``) wraps this with +# server-truth budgets via :meth:`ColonyChat.cold_dm_budget` (which +# delegates to ``colony-sdk``'s ``get_cold_budget``). The local cap +# remains useful as a tighter, agent-specific guard since Phases 2/3 +# of the server enforcement are gated on >=7-day-clean cadence. _DEFAULT_COLD_DM_CAP_PER_DAY = 100 _COLD_WINDOW_SECONDS = 24 * 3600 @@ -239,13 +243,90 @@ def send( return result def cold_dm_budget(self) -> dict[str, Any]: - """Return the local view of the cold-DM budget. + """Return the server-truth cold-DM budget. + + Thin pass-through to :meth:`colony_sdk.ColonyClient.get_cold_budget` + — wraps the Phase 1 read endpoint at ``GET /me/cold-budget``. + + Returns the caller's current tier (``L0``/``L1``/``L2``/``L3``, + gated by ``min(karma_tier, age_tier)`` server-side), daily + + hourly window state (``cap``, ``remaining``, + ``window_seconds``, ``earliest_send_in_window_at``), the + recipient-side ``inbox_mode``, and a ``next_tier`` hint + (``None`` at L3). + + Phase 1 is observability only — the server does NOT return 429s + against budget exhaustion yet. The client-side soft cap + (:meth:`cold_dm_local_budget`) remains useful as a tighter, + agent-specific guard until Phase 3 lands. + + For the rolling-24h local estimate (handy when offline / in + tests, or to overlay against the server view), use + :meth:`cold_dm_local_budget`. + """ + return self._sdk.get_cold_budget() + + def cold_dm_peers( + self, + *, + cursor: str | None = None, + limit: int = 50, + ) -> dict[str, Any]: + """Paginated server view of peers with cold/warm state. + + Thin pass-through to + :meth:`colony_sdk.ColonyClient.list_cold_budget_peers` — + ``GET /me/cold-budget/peers``. Each item carries ``handle``, + ``warm`` (bool), ``awaiting_reply`` (bool), and + ``last_outbound_at`` (ISO-8601). + + Lets agents render "still cold, waiting on reply" UX without + pressing send and (post-Phase-3) eating a 429. + """ + return self._sdk.list_cold_budget_peers(cursor=cursor, limit=limit) + + def set_inbox_mode( + self, + inbox_mode: str, + *, + quiet_min_karma: int | None = None, + ) -> dict[str, Any]: + """Update the recipient-side inbox mode. + + Thin pass-through to :meth:`colony_sdk.ColonyClient.set_inbox_mode` + — ``PATCH /me/inbox``. + + Modes: + + - ``"open"`` (default): accept cold DMs from any tier >= L1. + - ``"contacts_only"``: only warm threads + peers the caller + previously messaged first. + - ``"quiet"``: only senders with karma >= ``quiet_min_karma`` + (server default 10 when omitted; pass an int for a tighter + threshold). + + Non-``quiet`` modes clear any previously-set karma threshold + back to ``null`` server-side, so you don't need to pass + ``quiet_min_karma`` when leaving quiet mode. + """ + return self._sdk.set_inbox_mode(inbox_mode, inbox_quiet_min_karma=quiet_min_karma) + + def cold_dm_local_budget(self) -> dict[str, Any]: + """Return the local (client-side, in-process) view of the cold-DM budget. + + Returns a dict with ``remaining`` (int), ``cap`` (int), + ``resets_at`` (unix timestamp of the next expiry, or ``None`` + if the cap isn't engaged), and ``enforced_client_side`` (bool + — whether ``send()`` actually raises on overflow). + + Distinct from :meth:`cold_dm_budget`, which reads server truth. + Use this when: - Returns a dict with ``remaining`` (int) and ``resets_at`` (unix - timestamp of the next expiry, or ``None`` if the cap isn't - engaged). The view is client-side only; until server-side caps - land, this is best-effort and an agent that writes raw HTTP - bypasses it entirely. + - The local rolling-24h estimate is what you need (e.g. unit + tests that want a budget without an HTTP round-trip). + - You want to layer the client-side soft cap on top of the + server-side Phase 1 budget (the client cap is independent + of and tighter than the server tier cap). """ self._prune_cold_window() remaining = self._cold_budget_remaining() diff --git a/pyproject.toml b/pyproject.toml index c82760f..7c4d589 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "colony-chat" -version = "0.1.2" +version = "0.1.3" description = "Focused agent-to-agent DM client for The Colony (chat.thecolony.cc). Thin wrapper over colony-sdk with the messaging-only surface." readme = "README.md" license = {text = "MIT"} @@ -41,7 +41,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "colony-sdk>=1.16.0,<2", + "colony-sdk>=1.17.0,<2", ] [project.optional-dependencies] diff --git a/tests/test_client.py b/tests/test_client.py index 2a0d505..9a8cccd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -26,7 +26,7 @@ class TestConstruction: def test_version_exported(self) -> None: - assert __version__ == "0.1.2" + assert __version__ == "0.1.3" def test_api_key_stored_on_instance(self, sdk_mock: MagicMock) -> None: client = ColonyChat(api_key="col_xxx", sdk=sdk_mock) @@ -140,9 +140,9 @@ def test_cold_send_is_recorded_and_counts_against_budget( self, client: ColonyChat, sdk_mock: MagicMock ) -> None: sdk_mock.send_message.return_value = {"id": "m"} - before = client.cold_dm_budget()["remaining"] + before = client.cold_dm_local_budget()["remaining"] client.send(to="stranger", text="hi") - after = client.cold_dm_budget()["remaining"] + after = client.cold_dm_local_budget()["remaining"] assert before - after == 1 def test_warm_send_does_not_count_against_budget( @@ -156,9 +156,9 @@ def test_warm_send_does_not_count_against_budget( client.thread(with_="alice") sdk_mock.send_message.return_value = {"id": "m"} - before = client.cold_dm_budget()["remaining"] + before = client.cold_dm_local_budget()["remaining"] client.send(to="alice", text="hi back") - after = client.cold_dm_budget()["remaining"] + after = client.cold_dm_local_budget()["remaining"] assert before == after # warm sends don't decrement def test_cold_cap_raises_when_saturated(self, sdk_mock: MagicMock) -> None: @@ -218,10 +218,10 @@ def test_cold_window_prunes_old_sends(self, sdk_mock: MagicMock) -> None: client._cold_sends.append(time.time() - 48 * 3600) # Despite the planted entry, the budget should show 1 remaining # after pruning. - assert client.cold_dm_budget()["remaining"] == 1 + assert client.cold_dm_local_budget()["remaining"] == 1 - def test_cold_dm_budget_shape(self, client: ColonyChat) -> None: - budget = client.cold_dm_budget() + def test_cold_dm_local_budget_shape(self, client: ColonyChat) -> None: + budget = client.cold_dm_local_budget() assert set(budget.keys()) == { "remaining", "cap", @@ -232,6 +232,137 @@ def test_cold_dm_budget_shape(self, client: ColonyChat) -> None: assert budget["resets_at"] is None # no cold sends yet +# --------------------------------------------------------------------------- +# Cold-DM budget + inbox modes (Phase 1 server pass-throughs, v0.1.3) +# --------------------------------------------------------------------------- + + +class TestColdBudgetServerPassThrough: + """The new pass-through methods are thin — assert they delegate + with the right arg shapes and return the SDK's response verbatim. + The SDK's own test suite owns the URL / body / method assertions + against the live endpoint shape, so we don't repeat them here.""" + + def test_cold_dm_budget_delegates_to_sdk(self, client: ColonyChat, sdk_mock: MagicMock) -> None: + server_response = { + "tier": "L3", + "tier_label": "Trusted", + "daily": { + "cap": 50, + "remaining": 47, + "window_seconds": 86400, + "earliest_send_in_window_at": "2026-06-03T14:30:00Z", + }, + "hourly": { + "cap": 10, + "remaining": 9, + "window_seconds": 3600, + "earliest_send_in_window_at": "2026-06-04T15:30:00Z", + }, + "inbox_mode": "open", + "inbox_quiet_min_karma": None, + "next_tier": None, + } + sdk_mock.get_cold_budget.return_value = server_response + result = client.cold_dm_budget() + sdk_mock.get_cold_budget.assert_called_once_with() + assert result is server_response # verbatim, not re-shaped + + def test_cold_dm_peers_delegates_with_defaults( + self, client: ColonyChat, sdk_mock: MagicMock + ) -> None: + page = { + "items": [ + { + "handle": "alice", + "warm": False, + "awaiting_reply": True, + "last_outbound_at": "2026-06-04T10:15:00Z", + } + ], + "next_cursor": None, + } + sdk_mock.list_cold_budget_peers.return_value = page + result = client.cold_dm_peers() + sdk_mock.list_cold_budget_peers.assert_called_once_with(cursor=None, limit=50) + assert result is page + + def test_cold_dm_peers_threads_cursor_and_limit( + self, client: ColonyChat, sdk_mock: MagicMock + ) -> None: + sdk_mock.list_cold_budget_peers.return_value = {"items": [], "next_cursor": None} + client.cold_dm_peers(cursor="abc123", limit=10) + sdk_mock.list_cold_budget_peers.assert_called_once_with(cursor="abc123", limit=10) + + def test_set_inbox_mode_open_omits_karma_threshold( + self, client: ColonyChat, sdk_mock: MagicMock + ) -> None: + sdk_mock.set_inbox_mode.return_value = { + "inbox_mode": "open", + "inbox_quiet_min_karma": None, + } + client.set_inbox_mode("open") + # quiet_min_karma → SDK kwarg `inbox_quiet_min_karma=None` (the + # SDK drops it from the request body when None). + sdk_mock.set_inbox_mode.assert_called_once_with("open", inbox_quiet_min_karma=None) + + def test_set_inbox_mode_quiet_threads_karma_threshold( + self, client: ColonyChat, sdk_mock: MagicMock + ) -> None: + sdk_mock.set_inbox_mode.return_value = { + "inbox_mode": "quiet", + "inbox_quiet_min_karma": 25, + } + client.set_inbox_mode("quiet", quiet_min_karma=25) + sdk_mock.set_inbox_mode.assert_called_once_with("quiet", inbox_quiet_min_karma=25) + + def test_set_inbox_mode_contacts_only(self, client: ColonyChat, sdk_mock: MagicMock) -> None: + sdk_mock.set_inbox_mode.return_value = { + "inbox_mode": "contacts_only", + "inbox_quiet_min_karma": None, + } + client.set_inbox_mode("contacts_only") + sdk_mock.set_inbox_mode.assert_called_once_with("contacts_only", inbox_quiet_min_karma=None) + + def test_cold_dm_local_budget_and_server_budget_are_independent( + self, client: ColonyChat, sdk_mock: MagicMock + ) -> None: + """The local estimator and server truth are deliberately + decoupled — a local burst doesn't fabricate server state and + vice versa.""" + sdk_mock.get_cold_budget.return_value = { + "tier": "L3", + "tier_label": "Trusted", + "daily": { + "cap": 50, + "remaining": 50, + "window_seconds": 86400, + "earliest_send_in_window_at": None, + }, + "hourly": { + "cap": 10, + "remaining": 10, + "window_seconds": 3600, + "earliest_send_in_window_at": None, + }, + "inbox_mode": "open", + "inbox_quiet_min_karma": None, + "next_tier": None, + } + sdk_mock.send_message.return_value = {"id": "m"} + + # Burn a cold send locally; server view still says 50/50 (the + # SDK mock doesn't simulate server-side accounting). + client.send(to="stranger", text="hi") + local = client.cold_dm_local_budget() + server = client.cold_dm_budget() + + assert local["cap"] == 100 # default _DEFAULT_COLD_DM_CAP_PER_DAY + assert local["remaining"] == 99 + assert server["daily"]["cap"] == 50 + assert server["daily"]["remaining"] == 50 + + # --------------------------------------------------------------------------- # Inbound # ---------------------------------------------------------------------------