ai.pouchy.companion 0.1.0-preview.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,346 @@
1
+ # Changelog
2
+
3
+ All notable changes to the Pouchy Companion Unity/C# SDK are documented here.
4
+ This project adheres to [Semantic Versioning](https://semver.org).
5
+
6
+ > Parity note — **CORRECTED in 0.1.0-preview.6 (audit round 490)**. This note
7
+ > used to read: "the JS SDK 0.35.0 per-request header deadline
8
+ > (`requestTimeoutMs`) needs no port here — every request already runs under
9
+ > `CompanionClientOptions.Timeout`". That stated the wrong conclusion as
10
+ > settled. Running under *a* deadline was never the question; the point of the
11
+ > JS work is that the flat default is RAISED on the routes whose server handler
12
+ > declares `maxDuration: 300`, and this client's flat 90s was BELOW that ceiling
13
+ > on three of them. Ported in 0.1.0-preview.6 as
14
+ > `CompanionClientOptions.LongWorkTimeout`. Recorded here rather than rewritten
15
+ > away, because a parity ledger that quietly edits its own wrong entries is not
16
+ > auditable.
17
+
18
+ > Parity ledger — JS surfaces not yet ported (recorded audit round 26, so the
19
+ > gap is a decision, not drift): `handles`/`contextKinds` hello options (the
20
+ > game track's core play-along declaration — the natural NEXT port here),
21
+ > representative mode (`visitor` + `pairVisitor`), `GetAvatar` (a Unity host
22
+ > rendering the companion is a primary consumer), and binary `IngestFile`
23
+ > (`IngestKnowledgeAsync` text is covered). All are single REST calls — port
24
+ > on demand.
25
+ >
26
+ > Added audit round 32: JS `setToken` (swap the bearer token on a live client
27
+ > after ~1h session-token expiry) is also unported — `_token` is readonly
28
+ > here, so a Unity host currently reconnects with a fresh client instead (the
29
+ > session survives: a fresh token for the same user resumes the same session).
30
+ > Python gained `set_token()` this round; port here on demand.
31
+ >
32
+ > Added audit round 361: `EndSessionAsync()` cannot carry the VOICE `transcript`
33
+ > (nor `callGen`) — it posts an empty object. Voice audio is provider-direct, so
34
+ > that field is the only path from anything said aloud into long-term memory. A
35
+ > Unity host is a PRIMARY consumer here (a game that runs its own voice call and
36
+ > uses this client for the session plane loses the entire call at hangup — the
37
+ > server answers `skipped: 'no_content'`). The Dart SDK closed this in
38
+ > `pouchy_companion` 0.1.1; port here on demand — one optional argument, and the
39
+ > wire field has always existed.
40
+
41
+ ## 0.1.0-preview.10 — 2026-08-21
42
+
43
+ - **The RAW client's documented reconnect loop no longer hot-loops against a
44
+ gateway that accepts and instantly closes.** preview.9 clamped this shape in
45
+ `EventStreamPump` — and the README's raw-client idiom one paragraph earlier
46
+ ("call `StreamEventsAsync` again"; the Go/Python caller-owned-loop contract)
47
+ stayed un-clamped, exactly the carrier class #523 closed for Python/Go/Dart.
48
+ `StreamEventsAsync` now sleeps an escalating 1→2→4→8s before opening a
49
+ window whenever the previous window ended cleanly, delivered **zero
50
+ envelopes**, and lived under 2s from headers-received — a shape a healthy
51
+ origin cannot produce (every real window opens with `hello.ack` and closes
52
+ at ~45s). Any delivered envelope clears the ladder at delivery; an errored
53
+ window leaves it alone (errors still throw immediately, and backing off on
54
+ errors is already the caller's documented job). The bound is 2s
55
+ post-headers — the JS client's stamp — which preview.9's pump could not
56
+ use from outside the client; the pump keeps its coarser 5s
57
+ handshake-inclusive guard, and when both fire the delays add (bounded).
58
+
59
+ ## 0.1.0-preview.9 — 2026-08-21
60
+
61
+ - **`EventStreamPump` no longer hot-loops against a gateway that accepts and
62
+ instantly closes.** preview.8's policy — "clean window close → reconnect
63
+ immediately" — was written for the server's designed ~45s cadence, but it
64
+ also fired on a clean close a gateway produced in milliseconds
65
+ (auth-as-close, an LB idle policy, a misconfigured proxy). Every reconnect
66
+ got the same instant close, so the pump reconnected in a zero-delay hot
67
+ loop: unbounded requests against the gateway and a pinned core on the
68
+ device. This is the exact shape the JS client's WS leg has guarded since
69
+ its 0.35.0 short-lived-connection clamp; the pump claimed to mirror that
70
+ clamp and ported only the error half. Now a clean close that delivered
71
+ **zero events** in under 5s climbs the existing 1→30s ladder (silently —
72
+ a clean close is not an error, so `onError` stays quiet). A window that
73
+ delivered an event, however short, and a zero-event window that ran the
74
+ designed cadence both keep the immediate reconnect, so idle-but-healthy
75
+ subscriptions pay nothing. The bound is 5s where the JS clamp is 2s
76
+ because this measurement includes the handshake (the client exposes no
77
+ headers-received instant); both sit far below the 45s cadence.
78
+
79
+ ## 0.1.0-preview.8 — 2026-08-21
80
+
81
+ - **`PouchyCompanionBehaviour` is now an always-on subscription —
82
+ previously it went silent after exactly one stream window (~45s).**
83
+ The raw `StreamEventsAsync` consumes ONE window and returns when the
84
+ server closes it, which it does cleanly every ~45s by design (the
85
+ go/python idiom: the caller owns the loop). The behaviour called it
86
+ once, so a Unity host received events for 45 seconds and then silently
87
+ nothing, forever — the same user-visible shape as the Go cursor defect
88
+ (#492), reached through a missing loop instead of a poisoned cursor,
89
+ in the one component whose whole point is that a designer wires it in
90
+ the Inspector and never writes the loop themselves.
91
+ - **New: `EventStreamPump.RunAsync(client, onEvent, onError, ct)`** —
92
+ the engine-agnostic reconnect loop the behaviour now rides, public so
93
+ a pure .NET host gets the same always-on subscription without owning
94
+ the policy. Clean window close → immediate reconnect (the cursor has
95
+ advanced; nothing is lost or replayed) and the backoff ladder resets;
96
+ errors surface via `onError` and retry after
97
+ `max(ladder, min(RetryAfter, 60s))` with a 1,2,4,8,16,30s ladder — the
98
+ server can only ever slow the loop down, and a window that delivered
99
+ events resets the ladder (a working-then-dying link is a fresh
100
+ incident, not an escalation). `not_connected` stops the pump: the
101
+ session was ended on purpose and a retry can never mint it back.
102
+ Cancellation returns quietly, teardown noise included; `RunAsync`
103
+ never throws, which is what makes it safe to fire-and-forget from a
104
+ component lifecycle method. Handshake failures in the behaviour remain
105
+ fatal (a bad/expired token cannot be retried into working).
106
+ - Proven in the package's own runner: 6 new `dotnet test` cases
107
+ (reconnect-and-resume-from-cursor, RetryAfter-honouring backoff, the
108
+ 60s cap, ladder reset, `not_connected` stop, ladder shape), and four
109
+ hand-run mutations each fail exactly the intended case — removing the
110
+ loop (the defect itself), `min` for `max` on RetryAfter, dropping the
111
+ delivered-reset, dropping the `not_connected` stop. The repo-side
112
+ cross-SDK gate pins the pump policy and pins that the behaviour rides
113
+ the pump rather than the bare one-shot call.
114
+
115
+ ## 0.1.0-preview.7 — 2026-08-21
116
+
117
+ - **The event stream bounds byte-SILENCE —
118
+ `CompanionClientOptions.StreamStallTimeout` (default 60s).** A half-open
119
+ TCP drop (a NAT/LB idle-kill eats the FIN; no close ever arrives) left
120
+ `StreamEventsAsync` blocked forever on a socket that would never speak
121
+ again, with every later event silently lost — reproduced against the
122
+ pre-change client (one event delivered, then unbounded silence). The
123
+ server pings every 1.5s and closes each stream window at 45s, so a
124
+ HEALTHY connection is never byte-silent for a minute; 60s clears both
125
+ numbers, the same default and reasoning as the Go SDK's
126
+ `DefaultStreamStallTimeout`, Dart's `kStreamStallTimeout` and Python's
127
+ `read=60.0`. Armed per READ, not per stream — a healthy subscription is
128
+ never cut, and the host's own `onEvent` latency never counts as
129
+ silence. On a stall the call throws `CompanionError` (`Status` 0, code
130
+ `aborted`) whose message says what to do: call `StreamEventsAsync`
131
+ again, it resumes from `Cursor`. Zero/negative
132
+ (`Timeout.InfiniteTimeSpan`) disables the bound — the pre-preview.7
133
+ behaviour. Closes the C# half of CR-600 (the last SDK outside this
134
+ contract).
135
+ - **`CompanionError.RetryAfter` now has a DOMAIN: finite and
136
+ non-negative, on both carriers.** The header parse was
137
+ `Math.Max(0.0, secs)` over a `double.TryParse` that accepts `"NaN"`
138
+ and `"Infinity"` — and `Math.Max` PROPAGATES NaN by specification, so
139
+ a broken upstream's header reached the caller as NaN/Infinity, where
140
+ the README's own handler
141
+ (`Task.Delay(TimeSpan.FromSeconds(e.RetryAfter ?? 1))`) throws inside
142
+ the caller's catch block; a negative header clamped to 0, an instant
143
+ retry into the same 429. The body's `retryAfterSec` had NO domain at
144
+ all — and non-finite is reachable there from a fully COMPLIANT
145
+ producer, because the JSON overflow literal (`1e400`) is legal JSON
146
+ and Json.NET decodes it to `double.PositiveInfinity` (measured,
147
+ Newtonsoft 13.0.3). Both carriers now route through one `Sleepable`
148
+ predicate (the C# spelling of Go's `sleepableSeconds` / Dart's
149
+ `_sleepableSeconds` / Python's `_sleepable_seconds`): an out-of-domain
150
+ body value falls THROUGH to the header instead of suppressing it, an
151
+ out-of-domain header becomes `null`, and an HTTP-date already in the
152
+ past still clamps to 0 (that is clock skew from a compliant origin,
153
+ not corruption). Closes the C# halves of CR-613 and CR-615.
154
+ - **The package gains a behavioural test suite**
155
+ (`dotnet test packages/unity-sdk/ci/tests/Tests.csproj` — net8, xunit,
156
+ no Unity editor needed): 28 cases driving the pure .NET client through
157
+ an injected `HttpClient` stub. Both fixes were reproduced there FIRST
158
+ (12 of 23 Retry-After cases fail against the preview.6 sources; the
159
+ stall hang reproduced at >6s), and four hand-run mutations each fail
160
+ exactly the intended case. The repo-side cross-SDK gates
161
+ (`retry-after.drift.test.ts`, `stream-cursor.drift.test.ts`) now pin
162
+ these tests by name; both of their C#-shaped `unbound`/exception books
163
+ are EMPTY for the first time.
164
+
165
+ ## 0.1.0-preview.6 — 2026-08-18
166
+
167
+ - **Route-aware deadlines — `CompanionClientOptions.LongWorkTimeout`
168
+ (default 310s).** The client ran every request under one flat 90s
169
+ `Timeout`, armed as a single `CancellationTokenSource` under
170
+ `HttpCompletionOption.ResponseContentRead`, so it bounded the WHOLE
171
+ exchange. Three companion routes declare `maxDuration: 300` and are
172
+ reached by this client: `POST …/input` on the buffered (non-streaming)
173
+ leg, `POST …/tool-result` (the handler awaits the full billed
174
+ continuation inline), and `POST /api/companion/knowledge` (fetch +
175
+ chunk + embed all happen before the headers). The drive budget alone
176
+ is 240s, so 90s was exceeded by ordinary long turns rather than by
177
+ pathology — a succeeding, BILLED operation surfaced as
178
+ `request_timeout`, and the natural retry then raced the still-running
179
+ first request. `IsLongWork(method, path)` now selects
180
+ `LongWorkTimeout` for exactly those three; everything else keeps the
181
+ 90s `Timeout`, including `POST …/confirm`, whose response is honestly
182
+ bounded at ~47s (35s tool box + 12s phrasing hop) and already fits.
183
+ **Additive and non-breaking**: `Timeout` keeps its name, type and
184
+ default, and lowering it for fast failure on ordinary reads no longer
185
+ re-introduces the cut on the three long routes. Same number and same
186
+ reasoning as the Go SDK's `LongWorkTimeout` and the JS SDK's
187
+ `BUFFERED_TURN_TIMEOUT_MS`.
188
+ - **The streaming POST's deadline is disarmed once an SSE body is
189
+ confirmed**, so the options doc's claim about it holds by construction
190
+ rather than by whichever `HttpClientHandler` is underneath. Measured
191
+ on .NET 8 / `SocketsHttpHandler`: the fired token did NOT cut the body
192
+ (the read never receives it), so this was not an observed break there
193
+ — but Unity ships Mono's handler, which is a different implementation,
194
+ and the buffered fallback branch on the same code path deliberately
195
+ keeps its deadline because it reads a whole `maxDuration: 300` body.
196
+ - **A `request_timeout` now names the deadline that actually fired**
197
+ rather than always quoting `Timeout` — a long-work timeout blamed on
198
+ the ordinary knob sends the integrator to the wrong setting.
199
+ - Cross-SDK gate: `packages/unity-sdk` moves from `unbound` to `bound`
200
+ in `sdk-deadlines.drift.test.ts` and gains a block that binds this
201
+ client's predicate to the server's own `maxDuration` declarations in
202
+ both directions, so a new long route cannot land without a decision
203
+ here. Compiled and sentinel-checked before publishing
204
+ (`dotnet build packages/unity-sdk/ci/Compile.csproj`).
205
+
206
+ - **`quota_exhausted` joined the wire vocabulary** (`Protocol.cs` —
207
+ `ErrorCodes.QuotaExhausted`, 402, NOT retryable; contrast `RateLimited`).
208
+ Arrived via the drift-gated regeneration when the JS SDK shipped it in
209
+ 0.45.0 (2026-08-06); recorded here so the next preview release names it
210
+ instead of shipping it silently.
211
+ - **The C# is compiled now — CR-169 closed.** The package went to npm having
212
+ never been read by a compiler; the wire vocabulary was drift-tested, the code
213
+ itself was not. It compiles clean: every `Runtime/*.cs` including
214
+ `PouchyCompanionBehaviour`, under Unity 2021.3's own profile (.NET Standard
215
+ 2.1, C# 9, `UNITY_2021_1_OR_NEWER`) against the UnityEngine 2021.3.33
216
+ reference assemblies — **0 errors, 0 warnings**, with the .NET analyzers on.
217
+ The run is committed as `ci/Compile.csproj` and wired to
218
+ `.github/workflows/unity-sdk.yml`, so it is a gate rather than a one-time
219
+ result, and it needs no Unity install or licence on the runner.
220
+ Paired with a sentinel build that must FAIL: a green compile of code behind
221
+ `#if UNITY_2021_1_OR_NEWER` means nothing if the define or the engine
222
+ references stopped resolving, and the sentinel is what tells those apart.
223
+ **Still not proven, and stated plainly rather than implied away:** runtime
224
+ behaviour inside a real editor or player. No source or API change — this
225
+ release is the corrected status on the README, the package description and
226
+ the public `/sdk` reference, which all still said the compile pass was
227
+ pending.
228
+
229
+ ## 0.1.0-preview.5 — 2026-07-26
230
+
231
+ - **`SummaryKey` / `SummaryVars` on `ConfirmRequestPayload` and `PendingConfirm`**
232
+ (JS SDK 0.43.0 parity). The confirmation sentence now also travels as data — an
233
+ i18n key plus its placeholder values — so a Unity host that knows the player's
234
+ language can render the approval in it. `Summary` is unchanged and still always
235
+ present; both new fields are null for a tool with no keyed summary and on
236
+ confirmations recorded before this release, so fall back to `Summary`.
237
+
238
+ ## 0.1.0-preview.4 — 2026-07-17
239
+
240
+ ### Added
241
+
242
+ - **Paused-turn recovery fields (read-only), mirroring JS SDK 0.37.0:**
243
+ `HelloAckPayload.PendingToolCalls` (new `PendingToolCall` model —
244
+ `Id`, `Name`, `Args`, `TurnId?`, `PausedAt?`) lists the outstanding tool
245
+ calls of a turn paused before the handshake (non-empty only when the
246
+ session was resumed mid-pause), and `ToolCallPayload.Replayed` marks the
247
+ re-delivery of such a pause-orphaned call. A restarted host can complete
248
+ the paused turn by posting each result via `SendToolResultAsync` (server
249
+ apply is idempotent per call id) instead of abandoning it via
250
+ `EndSessionAsync`.
251
+
252
+ > Parity note (preview.4): the JS SDK's `replayPendingToolCalls` AUTO-replay
253
+ > is deliberately not ported — same decision as the Python SDK 0.1.6: this
254
+ > client has no tool-call handler registry to replay into (integrators
255
+ > consume the event stream themselves), so recovery stays the caller's
256
+ > explicit `PendingToolCalls` → perform → `SendToolResultAsync` walk.
257
+ > Recorded so the cross-SDK parity ledger stays auditable.
258
+
259
+ ## 0.1.0-preview.3 — 2026-07-17
260
+
261
+ ### Added
262
+
263
+ - `SendTextResult.Duplicate` — the server has flagged replayed turnIds with
264
+ `duplicate: true` all along, but the client dropped the field on both send
265
+ paths, so a caller could not tell a replay from a fresh billed turn. Now
266
+ populated on the plain POST and the streaming `done` frame (null for a fresh
267
+ turn). The `SendTextAsync` doc also no longer claims a plain duplicate
268
+ "returns the recorded reply" — the recorded TEXT only rides back on the
269
+ streaming path.
270
+ - `CompanionError(message, status, code, retryAfter, innerException)` overload,
271
+ used by the transport wrapping below to preserve the original exception.
272
+
273
+ ### Fixed
274
+
275
+ - **Transport failures now throw `CompanionError`** (the README's "every failed
276
+ call throws CompanionError" was false for them): `HttpRequestException` and
277
+ the per-request deadline's `TaskCanceledException` escaped raw from
278
+ `RequestJsonAsync`, `SendTextStreamingAsync`, and `StreamEventsAsync`. They
279
+ are wrapped with `Status=0` and `Code="request_timeout"` (deadline elapsed) /
280
+ `"aborted"` (other transport failures, incl. a dropped body stream) — the JS
281
+ SDK's synthesized-code vocabulary — with the cause as `InnerException`.
282
+ Caller-token cancellation on the event stream is deliberately NOT wrapped
283
+ (it stays an `OperationCanceledException`, the behaviour's teardown signal).
284
+ - **No more spurious `OnError` on teardown**: `PouchyCompanionBehaviour.OnDestroy`
285
+ cancels the CTS and disposes the response, which surfaced the resulting
286
+ `ObjectDisposedException`/`IOException` as an `OnError` on every normal
287
+ exit. `RunStreamAsync` now swallows any exception once cancellation was
288
+ requested.
289
+ - `RequireSession` tags its error `Code="not_connected"` (the JS SDK's code for
290
+ the same misuse) instead of null, so switch-on-`Code` handling covers it.
291
+ - `RetryAfter` also parses the RFC 9110 HTTP-date form of the `Retry-After`
292
+ header (delta from now, clamped at 0); only numeric seconds parsed before.
293
+ - `CompanionClientOptions.Timeout` doc no longer overpromises: it reliably
294
+ bounds time-to-response-headers on the streaming input POST, not whole-body
295
+ reads.
296
+ - README: escaped the `|` in the `SendWorldStateAsync(event \| events)` table
297
+ row (it split the GFM table cell); documented the transport-error codes.
298
+ - UPM manifest: added the `license` field and shipped the SDK LICENSE file
299
+ (the companion-sdk license text, UPM-adapted).
300
+
301
+ > Parity note (preview.3): `request_timeout` / `aborted` / `not_connected`
302
+ > mirror the JS SDK's SDK_SYNTHESIZED_ERROR_CODES and Python 0.1.5 — one
303
+ > cross-SDK error vocabulary. As in Python, `aborted` here covers any
304
+ > non-timeout transport failure (there is no AbortSignal equivalent).
305
+
306
+ ## 0.1.0-preview.2 — 2026-07-15
307
+
308
+ ### Fixed
309
+
310
+ - `IngestKnowledgeAsync` sent `{ "source": ... }`, but the server reads `name`
311
+ (title), `kind`, and `locale` and ignored `source` — so every ingested
312
+ document was titled "an uploaded file". The signature is now
313
+ `IngestKnowledgeAsync(text, name, kind, locale)` matching the server contract
314
+ and the JS/Python SDKs.
315
+
316
+ ## 0.1.0-preview.1 — 2026-07-14
317
+
318
+ Initial preview release — the C# counterpart of the Python and JS companion
319
+ SDKs, targeting Unity 2021.3+ (netstandard2.1) and plain .NET.
320
+
321
+ ### Added
322
+
323
+ - `CompanionClient` (pure .NET, no UnityEngine dependency): `ConnectAsync`,
324
+ `SendTextAsync` (plain + streaming await-reply with `onDelta`),
325
+ `StreamEventsAsync` (SSE with typed payloads + auto-advancing cursor),
326
+ `PendingConfirmsAsync`, `ConfirmActionAsync`, `SendToolResultAsync`,
327
+ `SendWorldStateAsync` (single + batch), `HistoryAsync`, `RecallAsync`,
328
+ `RememberAsync`, `IngestKnowledgeAsync`, `GetWalletAsync`,
329
+ `SetModalitiesAsync`, `PingAsync`, `EndSessionAsync`.
330
+ - `PouchyCompanionBehaviour` — optional Unity MonoBehaviour that owns a client,
331
+ streams events on a background Task, and marshals `OnMessage` / `OnTyping` /
332
+ `OnConfirmRequest` / `OnToolCall` / `OnError` onto the main thread.
333
+ - Typed models (`Runtime/Models.cs`) for every payload in the wire protocol,
334
+ with `[JsonExtensionData]` forward-compat and an `OutboundPayloadTypes` map.
335
+ - `CompanionError` with `Status` / `Code` / `RetryAfter`.
336
+ - `Runtime/Protocol.cs` — the message-type and error-code vocabulary, generated
337
+ from the TypeScript source of truth.
338
+ - UPM manifest (`ai.pouchy.companion`), Newtonsoft.Json dependency, asmdef.
339
+
340
+ ### Notes
341
+
342
+ - **Wire protocol is drift-guarded** against `src/lib/companion-sdk/protocol.ts`
343
+ (Protocol.cs byte-equality + Models.cs field-name pins in CI).
344
+ - **Compile verification pending** — this preview was authored without a local
345
+ .NET toolchain; the first Unity/CI compile pass will follow. Report issues.
346
+ - Voice/WebRTC calls are intentionally out of scope (REST/SSE only).
package/LICENSE ADDED
@@ -0,0 +1,42 @@
1
+ Pouchy Companion SDK License
2
+ Copyright (c) 2026 Pouchy.ai. All Rights Reserved.
3
+
4
+ This license governs use of the "ai.pouchy.companion" software package (the
5
+ "SDK") as distributed for the Unity Package Manager. The SDK is proprietary
6
+ software owned by Pouchy.ai. By installing, copying, or using the SDK you agree
7
+ to these terms.
8
+
9
+ 1. GRANT. Subject to these terms, Pouchy.ai grants you a worldwide,
10
+ non-exclusive, non-transferable, royalty-free, revocable license to install
11
+ and use the SDK, as distributed, solely to build and operate applications
12
+ that integrate with Pouchy.ai's official services and APIs.
13
+
14
+ 2. RESTRICTIONS. Except to the extent a restriction below is prohibited by
15
+ applicable law, you may not:
16
+ (a) modify, adapt, or create derivative works of the SDK's source for
17
+ redistribution;
18
+ (b) redistribute, sublicense, sell, rent, or lease the SDK as a standalone
19
+ product, or republish it under a different name;
20
+ (c) reverse engineer, decompile, or disassemble the SDK except to the extent
21
+ necessary for interoperability and expressly permitted by law;
22
+ (d) use the SDK to build or operate a service that competes with Pouchy.ai's
23
+ services, or to access Pouchy.ai's services in violation of their terms;
24
+ (e) remove or alter any copyright, trademark, or other proprietary notices.
25
+
26
+ 3. RESERVATION OF RIGHTS. All rights not expressly granted are reserved by
27
+ Pouchy.ai. No rights are granted in Pouchy.ai's source repositories,
28
+ trademarks, or services beyond what is needed to use the SDK as described.
29
+
30
+ 4. TERMINATION. This license terminates automatically if you breach it. On
31
+ termination you must stop using and delete all copies of the SDK.
32
+
33
+ 5. NO WARRANTY. THE SDK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
34
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
35
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
36
+
37
+ 6. LIMITATION OF LIABILITY. IN NO EVENT SHALL POUCHY.AI BE LIABLE FOR ANY CLAIM,
38
+ DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR
39
+ OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SDK OR THE USE OR
40
+ OTHER DEALINGS IN THE SDK.
41
+
42
+ For any other use, or for written permission, contact legal@pouchy.ai.
package/README.md ADDED
@@ -0,0 +1,269 @@
1
+ # Pouchy Companion — Unity / C# SDK
2
+
3
+ A thin, typed C# client for the [Pouchy](https://pouchy.ai) companion REST/SSE
4
+ plane — the same wire contract the JS (`@pouchy_ai/companion-sdk`) and Python
5
+ (`pouchy-companion`) SDKs speak, aimed at **Unity games and .NET integrators**.
6
+
7
+ > **⚠️ 0.1.0-preview** — the **wire protocol** is drift-tested against the
8
+ > TypeScript source of truth (`Runtime/Protocol.cs` is generated; `Models.cs`
9
+ > field names are pinned in CI), so the vocabulary cannot silently diverge.
10
+ > The **C# compiles clean** — every `Runtime/*.cs`, `PouchyCompanionBehaviour`
11
+ > included, under Unity 2021.3's own profile (.NET Standard 2.1, C# 9) against
12
+ > the UnityEngine 2021.3 reference assemblies: 0 errors, 0 warnings — and the
13
+ > pure .NET client is **behaviour-tested** (`dotnet test`, no editor needed:
14
+ > error/backoff contract, event-stream stall handling). What is **still
15
+ > unproven is runtime behaviour inside a real editor or player**. Please
16
+ > report anything you hit to <support@pouchy.ai>.
17
+
18
+ - `System.Net.Http.HttpClient` + async/await + **Newtonsoft.Json** — the pure
19
+ `CompanionClient` has **no UnityEngine dependency**, so it unit-tests outside
20
+ the editor. `PouchyCompanionBehaviour` is the optional Unity glue.
21
+ - Typed models for every envelope/payload; unknown/newer server fields are
22
+ preserved (`[JsonExtensionData]`) instead of failing to parse.
23
+ - Voice/WebRTC and browser-only surfaces are **deliberately out of scope**
24
+ (REST/SSE only) — they need a native WebRTC plugin. Use the JS SDK for embeds.
25
+
26
+ ## Install
27
+
28
+ The package is published on npm and installs through a **UPM scoped registry**
29
+ (Unity's Package Manager speaks the npm protocol natively). Add both blocks to
30
+ your project's `Packages/manifest.json`:
31
+
32
+ ```json
33
+ {
34
+ "scopedRegistries": [
35
+ {
36
+ "name": "Pouchy",
37
+ "url": "https://registry.npmjs.org",
38
+ "scopes": ["ai.pouchy"]
39
+ }
40
+ ],
41
+ "dependencies": {
42
+ "ai.pouchy.companion": "0.1.0-preview.10"
43
+ }
44
+ }
45
+ ```
46
+
47
+ Unity resolves it on the next editor focus; after that the package shows up in
48
+ **Window → Package Manager → My Registries** with the normal version/upgrade UI.
49
+
50
+ This package depends on **Newtonsoft.Json** via
51
+ `com.unity.nuget.newtonsoft-json` (declared in `package.json`). If your project
52
+ doesn't already resolve it, install *Newtonsoft Json* from the Package Manager
53
+ (or add `"com.unity.nuget.newtonsoft-json": "3.2.1"` to your manifest).
54
+
55
+ **Player settings:** the client uses `System.Net.Http`, so set **Project
56
+ Settings → Player → Api Compatibility Level = .NET Standard 2.1** (Unity's
57
+ default). On IL2CPP builds keep managed stripping at Low, or add a `link.xml`
58
+ preserving `Pouchy.Companion` and `Newtonsoft.Json`, so the reflection-based
59
+ JSON binding survives the AOT strip. (These are the two most likely
60
+ first-compile/runtime snags — see the preview note above.)
61
+
62
+ ## Quickstart
63
+
64
+ Mint a session token from **your backend** — one per end user. Never ship a
65
+ secret key (`pchy_sk_…`) in a client build; the token is the client credential.
66
+
67
+ ```bash
68
+ curl -X POST https://pouchy.ai/v1/sessions \
69
+ -H "Authorization: Bearer pchy_sk_…" \
70
+ -H "Content-Type: application/json" \
71
+ -d '{ "agent": "<agentId>", "external_user_id": "player_42" }'
72
+ ```
73
+
74
+ ### Plain .NET (`CompanionClient`)
75
+
76
+ ```csharp
77
+ using Pouchy.Companion;
78
+
79
+ using var client = new CompanionClient("https://pouchy.ai", sessionToken);
80
+ var ack = await client.ConnectAsync();
81
+ Console.WriteLine("scopes: " + string.Join(",", ack.GrantedScopes ?? new string[0]));
82
+
83
+ // Request/response in one call — streams server-side, returns the reply.
84
+ var reply = await client.SendTextAsync("what about the boss on floor 3?", awaitReply: true);
85
+ Console.WriteLine(reply.Text);
86
+
87
+ // Streaming deltas:
88
+ await client.SendTextAsync("tell me a story", awaitReply: true,
89
+ onDelta: (chunk, reset) => Console.Write(chunk));
90
+ ```
91
+
92
+ The event stream (proactive messages, confirms, tool calls) resumes from the
93
+ session cursor and gives each envelope a typed `TypedPayload` (every outbound
94
+ event has a class — e.g. `DataActivityPayload` for `companion.data_activity`,
95
+ the metadata-only Data-plane activity frame; needs the `data.activity` scope):
96
+
97
+ ```csharp
98
+ var cts = new CancellationTokenSource();
99
+ await client.StreamEventsAsync(async env =>
100
+ {
101
+ switch (env.TypedPayload)
102
+ {
103
+ case MessagePayload m:
104
+ Debug.Log("companion: " + m.Text);
105
+ break;
106
+ case ConfirmRequestPayload c:
107
+ // Platform session tokens may resolve confirms directly — your end
108
+ // user IS this instance's human. Show your own UI, then:
109
+ var res = await client.ConfirmActionAsync(c.ConfirmId, approve: true);
110
+ Debug.Log("outcome: " + res.Outcome);
111
+ break;
112
+ case ToolCallPayload t:
113
+ await client.SendToolResultAsync(t.Id, ok: true, result: new { answer = 42 });
114
+ break;
115
+ }
116
+ }, cts.Token);
117
+ ```
118
+
119
+ The server closes each stream window after ~45s (a clean end — call
120
+ `StreamEventsAsync` again; it resumes from `client.Cursor`, so nothing is lost
121
+ or repeated). That loop is storm-safe by construction: a clean instantly-closed
122
+ **empty** window (a gateway accepting and immediately closing — never the
123
+ designed cadence, which always delivers at least `hello.ack`) makes the next
124
+ open back off 1→8s (anti-storm, preview.10); any delivered envelope resets the
125
+ ladder, and errors still throw immediately. Byte-**silence** is bounded too (`StreamStallTimeout`, default
126
+ 60s): on a half-open TCP drop — a NAT idle-kill eats the FIN and no close ever
127
+ arrives — the call throws a `CompanionError` (`Status` 0) naming the stall
128
+ instead of blocking forever, and the same reconnect resumes. The bound is armed
129
+ per read, so a healthy subscription is never cut, however long it lives. Set
130
+ `StreamStallTimeout = Timeout.InfiniteTimeSpan` to disable it (then only your
131
+ CancellationToken can unblock a dead read).
132
+
133
+ Don't want to own that loop? **`EventStreamPump.RunAsync(client, onEvent,
134
+ onError, ct)`** runs the stream as a permanent subscription: it reconnects
135
+ immediately after every clean window close (except a zero-event close well
136
+ under the server's ~45s cadence — a gateway accepting and instantly closing —
137
+ which climbs the backoff ladder instead of hot-looping), backs off on errors
138
+ (1→30s, honouring the server's `RetryAfter`, capped at 60s), stops on
139
+ cancellation or a deliberately ended session, and never throws — errors reach
140
+ your `onError` callback instead. It is what `PouchyCompanionBehaviour` rides
141
+ internally.
142
+
143
+ ### Unity (`PouchyCompanionBehaviour`)
144
+
145
+ Add the component, set **Base Url** + **Token** in the Inspector, wire the
146
+ UnityEvents, and call `Connect()`. Every event is raised on the **main thread**,
147
+ so handlers can touch Unity APIs safely. The subscription is **always-on**: the
148
+ component rides `EventStreamPump`, so it reconnects through the server's ~45s
149
+ window closes and transient failures for the lifetime of the GameObject
150
+ (stream errors surface on `OnError` and are retried with backoff; a session
151
+ ended with `EndSessionAsync` stops it).
152
+
153
+ ```csharp
154
+ var companion = gameObject.AddComponent<PouchyCompanionBehaviour>();
155
+ companion.Token = sessionToken;
156
+ companion.OnMessage.AddListener(text => chatUI.Append(text));
157
+ companion.OnTyping.AddListener(active => typingDot.SetActive(active));
158
+ companion.Connect();
159
+ // ...later, from a send button:
160
+ companion.SendText("hi!");
161
+ ```
162
+
163
+ ### World state (play-along context)
164
+
165
+ ```csharp
166
+ await client.SendWorldStateAsync(new WorldStateEvent {
167
+ Type = "game.event.boss_spawned",
168
+ Data = Newtonsoft.Json.Linq.JToken.FromObject(new { name = "Ashen Knight", floor = 3 }),
169
+ Salience = 0.9,
170
+ });
171
+ ```
172
+
173
+ ## Methods
174
+
175
+ | Method | REST | Notes |
176
+ | --- | --- | --- |
177
+ | `ConnectAsync()` | `POST /api/companion/session` | handshake → `HelloAckPayload`; sets `SessionId`, `Cursor` |
178
+ | `SendTextAsync(text, awaitReply, onDelta, images, turnId)` | `POST …/session/{id}/input` | plain or streaming; always returns the `TurnId` (retry-idempotent — a replayed turn sets `Duplicate`) |
179
+ | `StreamEventsAsync(onEvent, ct)` | `GET …/session/{id}/stream` | SSE; typed `TypedPayload`, cursor auto-advances; byte-silence bounded (`StreamStallTimeout` 60s) |
180
+ | `PendingConfirmsAsync()` | `GET …/session/{id}/confirm` | `List<PendingConfirm>` |
181
+ | `ConfirmActionAsync(confirmId, approve)` | `POST …/session/{id}/confirm` | `ConfirmResolution` |
182
+ | `SendToolResultAsync(callId, ok, result)` | `POST …/session/{id}/tool-result` | returns `allDone` |
183
+ | `SendWorldStateAsync(event \| events)` | `POST …/session/{id}/context` | fills specversion/id/source; `WorldStateAccepted` |
184
+ | `HistoryAsync(limit)` | `GET …/session/{id}/history` | `List<HistoryTurn>` |
185
+ | `RecallAsync(query, limit)` | `GET /api/companion/memory` | `List<RecalledMemory>` |
186
+ | `RememberAsync(fact)` | `POST /api/companion/memory` | needs `memory.write:app` / `:core` |
187
+ | `IngestKnowledgeAsync(text, name, kind, locale)` | `POST /api/companion/knowledge` | — |
188
+ | `GetWalletAsync()` | `GET /api/companion/wallet` | read-only (`wallet.read`) |
189
+ | `SetModalitiesAsync(modalities)` | `POST …/session/{id}/modalities` | — |
190
+ | `PingAsync()` | `POST …/session/{id}/ping` | keep-alive |
191
+ | `EndSessionAsync()` | `POST …/session/{id}/end` | clears `SessionId` |
192
+
193
+ ## Tokens & scopes
194
+
195
+ A **platform session token** (minted via `POST /v1/sessions`) represents one of
196
+ your end users and may resolve confirms directly. The token's granted scopes
197
+ (`ack.GrantedScopes`) gate memory writes, wallet reads, social messages, etc.
198
+ The scope model, confirm flow, world-state salience, and limits are identical
199
+ across SDKs — see the [Python SDK](https://pypi.org/project/pouchy-companion/)
200
+ for the same token/scopes note, and the full reference at
201
+ [pouchy.ai/sdk](https://pouchy.ai/sdk) and
202
+ [companion-api-reference](https://pouchy.ai/docs/companion-api-reference).
203
+
204
+ ## Errors
205
+
206
+ Every failed call throws `CompanionError` with `.Status`, `.Code`
207
+ (machine-switchable — `rate_limited`, `turn_pending`, `confirm_resolved`, … see
208
+ `Protocol.ErrorCodes`) and `.RetryAfter` seconds when the server said when to
209
+ come back. `.RetryAfter` is guaranteed **finite and non-negative** — it is read
210
+ from the body's `retryAfterSec` first (it survives a header-stripping proxy),
211
+ then the `Retry-After` header (numeric or RFC 9110 HTTP-date), and anything a
212
+ broken upstream sends outside that domain (negative, `NaN`, `Infinity` — the
213
+ last is reachable from legal JSON via the `1e400` overflow literal) becomes
214
+ `null` rather than a value the handler below cannot sleep. Transport failures
215
+ are wrapped too — `Status` is `0` and `Code` is
216
+ `request_timeout` (the per-request deadline elapsed) or `aborted` (any other
217
+ network failure), with the original exception preserved as `InnerException`.
218
+
219
+ ```csharp
220
+ try { await client.SendTextAsync("hi"); }
221
+ catch (CompanionError e) when (e.Code == "rate_limited")
222
+ {
223
+ await Task.Delay(TimeSpan.FromSeconds(e.RetryAfter ?? 1));
224
+ }
225
+ ```
226
+
227
+ Not every refusal is worth retrying. `quota_exhausted` (402) means the account's
228
+ monthly allowance is spent — credits, or realtime voice minutes — and nothing
229
+ changes until the plan is upgraded or the 1st (UTC) arrives, so a retry loop on
230
+ it never terminates. Handle it separately from `rate_limited`:
231
+
232
+ ```csharp
233
+ catch (CompanionError e) when (e.Code == Protocol.ErrorCodes.QuotaExhausted)
234
+ {
235
+ ShowUpgradePrompt(e.Message); // do NOT retry
236
+ }
237
+ ```
238
+
239
+ ## Voice / WebRTC
240
+
241
+ Not included. This SDK is REST/SSE only; live voice calls need a native WebRTC
242
+ plugin the game supplies. `control.call_ready` / `CallReadyPayload` are part of
243
+ the typed vocabulary, but `startCall` (which returns the WebRTC credentials) is
244
+ not implemented here.
245
+
246
+ ## Not yet ported from the JS SDK
247
+
248
+ A few JS-SDK surfaces are deliberately unported (recorded decisions, not
249
+ drift — the full ledger with rationale lives at the top of `CHANGELOG.md`):
250
+ `handles`/`contextKinds` hello options, representative mode
251
+ (`visitor`/`pairVisitor`), `GetAvatar`, binary `IngestFile`
252
+ (`IngestKnowledgeAsync` text is covered), and `setToken` live-token swap (a
253
+ Unity host reconnects with a fresh client after ~1h token expiry — the
254
+ session survives the reconnect). Ask (or port) on demand.
255
+
256
+ ## Protocol parity
257
+
258
+ `Runtime/Protocol.cs` is **generated from the TypeScript source of truth**
259
+ (`src/lib/companion-sdk/protocol.ts`) and drift-tested in CI; the payload models
260
+ in `Runtime/Models.cs` have their field names pinned to the same interfaces, so
261
+ the three SDKs cannot silently diverge on the wire.
262
+
263
+ The C# is compiled in CI as well, under Unity 2021.3's profile and against the
264
+ UnityEngine 2021.3 reference assemblies — no Unity install or licence required
265
+ on the runner, so the check runs on every change rather than at release time.
266
+ The run pairs with a deliberately-broken sentinel that **must** fail: a compile
267
+ of `#if UNITY_2021_1_OR_NEWER` code proves nothing if the define or the engine
268
+ references quietly stopped resolving, and the sentinel is what makes that
269
+ visible instead of green.