@pouchy_ai/companion-sdk 0.12.0 → 0.13.0
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 +32 -2
- package/README.md +23 -3
- package/dist/client.d.ts +36 -4
- package/dist/client.js +97 -5
- package/dist/index.js +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,35 @@ a protocol bump is always called out explicitly here.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.13.0] - 2026-07-06
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Token streaming** — the reply can now render as it is generated instead
|
|
20
|
+
of arriving as one block. Register `onDelta((chunk, meta) => …)` and
|
|
21
|
+
`sendText` automatically streams: the POST response itself carries SSE
|
|
22
|
+
`delta` frames (raw text chunks), an optional `reset` (a hop that streamed
|
|
23
|
+
text turned out to be tool-call deliberation — clear the partial render),
|
|
24
|
+
and a final `done` frame. `onMessage` still fires exactly ONCE with the
|
|
25
|
+
authoritative final text (the done frame's envelope is emitted locally and
|
|
26
|
+
the event-stream replay of the same envelope id is deduplicated), so
|
|
27
|
+
existing render code keeps working unchanged — deltas are a pure add-on.
|
|
28
|
+
Pass `sendText(text, { stream: false })` to force the buffered path, or
|
|
29
|
+
`{ stream: true }` to stream without a subscriber. Requires a server with
|
|
30
|
+
streaming input support; on older servers the SDK transparently falls back
|
|
31
|
+
to the buffered response. No wire-protocol (envelope) change.
|
|
32
|
+
|
|
33
|
+
## [0.12.1] - 2026-07-05
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **`confirmAction` now returns the action's `outcome` text** alongside
|
|
38
|
+
`status`. The surface that clicked Approve renders the result immediately
|
|
39
|
+
instead of racing the stream copy (which still arrives as a normal
|
|
40
|
+
`companion.message`) — a field round caught an approved skill run whose
|
|
41
|
+
stream frame missed a 30s window while the server-side audit proved the
|
|
42
|
+
call executed. Additive; no wire-protocol change.
|
|
43
|
+
|
|
15
44
|
## [0.12.0] - 2026-07-05
|
|
16
45
|
|
|
17
46
|
### Added
|
|
@@ -116,7 +145,7 @@ self-hosted/CDN build; this entry captures the surface that ships publicly.
|
|
|
116
145
|
- **Public distribution.** Installable from the public npm registry as
|
|
117
146
|
`@pouchy_ai/companion-sdk`; also resolvable via `esm.sh` / `jsDelivr` for
|
|
118
147
|
no-bundler / import-map consumers, and self-hosted at
|
|
119
|
-
`https://
|
|
148
|
+
`https://pouchy.ai/sdk/companion-sdk.js`.
|
|
120
149
|
- **Chat.** Text and multimodal (text + images) messaging.
|
|
121
150
|
- **Voice.** Live WebRTC call (`connectCall`), provider-agnostic across OpenAI
|
|
122
151
|
Realtime (zero extra deps) and ElevenLabs Convai (optional
|
|
@@ -137,7 +166,8 @@ self-hosted/CDN build; this entry captures the surface that ships publicly.
|
|
|
137
166
|
- **Versioned wire protocol.** `PROTOCOL_VERSION = 1`, kept in lockstep with the
|
|
138
167
|
server by `protocol.drift.test.ts` (fails CI on divergence).
|
|
139
168
|
|
|
140
|
-
[Unreleased]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.12.
|
|
169
|
+
[Unreleased]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.12.1...HEAD
|
|
170
|
+
[0.12.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.12.0...companion-sdk-v0.12.1
|
|
141
171
|
[0.12.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.11.0...companion-sdk-v0.12.0
|
|
142
172
|
[0.11.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.10.1...companion-sdk-v0.11.0
|
|
143
173
|
[0.10.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.10.0...companion-sdk-v0.10.1
|
package/README.md
CHANGED
|
@@ -18,10 +18,26 @@ No bundler? Pull it from a CDN via an import map instead — see
|
|
|
18
18
|
[`docs/companion-integration-faq.md`](../../docs/companion-integration-faq.md).
|
|
19
19
|
Changes per release are tracked in [`CHANGELOG.md`](./CHANGELOG.md).
|
|
20
20
|
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
Two steps. **1)** Your backend exchanges the project **Secret Key** (create one
|
|
24
|
+
in the [dashboard](https://pouchy.ai/dashboard) → Keys) for a per-user
|
|
25
|
+
session token — first-seen `external_user_id`s are provisioned automatically:
|
|
26
|
+
|
|
27
|
+
```http
|
|
28
|
+
POST https://pouchy.ai/v1/sessions
|
|
29
|
+
Authorization: Bearer pchy_sk_…
|
|
30
|
+
{ "agent": "<agentId>", "external_user_id": "user_4211" }
|
|
31
|
+
|
|
32
|
+
→ { "session_token": "pchy_…", "expires_in": 3600, "instance": { … } }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**2)** The client connects with the session token (never ship the Secret Key):
|
|
36
|
+
|
|
21
37
|
```ts
|
|
22
38
|
import { createCompanion } from '@pouchy_ai/companion-sdk';
|
|
23
39
|
|
|
24
|
-
const companion = createCompanion({ baseUrl: 'https://
|
|
40
|
+
const companion = createCompanion({ baseUrl: 'https://pouchy.ai', token: sessionToken });
|
|
25
41
|
await companion.connect();
|
|
26
42
|
companion.onMessage((t) => console.log(t));
|
|
27
43
|
companion.start();
|
|
@@ -30,6 +46,9 @@ await companion.connectCall(); // live voice (browser)
|
|
|
30
46
|
companion.sendWorldState({ type: 'game.player.hp', data: { hp: 12 }, retained: true });
|
|
31
47
|
```
|
|
32
48
|
|
|
49
|
+
(Personal/single-user integrations can pass a server-minted Personal Access
|
|
50
|
+
Token as `token` instead — same API. See the docs' authentication section.)
|
|
51
|
+
|
|
33
52
|
- **Guide:** [`docs/companion-quickstart.md`](../../docs/companion-quickstart.md)
|
|
34
53
|
- **Spec:** [`docs/companion-sdk-protocol.md`](../../docs/companion-sdk-protocol.md)
|
|
35
54
|
- **API reference:** [`docs/companion-api-reference.md`](../../docs/companion-api-reference.md)
|
|
@@ -42,7 +61,8 @@ Subscribe with `on(type, fn)` (or `'*'`), or these typed convenience helpers:
|
|
|
42
61
|
|
|
43
62
|
| Helper | Event | Use |
|
|
44
63
|
| --- | --- | --- |
|
|
45
|
-
| `onMessage(fn)` | `companion.message` |
|
|
64
|
+
| `onMessage(fn)` | `companion.message` | the assistant reply (fires exactly once per turn, streaming or not) |
|
|
65
|
+
| `onDelta(fn)` | *(POST-response SSE)* | **token streaming** — registering makes `sendText` stream the reply as it is generated: `fn(chunk, {reset?})` fires per text chunk (`reset` = clear the partial — that text was tool-call deliberation); `onMessage` then fires once with the authoritative final text (replay-deduplicated). Force per call with `sendText(text, { stream: true \| false })` |
|
|
46
66
|
| `onToolCall(fn)` | `companion.tool_call` | the companion asks your app to run a declared tool → `sendToolResult` |
|
|
47
67
|
| `onRender(fn)` | `companion.ui_action` | **Instant UI** — draw `payload.interface` (platform-neutral genui schema) with your own renderer (web / iOS / Android / CLI). Needs `ui.render` |
|
|
48
68
|
| `onInterfaceUpdate(fn)` | `companion.ui_update` | live `{key,value}` update to an already-rendered panel (no rebuild) |
|
|
@@ -75,7 +95,7 @@ memory, system prompt, or PII. Works over text and voice.
|
|
|
75
95
|
|
|
76
96
|
```ts
|
|
77
97
|
const c = createCompanion({
|
|
78
|
-
baseUrl: 'https://
|
|
98
|
+
baseUrl: 'https://pouchy.ai',
|
|
79
99
|
token: OWNER_PAT, // must hold the `represent` scope
|
|
80
100
|
surface: 'support-widget',
|
|
81
101
|
appContext: { name: 'AcmeShop', description: 'Order support' },
|
package/dist/client.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { AudioClipPayload, CompanionEnvelope, ConfirmRequestPayload, Expres
|
|
|
4
4
|
export type WorldStateInput<D = unknown> = Omit<WorldStateEvent<D>, 'specversion' | 'id' | 'source'> & Partial<Pick<WorldStateEvent<D>, 'specversion' | 'id' | 'source'>>;
|
|
5
5
|
import { type CompanionCall, type CompanionCallOptions } from './call.js';
|
|
6
6
|
export interface CompanionClientOptions {
|
|
7
|
-
/** Origin of the Pouchy deployment, e.g. "https://
|
|
7
|
+
/** Origin of the Pouchy deployment, e.g. "https://pouchy.ai". */
|
|
8
8
|
baseUrl: string;
|
|
9
9
|
/** A Pouchy access token (PAT). */
|
|
10
10
|
token: string;
|
|
@@ -126,6 +126,15 @@ export declare class CompanionError extends Error {
|
|
|
126
126
|
constructor(message: string, status: number);
|
|
127
127
|
}
|
|
128
128
|
type Handler = (envelope: CompanionEnvelope) => void;
|
|
129
|
+
/** Token-streaming subscriber (P1-2). `chunk` is a raw text delta of the reply
|
|
130
|
+
* being generated; `meta.reset` (with an empty chunk) tells the renderer to
|
|
131
|
+
* CLEAR the partial text (a hop that streamed text turned out to be tool-call
|
|
132
|
+
* deliberation, not the reply). Deltas are advisory — the final
|
|
133
|
+
* companion.message (fired through onMessage as usual) is authoritative and
|
|
134
|
+
* should replace whatever the deltas rendered. */
|
|
135
|
+
type DeltaHandler = (chunk: string, meta: {
|
|
136
|
+
reset?: boolean;
|
|
137
|
+
}) => void;
|
|
129
138
|
export declare class CompanionClient {
|
|
130
139
|
private readonly opts;
|
|
131
140
|
private readonly doFetch;
|
|
@@ -135,6 +144,7 @@ export declare class CompanionClient {
|
|
|
135
144
|
private abort;
|
|
136
145
|
private readonly handlers;
|
|
137
146
|
private readonly seen;
|
|
147
|
+
private readonly deltaHandlers;
|
|
138
148
|
private readonly pendingVoiceTools;
|
|
139
149
|
constructor(opts: CompanionClientOptions);
|
|
140
150
|
/** The active session id, or null before connect(). */
|
|
@@ -158,12 +168,31 @@ export declare class CompanionClient {
|
|
|
158
168
|
}>;
|
|
159
169
|
/** Send a user text turn, optionally with images (data URLs) for a multimodal
|
|
160
170
|
* turn — images need the `files` scope. The reply arrives on the event stream
|
|
161
|
-
* as a `companion.message` — subscribe via onMessage()/start() to receive it.
|
|
171
|
+
* as a `companion.message` — subscribe via onMessage()/start() to receive it.
|
|
172
|
+
*
|
|
173
|
+
* TOKEN STREAMING (P1-2): when any onDelta() subscriber is registered (or
|
|
174
|
+
* `opts.stream` is true), the reply streams token-by-token on this very
|
|
175
|
+
* request — delta subscribers fire as chunks arrive, and onMessage still
|
|
176
|
+
* fires exactly ONCE with the authoritative final text (the streamed copy
|
|
177
|
+
* is emitted locally; the event-stream replay of the same envelope is
|
|
178
|
+
* deduplicated by id). Pass `stream: false` to force the buffered path. */
|
|
162
179
|
sendText(text: string, opts?: {
|
|
163
180
|
images?: string[];
|
|
181
|
+
stream?: boolean;
|
|
164
182
|
}): Promise<{
|
|
165
183
|
seq: number | null;
|
|
166
184
|
}>;
|
|
185
|
+
/** The streaming leg of sendText: parse the POST response's SSE frames —
|
|
186
|
+
* `delta` / `reset` → delta subscribers, `done` → resolve (emitting the
|
|
187
|
+
* final envelope locally so onMessage fires immediately and the log replay
|
|
188
|
+
* dedups by id). Falls back to buffered semantics if the server ever
|
|
189
|
+
* responds with plain JSON (e.g. an old deployment). */
|
|
190
|
+
private sendTextStreaming;
|
|
191
|
+
/** Subscribe to token-streaming deltas of the reply (P1-2). Registering a
|
|
192
|
+
* subscriber makes sendText stream automatically. Returns an unsubscribe
|
|
193
|
+
* function. Render chunks as they arrive; on `meta.reset` clear the partial;
|
|
194
|
+
* when onMessage fires, replace the partial with the final text. */
|
|
195
|
+
onDelta(handler: DeltaHandler): () => void;
|
|
167
196
|
/** Push live world-state — a single event or a batch. Retained events
|
|
168
197
|
* (retained:true) coalesce per kind; transient ones carry a salience.
|
|
169
198
|
*
|
|
@@ -349,12 +378,15 @@ export declare class CompanionClient {
|
|
|
349
378
|
/** Resolve a pending confirmation (platform session tokens only — see
|
|
350
379
|
* `onConfirmRequest`). Show the user what they're approving (the event's
|
|
351
380
|
* `summary`), collect an explicit tap, then pass their decision here. On
|
|
352
|
-
* approval the recorded action runs server-side
|
|
353
|
-
*
|
|
381
|
+
* approval the recorded action runs server-side; its result comes back as
|
|
382
|
+
* `outcome` in this response (render it where the user tapped) AND as a
|
|
383
|
+
* normal companion.message on the stream (so the conversation shows it).
|
|
384
|
+
* A denial returns/emits a brief decline the same way. One-time:
|
|
354
385
|
* re-resolving a settled confirmId fails with 409. */
|
|
355
386
|
confirmAction(confirmId: string, approve: boolean): Promise<{
|
|
356
387
|
ok: boolean;
|
|
357
388
|
status: 'approved' | 'denied';
|
|
389
|
+
outcome?: string;
|
|
358
390
|
}>;
|
|
359
391
|
/** The session's still-pending confirmations (display-safe: id, summary,
|
|
360
392
|
* scope, timestamps — never raw args). Use it to rebuild your confirm card
|
package/dist/client.js
CHANGED
|
@@ -37,6 +37,9 @@ export class CompanionClient {
|
|
|
37
37
|
abort = null;
|
|
38
38
|
handlers = new Map();
|
|
39
39
|
seen = new Set();
|
|
40
|
+
// Token-streaming delta subscribers (P1-2). When any are registered,
|
|
41
|
+
// sendText automatically streams the reply.
|
|
42
|
+
deltaHandlers = new Set();
|
|
40
43
|
// Voice tool-calls awaiting the app's sendToolResult, keyed by a synthetic id.
|
|
41
44
|
pendingVoiceTools = new Map();
|
|
42
45
|
constructor(opts) {
|
|
@@ -115,11 +118,98 @@ export class CompanionClient {
|
|
|
115
118
|
}
|
|
116
119
|
/** Send a user text turn, optionally with images (data URLs) for a multimodal
|
|
117
120
|
* turn — images need the `files` scope. The reply arrives on the event stream
|
|
118
|
-
* as a `companion.message` — subscribe via onMessage()/start() to receive it.
|
|
121
|
+
* as a `companion.message` — subscribe via onMessage()/start() to receive it.
|
|
122
|
+
*
|
|
123
|
+
* TOKEN STREAMING (P1-2): when any onDelta() subscriber is registered (or
|
|
124
|
+
* `opts.stream` is true), the reply streams token-by-token on this very
|
|
125
|
+
* request — delta subscribers fire as chunks arrive, and onMessage still
|
|
126
|
+
* fires exactly ONCE with the authoritative final text (the streamed copy
|
|
127
|
+
* is emitted locally; the event-stream replay of the same envelope is
|
|
128
|
+
* deduplicated by id). Pass `stream: false` to force the buffered path. */
|
|
119
129
|
async sendText(text, opts) {
|
|
120
130
|
const id = this.requireSession();
|
|
121
|
-
const
|
|
122
|
-
|
|
131
|
+
const wantStream = opts?.stream ?? this.deltaHandlers.size > 0;
|
|
132
|
+
const body = { text, ...(opts?.images?.length ? { images: opts.images } : {}) };
|
|
133
|
+
if (!wantStream) {
|
|
134
|
+
const json = await this.postJson(`/api/companion/session/${id}/input`, body);
|
|
135
|
+
return { seq: json.seq ?? null };
|
|
136
|
+
}
|
|
137
|
+
return this.sendTextStreaming(id, body);
|
|
138
|
+
}
|
|
139
|
+
/** The streaming leg of sendText: parse the POST response's SSE frames —
|
|
140
|
+
* `delta` / `reset` → delta subscribers, `done` → resolve (emitting the
|
|
141
|
+
* final envelope locally so onMessage fires immediately and the log replay
|
|
142
|
+
* dedups by id). Falls back to buffered semantics if the server ever
|
|
143
|
+
* responds with plain JSON (e.g. an old deployment). */
|
|
144
|
+
async sendTextStreaming(sessionId, body) {
|
|
145
|
+
const res = await this.doFetch(this.url(`/api/companion/session/${sessionId}/input`), {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: this.jsonHeaders(),
|
|
148
|
+
body: JSON.stringify({ ...body, stream: true })
|
|
149
|
+
});
|
|
150
|
+
const ctype = res.headers.get('content-type') ?? '';
|
|
151
|
+
if (!ctype.includes('text/event-stream')) {
|
|
152
|
+
// Old server / error before the stream opened — behave like postJson.
|
|
153
|
+
const json = (await res.json().catch(() => null));
|
|
154
|
+
if (!res.ok || !json || json.ok === false) {
|
|
155
|
+
throw new CompanionError(json?.error || `request failed (${res.status})`, res.status);
|
|
156
|
+
}
|
|
157
|
+
return { seq: json.seq ?? null };
|
|
158
|
+
}
|
|
159
|
+
if (!res.body)
|
|
160
|
+
throw new CompanionError('streaming response has no body', 502);
|
|
161
|
+
const reader = res.body.getReader();
|
|
162
|
+
const decoder = new TextDecoder();
|
|
163
|
+
let buf = '';
|
|
164
|
+
const fireDelta = (chunk, meta) => {
|
|
165
|
+
for (const h of this.deltaHandlers)
|
|
166
|
+
h(chunk, meta);
|
|
167
|
+
};
|
|
168
|
+
for (;;) {
|
|
169
|
+
const { done, value } = await reader.read();
|
|
170
|
+
if (value)
|
|
171
|
+
buf += decoder.decode(value, { stream: true });
|
|
172
|
+
const { events, rest } = parseSse(buf);
|
|
173
|
+
buf = rest;
|
|
174
|
+
for (const ev of events) {
|
|
175
|
+
if (ev.event === 'delta') {
|
|
176
|
+
const d = JSON.parse(ev.data);
|
|
177
|
+
if (typeof d.text === 'string' && d.text)
|
|
178
|
+
fireDelta(d.text, {});
|
|
179
|
+
}
|
|
180
|
+
else if (ev.event === 'reset') {
|
|
181
|
+
fireDelta('', { reset: true });
|
|
182
|
+
}
|
|
183
|
+
else if (ev.event === 'done') {
|
|
184
|
+
const d = JSON.parse(ev.data);
|
|
185
|
+
try {
|
|
186
|
+
await reader.cancel();
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
/* stream already finished */
|
|
190
|
+
}
|
|
191
|
+
if (d.ok === false) {
|
|
192
|
+
throw new CompanionError(d.error || 'turn failed', d.status ?? 500);
|
|
193
|
+
}
|
|
194
|
+
// Emit the final envelope NOW so onMessage fires with zero poll
|
|
195
|
+
// latency; the event-stream replay of the same id is deduped.
|
|
196
|
+
if (d.envelope && typeof d.envelope === 'object')
|
|
197
|
+
this.emit(d.envelope);
|
|
198
|
+
return { seq: d.seq ?? null };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (done)
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
throw new CompanionError('stream ended without a done frame', 502);
|
|
205
|
+
}
|
|
206
|
+
/** Subscribe to token-streaming deltas of the reply (P1-2). Registering a
|
|
207
|
+
* subscriber makes sendText stream automatically. Returns an unsubscribe
|
|
208
|
+
* function. Render chunks as they arrive; on `meta.reset` clear the partial;
|
|
209
|
+
* when onMessage fires, replace the partial with the final text. */
|
|
210
|
+
onDelta(handler) {
|
|
211
|
+
this.deltaHandlers.add(handler);
|
|
212
|
+
return () => this.deltaHandlers.delete(handler);
|
|
123
213
|
}
|
|
124
214
|
/** Push live world-state — a single event or a batch. Retained events
|
|
125
215
|
* (retained:true) coalesce per kind; transient ones carry a salience.
|
|
@@ -511,8 +601,10 @@ export class CompanionClient {
|
|
|
511
601
|
/** Resolve a pending confirmation (platform session tokens only — see
|
|
512
602
|
* `onConfirmRequest`). Show the user what they're approving (the event's
|
|
513
603
|
* `summary`), collect an explicit tap, then pass their decision here. On
|
|
514
|
-
* approval the recorded action runs server-side
|
|
515
|
-
*
|
|
604
|
+
* approval the recorded action runs server-side; its result comes back as
|
|
605
|
+
* `outcome` in this response (render it where the user tapped) AND as a
|
|
606
|
+
* normal companion.message on the stream (so the conversation shows it).
|
|
607
|
+
* A denial returns/emits a brief decline the same way. One-time:
|
|
516
608
|
* re-resolving a settled confirmId fails with 409. */
|
|
517
609
|
async confirmAction(confirmId, approve) {
|
|
518
610
|
const sessionId = this.requireSession();
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// surface (web / app / game / hardware) over the REST/SSE plane.
|
|
3
3
|
//
|
|
4
4
|
// import { createCompanion } from '$lib/companion-sdk';
|
|
5
|
-
// const c = createCompanion({ baseUrl: 'https://
|
|
5
|
+
// const c = createCompanion({ baseUrl: 'https://pouchy.ai', token: PAT });
|
|
6
6
|
// await c.connect();
|
|
7
7
|
// c.onMessage((text) => console.log(text));
|
|
8
8
|
// c.start();
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pouchy_ai/companion-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Embed the Pouchy companion — chat, voice, tools, memory, live world-state, instant UI, and agent-to-agent messaging — in any app, game, or site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
-
"homepage": "https://
|
|
7
|
+
"homepage": "https://pouchy.ai",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "https://github.com/oviswang/Pouchy.git",
|