Metadata-Version: 2.4
Name: pouchy-companion
Version: 0.2.1
Summary: Pouchy companion SDK for Python — thin httpx/SSE client + pydantic models for server-side integrators (game backends, bots, services).
Project-URL: Homepage, https://pouchy.ai/sdk
Project-URL: Documentation, https://pouchy.ai/sdk
Author: Pouchy
License-Expression: LicenseRef-Proprietary
License-File: LICENSE
Keywords: agent,ai-companion,pouchy,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.5
Description-Content-Type: text/markdown

# pouchy-companion — Pouchy Companion SDK for Python

A thin, typed client for the [Pouchy](https://pouchy.ai) companion REST/SSE
plane — the same wire contract the JS SDK (`@pouchy_ai/companion-sdk`) speaks,
aimed at **server-side integrators**: game backends, bots, services.

- **httpx** (sync) + minimal SSE — no framework assumptions
- **pydantic v2** models for every envelope/payload — including
  `DataActivityPayload` (`companion.data_activity`, metadata-only Data-plane
  activity: `{kind, capability, outcome, ms, actionId?}`; needs the
  `data.activity` scope)
- `protocol.py` is **generated from the TypeScript source of truth** and
  drift-tested against it in CI — the two SDKs cannot silently diverge
- Voice/WebRTC and browser-only surfaces are deliberately out of scope
  (use the JS SDK for embeds)

## Install

```bash
pip install pouchy-companion
```

## Quickstart

Mint a session token from your backend (one per end user):

```bash
curl -X POST https://pouchy.ai/v1/sessions \
  -H "Authorization: Bearer pchy_sk_…" \
  -H "Content-Type: application/json" \
  -d '{ "agent": "<agentId>", "external_user_id": "player_42" }'
```

Then talk:

```python
from pouchy_companion import CompanionClient

with CompanionClient(base_url="https://pouchy.ai", token=session_token) as client:
    ack = client.connect()
    print("scopes:", ack.grantedScopes)

    # Request/response in one call — streams server-side, returns the reply.
    out = client.send_text("what should I do about the boss on floor 3?", await_reply=True)
    print(out["text"])
```

### Streaming deltas

```python
client.send_text(
    "tell me a story",
    await_reply=True,
    on_delta=lambda chunk, reset: print(chunk, end="", flush=True),
)
```

### Idempotent retries

`send_text` accepts an optional `turn_id` — a client-chosen id the server dedupes
on for 10 minutes (the last 8 turns per session). Reusing the same `turn_id` after
a network failure is safe: a turn the server already saw returns
`{"duplicate": True, ...}` instead of running twice (with `await_reply=True` the
recorded reply text rides back too).

```python
tid = "order-42"
client.send_text("place the order", await_reply=True, turn_id=tid)
# ...connection dropped, unsure if it landed — safe to retry with the same id:
client.send_text("place the order", await_reply=True, turn_id=tid)
```

### The event stream (proactive messages, confirms, tool calls)

```python
from pouchy_companion import ConfirmRequestPayload, MessagePayload, ToolCallPayload

# The server closes each SSE window cleanly after ~45s, so events() ENDS
# (no exception) even on a healthy connection — a long-lived consumer
# loops around it; the cursor carries across windows, nothing is lost.
while True:
    for env in client.events():       # one SSE window; resumes from the cursor
        if isinstance(env.payload, MessagePayload):
            print("companion:", env.payload.text)
        elif isinstance(env.payload, ConfirmRequestPayload):
            # Platform session tokens may resolve confirms directly — your end
            # user IS this instance's human. Show your own confirm UI, then:
            res = client.confirm_action(env.payload.confirmId, approve=True)
            print("outcome:", res.outcome)
        elif isinstance(env.payload, ToolCallPayload):
            # An app-declared tool (declared via CompanionClient(tools=[...])).
            client.send_tool_result(env.payload.id, ok=True, result={"answer": 42})
```

### Declared tools + `send_text` (a paused turn is not an empty reply)

When this client declared `tools=[...]` and the model calls one, the turn
**pauses server-side** — there is no reply text yet, and the next `send_text`
409s `turn_pending` until every call is answered. `send_text` says so
(0.2.0): the return carries `"kind": "tool_calls"` and the calls themselves
(previously this came back as a silent `{"seq": None, "text": ""}`). Answer
each call, then collect the post-resume reply from `events()`:

```python
out = client.send_text("check my inventory", await_reply=True)
if out["kind"] == "tool_calls":
    for call in out["toolCalls"]:                      # {id, name, args}
        args = json.loads(call["args"])                # args is a raw JSON string
        result = perform_tool(call["name"], args)
        client.send_tool_result(call["id"], ok=True, result=result)
    for env in client.events():                        # post-resume reply
        if isinstance(env.payload, MessagePayload):
            print("companion:", env.payload.text)
            break
else:
    print(out["text"])
```

### Recovering a paused turn (restarted backend)

When a turn pauses on an app-declared tool call and your process restarts (or
reconnects a fresh session) before posting the result, the SSE stream resumes
*past* the original `companion.tool_call` frames — so a naive reconnect never
re-learns the outstanding calls and the only exit was abandoning the turn via
`end_session`. The handshake now hands you those calls directly: connect a
session and read `pendingToolCalls` off the ack, perform each, and post its
result. The server apply is idempotent per call id, so treat `id` as your
idempotency key for side-effecting tools.

```python
from pouchy_companion import PendingToolCall

ack = client.connect()  # /session handshake; ack.pendingToolCalls is [] when none
for call in ack.pendingToolCalls or []:
    assert isinstance(call, PendingToolCall)  # {id, name, args, turnId?, pausedAt?}
    result = perform_tool(call.name, call.args)      # your dispatch
    client.send_tool_result(call.id, ok=True, result=result)
# Once every outstanding call has a result, the paused turn resumes on the stream.
```

> A re-delivered call also carries `ToolCallPayload.replayed = True` on the
> stream. Unlike the JS SDK, the Python client does not auto-replay into a
> handler (there is no client-side handler registry) — the recovery loop is the
> explicit `pendingToolCalls` → perform → `send_tool_result` walk above.

### World state (play-along context)

```python
client.send_world_state({
    "type": "game.event.boss_spawned",
    "data": {"name": "Ashen Knight", "floor": 3},
    "salience": 0.9,
})
```

### Memory / history / wallet

```python
client.recall(query="player preferences", limit=10)
client.history(limit=20)
client.get_wallet()          # read-only (wallet.read)
client.end_session()
```

## Methods

| Method | REST | Notes |
| --- | --- | --- |
| `connect()` | `POST /api/companion/session` | handshake → `HelloAckPayload`; sets `session_id` + cursor (`pendingToolCalls` carries paused-turn recovery) |
| `send_text(text, *, await_reply, on_delta, images, turn_id)` | `POST …/session/{id}/input` | plain or streamed reply; pass your own `turn_id` for retry-idempotence (a replayed turn carries `"duplicate": True`); the return's `"kind"` is `"message"`, or `"tool_calls"` when the turn paused on your declared tools (see below) |
| `events(from_cursor=None)` | `GET …/session/{id}/stream` | ONE ~45s SSE window of typed envelopes — ends cleanly when the server closes the window, so loop around it (`while True:`); cursor auto-advances across windows and drops |
| `pending_confirms()` | `GET …/session/{id}/confirm` | `list[PendingConfirm]` still awaiting approval |
| `confirm_action(confirm_id, approve)` | `POST …/session/{id}/confirm` | resolve a confirm (platform session tokens only) → `ConfirmResolution` |
| `send_tool_result(call_id, *, ok, result)` | `POST …/session/{id}/tool-result` | answer a `companion.tool_call`; returns `allDone` |
| `send_world_state(event \| events)` | `POST …/session/{id}/context` | fills `specversion`/`id`/`source` when omitted → `WorldStateAccepted` |
| `history(limit=20)` | `GET …/session/{id}/history` | `list[HistoryTurn]` |
| `recall(query=None, limit=None)` | `GET /api/companion/memory` | `list[RecalledMemory]` (semantic when `query` given) |
| `remember(fact)` | `POST /api/companion/memory` | write one memory fact (needs `memory.write:app` / `:core`) |
| `ingest_knowledge(text, *, name, kind, locale)` | `POST /api/companion/knowledge` | needs `memory.write:core` |
| `get_wallet()` | `GET /api/companion/wallet` | read-only (`wallet.read`) |
| `set_modalities(modalities)` | `POST …/session/{id}/modalities` | returns the accepted list |
| `ping()` | `POST …/session/{id}/ping` | keep-alive |
| `end_session()` | `POST …/session/{id}/end` | cleanly end (may fold a summary into memory); clears `session_id` |
| `set_token(token)` | local | swap the bearer on the live client (see Token refresh below) |
| `close()` | local | release the HTTP client; also runs on `with … as client:` exit |

## Token refresh (long-running backends)

Session tokens are short-lived (~1h). When a call fails with a 401
(`code == "invalid_token"` — an expired token says so in the message), mint a
fresh session token from your backend and swap it on the live client — the
session survives the swap (a fresh token for the same user resumes the same
session):

```python
try:
    client.send_text("hi")
except CompanionError as e:
    if e.status == 401 and e.code == "invalid_token":
        client.set_token(mint_fresh_session_token())  # your backend call
        client.send_text("hi")
```

Note: assigning `client.token` directly does **not** re-authenticate anything
(the header lives on the underlying HTTP client) — always use `set_token()`.

## Errors

Every failed call raises `CompanionError` with `.status`, `.code`
(machine-switchable — `rate_limited`, `turn_pending`, `confirm_resolved`, …
see `COMPANION_ERROR_CODES`) and `.retry_after` seconds when the server said
when to come back. Transport failures (connect refused, timeouts, dropped
streams) are wrapped too — `status` is `0`, with the original httpx
exception preserved as `__cause__`. `code` is `request_timeout` on a request
deadline; every other transport failure carries no `code` (status `0` alone
signals a network failure — the same shape the JS SDK produces).

### Deadlines

The default is `httpx.Timeout(30.0, read=90.0)`, except for the requests that
hit handlers declaring `maxDuration: 300` — `send_text` (buffered **and**
streamed, as of 0.2.0), `send_tool_result`, and `ingest_knowledge`. Those get
**310s** (`LONG_WORK_TIMEOUT`); connect stays at 30s, so an unreachable host
still fails fast. On the buffered legs the answer only arrives when the whole
operation is done; on the streamed turn the deadline applies **per read**, and
the input stream carries no keepalives — a >90s byte-silent phase between
model hops (slow tool/skill upstream) is a healthy billed turn, not an
outage. A shorter deadline raises `request_timeout` for a turn that is
succeeding — and billing — server-side, and on ingest the retry it invites
races the still-running first ingest.

Passing `timeout=` to `CompanionClient` explicitly always wins and applies to
every **non-streaming** request, long or short. The `events()` stream is the
one exception: it always uses its own `Timeout(30.0, read=60.0)` — the 60s
read bound is the half-open-drop watchdog (the server pings every ~1.5s and
closes each window at ~45s, so a healthy stream is never byte-silent that
long) and a caller-supplied deadline must not disarm it.

```python
import time

from pouchy_companion import CompanionError

try:
    client.send_text("hi")
except CompanionError as e:
    if e.code == "rate_limited":
        time.sleep(e.retry_after or 1)
```

## Reference

The full REST semantics (scopes, confirm flow, world-state salience, limits)
are documented at [pouchy.ai/sdk](https://pouchy.ai/sdk) and in
[companion-api-reference](https://pouchy.ai/docs/companion-api-reference) —
the wire contract is identical across SDKs. Protocol vocabulary lives in `pouchy_companion.protocol`
(`PROTOCOL_VERSION`, `OUTBOUND_TYPES`, error code lists), generated from the
TypeScript source of truth.

## Not yet ported from the JS SDK

A few JS-SDK surfaces are deliberately unported (recorded decisions, not
drift — the full ledger with rationale lives at the top of `CHANGELOG.md`):
`handles`/`context_kinds` hello options, representative mode
(`visitor`/`pair_visitor`), `get_avatar`, and binary `ingest_file`
(`ingest_knowledge` text is covered — URL ingestion is a separate endpoint,
not yet ported). All are single REST calls — ask (or
port) on demand. Related wire nuance: on a re-handshake the server keeps the
previously declared `tools` set when the field is absent and clears it on an
explicit `[]` — this client omits empty lists (`if self.tools:`), so it can
re-declare or keep, but cannot send an explicit clear (record, not drift).

## Development

```bash
# Regenerate protocol.py after a protocol.ts change (CI enforces parity):
node scripts/generate-protocol.mjs

# Tests (offline, MockTransport):
pip install -e . pytest && pytest
```
