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
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,18 @@ jobs:
run: pip install build twine

- name: Install package
run: pip install .
run: pip install ".[dev]"

- name: Verify import
run: python -c "from pine_assistant import PineAI, AsyncPineAI, __version__; print(f'pine-assistant {__version__} OK')"

- name: Lint
run: ruff check src tests

# Contract and flow tests run offline against recorded fixtures.
# tests/integration needs a token and spends credits — see its README.
- name: Run tests
run: pip install pytest pytest-asyncio && pytest tests/ -v --ignore=tests/integration
run: pytest tests/ -v --ignore=tests/integration

- name: Build distribution
run: python -m build
Expand Down
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,70 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
project adheres to [Semantic Versioning](https://semver.org/).

## [0.4.0] - 2026-08-08

Aligned to the supported protocol scope: the subset of the task-session
Socket.IO protocol whose names, payloads, and semantics carry a compatibility
guarantee. What the SDK models is now that subset and nothing else.

### Added

- `is_supported_event()` and `SUPPORTED_EVENTS` — whether an event carries the
guarantee.
- `session:llm_thinking`, `session:tool_status`, `session:required_action` and
`session:restriction`, with models. All four are in the supported scope and
none were modelled before; `session:tool_status` is where an outbound call
reports its number, duration, credits, and textual outcome.
- `AsyncPineAI.rebuild()` — pages through history until the cursor is
exhausted. Recovery is an unconditional rebuild: joining never resumes from a
cursor, and a short or empty page does not mean a range is done.
- `AsyncPineAI.on_reconnect()` — fires after a reconnect has re-joined, so
callers can rebuild. A connection can stay open after delivery has stopped.
- `InputState` with `awaiting_credits` and `needs_phone_verification`. A
blocking condition is read from `session:input_state`, because the events that
elaborate on one are mostly outside the scope.
- `AsyncPineAI.emit_event()` — the escape hatch for sending anything outside the
supported surface.
- Protocol fixtures and contract tests under `tests/protocol`, and
`tests/integration/record_fixtures.py` to record them from a live session.
Re-recording is the only way server drift gets noticed.

### Changed

- `session:join` now carries `since_revision` "0", on first join and on
reconnect. The incremental-synchronization fields in the response are ignored.
- Events are deduplicated on the event identifier together with the message
type. Keying on the identifier alone drops real events, since identifiers
collide across types.
- A turn begins and ends on supported events only. It previously hinged on
`session:ask_for_location`, `session:interactive_auth_confirmation`,
`session:three_way_call` and `session:reward`, none of which are maintained.

### Fixed

- Sessions joined through `join_session()` were never re-joined after a
reconnect. Membership was tracked on the fire-and-forget emit path only, while
joining goes out through the request/response path.

### Removed

Everything below is still emitted by the server and still reaches callers
untouched — the SDK just no longer models it. Send with `emit_event()`.

- `send_auth_confirmation()`, `send_location_response()`,
`send_location_selection()`.
- `NotificationEvent`, the `notification:*` constants, and the `session:reward`
and `session:payment` models.
- The out-of-scope `S2CEvent` and `C2SEvent` members, including
`session:work_log`, `session:work_log_part` and `session:thinking`. The
reasoning stream in scope is `session:llm_thinking`, a different event that
the SDK did not previously carry.
- The `action` argument on `chat()` and `send_message()`, and `request_work_log`
on `get_history()`.
- Wall-clock filtering of events older than the moment a turn began. It
contradicts rebuilding from history, and a clock offset made it drop real
events.

## [0.3.3] - 2026-05-23

### Fixed
Expand Down
169 changes: 149 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,62 +23,191 @@ await client.connect()

session = await client.sessions.create()
await client.join_session(session["id"])
await client.rebuild(session["id"]) # load the session's messages

async for event in client.chat(session["id"], "Negotiate my Comcast bill"):
print(event.type, event.data)

await client.disconnect()
```

A client tracks one session. Concurrent sessions need one client each.

## Quick Start (CLI)

```bash
pine auth login # Email verification
pine chat # Interactive REPL
pine send "Negotiate my Comcast bill" # One-shot message
pine sessions list # List sessions
pine task start <session-id> # Start task (Pro)
pine task start <session-id> # Start task
```

## Handling Events
## The supported surface

The SDK models the supported protocol scope: the events whose names,
payloads, and semantics change compatibly or with notice.

**Connection and session**

| Event | What it is for |
|---|---|
| `ready` | Authentication succeeded and the connection is usable. Nothing is sent before it |
| `session:join` | Enter a session and read its current state. Sent both ways under this name |
| `session:history` | Read persisted messages. Also the only recovery mechanism in this scope |
| `session:error` | The only channel for server-reported failures |

**Conversation**

| Event | What it is for |
|---|---|
| `session:message` | Your input. Sent to the server, and returned under the same name in history |
| `session:text` | A complete agent message — the durable record |
| `session:text_part` | Streaming increments of one message, assembled by `message_id` |
| `session:rich_content` | A structured document, such as a search report. Its body is **not** repeated in `session:text`; ignore this event and the content is lost |
| `session:llm_thinking` | Reasoning and tool-call trace. Search has no event of its own — it appears here as a `tool_call` step |

**Session state**

| Event | What it is for |
|---|---|
| `session:state` | Where the task stands in its lifecycle |
| `session:input_state` | Whether input is accepted, and the reason when it is not. This is where a blocked session says why |
| `session:message_status` | What became of a message you sent — the only way to tell a rejected or rate-limited one from one still being worked on |
| `session:required_action` | Whether the session is waiting on you |
| `session:update_title` | The session title, as the agent revises it |
| `session:restriction` | An account restriction. The only statement that a task will not complete |

**Interaction**

| Event | What it is for |
|---|---|
| `session:form_to_user` | Structured data collection — how a task asks for the account details it needs to act. Sent both ways under this name, and the most frequent interaction here |

**Task and result**

| Event | What it is for |
|---|---|
| `session:task_ready` | What the task will cost in credits, and whether it is authorised. When the balance covers it the server starts the task itself and this is informational; when it does not, the session waits |
| `session:task_finished` | The result. `completion.result_title`, `result_description` and `outcome_narrative` carry the text; `completion.summary` is quantified, and `brief` is its only prose |
| `session:tool_status` | The record of one asynchronous operation. An outbound call reports here: the number, the duration, the credits, and `summary.text`. It updates in place, reusing its `message_id`, so expect several with the same one |

Payloads may gain fields at any time — tolerate fields you do not recognise.

A `tool_call` step in `session:llm_thinking` describes the same operation as the
matching `session:tool_status`. Do not show both.

A turn commonly delivers `session:text_part` alone: the composer reopens once
the agent has finished speaking, and the complete `session:text` is the durable
record, read back from history. Assemble the parts by `message_id` rather than
waiting for the complete message to arrive live.

## Everything else passes through

The server emits many more events. The SDK delivers every one of them unchanged
rather than dropping them, but it models none of them:

```python
from pine_assistant import is_supported_event

async for event in client.chat(session_id, "..."):
if not is_supported_event(event.type):
continue # or handle it yourself, at your own risk
```

An unsupported event may be renamed, have its payload changed, or stop being
emitted, without notice and without a version change. Tolerating one is
required; depending on one is not. To send one, use `client.emit_event(...)`.

Some of them are questions to the user that the SDK has no interface for.
Ignoring one leaves the conversation suspended, and the composer stays open —
show the message text and let the user answer in ordinary conversation. Never
fabricate an answer: the formats have no representation for refusal, and an
empty submission is indistinguishable from empty answers, so the agent may act
on it. Sending nothing is safe.

Pine AI behaves like a human assistant. After you send a message, it sends
acknowledgments, then work logs, then the real response (form, text, or task_ready).
**Don't respond to acknowledgments** — only respond to forms, specific questions,
and task lifecycle events, or you'll create an infinite loop.
## What to respond to

## Continuing Existing Sessions
Pine works the way a person would: a message is acknowledged, then reasoned
about, and only then answered. Acknowledgements and `session:llm_thinking`
arrive before the real response — a form, a text answer, or a task ready to run.

Respond only to what asks you something: `session:form_to_user`, a direct
question, and the task lifecycle. Replying to an acknowledgement starts a loop
in which each side answers the other's filler.

## Continuing an existing session

```python
# List all sessions
result = await client.sessions.list(limit=20)

# Continue an existing session
await client.join_session(existing_session_id)
history = await client.get_history(existing_session_id)
messages = await client.rebuild(existing_session_id)
async for event in client.chat(existing_session_id, "What is the status?"):
...
```

## Attachments
To hand a session back to the user in the web app:

```python
# Upload a document for dispute tasks
attachments = await client.sessions.upload_attachment("bill.pdf")
print(AsyncPineAI.session_url(session_id))
```

## Recovery

State is rebuilt, never resumed. `join_session()` always joins from scratch,
and `rebuild()` pages through history until the cursor is exhausted — a short
or empty page does not mean the range is done.

```python
remove = client.on_reconnect(lambda: asyncio.create_task(reload(session_id)))
```

## Stream Buffering
Rebuild on every join, on every reconnect, and whenever a session you are
tracking has been silent for a while: a connection can stay open after delivery
has stopped.

Text streaming is buffered internally. You receive one merged text event,
not individual chunks. Work log parts are debounced (3s silence).
`rebuild()` returns messages of every type, including unsupported ones.
Filtering them is yours to do.

## Payment
## Blocked sessions

Pro subscription recommended. For non-subscribers:
When the composer is disabled, `session:input_state` carries the reason. Read it
from there rather than inferring it from which events did or did not arrive.

```python
from pine_assistant import AsyncPineAI
print(f"Pay at: {AsyncPineAI.session_url(session_id)}")
from pine_assistant import InputState, S2CEvent

if event.type == S2CEvent.SESSION_INPUT_STATE:
state = InputState.model_validate(event.data)
if state.awaiting_credits:
... # cost is on session:task_ready; retry once the balance is restored
if state.needs_phone_verification:
... # no in-session remedy
```

An expired session has no reason code of its own — it presents only as a
disabled composer. Expiry is the `is_stale` field on the session object, over
REST. On finding one expired, create a new session and reference the old one in
your first message:

```python
new = await client.sessions.create()
client.send_message(new["id"], "...", referenced_sessions=[{"session_id": old_id}])
```

## Before an account is used

Two conditions have no remedy once a session is running:

- **Metered billing.** The account must be billed against a credit balance. On
the alternative path a session halts at a payment step the SDK cannot answer.
- **Phone verification.** Must be completed at provisioning time.

## Attachments

```python
attachments = await client.sessions.upload_attachment("bill.pdf")
```

## License
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "pine-assistant"
version = "0.3.3"
version = "0.4.0"
description = "Pine AI SDK — Let Pine AI handle your digital chores. Socket.IO + REST client."
readme = "README.md"
license = "MIT"
Expand Down Expand Up @@ -55,6 +55,11 @@ line-length = 120
select = ["E", "F", "I", "W", "UP", "B", "SIM"]
ignore = ["E501"]

[tool.ruff.lint.per-file-ignores]
"src/pine_assistant/models/__init__.py" = ["F403"] # deliberate star re-export
"src/pine_assistant/cli/main.py" = ["E402"] # subcommands import after the group exists
"tests/integration/*.py" = ["B017"] # a live server's failure type is not ours to pin

[tool.ruff.lint.isort]
known-first-party = ["pine_assistant"]

Expand Down
25 changes: 20 additions & 5 deletions src/pine_assistant/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,40 @@

Let Pine AI handle your digital chores.
Socket.IO + REST client for the Pine AI backend.

The SDK models the supported protocol scope. Events outside it are delivered
verbatim but carry no compatibility guarantee: tolerate them, do not depend on
them. `is_supported_event` tells the two apart.
"""

from pine_assistant.client import PineAI, AsyncPineAI
from pine_assistant.auth import Auth
from pine_assistant.chat import ChatEvent
from pine_assistant.client import AsyncPineAI, PineAI
from pine_assistant.errors import AuthError, ConnectionError, PineAIError, SessionError
from pine_assistant.models.events import (
SUPPORTED_EVENTS,
C2SEvent,
S2CEvent,
is_supported_event,
)
from pine_assistant.models.session import InputState, InputStateCode
from pine_assistant.sessions import SessionsAPI
from pine_assistant.errors import PineAIError, AuthError, SessionError, ConnectionError
from pine_assistant.models.events import C2SEvent, S2CEvent, NotificationEvent

__version__ = "0.3.3"
__version__ = "0.4.0"
__all__ = [
"PineAI",
"AsyncPineAI",
"Auth",
"SessionsAPI",
"ChatEvent",
"PineAIError",
"AuthError",
"SessionError",
"ConnectionError",
"C2SEvent",
"S2CEvent",
"NotificationEvent",
"SUPPORTED_EVENTS",
"is_supported_event",
"InputState",
"InputStateCode",
]
6 changes: 3 additions & 3 deletions src/pine_assistant/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

from typing import Any

from pine_assistant.transport.http import HttpClient
from pine_assistant.errors import AuthError
from pine_assistant.transport.http import HttpClient


class Auth:
Expand All @@ -19,7 +19,7 @@ async def request_code(self, email: str) -> dict[str, Any]:
try:
return await self._http.post("/v2/auth/email/request", {"email": email}, authenticated=False)
except Exception as e:
raise AuthError(f"Failed to request auth code: {e}")
raise AuthError(f"Failed to request auth code: {e}") from e

async def verify_code(self, email: str, code: str, request_token: str) -> dict[str, Any]:
"""Step 2: Verify code and get access token — spec 4.1.2"""
Expand All @@ -32,4 +32,4 @@ async def verify_code(self, email: str, code: str, request_token: str) -> dict[s
self._http.set_token(result["access_token"])
return result
except Exception as e:
raise AuthError(f"Failed to verify auth code: {e}")
raise AuthError(f"Failed to verify auth code: {e}") from e
Loading
Loading