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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion colony_chat/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.2"
__version__ = "0.1.3"
99 changes: 90 additions & 9 deletions colony_chat/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -41,7 +41,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"colony-sdk>=1.16.0,<2",
"colony-sdk>=1.17.0,<2",
]

[project.optional-dependencies]
Expand Down
147 changes: 139 additions & 8 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand All @@ -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
# ---------------------------------------------------------------------------
Expand Down