@voicyclaw/voicyclaw 0.0.2

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/src/runtime.ts ADDED
@@ -0,0 +1,152 @@
1
+ import type { ResolvedVoicyClawAccount } from "./config.js";
2
+
3
+ export type VoicyClawRuntimeSnapshot = {
4
+ accountId: string;
5
+ enabled: boolean;
6
+ configured: boolean;
7
+ displayName: string;
8
+ botId: string;
9
+ channelId: string;
10
+ baseUrl: string;
11
+ running: boolean;
12
+ connected: boolean;
13
+ sessionId: string | null;
14
+ reconnectAttempts: number;
15
+ lastStartAt: number | null;
16
+ lastStopAt: number | null;
17
+ lastConnectedAt: number | null;
18
+ lastDisconnect: {
19
+ at: number;
20
+ error?: string;
21
+ } | null;
22
+ lastError: string | null;
23
+ lastMessageAt: number | null;
24
+ lastInboundAt: number | null;
25
+ lastOutboundAt: number | null;
26
+ };
27
+
28
+ export type VoicyClawRuntime = ReturnType<typeof createVoicyClawRuntime>;
29
+
30
+ let currentRuntime: VoicyClawRuntime | null = null;
31
+
32
+ export function createVoicyClawRuntime() {
33
+ const snapshots = new Map<string, VoicyClawRuntimeSnapshot>();
34
+
35
+ const ensureAccount = (account: ResolvedVoicyClawAccount) => {
36
+ const existing = snapshots.get(account.accountId);
37
+ const next: VoicyClawRuntimeSnapshot = {
38
+ accountId: account.accountId,
39
+ enabled: account.enabled,
40
+ configured: account.configured,
41
+ displayName: account.displayName,
42
+ botId: account.botId,
43
+ channelId: account.channelId,
44
+ baseUrl: account.url,
45
+ running: existing?.running ?? false,
46
+ connected: existing?.connected ?? false,
47
+ sessionId: existing?.sessionId ?? null,
48
+ reconnectAttempts: existing?.reconnectAttempts ?? 0,
49
+ lastStartAt: existing?.lastStartAt ?? null,
50
+ lastStopAt: existing?.lastStopAt ?? null,
51
+ lastConnectedAt: existing?.lastConnectedAt ?? null,
52
+ lastDisconnect: existing?.lastDisconnect ?? null,
53
+ lastError: existing?.lastError ?? null,
54
+ lastMessageAt: existing?.lastMessageAt ?? null,
55
+ lastInboundAt: existing?.lastInboundAt ?? null,
56
+ lastOutboundAt: existing?.lastOutboundAt ?? null,
57
+ };
58
+
59
+ snapshots.set(account.accountId, next);
60
+ return next;
61
+ };
62
+
63
+ const getSnapshot = (accountId: string) => snapshots.get(accountId) ?? null;
64
+
65
+ return {
66
+ ensureAccount,
67
+ getSnapshot,
68
+ listSnapshots: () =>
69
+ Array.from(snapshots.values()).sort(compareByAccountId),
70
+ markStarting(account: ResolvedVoicyClawAccount) {
71
+ const snapshot = ensureAccount(account);
72
+ snapshot.running = true;
73
+ snapshot.connected = false;
74
+ snapshot.lastStartAt = Date.now();
75
+ snapshot.lastError = null;
76
+ snapshot.sessionId = null;
77
+ },
78
+ markConnected(account: ResolvedVoicyClawAccount, sessionId: string) {
79
+ const snapshot = ensureAccount(account);
80
+ snapshot.running = true;
81
+ snapshot.connected = true;
82
+ snapshot.sessionId = sessionId;
83
+ snapshot.reconnectAttempts = 0;
84
+ snapshot.lastConnectedAt = Date.now();
85
+ snapshot.lastError = null;
86
+ snapshot.lastDisconnect = null;
87
+ },
88
+ markDisconnected(account: ResolvedVoicyClawAccount, error?: string) {
89
+ const snapshot = ensureAccount(account);
90
+ snapshot.running = account.enabled && account.configured;
91
+ snapshot.connected = false;
92
+ snapshot.sessionId = null;
93
+ snapshot.lastError = error ?? null;
94
+ snapshot.lastDisconnect = {
95
+ at: Date.now(),
96
+ ...(error ? { error } : {}),
97
+ };
98
+ if (account.enabled && account.configured) {
99
+ snapshot.reconnectAttempts += 1;
100
+ }
101
+ },
102
+ markStopped(account: ResolvedVoicyClawAccount) {
103
+ const snapshot = ensureAccount(account);
104
+ snapshot.running = false;
105
+ snapshot.connected = false;
106
+ snapshot.sessionId = null;
107
+ snapshot.lastStopAt = Date.now();
108
+ },
109
+ markInbound(accountId: string) {
110
+ const snapshot = snapshots.get(accountId);
111
+ if (!snapshot) {
112
+ return;
113
+ }
114
+
115
+ const now = Date.now();
116
+ snapshot.lastMessageAt = now;
117
+ snapshot.lastInboundAt = now;
118
+ },
119
+ markOutbound(accountId: string) {
120
+ const snapshot = snapshots.get(accountId);
121
+ if (!snapshot) {
122
+ return;
123
+ }
124
+
125
+ const now = Date.now();
126
+ snapshot.lastMessageAt = now;
127
+ snapshot.lastOutboundAt = now;
128
+ },
129
+ reset() {
130
+ snapshots.clear();
131
+ },
132
+ };
133
+ }
134
+
135
+ export function setVoicyClawRuntime(runtime: VoicyClawRuntime) {
136
+ currentRuntime = runtime;
137
+ }
138
+
139
+ export function getVoicyClawRuntime() {
140
+ if (!currentRuntime) {
141
+ currentRuntime = createVoicyClawRuntime();
142
+ }
143
+
144
+ return currentRuntime;
145
+ }
146
+
147
+ function compareByAccountId(
148
+ left: VoicyClawRuntimeSnapshot,
149
+ right: VoicyClawRuntimeSnapshot,
150
+ ) {
151
+ return left.accountId.localeCompare(right.accountId);
152
+ }
@@ -0,0 +1,267 @@
1
+ import WebSocket from "ws";
2
+ import type { ResolvedVoicyClawAccount } from "./config.js";
3
+ import {
4
+ createHelloMessage,
5
+ createPreviewTextMessage,
6
+ createTtsTextMessage,
7
+ parseVoicyClawServerMessage,
8
+ type VoicyClawServerMessage,
9
+ type VoicyClawSttResultMessage,
10
+ type VoicyClawWelcomeMessage,
11
+ } from "./protocol.js";
12
+
13
+ type Logger = {
14
+ info: (message: string) => void;
15
+ warn: (message: string) => void;
16
+ error: (message: string) => void;
17
+ debug?: (message: string) => void;
18
+ };
19
+
20
+ export type VoicyClawSocketClientOptions = {
21
+ account: ResolvedVoicyClawAccount;
22
+ socketUrl: string;
23
+ logger: Logger;
24
+ onMessage?: (message: VoicyClawServerMessage) => Promise<void> | void;
25
+ onTranscript?: (message: VoicyClawSttResultMessage) => Promise<void> | void;
26
+ };
27
+
28
+ export class VoicyClawSocketClient {
29
+ private socket: WebSocket | null = null;
30
+ private welcomeMessage: VoicyClawWelcomeMessage | null = null;
31
+ private closePromise: Promise<void> | null = null;
32
+ private resolveClose: (() => void) | null = null;
33
+ private rejectClose: ((error: Error) => void) | null = null;
34
+
35
+ constructor(private readonly options: VoicyClawSocketClientOptions) {}
36
+
37
+ async connect() {
38
+ const socket = new WebSocket(this.options.socketUrl);
39
+ this.socket = socket;
40
+ this.closePromise = new Promise<void>((resolve, reject) => {
41
+ this.resolveClose = resolve;
42
+ this.rejectClose = reject;
43
+ });
44
+
45
+ return await new Promise<VoicyClawWelcomeMessage>((resolve, reject) => {
46
+ let settled = false;
47
+ const timeout = globalThis.setTimeout(() => {
48
+ const error = new Error(
49
+ `Timed out after ${this.options.account.connectTimeoutMs}ms waiting for VoicyClaw welcome.`,
50
+ );
51
+ if (!settled) {
52
+ settled = true;
53
+ reject(error);
54
+ }
55
+ socket.close();
56
+ }, this.options.account.connectTimeoutMs);
57
+
58
+ const settleResolve = (message: VoicyClawWelcomeMessage) => {
59
+ if (settled) {
60
+ return;
61
+ }
62
+
63
+ settled = true;
64
+ globalThis.clearTimeout(timeout);
65
+ resolve(message);
66
+ };
67
+
68
+ const settleReject = (error: Error) => {
69
+ if (settled) {
70
+ return;
71
+ }
72
+
73
+ settled = true;
74
+ globalThis.clearTimeout(timeout);
75
+ reject(error);
76
+ };
77
+
78
+ socket.once("open", () => {
79
+ socket.send(
80
+ JSON.stringify(
81
+ createHelloMessage({
82
+ token: this.options.account.token ?? "",
83
+ botId: this.options.account.botId,
84
+ channelId: this.options.account.channelId,
85
+ }),
86
+ ),
87
+ );
88
+ });
89
+
90
+ socket.on("message", async (raw, isBinary) => {
91
+ void this.handleIncomingMessage({
92
+ raw,
93
+ isBinary,
94
+ socket,
95
+ settleResolve,
96
+ settleReject,
97
+ });
98
+ });
99
+
100
+ socket.once("error", (error) => {
101
+ const failure =
102
+ error instanceof Error ? error : new Error(String(error));
103
+ settleReject(failure);
104
+ this.rejectClose?.(failure);
105
+ });
106
+
107
+ socket.once("close", (code, reason) => {
108
+ const detail = reason.toString("utf8").trim();
109
+ const message = detail
110
+ ? `VoicyClaw socket closed (${code}: ${detail})`
111
+ : `VoicyClaw socket closed (${code})`;
112
+
113
+ if (!settled && !this.welcomeMessage) {
114
+ settleReject(new Error(message));
115
+ }
116
+
117
+ this.resolveClose?.();
118
+ });
119
+ });
120
+ }
121
+
122
+ async waitUntilClosed() {
123
+ await this.closePromise;
124
+ }
125
+
126
+ sendPreview(utteranceId: string, text: string, isFinal = false) {
127
+ const sessionId = this.getRequiredSessionId();
128
+ this.getRequiredSocket().send(
129
+ JSON.stringify(
130
+ createPreviewTextMessage({
131
+ sessionId,
132
+ utteranceId,
133
+ text,
134
+ isFinal,
135
+ }),
136
+ ),
137
+ );
138
+ }
139
+
140
+ sendText(utteranceId: string, text: string, isFinal = true) {
141
+ const sessionId = this.getRequiredSessionId();
142
+ this.getRequiredSocket().send(
143
+ JSON.stringify(
144
+ createTtsTextMessage({
145
+ sessionId,
146
+ utteranceId,
147
+ text,
148
+ isFinal,
149
+ }),
150
+ ),
151
+ );
152
+ }
153
+
154
+ async close() {
155
+ if (!this.socket) {
156
+ return;
157
+ }
158
+
159
+ const socket = this.socket;
160
+ this.socket = null;
161
+
162
+ if (
163
+ socket.readyState === WebSocket.CLOSING ||
164
+ socket.readyState === WebSocket.CLOSED
165
+ ) {
166
+ return;
167
+ }
168
+
169
+ await new Promise<void>((resolve) => {
170
+ socket.once("close", () => resolve());
171
+ socket.close();
172
+ });
173
+ }
174
+
175
+ private getRequiredSocket() {
176
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
177
+ throw new Error("VoicyClaw socket is not connected");
178
+ }
179
+
180
+ return this.socket;
181
+ }
182
+
183
+ private getRequiredSessionId() {
184
+ const sessionId = this.welcomeMessage?.session_id;
185
+ if (!sessionId) {
186
+ throw new Error("VoicyClaw session is not ready");
187
+ }
188
+
189
+ return sessionId;
190
+ }
191
+
192
+ private async handleIncomingMessage(params: {
193
+ raw: WebSocket.RawData;
194
+ isBinary: boolean;
195
+ socket: WebSocket;
196
+ settleResolve: (message: VoicyClawWelcomeMessage) => void;
197
+ settleReject: (error: Error) => void;
198
+ }) {
199
+ const { raw, isBinary, socket, settleResolve, settleReject } = params;
200
+ if (isBinary) {
201
+ return;
202
+ }
203
+
204
+ let parsed: unknown;
205
+ try {
206
+ parsed = JSON.parse(raw.toString());
207
+ } catch {
208
+ this.options.logger.warn(
209
+ "[voicyclaw] ignoring non-JSON message from VoicyClaw server",
210
+ );
211
+ return;
212
+ }
213
+
214
+ const message = parseVoicyClawServerMessage(parsed);
215
+ if (!message) {
216
+ this.options.logger.warn(
217
+ "[voicyclaw] ignoring unsupported VoicyClaw protocol message",
218
+ );
219
+ return;
220
+ }
221
+
222
+ try {
223
+ await this.options.onMessage?.(message);
224
+ } catch (error) {
225
+ this.options.logger.error(
226
+ `[voicyclaw] onMessage handler failed: ${stringifyError(error)}`,
227
+ );
228
+ }
229
+
230
+ if (message.type === "WELCOME") {
231
+ this.welcomeMessage = message;
232
+ settleResolve(message);
233
+ return;
234
+ }
235
+
236
+ if (message.type === "ERROR") {
237
+ const error = new Error(
238
+ `[voicyclaw] handshake rejected: ${message.code} ${message.message}`,
239
+ );
240
+ settleReject(error);
241
+ socket.close();
242
+ return;
243
+ }
244
+
245
+ if (message.type === "STT_RESULT") {
246
+ try {
247
+ await this.options.onTranscript?.(message);
248
+ } catch (error) {
249
+ this.options.logger.error(
250
+ `[voicyclaw] onTranscript handler failed: ${stringifyError(error)}`,
251
+ );
252
+ }
253
+ return;
254
+ }
255
+
256
+ if (message.type === "DISCONNECT") {
257
+ this.options.logger.warn(
258
+ `[voicyclaw] server requested disconnect: ${message.reason}`,
259
+ );
260
+ socket.close();
261
+ }
262
+ }
263
+ }
264
+
265
+ function stringifyError(error: unknown) {
266
+ return error instanceof Error ? error.message : String(error);
267
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "isolatedModules": true,
9
+ "noEmit": true,
10
+ "allowSyntheticDefaultImports": true,
11
+ "types": ["node", "vitest/globals"]
12
+ },
13
+ "include": ["index.ts", "src/**/*.ts"]
14
+ }