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