@pouchy_ai/companion-sdk 0.30.0 → 0.32.1

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +51 -2
  3. package/dist/adapter-core.d.ts +58 -0
  4. package/dist/adapter-core.d.ts.map +1 -0
  5. package/dist/adapter-core.js +158 -0
  6. package/dist/adapter-core.js.map +1 -0
  7. package/dist/call.d.ts +1 -0
  8. package/dist/call.d.ts.map +1 -0
  9. package/dist/call.js +1 -0
  10. package/dist/call.js.map +1 -0
  11. package/dist/client.d.ts +48 -1
  12. package/dist/client.d.ts.map +1 -0
  13. package/dist/client.js +56 -3
  14. package/dist/client.js.map +1 -0
  15. package/dist/errors.d.ts +1 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +1 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/index.d.ts +4 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +6 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/protocol.d.ts +1 -0
  24. package/dist/protocol.d.ts.map +1 -0
  25. package/dist/protocol.js +1 -0
  26. package/dist/protocol.js.map +1 -0
  27. package/dist/scopes.d.ts +1 -0
  28. package/dist/scopes.d.ts.map +1 -0
  29. package/dist/scopes.js +1 -0
  30. package/dist/scopes.js.map +1 -0
  31. package/dist/sse.d.ts +1 -0
  32. package/dist/sse.d.ts.map +1 -0
  33. package/dist/sse.js +1 -0
  34. package/dist/sse.js.map +1 -0
  35. package/dist/svelte.d.ts +16 -0
  36. package/dist/svelte.d.ts.map +1 -0
  37. package/dist/svelte.js +25 -0
  38. package/dist/svelte.js.map +1 -0
  39. package/dist/vue.d.ts +15 -0
  40. package/dist/vue.d.ts.map +1 -0
  41. package/dist/vue.js +27 -0
  42. package/dist/vue.js.map +1 -0
  43. package/dist/ws-transport.d.ts +1 -0
  44. package/dist/ws-transport.d.ts.map +1 -0
  45. package/dist/ws-transport.js +1 -0
  46. package/dist/ws-transport.js.map +1 -0
  47. package/package.json +24 -6
  48. package/src/adapter-core.ts +227 -0
  49. package/src/call.ts +699 -0
  50. package/src/client.ts +1960 -0
  51. package/src/errors.ts +77 -0
  52. package/src/index.ts +105 -0
  53. package/src/protocol.ts +316 -0
  54. package/src/scopes.ts +94 -0
  55. package/src/sse.ts +41 -0
  56. package/src/svelte.ts +47 -0
  57. package/src/vue.ts +46 -0
  58. package/src/ws-transport.ts +70 -0
@@ -0,0 +1,227 @@
1
+ // Framework-agnostic view controller behind the /svelte and /vue adapters
2
+ // (0.31.0). One place owns the subscription wiring + snapshot bookkeeping the
3
+ // adapters share, so each adapter stays a logic-free ~40-line binding to its
4
+ // framework's reactivity primitive — and THIS file is where the behavior gets
5
+ // unit-tested once instead of per-framework. (React hosts use the standalone
6
+ // `@pouchy_ai/react` package instead — it owns the client LIFECYCLE and a
7
+ // voice-call hook, which this controller deliberately doesn't; conversely the
8
+ // render-state here — streamState, streamed draft, pendingConfirms — is richer
9
+ // than the react hooks. Complementary shapes, not a parity pair.)
10
+ //
11
+ // Contract (deliberately useSyncExternalStore-shaped, the strictest of the
12
+ // three): `getSnapshot()` returns the SAME object reference until something
13
+ // changes, and every change produces a new frozen snapshot + one listener
14
+ // sweep. Svelte's store contract and Vue's shallowRef both accept that shape
15
+ // as-is.
16
+
17
+ import type { CompanionClient } from './client';
18
+ import type { CompanionStreamState } from './client';
19
+ import type { ConfirmRequestPayload, TypingPayload } from './protocol';
20
+ import { CompanionError } from './errors';
21
+
22
+ /** One rendered line of the conversation, in arrival order. */
23
+ export interface CompanionTranscriptEntry {
24
+ role: 'user' | 'companion';
25
+ text: string;
26
+ /** Client wall-clock ms when the entry was appended. */
27
+ at: number;
28
+ }
29
+
30
+ /** Immutable view snapshot. A new object per change — safe to hand to
31
+ * useSyncExternalStore / $state / shallowRef without defensive copies. */
32
+ export interface CompanionViewSnapshot {
33
+ streamState: CompanionStreamState;
34
+ /** Rolling transcript, oldest first, capped at `transcriptCap`. */
35
+ transcript: readonly CompanionTranscriptEntry[];
36
+ /** The in-flight streamed reply (token deltas), '' when none. */
37
+ draft: string;
38
+ typing: boolean;
39
+ /** Confirm requests that arrived on the stream and haven't been resolved
40
+ * through this view yet. Rebuild after a reload via client.pendingConfirms()
41
+ * — confirm_request events are not replayed. */
42
+ pendingConfirms: readonly ConfirmRequestPayload[];
43
+ }
44
+
45
+ export interface CompanionViewOptions {
46
+ /** Max transcript entries kept (oldest dropped). Default 200. */
47
+ transcriptCap?: number;
48
+ /** Restore up to this many recent turns from `client.history()` on
49
+ * creation, so a reloaded tab isn't blank (same semantic as
50
+ * `@pouchy_ai/react`'s useMessages). Requires a connected client (a
51
+ * session must exist). Restored turns land ONLY while the live transcript
52
+ * is still empty — they never clobber messages that arrived first.
53
+ * Default 0 (off). */
54
+ restore?: number;
55
+ }
56
+
57
+ export interface CompanionView {
58
+ /** Stable-reference snapshot (useSyncExternalStore contract). */
59
+ getSnapshot(): CompanionViewSnapshot;
60
+ /** Subscribe to snapshot changes. Returns unsubscribe. */
61
+ subscribe(listener: () => void): () => void;
62
+ /** sendText that also appends the user turn to the transcript. */
63
+ sendText(
64
+ text: string,
65
+ opts?: Parameters<CompanionClient['sendText']>[1]
66
+ ): ReturnType<CompanionClient['sendText']>;
67
+ /** confirmAction that also removes the confirm from `pendingConfirms`
68
+ * (on approve AND deny — both resolve the card server-side). */
69
+ confirmAction(
70
+ confirmId: string,
71
+ approve: boolean,
72
+ opts?: { signal?: AbortSignal }
73
+ ): ReturnType<CompanionClient['confirmAction']>;
74
+ /** Detach every client subscription. The view goes inert (snapshot frozen,
75
+ * listeners dropped); the client itself is NOT closed — it belongs to the
76
+ * host. Idempotent. */
77
+ dispose(): void;
78
+ /** The underlying client, for everything the view doesn't wrap. */
79
+ readonly client: CompanionClient;
80
+ }
81
+
82
+ const DEFAULT_TRANSCRIPT_CAP = 200;
83
+
84
+ /** Wire a client into an immutable-snapshot view. Adapters bind this to their
85
+ * framework; hosts using none of the adapters can consume it directly. */
86
+ export function createCompanionView(
87
+ client: CompanionClient,
88
+ opts?: CompanionViewOptions
89
+ ): CompanionView {
90
+ const cap = Math.max(1, opts?.transcriptCap ?? DEFAULT_TRANSCRIPT_CAP);
91
+ const listeners = new Set<() => void>();
92
+ let disposed = false;
93
+
94
+ let snapshot: CompanionViewSnapshot = Object.freeze({
95
+ streamState: client.streamState,
96
+ transcript: Object.freeze([]) as readonly CompanionTranscriptEntry[],
97
+ draft: '',
98
+ typing: false,
99
+ pendingConfirms: Object.freeze([]) as readonly ConfirmRequestPayload[]
100
+ });
101
+
102
+ function commit(patch: Partial<CompanionViewSnapshot>): void {
103
+ if (disposed) return;
104
+ snapshot = Object.freeze({ ...snapshot, ...patch });
105
+ // Snapshot-first, then notify: a listener that re-reads getSnapshot()
106
+ // synchronously must see the new value. Handler isolation matches the
107
+ // client's emit() doctrine — one throwing listener must not starve the rest.
108
+ for (const l of [...listeners]) {
109
+ try {
110
+ l();
111
+ } catch {
112
+ /* listener errors are the listener's problem */
113
+ }
114
+ }
115
+ }
116
+
117
+ function appendTranscript(entry: CompanionTranscriptEntry): void {
118
+ const next = [...snapshot.transcript, entry];
119
+ commit({ transcript: Object.freeze(next.slice(-cap)) });
120
+ }
121
+
122
+ const restore = opts?.restore ?? 0;
123
+ if (restore > 0) {
124
+ void client
125
+ .history({ limit: restore })
126
+ .then((turns) => {
127
+ // Only fill a still-empty transcript — live traffic wins.
128
+ if (disposed || snapshot.transcript.length) return;
129
+ const restored: CompanionTranscriptEntry[] = [];
130
+ for (const t of turns) {
131
+ if (t.user) restored.push({ role: 'user', text: t.user, at: t.ts });
132
+ if (t.assistant) restored.push({ role: 'companion', text: t.assistant, at: t.ts });
133
+ }
134
+ if (restored.length) commit({ transcript: Object.freeze(restored.slice(-cap)) });
135
+ })
136
+ .catch(() => {
137
+ /* a brand-new session has no history — fine */
138
+ });
139
+ }
140
+
141
+ const unsubs: Array<() => void> = [
142
+ client.onStreamStateChange((next: CompanionStreamState) => commit({ streamState: next })),
143
+ client.onMessage((text: string) => {
144
+ // The authoritative full reply replaces any streamed draft; typing is
145
+ // definitionally over once the reply lands.
146
+ const next = [...snapshot.transcript, { role: 'companion' as const, text, at: Date.now() }];
147
+ commit({
148
+ transcript: Object.freeze(next.slice(-cap)),
149
+ draft: '',
150
+ typing: false
151
+ });
152
+ }),
153
+ client.onDelta((chunk: string, meta: { reset?: boolean }) => {
154
+ commit({ draft: meta.reset ? chunk : snapshot.draft + chunk });
155
+ }),
156
+ client.onTyping((payload: TypingPayload) => commit({ typing: !!payload.active })),
157
+ client.onConfirmRequest((payload: ConfirmRequestPayload) => {
158
+ // The stream can redeliver on reconnect replay — dedupe by confirmId.
159
+ if (snapshot.pendingConfirms.some((c) => c.confirmId === payload.confirmId)) return;
160
+ commit({ pendingConfirms: Object.freeze([...snapshot.pendingConfirms, payload]) });
161
+ })
162
+ ];
163
+
164
+ return {
165
+ client,
166
+ getSnapshot: () => snapshot,
167
+ subscribe(listener: () => void) {
168
+ listeners.add(listener);
169
+ return () => listeners.delete(listener);
170
+ },
171
+ async sendText(text, sendOpts) {
172
+ const entry: CompanionTranscriptEntry = { role: 'user', text, at: Date.now() };
173
+ appendTranscript(entry);
174
+ try {
175
+ return await client.sendText(text, sendOpts as never);
176
+ } catch (err) {
177
+ // The optimistic line must not survive a send the server never saw
178
+ // (network drop / 429 turn-claim) — roll it back by identity so the
179
+ // host's retry re-appends cleanly (0.32.1).
180
+ commit({
181
+ transcript: Object.freeze(snapshot.transcript.filter((e) => e !== entry))
182
+ });
183
+ throw err;
184
+ }
185
+ },
186
+ async confirmAction(confirmId, approve, confirmOpts) {
187
+ let res;
188
+ try {
189
+ res = await client.confirmAction(confirmId, approve, confirmOpts);
190
+ } catch (err) {
191
+ // The same confirm can be resolved OUT FROM UNDER this card — the
192
+ // hosted confirm page, or (since round 13) a channel keyword reply.
193
+ // A 409 confirm_resolved / 404 confirm_not_found means the card is
194
+ // dead either way; keeping it would strand a permanently-stale
195
+ // approve button (0.32.1). Other rejections (network) keep the card.
196
+ if (
197
+ err instanceof CompanionError &&
198
+ (err.code === 'confirm_resolved' || err.code === 'confirm_not_found')
199
+ ) {
200
+ commit({
201
+ pendingConfirms: Object.freeze(
202
+ snapshot.pendingConfirms.filter((c) => c.confirmId !== confirmId)
203
+ )
204
+ });
205
+ }
206
+ throw err;
207
+ }
208
+ // Remove on any settled resolution; exec_failed with retryable keeps the
209
+ // card — the host may re-approve the SAME confirmId (documented server
210
+ // semantic), so dropping it would strand the retry affordance.
211
+ if (!(res.status === 'exec_failed' && res.retryable)) {
212
+ commit({
213
+ pendingConfirms: Object.freeze(
214
+ snapshot.pendingConfirms.filter((c) => c.confirmId !== confirmId)
215
+ )
216
+ });
217
+ }
218
+ return res;
219
+ },
220
+ dispose() {
221
+ if (disposed) return;
222
+ for (const u of unsubs) u();
223
+ listeners.clear();
224
+ disposed = true;
225
+ }
226
+ };
227
+ }