@kognitivedev/backend-cloud 0.2.29

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 (43) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-test.log +14 -0
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +88 -0
  5. package/dist/cloud-voice-parameters.d.ts +11 -0
  6. package/dist/cloud-voice-parameters.js +219 -0
  7. package/dist/cloud-voice-prompt-service.d.ts +24 -0
  8. package/dist/cloud-voice-prompt-service.js +382 -0
  9. package/dist/cloud-voice-runtime-service.d.ts +73 -0
  10. package/dist/cloud-voice-runtime-service.js +443 -0
  11. package/dist/cloud-voice.d.ts +36 -0
  12. package/dist/cloud-voice.js +683 -0
  13. package/dist/index.d.ts +10 -0
  14. package/dist/index.js +26 -0
  15. package/dist/phone-control.d.ts +50 -0
  16. package/dist/phone-control.js +97 -0
  17. package/dist/phone-runtime/audio-playout-tracker.d.ts +51 -0
  18. package/dist/phone-runtime/audio-playout-tracker.js +93 -0
  19. package/dist/phone-runtime/openai-twilio-realtime.d.ts +95 -0
  20. package/dist/phone-runtime/openai-twilio-realtime.js +1074 -0
  21. package/dist/tools.d.ts +2 -0
  22. package/dist/tools.js +216 -0
  23. package/dist/types.d.ts +468 -0
  24. package/dist/types.js +2 -0
  25. package/dist/utils.d.ts +3 -0
  26. package/dist/utils.js +14 -0
  27. package/package.json +47 -0
  28. package/src/__tests__/audio-playout-tracker.test.ts +46 -0
  29. package/src/__tests__/cloud-voice.test.ts +1006 -0
  30. package/src/__tests__/openai-twilio-realtime.test.ts +1193 -0
  31. package/src/__tests__/phone-control.test.ts +105 -0
  32. package/src/cloud-voice-parameters.ts +236 -0
  33. package/src/cloud-voice-prompt-service.ts +493 -0
  34. package/src/cloud-voice-runtime-service.ts +465 -0
  35. package/src/cloud-voice.ts +831 -0
  36. package/src/index.ts +10 -0
  37. package/src/phone-control.ts +156 -0
  38. package/src/phone-runtime/audio-playout-tracker.ts +132 -0
  39. package/src/phone-runtime/openai-twilio-realtime.ts +1250 -0
  40. package/src/tools.ts +227 -0
  41. package/src/types.ts +529 -0
  42. package/src/utils.ts +11 -0
  43. package/tsconfig.json +13 -0
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { executeCloudVoicePhoneControlTool, executeCloudVoiceSipTransferTool } from "../index";
3
+
4
+ describe("@kognitivedev/backend-cloud phone control", () => {
5
+ it("executes hangup through the matching adapter", async () => {
6
+ await expect(executeCloudVoicePhoneControlTool({
7
+ toolId: "hang_up_call",
8
+ provider: "twilio",
9
+ providerCallId: "CA123",
10
+ args: { reason: "caller said goodbye" },
11
+ adapters: [{
12
+ provider: "twilio",
13
+ async hangUpCall(input) {
14
+ return {
15
+ provider: "twilio",
16
+ providerCallId: input.providerCallId,
17
+ status: "completed",
18
+ raw: { reason: input.reason },
19
+ };
20
+ },
21
+ }],
22
+ })).resolves.toEqual({
23
+ result: {
24
+ ok: true,
25
+ provider: "twilio",
26
+ providerCallId: "CA123",
27
+ status: "completed",
28
+ reason: "caller said goodbye",
29
+ },
30
+ raw: { reason: "caller said goodbye" },
31
+ });
32
+ });
33
+
34
+ it("rejects unknown phone control tools", async () => {
35
+ await expect(executeCloudVoicePhoneControlTool({
36
+ toolId: "lookup_order",
37
+ provider: "twilio",
38
+ providerCallId: "CA123",
39
+ args: {},
40
+ adapters: [],
41
+ })).rejects.toThrow("Unknown Cloud Voice phone control tool");
42
+ });
43
+
44
+ it("rejects unsupported providers", async () => {
45
+ await expect(executeCloudVoicePhoneControlTool({
46
+ toolId: "hang_up_call",
47
+ provider: "sip-provider",
48
+ providerCallId: "CALL123",
49
+ args: {},
50
+ adapters: {
51
+ twilio: {
52
+ provider: "twilio",
53
+ async hangUpCall() {
54
+ throw new Error("not used");
55
+ },
56
+ },
57
+ },
58
+ })).rejects.toThrow("not supported for provider");
59
+ });
60
+
61
+ it("executes SIP transfer through the matching adapter", async () => {
62
+ await expect(executeCloudVoiceSipTransferTool({
63
+ toolId: "sip_transfer_call",
64
+ provider: "telnyx",
65
+ providerCallId: "call_1",
66
+ args: {
67
+ destinationId: "support_queue",
68
+ mode: "warm",
69
+ reason: "caller asked for a human",
70
+ },
71
+ resolveDestination: async (destinationId) => destinationId === "support_queue"
72
+ ? {
73
+ destination: { type: "sip_uri", uri: "sip:support@example.com" },
74
+ policy: { mode: "warm", timeoutSeconds: 30 },
75
+ }
76
+ : null,
77
+ adapters: [{
78
+ provider: "telnyx",
79
+ async transferCall(input) {
80
+ return {
81
+ provider: "telnyx",
82
+ providerCallId: input.providerCallId,
83
+ status: "active",
84
+ destination: input.destination,
85
+ mode: input.mode ?? "blind",
86
+ transferId: "transfer_1",
87
+ raw: { reason: input.reason },
88
+ };
89
+ },
90
+ }],
91
+ })).resolves.toEqual({
92
+ result: {
93
+ ok: true,
94
+ provider: "telnyx",
95
+ providerCallId: "call_1",
96
+ status: "active",
97
+ transferId: "transfer_1",
98
+ mode: "warm",
99
+ destinationId: "support_queue",
100
+ reason: "caller asked for a human",
101
+ },
102
+ raw: { reason: "caller asked for a human" },
103
+ });
104
+ });
105
+ });
@@ -0,0 +1,236 @@
1
+ import type {
2
+ CloudVoiceAgentConfig,
3
+ CloudVoiceParameterDefinition,
4
+ CloudVoiceParameterResolutionMode,
5
+ CloudVoiceParameterResolutionResult,
6
+ CloudVoiceParameterValueMap,
7
+ } from "./types";
8
+ import { getRecord, getString } from "./utils";
9
+
10
+ export const CLOUD_VOICE_PREDEFINED_PARAMETERS: CloudVoiceParameterDefinition[] = [
11
+ { key: "user_id", label: "User ID", type: "string", source: "system", preset: "user_id", description: "Resolved user identifier for the voice session." },
12
+ { key: "session_id", label: "Session ID", type: "string", source: "system", preset: "session_id", description: "Public Cloud Voice session id." },
13
+ { key: "agent_slug", label: "Agent slug", type: "string", source: "system", preset: "agent_slug", description: "Slug of the selected voice agent." },
14
+ { key: "channel", label: "Channel", type: "string", source: "system", preset: "channel", description: "Run channel such as web, script, phone, or outbound." },
15
+ { key: "phone", label: "Phone", type: "phone", source: "call", preset: "phone", description: "Primary phone number for the caller when available." },
16
+ { key: "caller_phone", label: "Caller phone", type: "phone", source: "call", preset: "caller_phone", description: "Caller phone number when available." },
17
+ { key: "from_phone", label: "From phone", type: "phone", source: "call", preset: "from_phone", description: "Originating phone number when available." },
18
+ { key: "to_phone", label: "To phone", type: "phone", source: "call", preset: "to_phone", description: "Destination phone number when available." },
19
+ { key: "provider", label: "Telephony provider", type: "string", source: "call", preset: "provider", description: "Phone provider when available." },
20
+ { key: "provider_call_id", label: "Provider call ID", type: "string", source: "call", preset: "provider_call_id", description: "Provider call id when available." },
21
+ { key: "call_direction", label: "Call direction", type: "string", source: "call", preset: "call_direction", description: "Inbound or outbound direction when available." },
22
+ { key: "phone_number_id", label: "Phone number ID", type: "string", source: "call", preset: "phone_number_id", description: "Configured Cloud Voice phone number id when available." },
23
+ { key: "embed_origin", label: "Embed origin", type: "string", source: "session", preset: "embed_origin", description: "Embed origin for iframe/script starts when available." },
24
+ ];
25
+
26
+ const RESERVED_TEMPLATE_KEYS = [
27
+ "session_messages",
28
+ "tools",
29
+ "voice_context",
30
+ "project_id",
31
+ "workflow_run_id",
32
+ "workflow_name",
33
+ "workflow_input",
34
+ "workflow_output",
35
+ ];
36
+ const RESERVED_PARAMETER_KEYS = new Set([
37
+ ...CLOUD_VOICE_PREDEFINED_PARAMETERS.map((parameter) => parameter.key),
38
+ ...RESERVED_TEMPLATE_KEYS,
39
+ ]);
40
+ const VALID_PARAMETER_TYPES = new Set(["string", "number", "boolean", "json", "phone", "email"]);
41
+
42
+ function normalizeParameterKey(value: unknown) {
43
+ return getString(value, "")
44
+ .trim()
45
+ .toLowerCase()
46
+ .replace(/[^a-z0-9_]+/g, "_")
47
+ .replace(/^_+|_+$/g, "");
48
+ }
49
+
50
+ function normalizeParameterType(value: unknown): CloudVoiceParameterDefinition["type"] {
51
+ return VALID_PARAMETER_TYPES.has(String(value)) ? value as CloudVoiceParameterDefinition["type"] : "string";
52
+ }
53
+
54
+ function isCustomParameter(definition: CloudVoiceParameterDefinition) {
55
+ return !definition.preset && !RESERVED_PARAMETER_KEYS.has(definition.key);
56
+ }
57
+
58
+ function normalizeEnum(value: unknown) {
59
+ return Array.isArray(value) ? value.filter((item) => item !== undefined) : undefined;
60
+ }
61
+
62
+ export function normalizeCloudVoiceParameterDefinitions(value: unknown): CloudVoiceParameterDefinition[] {
63
+ const rawDefinitions = Array.isArray(value) ? value : [];
64
+ const definitions = new Map<string, CloudVoiceParameterDefinition>();
65
+
66
+ for (const predefined of CLOUD_VOICE_PREDEFINED_PARAMETERS) {
67
+ definitions.set(predefined.key, predefined);
68
+ }
69
+
70
+ for (const raw of rawDefinitions) {
71
+ const record = getRecord(raw);
72
+ const key = normalizeParameterKey(record.key ?? record.name);
73
+ if (!key || RESERVED_PARAMETER_KEYS.has(key)) continue;
74
+ definitions.set(key, {
75
+ key,
76
+ label: getString(record.label, getString(record.name, key)),
77
+ type: normalizeParameterType(record.type),
78
+ source: "custom",
79
+ required: record.required === true,
80
+ description: getString(record.description, "") || undefined,
81
+ defaultValue: record.defaultValue,
82
+ enum: normalizeEnum(record.enum ?? record.options),
83
+ sensitive: record.sensitive === true,
84
+ jsonSchema: getRecord(record.jsonSchema ?? record.schema),
85
+ });
86
+ }
87
+
88
+ return Array.from(definitions.values());
89
+ }
90
+
91
+ function hasOwn(record: Record<string, unknown>, key: string) {
92
+ return Object.prototype.hasOwnProperty.call(record, key);
93
+ }
94
+
95
+ function readSystemValue(key: string, system: Record<string, unknown>) {
96
+ switch (key) {
97
+ case "user_id":
98
+ return system.userId;
99
+ case "session_id":
100
+ return system.sessionId;
101
+ case "agent_slug":
102
+ return system.agentSlug;
103
+ case "channel":
104
+ return system.channel;
105
+ case "phone":
106
+ return system.phone ?? system.callerPhone ?? system.fromPhone;
107
+ case "caller_phone":
108
+ return system.callerPhone ?? system.phone ?? system.fromPhone;
109
+ case "from_phone":
110
+ return system.fromPhone;
111
+ case "to_phone":
112
+ return system.toPhone ?? system.phoneNumber;
113
+ case "provider":
114
+ return system.provider;
115
+ case "provider_call_id":
116
+ return system.providerCallId;
117
+ case "call_direction":
118
+ return system.callDirection ?? system.direction;
119
+ case "phone_number_id":
120
+ return system.phoneNumberId;
121
+ case "embed_origin":
122
+ return system.embedOrigin ?? system.origin;
123
+ default:
124
+ return undefined;
125
+ }
126
+ }
127
+
128
+ function coerceValue(value: unknown, definition: CloudVoiceParameterDefinition): { ok: boolean; value?: unknown; error?: string } {
129
+ if (value === undefined || value === null || value === "") return { ok: true, value: undefined };
130
+
131
+ if (definition.enum && definition.enum.length > 0 && !definition.enum.includes(value)) {
132
+ return { ok: false, error: `${definition.key} must be one of ${definition.enum.map(String).join(", ")}` };
133
+ }
134
+
135
+ if (definition.type === "number") {
136
+ const numberValue = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN;
137
+ return Number.isFinite(numberValue) ? { ok: true, value: numberValue } : { ok: false, error: `${definition.key} must be a number` };
138
+ }
139
+
140
+ if (definition.type === "boolean") {
141
+ if (typeof value === "boolean") return { ok: true, value };
142
+ if (value === "true") return { ok: true, value: true };
143
+ if (value === "false") return { ok: true, value: false };
144
+ return { ok: false, error: `${definition.key} must be a boolean` };
145
+ }
146
+
147
+ if (definition.type === "json") {
148
+ return { ok: true, value };
149
+ }
150
+
151
+ return { ok: true, value: String(value) };
152
+ }
153
+
154
+ export function resolveCloudVoiceParameters(input: {
155
+ config: Pick<CloudVoiceAgentConfig, "parameters">;
156
+ supplied?: CloudVoiceParameterValueMap | null;
157
+ system?: Record<string, unknown> | null;
158
+ mode?: CloudVoiceParameterResolutionMode;
159
+ }): CloudVoiceParameterResolutionResult {
160
+ const mode = input.mode ?? "strict";
161
+ const supplied = getRecord(input.supplied);
162
+ const system = getRecord(input.system);
163
+ const definitions = normalizeCloudVoiceParameterDefinitions(input.config.parameters);
164
+ const values: CloudVoiceParameterValueMap = {};
165
+ const missingRequired: string[] = [];
166
+ const errors: string[] = [];
167
+ const sensitiveKeys: string[] = [];
168
+
169
+ for (const definition of definitions) {
170
+ const custom = isCustomParameter(definition);
171
+ const rawValue = custom && hasOwn(supplied, definition.key)
172
+ ? supplied[definition.key]
173
+ : custom && definition.defaultValue !== undefined
174
+ ? definition.defaultValue
175
+ : !custom
176
+ ? readSystemValue(definition.key, system)
177
+ : undefined;
178
+
179
+ const coerced = coerceValue(rawValue, definition);
180
+ if (!coerced.ok) {
181
+ errors.push(coerced.error ?? `${definition.key} is invalid`);
182
+ continue;
183
+ }
184
+ if (coerced.value !== undefined) {
185
+ values[definition.key] = coerced.value;
186
+ if (definition.sensitive) sensitiveKeys.push(definition.key);
187
+ continue;
188
+ }
189
+ if (definition.required && custom) {
190
+ missingRequired.push(definition.key);
191
+ }
192
+ }
193
+
194
+ if (mode === "strict" && (missingRequired.length > 0 || errors.length > 0)) {
195
+ const parts = [
196
+ missingRequired.length ? `missing required parameters: ${missingRequired.join(", ")}` : "",
197
+ ...errors,
198
+ ].filter(Boolean);
199
+ throw new Error(`Cloud voice parameters are invalid: ${parts.join("; ")}`);
200
+ }
201
+
202
+ return {
203
+ definitions,
204
+ values,
205
+ missingRequired,
206
+ errors,
207
+ sensitiveKeys,
208
+ };
209
+ }
210
+
211
+ function stringifyTemplateValue(value: unknown) {
212
+ if (value === undefined || value === null) return "";
213
+ if (typeof value === "string") return value;
214
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
215
+ return JSON.stringify(value);
216
+ }
217
+
218
+ export function renderCloudVoiceParameterTemplate(text: string, parameters: CloudVoiceParameterValueMap) {
219
+ if (!text || !text.includes("{{")) return text;
220
+ return text
221
+ .replace(/\{\{\s*(?:params|parameters)\.([a-zA-Z0-9_]+)\s*\}\}/g, (_match, key: string) =>
222
+ stringifyTemplateValue(parameters[key]))
223
+ .replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (match, key: string) =>
224
+ Object.prototype.hasOwnProperty.call(parameters, key) ? stringifyTemplateValue(parameters[key]) : match);
225
+ }
226
+
227
+ export function renderCloudVoiceParameterObject<T>(value: T, parameters: CloudVoiceParameterValueMap): T {
228
+ if (typeof value === "string") return renderCloudVoiceParameterTemplate(value, parameters) as T;
229
+ if (Array.isArray(value)) return value.map((item) => renderCloudVoiceParameterObject(item, parameters)) as T;
230
+ if (!value || typeof value !== "object") return value;
231
+ const next: Record<string, unknown> = {};
232
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
233
+ next[key] = renderCloudVoiceParameterObject(child, parameters);
234
+ }
235
+ return next as T;
236
+ }