@alexkroman1/aai 0.9.3 → 0.10.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 (57) hide show
  1. package/dist/_internal-types.d.ts +49 -22
  2. package/dist/_internal-types.js +43 -1
  3. package/dist/_mock-ws.d.ts +1 -2
  4. package/dist/_run-code.d.ts +31 -0
  5. package/dist/_session-ctx.d.ts +73 -0
  6. package/dist/_session-otel.d.ts +43 -0
  7. package/dist/_session-persist.d.ts +30 -0
  8. package/dist/_ssrf.d.ts +30 -0
  9. package/dist/_ssrf.js +123 -0
  10. package/dist/_utils.d.ts +25 -0
  11. package/dist/_utils.js +54 -1
  12. package/dist/builtin-tools.d.ts +5 -34
  13. package/dist/direct-executor-Ca0wt5H0.js +572 -0
  14. package/dist/direct-executor.d.ts +34 -5
  15. package/dist/index.d.ts +2 -1
  16. package/dist/index.js +2 -2
  17. package/dist/kv.d.ts +30 -38
  18. package/dist/kv.js +19 -86
  19. package/dist/matchers.d.ts +20 -0
  20. package/dist/matchers.js +41 -0
  21. package/dist/memory-tools.d.ts +39 -0
  22. package/dist/middleware-core.d.ts +47 -0
  23. package/dist/middleware-core.js +107 -0
  24. package/dist/middleware.d.ts +37 -0
  25. package/dist/protocol.d.ts +44 -24
  26. package/dist/protocol.js +34 -14
  27. package/dist/runtime.d.ts +26 -2
  28. package/dist/runtime.js +44 -7
  29. package/dist/s2s.d.ts +19 -29
  30. package/dist/s2s.js +117 -87
  31. package/dist/server.d.ts +31 -3
  32. package/dist/server.js +102 -28
  33. package/dist/session-BkN9u0ni.js +683 -0
  34. package/dist/session.d.ts +55 -28
  35. package/dist/session.js +2 -312
  36. package/dist/sqlite-kv.d.ts +34 -0
  37. package/dist/sqlite-kv.js +133 -0
  38. package/dist/sqlite-vector.d.ts +58 -0
  39. package/dist/sqlite-vector.js +149 -0
  40. package/dist/system-prompt.d.ts +21 -0
  41. package/dist/telemetry.d.ts +49 -0
  42. package/dist/telemetry.js +95 -0
  43. package/dist/testing-MRl3SXsI.js +519 -0
  44. package/dist/testing.d.ts +299 -0
  45. package/dist/testing.js +2 -0
  46. package/dist/types.d.ts +324 -39
  47. package/dist/types.js +62 -9
  48. package/dist/vector.d.ts +18 -22
  49. package/dist/vector.js +41 -48
  50. package/dist/worker-entry.d.ts +11 -3
  51. package/dist/worker-entry.js +19 -8
  52. package/dist/ws-handler.d.ts +7 -3
  53. package/dist/ws-handler.js +64 -12
  54. package/package.json +55 -8
  55. package/dist/_mock-ws.js +0 -158
  56. package/dist/builtin-tools.js +0 -270
  57. package/dist/direct-executor.js +0 -125
package/dist/s2s.js CHANGED
@@ -1,110 +1,116 @@
1
1
  import { consoleLogger } from "./runtime.js";
2
- import { z } from "zod";
3
- import { WebSocket } from "ws";
2
+ import { s2sConnectionDuration, s2sErrorCounter, tracer } from "./telemetry.js";
4
3
  import { createNanoEvents } from "nanoevents";
4
+ import { WebSocket } from "ws";
5
5
  //#region s2s.ts
6
6
  const uint8ToBase64 = (bytes) => Buffer.from(bytes).toString("base64");
7
7
  const base64ToUint8 = (base64) => new Uint8Array(Buffer.from(base64, "base64"));
8
- /** WebSocket readyState constant for OPEN. */
9
8
  const WS_OPEN = 1;
10
- /** Default S2S WebSocket factory using the `ws` package (Node-only). */
11
9
  const defaultCreateS2sWebSocket = (url, opts) => new WebSocket(url, { headers: opts.headers });
12
- const S2sServerMessageSchema = z.discriminatedUnion("type", [
13
- z.object({
14
- type: z.literal("session.ready"),
15
- session_id: z.string()
16
- }),
17
- z.object({ type: z.literal("session.updated") }).passthrough(),
18
- z.object({ type: z.literal("input.speech.started") }),
19
- z.object({ type: z.literal("input.speech.stopped") }),
20
- z.object({
21
- type: z.literal("transcript.user.delta"),
22
- text: z.string()
23
- }),
24
- z.object({
25
- type: z.literal("transcript.user"),
26
- item_id: z.string(),
27
- text: z.string()
28
- }),
29
- z.object({
30
- type: z.literal("reply.started"),
31
- reply_id: z.string()
32
- }),
33
- z.object({
34
- type: z.literal("transcript.agent.delta"),
35
- delta: z.string()
36
- }).passthrough(),
37
- z.object({
38
- type: z.literal("transcript.agent"),
39
- text: z.string()
40
- }),
41
- z.object({ type: z.literal("reply.content_part.started") }).passthrough(),
42
- z.object({ type: z.literal("reply.content_part.done") }).passthrough(),
43
- z.object({
44
- type: z.literal("tool.call"),
45
- call_id: z.string(),
46
- name: z.string(),
47
- args: z.record(z.string(), z.unknown()).optional().default({})
48
- }),
49
- z.object({
50
- type: z.literal("reply.done"),
51
- status: z.string().optional()
52
- }),
53
- z.object({
54
- type: z.literal("session.error"),
55
- code: z.string(),
56
- message: z.string()
57
- }),
58
- z.object({
59
- type: z.literal("error"),
60
- message: z.string()
61
- })
10
+ function hasStringFields(obj, ...keys) {
11
+ for (const k of keys) if (typeof obj[k] !== "string") return false;
12
+ return true;
13
+ }
14
+ function parseAgentTranscript(obj) {
15
+ if (typeof obj.text !== "string") return;
16
+ return {
17
+ type: "transcript.agent",
18
+ text: obj.text,
19
+ reply_id: typeof obj.reply_id === "string" ? obj.reply_id : "",
20
+ item_id: typeof obj.item_id === "string" ? obj.item_id : "",
21
+ interrupted: obj.interrupted === true
22
+ };
23
+ }
24
+ function parseToolCall(obj) {
25
+ if (typeof obj.call_id !== "string" || typeof obj.name !== "string") return;
26
+ const args = obj.args != null && typeof obj.args === "object" && !Array.isArray(obj.args) ? obj.args : {};
27
+ return {
28
+ type: "tool.call",
29
+ call_id: obj.call_id,
30
+ name: obj.name,
31
+ args
32
+ };
33
+ }
34
+ function passthrough(obj) {
35
+ return obj;
36
+ }
37
+ function requireFields(...keys) {
38
+ return (obj) => hasStringFields(obj, ...keys) ? obj : void 0;
39
+ }
40
+ const MESSAGE_VALIDATORS = new Map([
41
+ ["session.ready", requireFields("session_id")],
42
+ ["session.updated", passthrough],
43
+ ["input.speech.started", passthrough],
44
+ ["input.speech.stopped", passthrough],
45
+ ["reply.content_part.started", passthrough],
46
+ ["reply.content_part.done", passthrough],
47
+ ["transcript.user.delta", requireFields("text")],
48
+ ["transcript.user", requireFields("item_id", "text")],
49
+ ["reply.started", requireFields("reply_id")],
50
+ ["transcript.agent.delta", requireFields("delta")],
51
+ ["transcript.agent", parseAgentTranscript],
52
+ ["tool.call", parseToolCall],
53
+ ["reply.done", (obj) => ({
54
+ type: "reply.done",
55
+ ...typeof obj.status === "string" ? { status: obj.status } : {}
56
+ })],
57
+ ["session.error", requireFields("code", "message")],
58
+ ["error", requireFields("message")]
62
59
  ]);
63
- /** Dispatch a parsed S2S server message to the emitter. */
60
+ function parseS2sMessage(obj) {
61
+ const type = obj.type;
62
+ if (typeof type !== "string") return;
63
+ return MESSAGE_VALIDATORS.get(type)?.(obj);
64
+ }
64
65
  function dispatchS2sMessage(emitter, msg) {
65
66
  switch (msg.type) {
66
67
  case "session.ready":
67
- emitter.emit("ready", { session_id: msg.session_id });
68
+ emitter.emit("ready", { sessionId: msg.session_id });
68
69
  break;
69
70
  case "session.updated":
70
- emitter.emit("session_updated", msg);
71
+ emitter.emit("sessionUpdated", msg);
71
72
  break;
72
73
  case "input.speech.started":
73
- emitter.emit("speech_started");
74
+ emitter.emit("speechStarted");
74
75
  break;
75
76
  case "input.speech.stopped":
76
- emitter.emit("speech_stopped");
77
+ emitter.emit("speechStopped");
77
78
  break;
78
79
  case "transcript.user.delta":
79
- emitter.emit("user_transcript_delta", { text: msg.text });
80
+ emitter.emit("userTranscriptDelta", { text: msg.text });
80
81
  break;
81
82
  case "transcript.user":
82
- emitter.emit("user_transcript", {
83
- item_id: msg.item_id,
83
+ emitter.emit("userTranscript", {
84
+ itemId: msg.item_id,
84
85
  text: msg.text
85
86
  });
86
87
  break;
87
88
  case "reply.started":
88
- emitter.emit("reply_started", { reply_id: msg.reply_id });
89
+ emitter.emit("replyStarted", { replyId: msg.reply_id });
89
90
  break;
90
91
  case "transcript.agent.delta":
91
- emitter.emit("agent_transcript_delta", { text: msg.delta });
92
+ emitter.emit("agentTranscriptDelta", { text: msg.delta });
92
93
  break;
93
94
  case "transcript.agent":
94
- emitter.emit("agent_transcript", { text: msg.text });
95
+ emitter.emit("agentTranscript", {
96
+ text: msg.text,
97
+ replyId: msg.reply_id,
98
+ itemId: msg.item_id,
99
+ interrupted: msg.interrupted
100
+ });
95
101
  break;
96
102
  case "tool.call":
97
- emitter.emit("tool_call", {
98
- call_id: msg.call_id,
103
+ emitter.emit("toolCall", {
104
+ callId: msg.call_id,
99
105
  name: msg.name,
100
106
  args: msg.args
101
107
  });
102
108
  break;
103
109
  case "reply.done":
104
- emitter.emit("reply_done", { ...msg.status ? { status: msg.status } : {} });
110
+ emitter.emit("replyDone", msg.status ? { status: msg.status } : {});
105
111
  break;
106
112
  case "session.error":
107
- if (msg.code === "session_not_found" || msg.code === "session_forbidden") emitter.emit("session_expired", {
113
+ if (msg.code === "session_not_found" || msg.code === "session_forbidden") emitter.emit("sessionExpired", {
108
114
  code: msg.code,
109
115
  message: msg.message
110
116
  });
@@ -121,26 +127,23 @@ function dispatchS2sMessage(emitter, msg) {
121
127
  break;
122
128
  case "reply.content_part.started":
123
129
  case "reply.content_part.done": break;
130
+ default: break;
124
131
  }
125
132
  }
126
- /**
127
- * Connect to AssemblyAI's Speech-to-Speech WebSocket API.
128
- *
129
- * Returns an {@link S2sHandle} with a typed `on()` method.
130
- * Consumers listen for events: `ready`, `speech_started`, `speech_stopped`,
131
- * `user_transcript_delta`, `user_transcript`, `reply_started`,
132
- * `reply_done`, `audio`, `agent_transcript`, `tool_call`,
133
- * `session_expired`, `error`, `close`.
134
- */
135
133
  function connectS2s(opts) {
136
134
  const { apiKey, config, createWebSocket, logger: log = consoleLogger } = opts;
137
135
  return new Promise((resolve, reject) => {
138
136
  log.info("S2S connecting", { url: config.wssUrl });
137
+ const connectionSpan = tracer.startSpan("s2s.connection", { attributes: { "aai.s2s.url": config.wssUrl } });
138
+ const connectStart = performance.now();
139
139
  const ws = createWebSocket(config.wssUrl, { headers: { Authorization: `Bearer ${apiKey}` } });
140
140
  const emitter = createNanoEvents();
141
141
  let opened = false;
142
142
  function send(msg) {
143
- if (ws.readyState !== WS_OPEN) return;
143
+ if (ws.readyState !== WS_OPEN) {
144
+ log.debug("S2S send dropped: socket not open", { type: msg.type });
145
+ return;
146
+ }
144
147
  const json = JSON.stringify(msg);
145
148
  if (msg.type !== "input.audio") log.info(`S2S >> ${msg.type}`, msg.type === "session.update" ? { payload: json } : void 0);
146
149
  ws.send(json);
@@ -148,7 +151,10 @@ function connectS2s(opts) {
148
151
  const handle = {
149
152
  on: emitter.on.bind(emitter),
150
153
  sendAudio(audio) {
151
- if (ws.readyState !== WS_OPEN) return;
154
+ if (ws.readyState !== WS_OPEN) {
155
+ log.debug("S2S sendAudio dropped: socket not open");
156
+ return;
157
+ }
152
158
  ws.send(`{"type":"input.audio","audio":"${uint8ToBase64(audio)}"}`);
153
159
  },
154
160
  sendToolResult(callId, result) {
@@ -164,9 +170,13 @@ function connectS2s(opts) {
164
170
  send(msg);
165
171
  },
166
172
  updateSession(sessionConfig) {
173
+ const { systemPrompt, ...rest } = sessionConfig;
167
174
  send({
168
175
  type: "session.update",
169
- session: sessionConfig
176
+ session: {
177
+ system_prompt: systemPrompt,
178
+ ...rest
179
+ }
170
180
  });
171
181
  },
172
182
  resumeSession(sessionId) {
@@ -183,6 +193,7 @@ function connectS2s(opts) {
183
193
  ws.addEventListener("open", () => {
184
194
  opened = true;
185
195
  log.info("S2S WebSocket open");
196
+ connectionSpan.addEvent("ws.open");
186
197
  resolve(handle);
187
198
  });
188
199
  function tryParseJson(data) {
@@ -190,7 +201,6 @@ function connectS2s(opts) {
190
201
  return JSON.parse(String(data));
191
202
  } catch {
192
203
  log.warn("S2S << invalid JSON", { data: String(data).slice(0, 200) });
193
- return;
194
204
  }
195
205
  }
196
206
  function handleAudioFastPath(obj) {
@@ -208,15 +218,19 @@ function connectS2s(opts) {
208
218
  function handleS2sMessage(ev) {
209
219
  const raw = tryParseJson(ev.data);
210
220
  if (raw === void 0) return;
221
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
222
+ log.warn("S2S << non-object JSON message", { type: typeof raw });
223
+ return;
224
+ }
211
225
  const obj = raw;
212
226
  logIncoming(obj);
213
227
  if (handleAudioFastPath(obj)) return;
214
- const parsed = S2sServerMessageSchema.safeParse(raw);
215
- if (!parsed.success) {
228
+ const parsed = parseS2sMessage(obj);
229
+ if (!parsed) {
216
230
  log.warn(`S2S << unrecognised message type: ${obj.type ?? JSON.stringify(raw).slice(0, 200)}`);
217
231
  return;
218
232
  }
219
- dispatchS2sMessage(emitter, parsed.data);
233
+ dispatchS2sMessage(emitter, parsed);
220
234
  }
221
235
  ws.addEventListener("message", handleS2sMessage);
222
236
  ws.addEventListener("close", (ev) => {
@@ -224,14 +238,30 @@ function connectS2s(opts) {
224
238
  code: ev.code ?? 0,
225
239
  reason: ev.reason ?? ""
226
240
  });
241
+ const elapsed = (performance.now() - connectStart) / 1e3;
242
+ s2sConnectionDuration.record(elapsed);
243
+ connectionSpan.addEvent("ws.closed", {
244
+ "ws.close.code": ev.code ?? 0,
245
+ "ws.close.reason": ev.reason ?? ""
246
+ });
247
+ connectionSpan.end();
248
+ if (!opened) reject(/* @__PURE__ */ new Error(`WebSocket closed before open (code: ${ev.code ?? 0})`));
227
249
  emitter.emit("close");
228
250
  });
229
251
  ws.addEventListener("error", (ev) => {
230
252
  const message = typeof ev.message === "string" ? ev.message : "WebSocket error";
231
253
  const errObj = new Error(message);
232
254
  log.error("S2S WebSocket error", { error: errObj.message });
233
- if (!opened) reject(errObj);
234
- else emitter.emit("error", {
255
+ s2sErrorCounter.add(1);
256
+ connectionSpan.setStatus({
257
+ code: 2,
258
+ message: errObj.message
259
+ });
260
+ connectionSpan.recordException(errObj);
261
+ if (!opened) {
262
+ connectionSpan.end();
263
+ reject(errObj);
264
+ } else emitter.emit("error", {
235
265
  code: "ws_error",
236
266
  message: errObj.message
237
267
  });
package/dist/server.d.ts CHANGED
@@ -3,18 +3,26 @@
3
3
  *
4
4
  * `createServer()` returns a server with `listen()` for HTTP + WebSocket.
5
5
  * Calls `createDirectExecutor` + `wireSessionSocket` directly — no
6
- * intermediate WintercServer layer.
6
+ * intermediary needed.
7
7
  */
8
8
  import type { Kv } from "./kv.ts";
9
9
  import type { Logger, S2SConfig } from "./runtime.ts";
10
10
  import type { AgentDef } from "./types.ts";
11
+ import type { VectorStore } from "./vector.ts";
12
+ /**
13
+ * Configuration for a self-hosted agent server created by {@link createServer}.
14
+ *
15
+ * @public
16
+ */
11
17
  export type ServerOptions = {
12
18
  /** The agent definition returned by `defineAgent()`. */
13
- agent: AgentDef;
19
+ agent: AgentDef<any>;
14
20
  /** Environment variables. Defaults to `process.env`. */
15
21
  env?: Record<string, string>;
16
- /** KV store. Defaults to in-memory. */
22
+ /** KV store. Defaults to SQLite-backed (`.aai/local.db`). */
17
23
  kv?: Kv;
24
+ /** Vector store. Defaults to SQLite-backed (`.aai/local.db`). */
25
+ vector?: VectorStore;
18
26
  /** HTML to serve at `GET /`. */
19
27
  clientHtml?: string;
20
28
  /** Directory containing built client files (index.html + assets/). */
@@ -23,11 +31,31 @@ export type ServerOptions = {
23
31
  logger?: Logger;
24
32
  /** S2S configuration. Defaults to AssemblyAI production. */
25
33
  s2sConfig?: S2SConfig;
34
+ /**
35
+ * Timeout in ms for `session.start()` (S2S connection setup).
36
+ * Defaults to 10 000 (10 s). If the session doesn't initialize within
37
+ * this window the connection is cleaned up.
38
+ */
39
+ sessionStartTimeoutMs?: number;
40
+ /**
41
+ * Maximum time in milliseconds to wait for sessions to stop during
42
+ * {@link AgentServer.close | close()}. Sessions still running after this
43
+ * deadline are force-closed. Defaults to `30_000` (30 seconds).
44
+ */
45
+ shutdownTimeoutMs?: number;
26
46
  };
47
+ /**
48
+ * Handle returned by {@link createServer} with lifecycle methods to start
49
+ * and stop the HTTP + WebSocket server.
50
+ *
51
+ * @public
52
+ */
27
53
  export type AgentServer = {
28
54
  /** Start listening on the given port. */
29
55
  listen(port?: number): Promise<void>;
30
56
  /** Stop the server. */
31
57
  close(): Promise<void>;
58
+ /** The port the server is listening on, or `undefined` before `listen()`. */
59
+ port: number | undefined;
32
60
  };
33
61
  export declare function createServer(options: ServerOptions): AgentServer;
package/dist/server.js CHANGED
@@ -1,56 +1,115 @@
1
- import { AUDIO_FORMAT } from "./protocol.js";
1
+ import { filterEnv } from "./_utils.js";
2
+ import { t as createDirectExecutor } from "./direct-executor-Ca0wt5H0.js";
3
+ import { buildReadyConfig } from "./protocol.js";
2
4
  import { DEFAULT_S2S_CONFIG, consoleLogger } from "./runtime.js";
3
- import { createDirectExecutor } from "./direct-executor.js";
5
+ import { createSqliteKv } from "./sqlite-kv.js";
4
6
  import { wireSessionSocket } from "./ws-handler.js";
7
+ import { mkdirSync } from "node:fs";
8
+ import { WebSocketServer } from "ws";
5
9
  import { serve } from "@hono/node-server";
6
10
  import { serveStatic } from "@hono/node-server/serve-static";
7
11
  import { Hono } from "hono";
8
- import { WebSocketServer } from "ws";
9
12
  //#region server.ts
10
13
  /**
11
14
  * Self-hostable agent server.
12
15
  *
13
16
  * `createServer()` returns a server with `listen()` for HTTP + WebSocket.
14
17
  * Calls `createDirectExecutor` + `wireSessionSocket` directly — no
15
- * intermediate WintercServer layer.
18
+ * intermediary needed.
16
19
  */
17
- function resolveEnv(env) {
18
- return Object.fromEntries(Object.entries(env).filter(([, v]) => v !== void 0));
20
+ /** Escape HTML special characters to prevent XSS. */
21
+ function escapeHtml(s) {
22
+ return s.replace(/[&<>"']/g, (ch) => ({
23
+ "&": "&amp;",
24
+ "<": "&lt;",
25
+ ">": "&gt;",
26
+ "\"": "&quot;",
27
+ "'": "&#39;"
28
+ })[ch]);
29
+ }
30
+ /**
31
+ * Create an HTTP + WebSocket server for self-hosted agent deployments.
32
+ *
33
+ * Sets up a Hono HTTP server with a `/health` endpoint and WebSocket upgrade
34
+ * handling. Agent tools execute directly in-process via {@link createDirectExecutor}.
35
+ *
36
+ * @param options - Server configuration including the agent definition, optional
37
+ * KV store, client assets, logger, and S2S config. See {@link ServerOptions}.
38
+ * @returns An {@link AgentServer} with `listen()` and `close()` lifecycle methods.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * import { defineAgent } from "@alexkroman1/aai";
43
+ * import { createServer } from "@alexkroman1/aai/server";
44
+ *
45
+ * const agent = defineAgent({ name: "my-agent" });
46
+ * const server = createServer({ agent });
47
+ * await server.listen(3000);
48
+ * ```
49
+ *
50
+ * @public
51
+ */
52
+ async function drainSessions(sessions, shutdownTimeoutMs, logger) {
53
+ if (sessions.size === 0) return;
54
+ let timer;
55
+ const timeout = new Promise((resolve) => {
56
+ timer = setTimeout(resolve, shutdownTimeoutMs, "timeout");
57
+ });
58
+ const graceful = Promise.allSettled([...sessions.values()].map((s) => s.stop())).then((results) => {
59
+ for (const r of results) if (r.status === "rejected") logger.warn(`Session stop failed during close: ${r.reason}`);
60
+ return "done";
61
+ });
62
+ const outcome = await Promise.race([graceful, timeout]);
63
+ if (timer) clearTimeout(timer);
64
+ if (outcome === "timeout") logger.warn(`Shutdown timeout (${shutdownTimeoutMs}ms) exceeded — force-closing ${sessions.size} remaining session(s)`);
65
+ sessions.clear();
19
66
  }
20
67
  function createServer(options) {
21
- const { agent, kv, clientHtml, clientDir, logger = consoleLogger, s2sConfig = DEFAULT_S2S_CONFIG } = options;
68
+ if (options.clientHtml && options.clientDir) throw new Error("ServerOptions: clientHtml and clientDir are mutually exclusive provide one or the other, not both.");
69
+ const { agent, kv, vector, clientHtml, clientDir, logger = consoleLogger, s2sConfig = DEFAULT_S2S_CONFIG, shutdownTimeoutMs = 3e4 } = options;
70
+ const env = filterEnv(options.env ?? (typeof process !== "undefined" ? process.env : {}));
71
+ const resolvedKv = kv ?? (() => {
72
+ mkdirSync(".aai", { recursive: true });
73
+ return createSqliteKv();
74
+ })();
22
75
  const executor = createDirectExecutor({
23
76
  agent,
24
- env: resolveEnv(options.env ?? (typeof process !== "undefined" ? process.env : {})),
25
- ...kv ? { kv } : {},
77
+ env,
78
+ kv: resolvedKv,
79
+ ...vector ? { vector } : {},
26
80
  logger,
27
81
  s2sConfig
28
82
  });
29
83
  const sessions = /* @__PURE__ */ new Map();
30
- const readyConfig = {
31
- audioFormat: AUDIO_FORMAT,
32
- sampleRate: s2sConfig.inputSampleRate,
33
- ttsSampleRate: s2sConfig.outputSampleRate
34
- };
35
- function handleWs(ws, skipGreeting) {
84
+ const readyConfig = buildReadyConfig(s2sConfig);
85
+ const safeAgentName = escapeHtml(agent.name);
86
+ function handleWs(ws, skipGreeting, resumeFrom) {
36
87
  wireSessionSocket(ws, {
37
88
  sessions,
38
89
  createSession: (sid, client) => executor.createSession({
39
90
  id: sid,
40
91
  agent: agent.name,
41
92
  client,
42
- skipGreeting
93
+ skipGreeting,
94
+ ...resumeFrom ? { resumeFrom } : {}
43
95
  }),
44
96
  readyConfig,
45
- logger
97
+ logger,
98
+ ...options.sessionStartTimeoutMs !== void 0 ? { sessionStartTimeoutMs: options.sessionStartTimeoutMs } : {},
99
+ ...resumeFrom ? { resumeFrom } : {}
46
100
  });
47
101
  }
48
102
  let serverHandle = null;
103
+ let listenPort;
49
104
  return {
105
+ get port() {
106
+ return listenPort;
107
+ },
50
108
  async listen(port = 3e3) {
109
+ if (serverHandle) throw new Error("Server is already listening");
51
110
  const app = new Hono();
52
111
  app.onError((err, c) => {
53
- logger.error(`${c.req.method} ${new URL(c.req.url).pathname} error: ${err.message}`);
112
+ logger.error(`${c.req.method} ${c.req.path} error: ${err.message}`);
54
113
  return c.json({ error: "Internal Server Error" }, 500);
55
114
  });
56
115
  app.use("/*", async (c, next) => {
@@ -59,7 +118,7 @@ function createServer(options) {
59
118
  const ms = Date.now() - start;
60
119
  const { status } = c.res;
61
120
  const method = c.req.method;
62
- const path = new URL(c.req.url).pathname;
121
+ const path = c.req.path;
63
122
  if (status >= 400) logger.error(`${method} ${path} ${status} ${ms}ms`);
64
123
  else logger.info(`${method} ${path} ${status} ${ms}ms`);
65
124
  });
@@ -67,37 +126,52 @@ function createServer(options) {
67
126
  status: "ok",
68
127
  name: agent.name
69
128
  }));
129
+ app.get("/kv", async (c) => {
130
+ const key = c.req.query("key");
131
+ if (!key) return c.json({ error: "Missing key query parameter" }, 400);
132
+ const value = await resolvedKv.get(key);
133
+ if (value === null) return c.json(null, 404);
134
+ return c.json(value);
135
+ });
70
136
  if (clientDir) app.use("/*", serveStatic({ root: clientDir }));
137
+ const csp = "default-src 'self'; script-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src 'self' wss: ws:; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; object-src 'none'; base-uri 'self'";
71
138
  app.get("/", (c) => {
72
- if (clientHtml) return c.html(clientHtml);
73
- return c.html(`<!DOCTYPE html><html><body><h1>${agent.name}</h1><p>Agent server running.</p></body></html>`);
139
+ if (clientHtml) return c.html(clientHtml, 200, { "Content-Security-Policy": csp });
140
+ return c.html(`<!DOCTYPE html><html><body><h1>${safeAgentName}</h1><p>Agent server running.</p></body></html>`, 200, { "Content-Security-Policy": csp });
74
141
  });
75
142
  const nodeServer = serve({
76
143
  fetch: app.fetch,
77
144
  port
78
145
  });
79
- await new Promise((resolve) => {
146
+ await new Promise((resolve, reject) => {
80
147
  nodeServer.on("listening", resolve);
148
+ nodeServer.on("error", reject);
81
149
  });
150
+ const addr = nodeServer.address();
151
+ listenPort = typeof addr === "object" && addr ? addr.port : port;
82
152
  const wss = new WebSocketServer({ noServer: true });
83
153
  nodeServer.on("upgrade", (req, socket, head) => {
84
- const reqUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
85
- const resume = reqUrl.searchParams.has("resume");
86
- logger.info(`WS upgrade ${reqUrl.pathname}${resume ? " (resume)" : ""}`);
154
+ const reqUrl = new URL(req.url ?? "/", `http://localhost:${listenPort}`);
155
+ const resumeFrom = reqUrl.searchParams.get("sessionId") ?? void 0;
156
+ const skipGreeting = reqUrl.searchParams.has("resume") || resumeFrom !== void 0;
157
+ logger.info(`WS upgrade ${reqUrl.pathname}${skipGreeting ? " (resume)" : ""}`);
87
158
  wss.handleUpgrade(req, socket, head, (ws) => {
88
- handleWs(ws, resume);
159
+ handleWs(ws, skipGreeting, resumeFrom);
89
160
  });
90
161
  });
91
162
  serverHandle = { async shutdown() {
163
+ await new Promise((resolve, reject) => {
164
+ wss.close((err) => err ? reject(err) : resolve());
165
+ });
92
166
  await new Promise((resolve, reject) => {
93
167
  nodeServer.close((err) => err ? reject(err) : resolve());
94
168
  });
95
169
  } };
96
170
  },
97
171
  async close() {
98
- for (const session of sessions.values()) await session.stop();
99
- sessions.clear();
172
+ await drainSessions(sessions, shutdownTimeoutMs, logger);
100
173
  await serverHandle?.shutdown();
174
+ listenPort = void 0;
101
175
  }
102
176
  };
103
177
  }