@guava-ai/guava-sdk 0.23.0 → 0.25.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.
Files changed (91) hide show
  1. package/README.md +13 -3
  2. package/bin/example-runner.ts +138 -11
  3. package/dist/bin/example-runner.d.ts +23 -6
  4. package/dist/bin/example-runner.js +112 -9
  5. package/dist/bin/example-runner.js.map +1 -1
  6. package/dist/examples/{example.test.js → agent-testing.test.js} +2 -2
  7. package/dist/examples/agent-testing.test.js.map +1 -0
  8. package/dist/examples/help-desk.js +22 -8
  9. package/dist/examples/help-desk.js.map +1 -1
  10. package/dist/examples/multiple-agents.d.ts +1 -0
  11. package/dist/examples/multiple-agents.js +52 -0
  12. package/dist/examples/multiple-agents.js.map +1 -0
  13. package/dist/examples/property-insurance.js +14 -3
  14. package/dist/examples/property-insurance.js.map +1 -1
  15. package/dist/examples/restaurant-waitlist.js +9 -1
  16. package/dist/examples/restaurant-waitlist.js.map +1 -1
  17. package/dist/examples/scheduling-outbound.js +5 -6
  18. package/dist/examples/scheduling-outbound.js.map +1 -1
  19. package/dist/src/action-item.d.ts +4 -4
  20. package/dist/src/action-item.js +1 -1
  21. package/dist/src/action-item.js.map +1 -1
  22. package/dist/src/agent.d.ts +31 -11
  23. package/dist/src/agent.js +278 -135
  24. package/dist/src/agent.js.map +1 -1
  25. package/dist/src/call.d.ts +12 -1
  26. package/dist/src/call.js +87 -24
  27. package/dist/src/call.js.map +1 -1
  28. package/dist/src/client.d.ts +3 -0
  29. package/dist/src/client.js +6 -0
  30. package/dist/src/client.js.map +1 -1
  31. package/dist/src/commands.d.ts +37 -9
  32. package/dist/src/commands.js +13 -3
  33. package/dist/src/commands.js.map +1 -1
  34. package/dist/src/events.d.ts +28 -1
  35. package/dist/src/events.js +10 -2
  36. package/dist/src/events.js.map +1 -1
  37. package/dist/src/example-utils.d.ts +2 -0
  38. package/dist/src/example-utils.js +115 -0
  39. package/dist/src/example-utils.js.map +1 -0
  40. package/dist/src/health.d.ts +35 -0
  41. package/dist/src/health.js +99 -0
  42. package/dist/src/health.js.map +1 -0
  43. package/dist/src/helpers/index.d.ts +1 -0
  44. package/dist/src/helpers/index.js +9 -0
  45. package/dist/src/helpers/index.js.map +1 -0
  46. package/dist/src/helpers/llm.d.ts +43 -1
  47. package/dist/src/helpers/llm.js +247 -0
  48. package/dist/src/helpers/llm.js.map +1 -1
  49. package/dist/src/helpers/server-rag.d.ts +25 -0
  50. package/dist/src/helpers/server-rag.js +119 -0
  51. package/dist/src/helpers/server-rag.js.map +1 -0
  52. package/dist/src/index.d.ts +3 -1
  53. package/dist/src/index.js +4 -1
  54. package/dist/src/index.js.map +1 -1
  55. package/dist/src/runner.d.ts +10 -0
  56. package/dist/src/runner.js +101 -0
  57. package/dist/src/runner.js.map +1 -0
  58. package/dist/src/socket/protocol.d.ts +4 -4
  59. package/dist/src/testing/mocks.d.ts +6 -0
  60. package/dist/src/testing/mocks.js +12 -0
  61. package/dist/src/testing/mocks.js.map +1 -1
  62. package/dist/src/testing/session.d.ts +7 -0
  63. package/dist/src/testing/session.js +3 -22
  64. package/dist/src/testing/session.js.map +1 -1
  65. package/dist/src/version.d.ts +1 -1
  66. package/dist/src/version.js +1 -1
  67. package/examples/{example.test.ts → agent-testing.test.ts} +1 -1
  68. package/examples/help-desk.ts +21 -12
  69. package/examples/multiple-agents.ts +18 -0
  70. package/examples/property-insurance.ts +15 -3
  71. package/examples/restaurant-waitlist.ts +10 -1
  72. package/examples/scheduling-outbound.ts +5 -6
  73. package/package.json +9 -1
  74. package/src/action-item.ts +1 -1
  75. package/src/agent.ts +273 -147
  76. package/src/call.ts +115 -24
  77. package/src/client.ts +7 -0
  78. package/src/commands.ts +16 -2
  79. package/src/events.ts +11 -1
  80. package/src/example-utils.ts +87 -0
  81. package/src/health.ts +120 -0
  82. package/src/helpers/index.ts +2 -0
  83. package/src/helpers/llm.ts +170 -1
  84. package/src/helpers/server-rag.ts +132 -0
  85. package/src/index.ts +3 -1
  86. package/src/runner.ts +39 -0
  87. package/src/testing/mocks.ts +17 -0
  88. package/src/testing/session.ts +2 -22
  89. package/src/version.ts +1 -1
  90. package/dist/examples/example.test.js.map +0 -1
  91. /package/dist/examples/{example.test.d.ts → agent-testing.test.d.ts} +0 -0
package/src/call.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  TransferCommand,
10
10
  ReadScriptCommand,
11
11
  RetryTaskCommand,
12
+ SetAgentDTMFCommand,
12
13
  } from "./commands.ts";
13
14
  import type * as z from "zod";
14
15
  import type {
@@ -20,6 +21,7 @@ import type {
20
21
  } from "./action-item.ts";
21
22
  import { Say } from "./action-item.ts";
22
23
  import { telemetryClient } from "./telemetry.ts";
24
+ import type { CallInfo } from "./socket/call-info.ts";
23
25
 
24
26
  export type TaskObjective =
25
27
  | { objective: string }
@@ -31,8 +33,33 @@ export type ReachPersonOutcome = {
31
33
  nextActionPreview?: string;
32
34
  };
33
35
 
36
+ export const DEFAULT_REACH_PERSON_OUTCOMES: ReachPersonOutcome[] = [
37
+ { key: "available", description: "The intended contact is confirmed on the line." },
38
+ {
39
+ key: "unavailable",
40
+ description:
41
+ "The contact could not be reached. A third party, gatekeeper, or IVR was unable to transfer the call to the contact.",
42
+ },
43
+ { key: "voicemail", description: "An answering machine or voicemail system was reached." },
44
+ { key: "wrong_number", description: "The number does not reach the intended contact." },
45
+ {
46
+ key: "do_not_contact",
47
+ description: "The person on the line has indicated this number should not be called.",
48
+ },
49
+ ];
50
+
51
+ function voicemailHangupInstruction(): string {
52
+ return "DO NOT leave a message. REMAIN SILENT AND HANG UP WITHOUT RESPONDING.";
53
+ }
54
+
55
+ function voicemailMessageInstruction(message: string): string {
56
+ return `Say this message VERBATIM: "${message}" Then hang up.`;
57
+ }
58
+
34
59
  @telemetryClient.trackClass()
35
60
  export class Call {
61
+ private _callId: string;
62
+ private _callInfo: CallInfo;
36
63
  protected _commandQueue: Command[] = [];
37
64
  private _variables: Record<string, any> = {};
38
65
  protected logger: Logger;
@@ -42,8 +69,17 @@ export class Call {
42
69
  // input (mutating it) (i.e. _drain should use Array.splice)
43
70
  private _drain?: (_: Command[]) => Promise<void>;
44
71
  _fieldValues: Record<string, unknown> = {};
72
+ _fieldKeysByTaskId: Record<string, string[]> = {};
73
+
74
+ constructor(
75
+ callId: string,
76
+ callInfo: CallInfo,
77
+ variables: Record<string, any> = {},
78
+ logger: Logger = getDefaultLogger(),
79
+ ) {
80
+ this._callId = callId;
81
+ this._callInfo = callInfo;
45
82
 
46
- constructor(variables: Record<string, any> = {}, logger: Logger = getDefaultLogger()) {
47
83
  // Set initial variables.
48
84
  this._variables = { ...variables };
49
85
 
@@ -51,6 +87,14 @@ export class Call {
51
87
  this.logger = logger;
52
88
  }
53
89
 
90
+ get id(): string {
91
+ return this._callId;
92
+ }
93
+
94
+ get callInfo(): CallInfo {
95
+ return this._callInfo;
96
+ }
97
+
54
98
  /**
55
99
  * @description Supply a function used to consume commands from the internal command queue.
56
100
  *
@@ -75,6 +119,10 @@ export class Call {
75
119
  }
76
120
  }
77
121
 
122
+ hasField(key: string): boolean {
123
+ return key in this._fieldValues;
124
+ }
125
+
78
126
  async sendCommand<C extends Command, Schema extends z.ZodType<C>>(
79
127
  schema: Schema,
80
128
  data: z.input<Schema>,
@@ -84,6 +132,13 @@ export class Call {
84
132
  await this.flush();
85
133
  }
86
134
 
135
+ async setAgentDtmf(enabled: boolean) {
136
+ await this.sendCommand(SetAgentDTMFCommand, {
137
+ command_type: "set-agent-dtmf",
138
+ enabled,
139
+ });
140
+ }
141
+
87
142
  async setLanguageMode(args: { primary?: Language; secondary?: Language[] }) {
88
143
  await this.sendCommand(SetLanguageModeCommand, {
89
144
  command_type: "set-language-mode",
@@ -128,6 +183,7 @@ export class Call {
128
183
  throw new Error("At least one of ['objective', 'checklist'] must be provided.");
129
184
  }
130
185
 
186
+ const fieldKeys: string[] = [];
131
187
  const action_items = checklist.map((item): ActionItem => {
132
188
  if (typeof item === "string") {
133
189
  return { item_type: "todo", description: item } satisfies TodoItem;
@@ -138,12 +194,15 @@ export class Call {
138
194
  "choiceGenerator is not compatible with the Agent / Call API. Use searchable=true and register a handler.",
139
195
  );
140
196
  }
197
+ fieldKeys.push(item.key);
141
198
  const { choiceGenerator: _, ...fieldData } = item;
142
199
  return { ...fieldData, is_search_field: item.searchable } satisfies SerializableFieldItem;
143
200
  }
144
201
  return item;
145
202
  });
146
203
 
204
+ this._fieldKeysByTaskId[taskId] = fieldKeys;
205
+
147
206
  await this.sendCommand(SetTaskCommand, {
148
207
  command_type: "set-task",
149
208
  task_id: taskId,
@@ -206,19 +265,38 @@ export class Call {
206
265
 
207
266
  async reachPerson(
208
267
  contactFullName: string,
209
- options: { outcomes?: ReachPersonOutcome[]; greeting?: string } = {},
268
+ options: {
269
+ outcomes?: ReachPersonOutcome[];
270
+ greeting?: string;
271
+ voicemailMessage?: string;
272
+ voicemailHangup?: boolean;
273
+ } = {},
210
274
  ) {
211
- const outcomes = options.outcomes ?? [
212
- { key: "available", description: "The contact is available to speak." },
213
- {
214
- key: "unavailable",
215
- description:
216
- "The contact is not available to speak. This includes reaching a wrong number.",
217
- },
218
- ];
275
+ if ((await this.getVariable("_voicemail_handler")) === "set_voicemail_action") {
276
+ throw new Error(
277
+ "Cannot call reachPerson() after setVoicemailAction(). " +
278
+ "Use the voicemailMessage or voicemailHangup parameters on reachPerson() instead.",
279
+ );
280
+ }
281
+ await this.setVariable("_voicemail_handler", "reach_person");
282
+
283
+ if (options.voicemailMessage && options.voicemailHangup) {
284
+ throw new Error("Cannot specify both 'voicemailMessage' and 'voicemailHangup'.");
285
+ }
286
+
287
+ const outcomes = options.outcomes ?? DEFAULT_REACH_PERSON_OUTCOMES;
288
+
289
+ let voicemailRule: string;
290
+ if (options.voicemailHangup) {
291
+ voicemailRule = voicemailHangupInstruction();
292
+ } else if (options.voicemailMessage) {
293
+ voicemailRule = voicemailMessageInstruction(options.voicemailMessage);
294
+ } else {
295
+ voicemailRule = "Leave an appropriate voicemail message.";
296
+ }
219
297
 
220
298
  const availabilityDescription =
221
- `The availability of ${contactFullName}` +
299
+ `The availability of ${contactFullName}.` +
222
300
  (outcomes.some((o) => o.description)
223
301
  ? "\nDetailed descriptions of each choice:\n" +
224
302
  outcomes
@@ -230,7 +308,7 @@ export class Call {
230
308
  const checklist: (FieldItem | SayItem | string)[] = [
231
309
  options.greeting !== undefined
232
310
  ? Say(options.greeting)
233
- : `Greet the person who answered the phone. Notify them who you are calling on behalf of and the purpose of the call. Ask to speak with ${contactFullName}`,
311
+ : `Greet the person, IVR, or system who answered the phone. Notify them who you are calling on behalf of and the purpose of the call. Ask to speak with ${contactFullName}. Do not greet if you detect an answering machine or voicemail system.`,
234
312
  {
235
313
  item_type: "field",
236
314
  key: "contact_availability",
@@ -245,38 +323,51 @@ export class Call {
245
323
  .map((o) => `- ${o.key} → ${o.nextActionPreview}`);
246
324
  if (nextActionLines.length > 0) {
247
325
  checklist.push(
248
- "If a next action is defined below for the value of `contact_availability`, briefly ask the contact to wait just a second while you perform it.\n" +
326
+ "If a next action is defined below for the recorded value of `contact_availability`, briefly let the contact know while you perform it.\n" +
249
327
  nextActionLines.join("\n"),
250
328
  );
251
329
  }
252
330
 
253
331
  const objective = `\
254
332
  OBJECTIVE:
255
- Your goal is to reach ${contactFullName} and determine their availability to proceed with this call.
333
+ Your goal is to reach ${contactFullName} and confirm they are on the line.
256
334
 
257
335
  RULES:
258
- 1. If the initial respondent is NOT ${contactFullName}:
259
- - Politely ask to speak with ${contactFullName}
260
- - Wait to be transferred or for ${contactFullName} to come to the phone
261
- 2. Once you have ${contactFullName} on the line:
336
+ 1. If someone other than ${contactFullName} answers - including a person or IVR:
337
+ - Politely ask to speak with ${contactFullName}, or navigate menus and prompts to reach them.
338
+ - Wait to be transferred or for ${contactFullName} to come to the phone.
339
+ - If ${contactFullName} cannot be reached, record \`contact_availability\` appropriately.
340
+ 2. Once ${contactFullName} is confirmed on the line:
262
341
  - Briefly restate who you are and the purpose of your call
263
- - Determine and record their current availability status
264
- 3. DO NOT hang up the call under any circumstances, unless it's a wrong number.
342
+ - Record their availability as available, or equivalent, in \`contact_availability\`.
343
+ 3. If it is clearly a wrong number or you have been asked not to call, politely end the call and hang up.
344
+ 4. If you reach an answering machine or voicemail: ${voicemailRule}
345
+ `;
265
346
 
347
+ const completionCriteria = `\
266
348
  TASK COMPLETION REQUIREMENTS:
267
- - The availability of ${contactFullName} must be recorded in \`contact_availability\`.`;
349
+ - The availability of ${contactFullName} must be recorded in \`contact_availability\`.
350
+ `;
268
351
 
269
- await this.setTask({ taskId: "reach_person", objective, checklist });
352
+ await this.setTask({ taskId: "reach_person", objective, checklist, completionCriteria });
270
353
  }
271
354
 
272
355
  async setVoicemailAction(action: { hangup: true } | { message: string }) {
356
+ if ((await this.getVariable("_voicemail_handler")) === "reach_person") {
357
+ throw new Error(
358
+ "Cannot call setVoicemailAction() after reachPerson(). " +
359
+ "Use the voicemailMessage or voicemailHangup parameters on reachPerson() instead.",
360
+ );
361
+ }
362
+ await this.setVariable("_voicemail_handler", "set_voicemail_action");
363
+
273
364
  if ("hangup" in action) {
274
365
  await this.sendInstruction(
275
- "If you encounter an answering machine, DO NOT leave a message. REMAIN SILENT AND HANG UP WITHOUT RESPONDING. You should only do this when it's clear you are unable to reach the person.",
366
+ `If you encounter an answering machine, ${voicemailHangupInstruction()} You should only do this when it's clear you are unable to reach the person.`,
276
367
  );
277
368
  } else {
278
369
  await this.sendInstruction(
279
- `If you encounter an answering machine, say this message VERBATIM: ${action.message}. You should only leave this message if it's clear you are unable to reach the person.`,
370
+ `If you encounter an answering machine, ${voicemailMessageInstruction(action.message)} You should only do this when it's clear you are unable to reach the person.`,
280
371
  );
281
372
  }
282
373
  }
package/src/client.ts CHANGED
@@ -172,6 +172,13 @@ export class Client {
172
172
  return body.webrtc_code;
173
173
  }
174
174
 
175
+ async listNumbers(): Promise<{ phoneNumber: string }[]> {
176
+ const url = new URL("v1/phone-numbers", this.getHttpBase());
177
+ const response = await fetchOrThrow(url, { headers: await this.headers() });
178
+ const items = (await response.json()) as { phone_number: string }[];
179
+ return items.map((item) => ({ phoneNumber: item.phone_number }));
180
+ }
181
+
175
182
  /**
176
183
  * Sends an SMS message from one of your Guava numbers.
177
184
  * @param fromNumber - One of your Guava numbers (E.164). Must have SMS configured.
package/src/commands.ts CHANGED
@@ -94,14 +94,21 @@ export const RegisteredHooksCommand = z.strictObject({
94
94
  has_on_question: z.boolean(),
95
95
  has_on_intent: z.boolean(),
96
96
  has_on_action_requested: z.boolean().optional().default(false),
97
+ has_on_escalate: z.boolean().optional().default(false),
98
+ accept_dtmf_for_numbers: z.boolean().default(true),
97
99
  });
98
100
  export type RegisteredHooksCommand = z.input<typeof RegisteredHooksCommand>;
99
101
 
102
+ export const ActionCandidate = z.strictObject({
103
+ key: z.string(),
104
+ description: z.string().default(""),
105
+ });
106
+ export type ActionCandidate = z.input<typeof ActionCandidate>;
107
+
100
108
  export const ActionSuggestionCommand = z.strictObject({
101
109
  command_type: z.literal("action-suggestion"),
102
110
  intent_id: z.string(),
103
- action_key: z.string().nullable(),
104
- action_description: z.string().default(""),
111
+ actions: z.array(ActionCandidate),
105
112
  });
106
113
  export type ActionSuggestionCommand = z.input<typeof ActionSuggestionCommand>;
107
114
 
@@ -120,6 +127,12 @@ export const RetryTaskCommand = z.strictObject({
120
127
  });
121
128
  export type RetryTaskCommand = z.input<typeof RetryTaskCommand>;
122
129
 
130
+ export const SetAgentDTMFCommand = z.strictObject({
131
+ command_type: z.literal("set-agent-dtmf"),
132
+ enabled: z.boolean(),
133
+ });
134
+ export type SetAgentDTMFCommand = z.input<typeof SetAgentDTMFCommand>;
135
+
123
136
  export const AnyCommand = z.union([
124
137
  StartOutboundCallCommand,
125
138
  ListenInboundCommand,
@@ -136,6 +149,7 @@ export const AnyCommand = z.union([
136
149
  ChoiceResultCommand,
137
150
  ActionSuggestionCommand,
138
151
  RetryTaskCommand,
152
+ SetAgentDTMFCommand,
139
153
  ]);
140
154
  export type Command = z.input<typeof AnyCommand>;
141
155
 
package/src/events.ts CHANGED
@@ -14,12 +14,14 @@ export const InboundCallEvent = z.object({
14
14
  export type InboundCallEvent = z.infer<typeof InboundCallEvent>;
15
15
 
16
16
  /**
17
- * @description The caller has said something.
17
+ * @description The caller has said something. For utterances with the same id,
18
+ * all but the latest is out of date.
18
19
  */
19
20
  export const CallerSpeechEvent = z.object({
20
21
  event_type: z.literal("caller-speech"),
21
22
 
22
23
  utterance: z.string(),
24
+ utterance_id: z.string().optional(),
23
25
  });
24
26
  export type CallerSpeechEvent = z.infer<typeof CallerSpeechEvent>;
25
27
 
@@ -145,9 +147,16 @@ export type DTMFDigit =
145
147
  export const DTMFPressedEvent = z.object({
146
148
  event_type: z.literal("dtmf"),
147
149
  digit: z.enum(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#", "A", "B", "C", "D"]),
150
+ recent_digits: z.string().default(""),
148
151
  });
149
152
  export type DTMFPressedEvent = z.infer<typeof DTMFPressedEvent>;
150
153
 
154
+ export const EscalateEvent = z.object({
155
+ event_type: z.literal("escalate"),
156
+ requested_by: z.enum(["human", "agent"]).default("human"),
157
+ });
158
+ export type EscalateEvent = z.infer<typeof EscalateEvent>;
159
+
151
160
  export const GuavaEvent = z.discriminatedUnion("event_type", [
152
161
  SessionStartedEvent,
153
162
  InboundCallEvent,
@@ -165,6 +174,7 @@ export const GuavaEvent = z.discriminatedUnion("event_type", [
165
174
  ChoiceQueryEvent,
166
175
  ActionRequestEvent,
167
176
  ExecuteActionEvent,
177
+ EscalateEvent,
168
178
  DTMFPressedEvent,
169
179
  ]);
170
180
  export type GuavaEvent = z.infer<typeof GuavaEvent>;
@@ -0,0 +1,87 @@
1
+ import * as process from "node:process";
2
+ import { Client } from "./client.ts";
3
+
4
+ export async function getAgentNumber(): Promise<string> {
5
+ if (process.env.GUAVA_AGENT_NUMBER) return process.env.GUAVA_AGENT_NUMBER;
6
+
7
+ const client = new Client();
8
+ const numbers = await client.listNumbers();
9
+
10
+ if (numbers.length === 0) {
11
+ console.error("No agent phone numbers found. Please purchase a number first.");
12
+ process.exit(1);
13
+ }
14
+ if (numbers.length === 1) return numbers[0].phoneNumber;
15
+ if (process.stdin.isTTY && process.stdout.isTTY) {
16
+ return terminalPicker(
17
+ numbers.map((n) => n.phoneNumber),
18
+ "Multiple agent phone numbers found. Select one",
19
+ );
20
+ }
21
+ console.error(
22
+ `Multiple agent phone numbers found: ${numbers.map((n) => n.phoneNumber).join(", ")}. Please set GUAVA_AGENT_NUMBER.`,
23
+ );
24
+ process.exit(1);
25
+ }
26
+
27
+ export async function terminalPicker(
28
+ options: string[],
29
+ prompt = "Select an option",
30
+ ): Promise<string> {
31
+ if (!options.length) throw new Error("options must be non-empty");
32
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
33
+ throw new Error("terminalPicker requires a TTY stdin and stdout");
34
+
35
+ return new Promise((resolve, reject) => {
36
+ let selected = 0;
37
+ let renderedLines = 0;
38
+
39
+ function render() {
40
+ const lines = [`${prompt}:`];
41
+ for (let i = 0; i < options.length; i++) {
42
+ lines.push(`${i === selected ? "❯" : " "} ${options[i]}`);
43
+ }
44
+ if (renderedLines > 0) process.stdout.write(`\x1b[${renderedLines}F`);
45
+ for (const line of lines) {
46
+ process.stdout.write("\r\x1b[2K");
47
+ process.stdout.write(line);
48
+ process.stdout.write("\n");
49
+ }
50
+ for (let i = 0; i < renderedLines - lines.length; i++) {
51
+ process.stdout.write("\r\x1b[2K\n");
52
+ }
53
+ renderedLines = lines.length;
54
+ }
55
+
56
+ function cleanup() {
57
+ process.stdin.removeListener("data", onData);
58
+ process.stdin.setRawMode(false);
59
+ process.stdin.pause();
60
+ process.stdout.write("\x1b[?25h");
61
+ }
62
+
63
+ function onData(data: Buffer) {
64
+ const ch = data.toString();
65
+ if (ch === "\r" || ch === "\n") {
66
+ cleanup();
67
+ process.stdout.write(`\r\x1b[2KSelected: ${options[selected]}\n`);
68
+ resolve(options[selected]);
69
+ } else if (ch === "\x03") {
70
+ cleanup();
71
+ reject(new Error("Interrupted"));
72
+ } else if (ch === "\x1b[A") {
73
+ selected = (selected - 1 + options.length) % options.length;
74
+ render();
75
+ } else if (ch === "\x1b[B") {
76
+ selected = (selected + 1) % options.length;
77
+ render();
78
+ }
79
+ }
80
+
81
+ process.stdin.setRawMode(true);
82
+ process.stdin.resume();
83
+ process.stdout.write("\x1b[?25l");
84
+ render();
85
+ process.stdin.on("data", onData);
86
+ });
87
+ }
package/src/health.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { type Server, createServer } from "node:http";
2
+ import { getDefaultLogger, type Logger } from "./logging.ts";
3
+
4
+ export type HealthState = "starting" | "live" | "stopped";
5
+
6
+ export class HealthContext {
7
+ private _state: HealthState = "starting";
8
+
9
+ setState(newState: HealthState): void {
10
+ this._state = newState;
11
+ }
12
+
13
+ ready(): void {
14
+ this.setState("live");
15
+ }
16
+
17
+ stopped(): void {
18
+ this.setState("stopped");
19
+ }
20
+
21
+ isLive(): boolean {
22
+ return this._state === "live";
23
+ }
24
+ }
25
+
26
+ export class MultiHealthContext {
27
+ private _ctxs: HealthContext[] = [];
28
+
29
+ createCtx(): HealthContext {
30
+ const ctx = new HealthContext();
31
+ this._ctxs.push(ctx);
32
+ return ctx;
33
+ }
34
+
35
+ isLive(): boolean {
36
+ return this._ctxs.length > 0 && this._ctxs.every((ctx) => ctx.isLive());
37
+ }
38
+ }
39
+
40
+ export interface HealthServerOptions {
41
+ host?: string;
42
+ port?: number;
43
+ logger?: Logger;
44
+ }
45
+
46
+ export class HealthServer implements AsyncDisposable {
47
+ private readonly _server: Server;
48
+ private readonly _logger: Logger;
49
+ private _stopping: Promise<void> | null = null;
50
+
51
+ private constructor(server: Server, logger: Logger) {
52
+ this._server = server;
53
+ this._logger = logger;
54
+ }
55
+
56
+ static async start(
57
+ healthCtx: HealthContext | MultiHealthContext,
58
+ options: HealthServerOptions = {},
59
+ ): Promise<HealthServer> {
60
+ const host = options.host ?? "0.0.0.0";
61
+ const port = options.port ?? 4828;
62
+ const logger = options.logger ?? getDefaultLogger();
63
+
64
+ const server = createServer((req, res) => {
65
+ if (req.method === "GET" && req.url === "/live") {
66
+ res.statusCode = healthCtx.isLive() ? 200 : 503;
67
+ } else {
68
+ res.statusCode = 404;
69
+ }
70
+ res.end();
71
+ });
72
+
73
+ await new Promise<void>((resolve, reject) => {
74
+ server.once("error", reject);
75
+ server.listen(port, host, () => {
76
+ server.off("error", reject);
77
+ resolve();
78
+ });
79
+ });
80
+
81
+ logger.info("Health check server listening on http://%s:%d/live", host, port);
82
+ return new HealthServer(server, logger);
83
+ }
84
+
85
+ stop(): Promise<void> {
86
+ if (this._stopping) return this._stopping;
87
+ this._logger.info("Health check server shutting down.");
88
+ this._stopping = new Promise<void>((resolve, reject) => {
89
+ this._server.closeAllConnections();
90
+ this._server.close((err) => (err ? reject(err) : resolve()));
91
+ });
92
+ return this._stopping;
93
+ }
94
+
95
+ async [Symbol.asyncDispose](): Promise<void> {
96
+ await this.stop();
97
+ }
98
+ }
99
+
100
+ const NOOP_DISPOSABLE: AsyncDisposable = {
101
+ async [Symbol.asyncDispose]() {},
102
+ };
103
+
104
+ function isEnabled(): boolean {
105
+ const value = (process.env.GUAVA_HEALTH_SERVER ?? "false").toLowerCase().trim();
106
+ return value === "yes" || value === "true" || value === "on";
107
+ }
108
+
109
+ /**
110
+ * Returns an AsyncDisposable that, when the GUAVA_HEALTH_SERVER env var is
111
+ * truthy ("yes", "true", "on"), starts an HTTP health check server exposing
112
+ * `/live` on port 4828. Otherwise returns a no-op disposable. Intended for use
113
+ * with `await using`.
114
+ */
115
+ export async function getHealthServer(
116
+ healthCtx: HealthContext | MultiHealthContext,
117
+ ): Promise<AsyncDisposable> {
118
+ if (!isEnabled()) return NOOP_DISPOSABLE;
119
+ return HealthServer.start(healthCtx);
120
+ }
@@ -0,0 +1,2 @@
1
+ /* This file must not include the OpenAI helpers. */
2
+ export { DatetimeFilter, DocumentQA, IntentRecognizer } from "./llm.ts";