wizards 0.4.0 → 0.4.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.
Files changed (124) hide show
  1. package/apps/runtime/dist/agents/studio-assistant.js +67 -20
  2. package/apps/runtime/dist/docs/dev/plugins/reference.md +7 -4
  3. package/apps/runtime/dist/docs/dev/plugins/reloading.md +3 -3
  4. package/apps/runtime/dist/docs/dev/plugins/runners.md +9 -12
  5. package/apps/runtime/dist/docs/dev/plugins/server.md +3 -1
  6. package/apps/runtime/dist/docs/dev/plugins/space.md +19 -1
  7. package/apps/runtime/dist/docs/dev/plugins/studio.md +43 -6
  8. package/apps/runtime/dist/docs/dev/plugins/styling.md +8 -1
  9. package/apps/runtime/dist/docs/user/space.md +4 -4
  10. package/apps/runtime/dist/docs/user/test-and-share.md +22 -58
  11. package/apps/runtime/dist/engine/steps.js +3 -2
  12. package/apps/runtime/dist/engine/template.js +14 -10
  13. package/apps/runtime/dist/film/project.js +15 -3
  14. package/apps/runtime/dist/plugins/loader.js +38 -2
  15. package/apps/runtime/dist/routes/studio.js +28 -1
  16. package/apps/runtime/dist/services/brand.js +61 -1
  17. package/apps/runtime/dist/services/projects.js +19 -10
  18. package/apps/runtime/dist/tools/plugin.js +11 -3
  19. package/apps/web/dist/assets/AboutDialog-CH1rQdia.js +1 -0
  20. package/apps/web/dist/assets/{ChangelogDialog-DynvQuuR.js → ChangelogDialog-78MQLHfA.js} +1 -1
  21. package/apps/web/dist/assets/ChatView-DLJfy69U.js +10 -0
  22. package/apps/web/dist/assets/EditorPage-BYrZokVk.js +3 -0
  23. package/apps/web/dist/assets/{FlowPreview-BZLnG7GV.js → FlowPreview-xIWPdOCV.js} +1 -1
  24. package/apps/web/dist/assets/{Harness-CNh3nbB_.js → Harness-CsDhNLSW.js} +2 -2
  25. package/apps/web/dist/assets/NewWizardChannels-JB3V6DCL.js +1 -0
  26. package/apps/web/dist/assets/NewWizardPage-Bkh-hjSe.js +7 -0
  27. package/apps/web/dist/assets/OnboardingPage-Diw1ILjC.js +1 -0
  28. package/apps/web/dist/assets/ProjectPage-FgoZDzPI.js +1 -0
  29. package/apps/web/dist/assets/PublicRunner-ClT6B7t5.js +1 -0
  30. package/apps/web/dist/assets/{ReadOnly-DTjv2Jzm.js → ReadOnly-DQPHmvS0.js} +1 -1
  31. package/apps/web/dist/assets/RunDrawer-DAwTEyjM.js +4 -0
  32. package/apps/web/dist/assets/{RunnerView-BPPmlv9z.js → RunnerView-B_tJroFM.js} +1 -1
  33. package/apps/web/dist/assets/SettingsPage-BsMN-XQs.js +1 -0
  34. package/apps/web/dist/assets/SetupPage-C7FuIpSt.js +1 -0
  35. package/apps/web/dist/assets/SharePage-CbFe83L9.js +1 -0
  36. package/apps/web/dist/assets/changelog-mYclku73.js +1 -0
  37. package/apps/web/dist/assets/{circle-alert-CVOH4wAv.js → circle-alert-DCBV7n5k.js} +1 -1
  38. package/apps/web/dist/assets/{esm-BfXdeWaI.js → esm-BhIeKBJY.js} +1 -1
  39. package/apps/web/dist/assets/index-CmxVazOf.css +1 -0
  40. package/apps/web/dist/assets/index-fflYi8P1.js +14 -0
  41. package/apps/web/dist/assets/{lock-DHBh62LA.js → lock-DlzTfwxP.js} +1 -1
  42. package/apps/web/dist/assets/{phone-Bt1cyWpY.js → phone-BFD815nc.js} +1 -1
  43. package/apps/web/dist/assets/{settings-menu-ace1vI3i.js → settings-menu-C5Y8SOhG.js} +1 -1
  44. package/apps/web/dist/assets/ui-DTuixH4y.js +134 -0
  45. package/apps/web/dist/assets/{upload-CCEq88l4.js → upload-BEf5-d6X.js} +1 -1
  46. package/apps/web/dist/index.html +4 -5
  47. package/apps/web/dist/mcp/flow.html +2 -2
  48. package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +36 -2
  49. package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +44 -1
  50. package/node_modules/@engenty-wizards/shared/dist/feature-flags.d.ts +18 -0
  51. package/node_modules/@engenty-wizards/shared/dist/feature-flags.js +18 -0
  52. package/node_modules/@engenty-wizards/shared/dist/projects.d.ts +22 -6
  53. package/node_modules/@engenty-wizards/shared/dist/projects.js +20 -0
  54. package/package.json +1 -1
  55. package/apps/web/dist/assets/AboutDialog-CPQiN7H9.js +0 -1
  56. package/apps/web/dist/assets/ChatView-CKo_9IZD.js +0 -10
  57. package/apps/web/dist/assets/EditorPage-CDIJXC4T.js +0 -3
  58. package/apps/web/dist/assets/NewWizardChannels-C1Ss9UMY.js +0 -1
  59. package/apps/web/dist/assets/NewWizardPage-2qLAbvDL.js +0 -7
  60. package/apps/web/dist/assets/OnboardingPage-C1FJwEsT.js +0 -1
  61. package/apps/web/dist/assets/ProjectPage-CGuLUuOs.js +0 -1
  62. package/apps/web/dist/assets/PublicRunner-BKw1lOW4.js +0 -1
  63. package/apps/web/dist/assets/RunDrawer-D1I5xqVf.js +0 -4
  64. package/apps/web/dist/assets/SettingsPage-zbomQfGy.js +0 -1
  65. package/apps/web/dist/assets/SetupPage-D3F7M6Pz.js +0 -1
  66. package/apps/web/dist/assets/SharePage-BY49IJgT.js +0 -1
  67. package/apps/web/dist/assets/changelog-DYZC2E6q.js +0 -1
  68. package/apps/web/dist/assets/index-B9xVkr2X.css +0 -1
  69. package/apps/web/dist/assets/index-BH0PYgov.js +0 -15
  70. package/apps/web/dist/assets/logo-C4V2_3Ar.js +0 -1
  71. package/apps/web/dist/assets/ui-D2SqIZeQ.js +0 -133
  72. package/modules/calls/dist/client.css +0 -3
  73. package/modules/calls/dist/client.js +0 -1
  74. package/modules/calls/engenty.plugin.json +0 -8
  75. package/modules/calls/migrations/0001_init.sql +0 -26
  76. package/modules/calls/package.json +0 -25
  77. package/modules/calls/src/openai.ts +0 -155
  78. package/modules/calls/src/plugin.ts +0 -407
  79. package/modules/calls/src/rules.ts +0 -226
  80. package/modules/calls/src/schema.ts +0 -39
  81. package/modules/calls/src/session.ts +0 -353
  82. package/modules/calls/src/store.ts +0 -124
  83. package/modules/calls/src/twilio.ts +0 -90
  84. package/modules/calls/tsconfig.json +0 -10
  85. package/modules/calls/ui/Settings.tsx +0 -137
  86. package/modules/calls/ui/app.ts +0 -50
  87. package/modules/calls/ui/messages.ts +0 -81
  88. package/modules/calls/ui/parts.tsx +0 -36
  89. package/modules/calls/ui/plugin.tsx +0 -15
  90. package/modules/sms/dist/client.css +0 -3
  91. package/modules/sms/dist/client.js +0 -1
  92. package/modules/sms/engenty.plugin.json +0 -8
  93. package/modules/sms/migrations/0001_init.sql +0 -26
  94. package/modules/sms/package.json +0 -24
  95. package/modules/sms/src/plugin.ts +0 -329
  96. package/modules/sms/src/schema.ts +0 -26
  97. package/modules/sms/src/store.ts +0 -140
  98. package/modules/sms/src/surface.ts +0 -70
  99. package/modules/sms/src/twilio.ts +0 -130
  100. package/modules/sms/tsconfig.json +0 -10
  101. package/modules/sms/ui/Settings.tsx +0 -113
  102. package/modules/sms/ui/Share.tsx +0 -60
  103. package/modules/sms/ui/app.ts +0 -51
  104. package/modules/sms/ui/messages.ts +0 -70
  105. package/modules/sms/ui/parts.tsx +0 -36
  106. package/modules/sms/ui/plugin.tsx +0 -20
  107. package/modules/whatsapp/dist/client.css +0 -3
  108. package/modules/whatsapp/dist/client.js +0 -1
  109. package/modules/whatsapp/engenty.plugin.json +0 -8
  110. package/modules/whatsapp/migrations/0001_init.sql +0 -30
  111. package/modules/whatsapp/package.json +0 -24
  112. package/modules/whatsapp/src/graph.ts +0 -271
  113. package/modules/whatsapp/src/inbound.ts +0 -143
  114. package/modules/whatsapp/src/plugin.ts +0 -374
  115. package/modules/whatsapp/src/schema.ts +0 -32
  116. package/modules/whatsapp/src/store.ts +0 -156
  117. package/modules/whatsapp/src/surface.ts +0 -127
  118. package/modules/whatsapp/tsconfig.json +0 -10
  119. package/modules/whatsapp/ui/Settings.tsx +0 -141
  120. package/modules/whatsapp/ui/Share.tsx +0 -60
  121. package/modules/whatsapp/ui/app.ts +0 -54
  122. package/modules/whatsapp/ui/messages.ts +0 -80
  123. package/modules/whatsapp/ui/parts.tsx +0 -36
  124. package/modules/whatsapp/ui/plugin.tsx +0 -20
@@ -1,407 +0,0 @@
1
- import { AsyncResource } from "node:async_hooks";
2
- import { definePlugin, type PluginRequest } from "@engenty-wizards/plugin-sdk";
3
- import { z } from "zod";
4
- import {
5
- acceptCall,
6
- hangUp,
7
- incomingCall,
8
- openSocket,
9
- rejectCall,
10
- sipUri,
11
- webhookOk,
12
- } from "./openai";
13
- import { ASKS, CALL_TOOLS, instructions, RUNNER, VOICES } from "./rules";
14
- import { CallSession, type Offered } from "./session";
15
- import { callByToken, dropAccount, getAccount, newCall, putAccount, updateCall } from "./store";
16
- import { dialTwiml, e164, emptyTwiml, sendText, signatureOk } from "./twilio";
17
-
18
- /**
19
- * Calls as a door. A WhatsApp call reaches the tenant's business number, Meta sends it as SIP
20
- * to the tenant's Twilio SIP domain; a phone call reaches the tenant's Twilio number. Either
21
- * way Twilio asks this plugin for TwiML, which hands the call to the tenant's OpenAI project
22
- * over SIP with a token in a header. OpenAI posts the incoming call here; the plugin picks it
23
- * up with the wizard's rules and tools and drives the run over a WebSocket while the person
24
- * and the model talk. The studio half is ui/plugin.tsx.
25
- */
26
-
27
- const fail = (status: number, code: string, message: string): never => {
28
- throw Object.assign(new Error(message), { status, code });
29
- };
30
-
31
- const accountInput = z.object({
32
- twilioSid: z.string().trim().min(1).max(60),
33
- twilioToken: z.string().trim().max(200).optional(),
34
- smsFrom: z.string().trim().max(20).nullable().optional(),
35
- openaiProject: z.string().trim().min(1).max(80),
36
- openaiWebhookSecret: z.string().trim().max(200).optional(),
37
- region: z.enum(["eu", "us"]),
38
- });
39
-
40
- /** A form field by its name, whatever the case Twilio and Meta used. */
41
- function field(form: URLSearchParams, name: string): string | null {
42
- const lower = name.toLowerCase();
43
- for (const [key, value] of form) {
44
- if (key.toLowerCase() === lower) {
45
- return value;
46
- }
47
- }
48
- return null;
49
- }
50
-
51
- export default definePlugin((wizards) => {
52
- const { server, log } = wizards;
53
- const db = () => server.getTenantDb();
54
-
55
- server.registerMigrations("migrations");
56
-
57
- interface Live {
58
- session: CallSession;
59
- socket: WebSocket;
60
- }
61
- const live = new Map<string, Live>();
62
- const watching = new Map<string, () => void>();
63
- const timers = new Map<string, ReturnType<typeof setTimeout>>();
64
-
65
- const offered = () => server.runs.wizards(RUNNER) as Promise<Offered[]>;
66
-
67
- function watch(runId: string) {
68
- if (watching.has(runId)) {
69
- return;
70
- }
71
- const stop = server.runs.subscribe(runId, (signal) => {
72
- if (signal.event && signal.event.type !== "step_done" && signal.event.type !== "error") {
73
- return;
74
- }
75
- clearTimeout(timers.get(runId));
76
- timers.set(
77
- runId,
78
- setTimeout(() => {
79
- timers.delete(runId);
80
- for (const { session } of live.values()) {
81
- if (session.runId === runId) {
82
- void session.changed().catch((err) => log.error("after a change of the run:", err));
83
- }
84
- }
85
- }, 300),
86
- );
87
- });
88
- watching.set(runId, stop);
89
- }
90
-
91
- function unwatch(runId: string) {
92
- watching.get(runId)?.();
93
- watching.delete(runId);
94
- clearTimeout(timers.get(runId));
95
- timers.delete(runId);
96
- }
97
-
98
- server.onUnload(() => {
99
- for (const { socket } of live.values()) {
100
- socket.close();
101
- }
102
- live.clear();
103
- for (const stop of watching.values()) {
104
- stop();
105
- }
106
- watching.clear();
107
- for (const timer of timers.values()) {
108
- clearTimeout(timer);
109
- }
110
- });
111
-
112
- // ── The door ───────────────────────────────────────────────────────────────
113
-
114
- server.registerRunner({
115
- id: RUNNER,
116
- label: { de: "Anruf", en: "Call" },
117
- kind: "channel",
118
- capabilities: {
119
- input: ASKS,
120
- output: { shows: ["text", "data"], pictures: [] },
121
- asks: ["confirm"],
122
- review: ["accept", "regenerate"],
123
- waits: true,
124
- handoff: ["sms"],
125
- },
126
- problem: async () => {
127
- if (!(await getAccount(db()))) {
128
- return "Keine Nummer verbunden (Einstellungen → Anrufe)";
129
- }
130
- try {
131
- await server.speech.conversation();
132
- return null;
133
- } catch (err) {
134
- return (err as Error).message;
135
- }
136
- },
137
- });
138
-
139
- // Twilio: a call came in on the SIP domain (WhatsApp) or on the number (phone). The TwiML
140
- // hands it to OpenAI's SIP endpoint with a token that names the call to us.
141
- server.registerPublicRoute({
142
- method: "POST",
143
- path: "/voice",
144
- handler: async ({ request }) => {
145
- const account = await getAccount(db());
146
- if (!account) {
147
- return emptyTwiml();
148
- }
149
- const form = new URLSearchParams(await request.text());
150
- const urls = [await server.publicUrl("/voice"), request.url];
151
- if (
152
- !signatureOk(account.twilioToken, urls, form, request.headers.get("x-twilio-signature"))
153
- ) {
154
- return fail(403, "bad_signature", "The signature does not match.");
155
- }
156
- const caller = e164(form.get("From") ?? "");
157
- if (caller.length < 6) {
158
- return fail(400, "no_caller", "No caller.");
159
- }
160
- const wacid = field(form, "SipHeader_x-wa-meta-wacid");
161
- const payload =
162
- field(form, "SipHeader_x-wa-meta-cta-payload") ??
163
- field(form, "SipHeader_x-wa-meta-deeplink-payload");
164
- const row = await newCall(db(), {
165
- caller,
166
- channel: wacid ? "whatsapp" : "phone",
167
- payload,
168
- twilioCallSid: form.get("CallSid"),
169
- });
170
- return new Response(dialTwiml(sipUri(account.openaiProject, account.region), row.token), {
171
- headers: { "content-type": "text/xml" },
172
- });
173
- },
174
- });
175
-
176
- // OpenAI: the call reached the project. Picked up with the wizard's rules; from here on the
177
- // model speaks and this plugin answers its tool calls over the WebSocket.
178
- server.registerPublicRoute({
179
- method: "POST",
180
- path: "/openai",
181
- handler: async ({ request }) => {
182
- const account = await getAccount(db());
183
- if (!account) {
184
- return fail(404, "not_connected", "No account.");
185
- }
186
- const raw = await request.text();
187
- const h = (name: string) => request.headers.get(name);
188
- if (
189
- !webhookOk(
190
- account.openaiWebhookSecret,
191
- {
192
- id: h("webhook-id"),
193
- timestamp: h("webhook-timestamp"),
194
- signature: h("webhook-signature"),
195
- },
196
- raw,
197
- )
198
- ) {
199
- return fail(401, "bad_signature", "The signature does not match.");
200
- }
201
- let body: unknown;
202
- try {
203
- body = JSON.parse(raw);
204
- } catch {
205
- return fail(400, "bad_body", "Not JSON.");
206
- }
207
- const incoming = incomingCall(body);
208
- if (!incoming) {
209
- return { ok: true };
210
- }
211
- const { apiKey, model } = await server.speech.conversation();
212
- const token = incoming.headers.get("x-engenty-call");
213
- const row = token ? await callByToken(db(), token) : null;
214
- if (row?.status !== "ringing") {
215
- // Not a call of ours, or one answered already.
216
- await rejectCall(apiKey, incoming.callId).catch((err) => log.warn("reject:", err));
217
- return { ok: true };
218
- }
219
- const on = await offered();
220
- const byPayload = row.payload
221
- ? on.find((w) => w.token === row.payload || w.wizardId === row.payload)
222
- : undefined;
223
- const first = byPayload ?? (on.length === 1 ? on[0] : null);
224
- const lang = first?.lang ?? on[0]?.lang ?? "de";
225
- let socket: WebSocket | null = null;
226
- const session = new CallSession(
227
- {
228
- runs: server.runs,
229
- send: (event) => {
230
- if (socket?.readyState === WebSocket.OPEN) {
231
- socket.send(JSON.stringify(event));
232
- }
233
- },
234
- text: async (body) => {
235
- if (!account.smsFrom) {
236
- throw new Error("No number to text from (Settings → Calls).");
237
- }
238
- await sendText(
239
- { sid: account.twilioSid, token: account.twilioToken },
240
- account.smsFrom,
241
- row.caller,
242
- body,
243
- );
244
- },
245
- hangUp: async () => {
246
- await hangUp(apiKey, incoming.callId).catch((err) => log.warn("hangup:", err));
247
- // The tool's answer still goes out; the socket closes once it did.
248
- setTimeout(() => socket?.close(), 1000);
249
- },
250
- watch,
251
- unwatch,
252
- log,
253
- },
254
- row.caller,
255
- row.channel,
256
- first ? [] : on,
257
- lang,
258
- );
259
- if (first) {
260
- await session.start(first);
261
- }
262
- await acceptCall(apiKey, incoming.callId, {
263
- model,
264
- instructions: instructions(
265
- lang,
266
- first ? { title: first.title } : null,
267
- first ? [] : on.map((w) => w.title),
268
- ),
269
- tools: [...CALL_TOOLS],
270
- voice: VOICES[lang],
271
- });
272
- await updateCall(db(), row.token, {
273
- openaiCallId: incoming.callId,
274
- runId: session.runId,
275
- status: "live",
276
- });
277
- // The socket's events arrive outside any request: bound to this call's tenant here.
278
- const bound = <A extends unknown[]>(fn: (...args: A) => void) => AsyncResource.bind(fn);
279
- socket = openSocket(apiKey, incoming.callId);
280
- live.set(row.token, { session, socket });
281
- socket.addEventListener(
282
- "open",
283
- bound(() => {
284
- void session.hello().catch((err) => log.error("hello:", err));
285
- }),
286
- );
287
- socket.addEventListener(
288
- "message",
289
- bound((event: MessageEvent) => {
290
- void onMessage(session, String(event.data)).catch((err) => log.error("event:", err));
291
- }),
292
- );
293
- socket.addEventListener(
294
- "close",
295
- bound(() => {
296
- session.stop();
297
- live.delete(row.token);
298
- void updateCall(db(), row.token, { status: "done" }).catch(() => undefined);
299
- }),
300
- );
301
- socket.addEventListener(
302
- "error",
303
- bound(() => log.warn(`call ${row.token}: the socket to the model failed`)),
304
- );
305
- return { ok: true };
306
- },
307
- });
308
-
309
- /** What the model sent: a tool call is run and answered; the rest is noise here. */
310
- async function onMessage(session: CallSession, data: string): Promise<void> {
311
- let event: {
312
- type?: string;
313
- name?: string;
314
- arguments?: string;
315
- call_id?: string;
316
- error?: unknown;
317
- };
318
- try {
319
- event = JSON.parse(data);
320
- } catch {
321
- return;
322
- }
323
- if (event.type === "response.function_call_arguments.done" && event.name) {
324
- let args: Record<string, unknown> = {};
325
- try {
326
- args = event.arguments ? (JSON.parse(event.arguments) as Record<string, unknown>) : {};
327
- } catch {
328
- args = {};
329
- }
330
- const output = await session
331
- .call(event.name, args)
332
- .catch((err) => `Failed: ${(err as Error).message}`);
333
- const found = [...live.values()].find((l) => l.session === session);
334
- if (found?.socket.readyState === WebSocket.OPEN) {
335
- found.socket.send(
336
- JSON.stringify({
337
- type: "conversation.item.create",
338
- item: { type: "function_call_output", call_id: event.call_id, output },
339
- }),
340
- );
341
- found.socket.send(JSON.stringify({ type: "response.create" }));
342
- }
343
- } else if (event.type === "error") {
344
- log.warn("the model reported:", JSON.stringify(event.error));
345
- }
346
- }
347
-
348
- // ── The studio's side ──────────────────────────────────────────────────────
349
-
350
- const admin = (request: PluginRequest) => {
351
- if (request.user.role === "member") {
352
- fail(403, "forbidden", "Owners and admins only.");
353
- }
354
- };
355
-
356
- async function accountView() {
357
- const account = await getAccount(db());
358
- return {
359
- connected: Boolean(account),
360
- twilioSid: account?.twilioSid ?? "",
361
- hasToken: Boolean(account?.twilioToken),
362
- smsFrom: account?.smsFrom ?? "",
363
- openaiProject: account?.openaiProject ?? "",
364
- hasSecret: Boolean(account?.openaiWebhookSecret),
365
- region: account?.region ?? "eu",
366
- voiceWebhook: await server.publicUrl("/voice"),
367
- openaiWebhook: await server.publicUrl("/openai"),
368
- sipUri: account ? sipUri(account.openaiProject, account.region) : null,
369
- };
370
- }
371
-
372
- server.registerHttpRoute({
373
- method: "GET",
374
- path: "/account",
375
- role: "admin",
376
- handler: accountView,
377
- });
378
-
379
- server.registerHttpRoute({
380
- method: "PUT",
381
- path: "/account",
382
- role: "admin",
383
- handler: async (request) => {
384
- admin(request);
385
- const parsed = accountInput.safeParse(await request.json());
386
- if (!parsed.success) {
387
- return fail(400, "invalid", parsed.error.issues[0]?.message ?? "invalid");
388
- }
389
- await putAccount(db(), {
390
- ...parsed.data,
391
- smsFrom: parsed.data.smsFrom ? e164(parsed.data.smsFrom) : null,
392
- });
393
- return accountView();
394
- },
395
- });
396
-
397
- server.registerHttpRoute({
398
- method: "DELETE",
399
- path: "/account",
400
- role: "admin",
401
- handler: async (request) => {
402
- admin(request);
403
- await dropAccount(db());
404
- return accountView();
405
- },
406
- });
407
- });
@@ -1,226 +0,0 @@
1
- import type { PluginRunReport } from "@engenty-wizards/plugin-sdk";
2
- import { clipText, type ThreadLang } from "@engenty-wizards/plugin-sdk/thread";
3
-
4
- /**
5
- * What the model works with on a call: its tools (one per command of the run), its rules in
6
- * the wizard's language, and the state of the run as text, which it reads after every change.
7
- */
8
-
9
- export const RUNNER = "call";
10
-
11
- /** The field kinds a voice can answer; the rest is texted as a link to the run page. */
12
- export const ASKS = [
13
- "text",
14
- "textarea",
15
- "number",
16
- "select",
17
- "multiselect",
18
- "date",
19
- "email",
20
- "url",
21
- "toggle",
22
- "location",
23
- ];
24
-
25
- export const VOICES: Record<ThreadLang, string> = { de: "marin", en: "marin" };
26
-
27
- const object = (properties: Record<string, unknown>, required: string[] = []) => ({
28
- type: "object",
29
- properties,
30
- required,
31
- additionalProperties: false,
32
- });
33
-
34
- export const CALL_TOOLS = [
35
- {
36
- type: "function",
37
- name: "get_state",
38
- description:
39
- "What the run waits for right now: the page with its fields (which are answered, which are still open, which need the screen), the review, or what the wizard is working on. Call it when unsure, and after the state changed.",
40
- parameters: object({}),
41
- },
42
- {
43
- type: "function",
44
- name: "set_field",
45
- description:
46
- "Keeps the person's answer to one field of the current page. Call it once per field as soon as the person answered; for a choice give one of the options exactly.",
47
- parameters: object(
48
- {
49
- field: { type: "string", description: "The field's id from the state." },
50
- value: {
51
- description:
52
- "The answer: a string, a number, true/false for a toggle, or an array of options for a multiple choice.",
53
- },
54
- },
55
- ["field", "value"],
56
- ),
57
- },
58
- {
59
- type: "function",
60
- name: "submit_page",
61
- description:
62
- "Sends the current page once every required field that can be answered by voice has a value.",
63
- parameters: object({}),
64
- },
65
- {
66
- type: "function",
67
- name: "accept_review",
68
- description: "Accepts what the wizard made, when the person is happy with it.",
69
- parameters: object({}),
70
- },
71
- {
72
- type: "function",
73
- name: "regenerate",
74
- description: "Asks the wizard to make one of the shown outputs again, with what should change.",
75
- parameters: object(
76
- {
77
- target: { type: "string", description: "The step id of the output, from the state." },
78
- note: { type: "string", description: "What should change, in the person's words." },
79
- },
80
- ["target", "note"],
81
- ),
82
- },
83
- {
84
- type: "function",
85
- name: "answer_ask",
86
- description:
87
- "Answers the question a running step asks before it changes something in a connected account: allow it, or skip it.",
88
- parameters: object({ allow: { type: "boolean" } }, ["allow"]),
89
- },
90
- {
91
- type: "function",
92
- name: "send_link",
93
- description:
94
- "Texts the person a link to the run on their phone: for a page that needs the screen (a photo, a file, a signature), and for the result at the end. Say that a text is on its way.",
95
- parameters: object({}),
96
- },
97
- {
98
- type: "function",
99
- name: "go_back",
100
- description: "Goes back to the page before, when the person wants to change an earlier answer.",
101
- parameters: object({}),
102
- },
103
- {
104
- type: "function",
105
- name: "choose_wizard",
106
- description:
107
- "Starts one of the wizards offered on this number, by its name, once the person said which.",
108
- parameters: object({ name: { type: "string" } }, ["name"]),
109
- },
110
- {
111
- type: "function",
112
- name: "hang_up",
113
- description: "Ends the call, after saying goodbye.",
114
- parameters: object({}),
115
- },
116
- ] as const;
117
-
118
- export interface About {
119
- title: string;
120
- description?: string;
121
- }
122
-
123
- /** The rules the model speaks by. `lobby`: the wizards to choose from, when more than one is on. */
124
- export function instructions(lang: ThreadLang, about: About | null, lobby: string[]): string {
125
- const de = lang === "de";
126
- const who = about
127
- ? [`${de ? "Du bist" : "You are"} ${about.title}.`, about.description].filter(Boolean).join(" ")
128
- : de
129
- ? "Du bist der Assistent dieser Nummer."
130
- : "You are the assistant on this number.";
131
- const choose = lobby.length
132
- ? de
133
- ? `Auf dieser Nummer gibt es mehrere Wizards: ${lobby.join(", ")}. Frag zuerst, welcher es sein soll, und rufe dann choose_wizard auf.`
134
- : `This number offers several wizards: ${lobby.join(", ")}. Ask first which one it should be, then call choose_wizard.`
135
- : "";
136
- const none =
137
- !about && !lobby.length
138
- ? de
139
- ? "Hier ist noch kein Wizard eingerichtet: sag das in einem Satz und beende das Gespräch mit hang_up."
140
- : "No wizard is set up here yet: say so in one sentence and end the call with hang_up."
141
- : "";
142
- const rules = de
143
- ? [
144
- "Du führst ein Telefongespräch, das einen Wizard Schritt für Schritt ausfüllt. Die Person hat keinen Bildschirm vor sich: nur deine Stimme.",
145
- "Sprich Deutsch, kurz und freundlich. Ein Satz, dann die nächste Frage. Begrüße die Person mit einem Satz und sag, worum es geht.",
146
- "Frag ein Feld nach dem anderen, in der Reihenfolge des Zustands. Nach jeder Antwort rufst du set_field auf. Sind alle Felder beantwortet, die du per Sprache füllen kannst, rufst du submit_page auf.",
147
- "Bei einer Auswahl nennst du die Möglichkeiten (höchstens vier auf einmal) und gibst genau eine davon weiter.",
148
- "E-Mail-Adressen, Links und Zahlen wiederholst du zur Bestätigung, bevor du sie weitergibst.",
149
- "Ein Feld, das den Bildschirm braucht (ein Foto, eine Datei, eine Unterschrift), kann die Person nur über einen Link ausfüllen: sag das, rufe send_link auf, und warte, bis der Zustand sich ändert.",
150
- "Während der Wizard arbeitet, sagst du in einem Satz, was er gerade tut, und wartest. Erfinde nichts.",
151
- "Bei einer Prüfung liest du den Text kurz zusammengefasst vor und fragst, ob er passt. Dann accept_review, oder regenerate mit dem Wunsch der Person.",
152
- "Am Ende sagst du, was entstanden ist, rufst send_link auf, damit die Person alles als Link bekommt, verabschiedest dich und rufst hang_up auf.",
153
- ]
154
- : [
155
- "You hold a phone conversation that fills in a wizard step by step. The person has no screen: only your voice.",
156
- "Speak English, short and friendly. One sentence, then the next question. Greet the person in one sentence and say what this is about.",
157
- "Ask one field at a time, in the order of the state. After each answer call set_field. When every field you can fill by voice is answered, call submit_page.",
158
- "For a choice, name the options (at most four at a time) and pass exactly one of them on.",
159
- "Read e-mail addresses, links and numbers back before passing them on.",
160
- "A field that needs the screen (a photo, a file, a signature) can only be filled through a link: say so, call send_link, and wait until the state changes.",
161
- "While the wizard works, say in one sentence what it is doing and wait. Never make anything up.",
162
- "At a review, sum the text up in a few words and ask whether it fits. Then accept_review, or regenerate with what the person wants changed.",
163
- "At the end, say what was made, call send_link so the person has it all as a link, say goodbye and call hang_up.",
164
- ];
165
- return [who, choose, none, "", ...rules].filter((line) => line !== undefined).join("\n");
166
- }
167
-
168
- /** The run as text for the model: the page with every field and what it knows, or what else it waits for. */
169
- export function stateText(
170
- report: PluginRunReport | null,
171
- draft: Record<string, unknown>,
172
- lobby: string[],
173
- asks: Set<string>,
174
- ): string {
175
- if (!report) {
176
- return lobby.length
177
- ? `[state]\nNo wizard chosen yet. Offered: ${lobby.join(", ")}. Ask which, then choose_wizard.`
178
- : "[state]\nNo wizard is set up on this number.";
179
- }
180
- const lines = [`[state]`, `Status: ${report.status}.`];
181
- const w = report.waitingFor;
182
- if (report.status === "done") {
183
- lines.push("The wizard is done. Say what was made, send_link, say goodbye, hang_up.");
184
- } else if (report.status === "failed") {
185
- lines.push(`The wizard stopped: ${report.error ?? "an error"}. Say so, send_link, hang_up.`);
186
- } else if (!w) {
187
- lines.push(
188
- `The wizard is working on "${report.step?.title ?? "…"}". Say so in a sentence and wait; the state comes again when it is done.`,
189
- );
190
- } else if ("page" in w) {
191
- lines.push(`Page "${w.title}" (id ${w.page}). Fields, in order:`);
192
- for (const f of w.fields) {
193
- if (f.shown === false) {
194
- continue;
195
- }
196
- const value = f.id in draft ? draft[f.id] : f.value;
197
- const has = value !== undefined && value !== null && value !== "";
198
- const kind = f.options?.length ? `${f.kind}: ${f.options.join(" | ")}` : f.kind;
199
- const how = asks.has(f.kind) ? "" : " — needs the screen: send_link";
200
- lines.push(
201
- `- ${f.id} "${f.label}" (${kind}${f.required ? ", required" : ""}${how}): ${has ? `answered: ${JSON.stringify(value)}` : "open"}${f.help ? ` — ${f.help}` : ""}`,
202
- );
203
- }
204
- } else if ("review" in w) {
205
- lines.push(`Review "${w.title}" (id ${w.review}) of: ${w.show.join(", ")}.`);
206
- for (const output of report.outputs.filter((o) => w.show.includes(o.stepId))) {
207
- lines.push(
208
- `Output ${output.stepId} "${output.title}": ${output.text ? clipText(output.text, 1200) : output.json ? clipText(output.json, 600) : "(not text: a file)"}`,
209
- );
210
- }
211
- lines.push("Ask whether it fits: accept_review, or regenerate with target and note.");
212
- } else if (w.kind === "confirm") {
213
- lines.push(
214
- `Question (id ${w.ask}): ${w.reason} — ${w.action}. answer_ask with allow true or false.`,
215
- );
216
- } else {
217
- lines.push("A sign-in is needed on the screen: send_link and wait.");
218
- }
219
- const made = report.outputs.filter((o) => o.text && (!w || !("review" in w)));
220
- if (made.length && report.status === "done") {
221
- for (const output of made.slice(-2)) {
222
- lines.push(`Made "${output.title}": ${clipText(output.text ?? "", 600)}`);
223
- }
224
- }
225
- return lines.join("\n");
226
- }
@@ -1,39 +0,0 @@
1
- import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
-
3
- /** The tables of migrations/, for Drizzle. Kept in step with the SQL by hand. */
4
-
5
- export const account = sqliteTable("calls_account", {
6
- id: integer("id").primaryKey(),
7
- twilioSid: text("twilio_sid").notNull(),
8
- twilioToken: text("twilio_token").notNull(),
9
- /** The OpenAI project the calls are handed to over SIP: proj_… */
10
- openaiProject: text("openai_project").notNull(),
11
- /** Signs what OpenAI posts about incoming calls: whsec_… */
12
- openaiWebhookSecret: text("openai_webhook_secret").notNull(),
13
- /** Where OpenAI takes the call: `eu` or `us`. */
14
- region: text("region", { enum: ["eu", "us"] })
15
- .notNull()
16
- .default("eu"),
17
- /** The Twilio number the links are texted from; none: no texts. */
18
- smsFrom: text("sms_from"),
19
- updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
20
- });
21
-
22
- export const call = sqliteTable("calls_call", {
23
- /** Travels with the call as a SIP header; names it to OpenAI's webhook. */
24
- token: text("token").primaryKey(),
25
- /** The caller's number, E.164. */
26
- caller: text("caller").notNull(),
27
- /** `whatsapp`: a WhatsApp call over Meta's SIP; `phone`: a call to the Twilio number. */
28
- channel: text("channel", { enum: ["whatsapp", "phone"] }).notNull(),
29
- /** What a WhatsApp call button or deep link carried: a wizard's share token. */
30
- payload: text("payload"),
31
- twilioCallSid: text("twilio_call_sid"),
32
- openaiCallId: text("openai_call_id"),
33
- runId: text("run_id"),
34
- status: text("status", { enum: ["ringing", "live", "done"] })
35
- .notNull()
36
- .default("ringing"),
37
- createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
38
- updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
39
- });