@pouchy_ai/companion-sdk 0.12.1 → 0.14.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 CHANGED
@@ -12,6 +12,42 @@ a protocol bump is always called out explicitly here.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.14.0] - 2026-07-06
16
+
17
+ ### Added
18
+
19
+ - **Opt-in voice barge-in** — `connectCall({ bargeIn: true })` keeps the
20
+ microphone OPEN while the companion speaks, so the user can interrupt
21
+ mid-utterance and both providers' native VAD interruption takes over
22
+ (OpenAI Realtime server VAD; ElevenLabs Convai interruption handling).
23
+ Default remains half-duplex (mic muted during agent speech): on phone
24
+ speakers browser echo cancellation does not fully remove the companion's
25
+ own voice, and it would barge in on itself. Enable only where AEC holds —
26
+ headphones, desktop, or devices you have verified.
27
+ - The OpenAI Realtime microphone request now asks for explicit
28
+ `echoCancellation` / `noiseSuppression` / `autoGainControl` processing
29
+ constraints (harmless in half-duplex; required for barge-in to be viable).
30
+
31
+ No wire-protocol change (`PROTOCOL_VERSION` stays `1`).
32
+
33
+ ## [0.13.0] - 2026-07-06
34
+
35
+ ### Added
36
+
37
+ - **Token streaming** — the reply can now render as it is generated instead
38
+ of arriving as one block. Register `onDelta((chunk, meta) => …)` and
39
+ `sendText` automatically streams: the POST response itself carries SSE
40
+ `delta` frames (raw text chunks), an optional `reset` (a hop that streamed
41
+ text turned out to be tool-call deliberation — clear the partial render),
42
+ and a final `done` frame. `onMessage` still fires exactly ONCE with the
43
+ authoritative final text (the done frame's envelope is emitted locally and
44
+ the event-stream replay of the same envelope id is deduplicated), so
45
+ existing render code keeps working unchanged — deltas are a pure add-on.
46
+ Pass `sendText(text, { stream: false })` to force the buffered path, or
47
+ `{ stream: true }` to stream without a subscriber. Requires a server with
48
+ streaming input support; on older servers the SDK transparently falls back
49
+ to the buffered response. No wire-protocol (envelope) change.
50
+
15
51
  ## [0.12.1] - 2026-07-05
16
52
 
17
53
  ### Added
@@ -127,7 +163,7 @@ self-hosted/CDN build; this entry captures the surface that ships publicly.
127
163
  - **Public distribution.** Installable from the public npm registry as
128
164
  `@pouchy_ai/companion-sdk`; also resolvable via `esm.sh` / `jsDelivr` for
129
165
  no-bundler / import-map consumers, and self-hosted at
130
- `https://www.pouchy.ai/sdk/companion-sdk.js`.
166
+ `https://pouchy.ai/sdk/companion-sdk.js`.
131
167
  - **Chat.** Text and multimodal (text + images) messaging.
132
168
  - **Voice.** Live WebRTC call (`connectCall`), provider-agnostic across OpenAI
133
169
  Realtime (zero extra deps) and ElevenLabs Convai (optional
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://www.pouchy.ai', token: PAT });
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` | streamed assistant text |
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://www.pouchy.ai',
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' },
@@ -103,6 +123,18 @@ the [source README](../../src/lib/companion-sdk/README.md#representative-mode-
103
123
 
104
124
  ## Voice (optional dependency)
105
125
 
126
+ **Zero-code alternative:** if you just want a chat box, skip the SDK entirely —
127
+ `<iframe src="https://pouchy.ai/embed?token=…&theme=dark&accent=%23ff6b81">` is a
128
+ hosted drop-in widget with a postMessage control plane (docs:
129
+ `docs/companion-widget.md` in the repo / pouchy.ai/sdk → Drop-in widget).
130
+
131
+ `connectCall({ bargeIn: true })` opts into **full-duplex** voice: the mic stays
132
+ open while the companion speaks so the user can interrupt mid-utterance (the
133
+ providers' native interruption takes over). The default is half-duplex — on
134
+ phone speakers echo cancellation can't fully remove the companion's own voice
135
+ and it would interrupt itself. Enable it only where AEC holds (headphones,
136
+ desktop, or devices you've verified).
137
+
106
138
  `connectCall()` for the **ElevenLabs Convai** provider needs the optional peer
107
139
  dependency:
108
140
 
package/dist/call.d.ts CHANGED
@@ -46,6 +46,14 @@ export interface CompanionCallOptions {
46
46
  }) => void;
47
47
  onSpeakingChange?: (speaking: boolean) => void;
48
48
  onError?: (err: Error) => void;
49
+ /** OPT-IN full duplex: keep the mic OPEN while the companion speaks so the
50
+ * user can interrupt mid-utterance (both providers' native VAD interruption
51
+ * then works). Default false = half-duplex — the mic is muted during agent
52
+ * speech, because on phone SPEAKERS browser echo cancellation does not fully
53
+ * remove the companion's own voice and it barges in on itself (the SDK-voice
54
+ * "说一句就断了" field report). Enable only where AEC actually holds:
55
+ * headphones, desktop, or devices you have verified. */
56
+ bargeIn?: boolean;
49
57
  }
50
58
  export interface CompanionCall {
51
59
  /** Hang up. Idempotent. */
package/dist/call.js CHANGED
@@ -163,7 +163,11 @@ const SDP_TIMEOUT_MS = 8_000;
163
163
  * we've already given up on the call. */
164
164
  async function getMicWithTimeout() {
165
165
  let timedOut = false;
166
- const micPromise = navigator.mediaDevices.getUserMedia({ audio: true });
166
+ const micPromise = navigator.mediaDevices.getUserMedia({
167
+ // Explicit processing constraints: echoCancellation is what makes the
168
+ // opt-in barge-in mode viable at all, and it is harmless in half-duplex.
169
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
170
+ });
167
171
  micPromise
168
172
  .then((m) => {
169
173
  if (timedOut)
@@ -299,8 +303,10 @@ async function openOpenAICall(creds, opts, bridge) {
299
303
  else if (msg.type === 'output_audio_buffer.started') {
300
304
  opts.onSpeakingChange?.(true);
301
305
  // Model started speaking → mute the mic so its own audio can't
302
- // echo-transcribe into a self-barge-in.
303
- muteMicForSpeech();
306
+ // echo-transcribe into a self-barge-in. bargeIn opts out (B wave):
307
+ // the mic stays open and OpenAI's server VAD handles interruption.
308
+ if (opts.bargeIn !== true)
309
+ muteMicForSpeech();
304
310
  }
305
311
  else if (msg.type === 'output_audio_buffer.stopped' ||
306
312
  msg.type === 'output_audio_buffer.cleared' ||
@@ -311,7 +317,8 @@ async function openOpenAICall(creds, opts, bridge) {
311
317
  // audio doesn't get captured the instant we unmute. Several event names
312
318
  // can end a turn; any of them re-arms the mic.
313
319
  opts.onSpeakingChange?.(false);
314
- scheduleMicUnmute();
320
+ if (opts.bargeIn !== true)
321
+ scheduleMicUnmute();
315
322
  }
316
323
  };
317
324
  const offer = await pc.createOffer();
@@ -497,6 +504,10 @@ async function openConvaiCall(creds, opts, bridge) {
497
504
  return;
498
505
  const speaking = mode === 'speaking';
499
506
  opts.onSpeakingChange?.(speaking);
507
+ // bargeIn opts out of half-duplex (B wave): the mic stays open and
508
+ // ElevenLabs' native interruption handling takes over.
509
+ if (opts.bargeIn === true)
510
+ return;
500
511
  if (speaking) {
501
512
  if (micUnmuteTimer) {
502
513
  clearTimeout(micUnmuteTimer);
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://www.pouchy.ai". */
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
  *
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 json = await this.postJson(`/api/companion/session/${id}/input`, { text, ...(opts?.images?.length ? { images: opts.images } : {}) });
122
- return { seq: json.seq ?? null };
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.
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://www.pouchy.ai', token: PAT });
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.12.1",
3
+ "version": "0.14.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://www.pouchy.ai",
7
+ "homepage": "https://pouchy.ai",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "https://github.com/oviswang/Pouchy.git",