wizards 0.4.1 → 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.
- package/apps/runtime/dist/docs/dev/plugins/reloading.md +3 -3
- package/apps/runtime/dist/docs/dev/plugins/runners.md +9 -12
- package/apps/runtime/dist/docs/user/test-and-share.md +22 -58
- package/apps/web/dist/assets/{AboutDialog-CXEpsI6L.js → AboutDialog-CH1rQdia.js} +1 -1
- package/apps/web/dist/assets/{ChangelogDialog-Cc2APUGI.js → ChangelogDialog-78MQLHfA.js} +1 -1
- package/apps/web/dist/assets/{ChatView-B2r4dW_m.js → ChatView-DLJfy69U.js} +1 -1
- package/apps/web/dist/assets/{EditorPage-CNAYlSms.js → EditorPage-BYrZokVk.js} +1 -1
- package/apps/web/dist/assets/{FlowPreview-DzU1nGDb.js → FlowPreview-xIWPdOCV.js} +1 -1
- package/apps/web/dist/assets/{Harness-ALQ9Csm6.js → Harness-CsDhNLSW.js} +1 -1
- package/apps/web/dist/assets/{NewWizardChannels-DMPqHkCF.js → NewWizardChannels-JB3V6DCL.js} +1 -1
- package/apps/web/dist/assets/{NewWizardPage-pi053e8N.js → NewWizardPage-Bkh-hjSe.js} +1 -1
- package/apps/web/dist/assets/{OnboardingPage-TYNX6Umd.js → OnboardingPage-Diw1ILjC.js} +1 -1
- package/apps/web/dist/assets/{ProjectPage-CvEkx_Ux.js → ProjectPage-FgoZDzPI.js} +1 -1
- package/apps/web/dist/assets/{PublicRunner-BrivKNHo.js → PublicRunner-ClT6B7t5.js} +1 -1
- package/apps/web/dist/assets/{RunDrawer-3osugizk.js → RunDrawer-DAwTEyjM.js} +1 -1
- package/apps/web/dist/assets/{RunnerView-Ci4_WzVv.js → RunnerView-B_tJroFM.js} +1 -1
- package/apps/web/dist/assets/{SettingsPage-DKntf7-9.js → SettingsPage-BsMN-XQs.js} +1 -1
- package/apps/web/dist/assets/{SetupPage-DZQxEQyk.js → SetupPage-C7FuIpSt.js} +1 -1
- package/apps/web/dist/assets/{SharePage-B9l4KthU.js → SharePage-CbFe83L9.js} +1 -1
- package/apps/web/dist/assets/changelog-mYclku73.js +1 -0
- package/apps/web/dist/assets/{esm-CuqXcPS3.js → esm-BhIeKBJY.js} +1 -1
- package/apps/web/dist/assets/{index-j09nLtEs.js → index-fflYi8P1.js} +3 -3
- package/apps/web/dist/assets/{settings-menu-C7VsRpe9.js → settings-menu-C5Y8SOhG.js} +1 -1
- package/apps/web/dist/index.html +1 -1
- package/package.json +1 -1
- package/apps/web/dist/assets/changelog-CxdXZo3V.js +0 -1
- package/modules/calls/dist/client.css +0 -3
- package/modules/calls/dist/client.js +0 -1
- package/modules/calls/engenty.plugin.json +0 -8
- package/modules/calls/migrations/0001_init.sql +0 -26
- package/modules/calls/package.json +0 -25
- package/modules/calls/src/openai.ts +0 -155
- package/modules/calls/src/plugin.ts +0 -407
- package/modules/calls/src/rules.ts +0 -226
- package/modules/calls/src/schema.ts +0 -39
- package/modules/calls/src/session.ts +0 -353
- package/modules/calls/src/store.ts +0 -124
- package/modules/calls/src/twilio.ts +0 -90
- package/modules/calls/tsconfig.json +0 -10
- package/modules/calls/ui/Settings.tsx +0 -137
- package/modules/calls/ui/app.ts +0 -50
- package/modules/calls/ui/messages.ts +0 -81
- package/modules/calls/ui/parts.tsx +0 -36
- package/modules/calls/ui/plugin.tsx +0 -15
- package/modules/sms/dist/client.css +0 -3
- package/modules/sms/dist/client.js +0 -1
- package/modules/sms/engenty.plugin.json +0 -8
- package/modules/sms/migrations/0001_init.sql +0 -26
- package/modules/sms/package.json +0 -24
- package/modules/sms/src/plugin.ts +0 -329
- package/modules/sms/src/schema.ts +0 -26
- package/modules/sms/src/store.ts +0 -140
- package/modules/sms/src/surface.ts +0 -70
- package/modules/sms/src/twilio.ts +0 -130
- package/modules/sms/tsconfig.json +0 -10
- package/modules/sms/ui/Settings.tsx +0 -113
- package/modules/sms/ui/Share.tsx +0 -60
- package/modules/sms/ui/app.ts +0 -51
- package/modules/sms/ui/messages.ts +0 -70
- package/modules/sms/ui/parts.tsx +0 -36
- package/modules/sms/ui/plugin.tsx +0 -20
- package/modules/whatsapp/dist/client.css +0 -3
- package/modules/whatsapp/dist/client.js +0 -1
- package/modules/whatsapp/engenty.plugin.json +0 -8
- package/modules/whatsapp/migrations/0001_init.sql +0 -30
- package/modules/whatsapp/package.json +0 -24
- package/modules/whatsapp/src/graph.ts +0 -271
- package/modules/whatsapp/src/inbound.ts +0 -143
- package/modules/whatsapp/src/plugin.ts +0 -374
- package/modules/whatsapp/src/schema.ts +0 -32
- package/modules/whatsapp/src/store.ts +0 -156
- package/modules/whatsapp/src/surface.ts +0 -127
- package/modules/whatsapp/tsconfig.json +0 -10
- package/modules/whatsapp/ui/Settings.tsx +0 -141
- package/modules/whatsapp/ui/Share.tsx +0 -60
- package/modules/whatsapp/ui/app.ts +0 -54
- package/modules/whatsapp/ui/messages.ts +0 -80
- package/modules/whatsapp/ui/parts.tsx +0 -36
- package/modules/whatsapp/ui/plugin.tsx +0 -20
|
@@ -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
|
-
});
|
|
@@ -1,353 +0,0 @@
|
|
|
1
|
-
import type { PluginRunReport, PluginRuns } from "@engenty-wizards/plugin-sdk";
|
|
2
|
-
import { type ThreadLang, toIso } from "@engenty-wizards/plugin-sdk/thread";
|
|
3
|
-
import { ASKS, RUNNER, stateText } from "./rules";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* One call: the run it drives, the answers collected for the current page, and the tools the
|
|
7
|
-
* model calls over the WebSocket. The model hears the state of the run as text after every
|
|
8
|
-
* change, and a link is texted for what a voice cannot do.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export interface Offered {
|
|
12
|
-
wizardId: string;
|
|
13
|
-
token: string;
|
|
14
|
-
title: string;
|
|
15
|
-
lang: ThreadLang;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface SessionDeps {
|
|
19
|
-
runs: PluginRuns;
|
|
20
|
-
/** Sends one event to the model. */
|
|
21
|
-
send(event: Record<string, unknown>): void;
|
|
22
|
-
/** Texts the caller. */
|
|
23
|
-
text(body: string): Promise<void>;
|
|
24
|
-
hangUp(): Promise<void>;
|
|
25
|
-
/** Hear the run, and call `changed` when it moves. */
|
|
26
|
-
watch(runId: string): void;
|
|
27
|
-
unwatch(runId: string): void;
|
|
28
|
-
log: { warn(...args: unknown[]): void };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const asks = new Set(ASKS);
|
|
32
|
-
|
|
33
|
-
const YES = new Set(["true", "yes", "ja", "1", "on"]);
|
|
34
|
-
const NO = new Set(["false", "no", "nein", "0", "off"]);
|
|
35
|
-
|
|
36
|
-
export class CallSession {
|
|
37
|
-
runId: string | null = null;
|
|
38
|
-
lang: ThreadLang;
|
|
39
|
-
private draft: { step: string; values: Record<string, unknown> } | null = null;
|
|
40
|
-
private report: PluginRunReport | null = null;
|
|
41
|
-
private linked = false;
|
|
42
|
-
private ended = false;
|
|
43
|
-
|
|
44
|
-
constructor(
|
|
45
|
-
private readonly deps: SessionDeps,
|
|
46
|
-
readonly caller: string,
|
|
47
|
-
readonly channel: "whatsapp" | "phone",
|
|
48
|
-
private lobby: Offered[],
|
|
49
|
-
lang: ThreadLang,
|
|
50
|
-
) {
|
|
51
|
-
this.lang = lang;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** The wizard the call runs, started; the rules are made from it. */
|
|
55
|
-
async start(wizard: Offered): Promise<PluginRunReport | null> {
|
|
56
|
-
this.lang = wizard.lang;
|
|
57
|
-
// On WhatsApp the person is the same as in the thread: the number without its plus.
|
|
58
|
-
const id = this.channel === "whatsapp" ? this.caller.replace(/^\+/, "") : this.caller;
|
|
59
|
-
const { runId } = await this.deps.runs.start({
|
|
60
|
-
token: wizard.token,
|
|
61
|
-
person: { channel: this.channel, id },
|
|
62
|
-
runner: RUNNER,
|
|
63
|
-
});
|
|
64
|
-
this.runId = runId;
|
|
65
|
-
this.lobby = [];
|
|
66
|
-
this.draft = null;
|
|
67
|
-
this.linked = false;
|
|
68
|
-
return this.look(5);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** The state as the model reads it. */
|
|
72
|
-
state(): string {
|
|
73
|
-
return stateText(
|
|
74
|
-
this.report,
|
|
75
|
-
this.draft?.values ?? {},
|
|
76
|
-
this.lobby.map((w) => w.title),
|
|
77
|
-
asks,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Tells the model the state and lets it speak: at the start, and when the run changed. */
|
|
82
|
-
async hello(): Promise<void> {
|
|
83
|
-
await this.look(0);
|
|
84
|
-
this.say(
|
|
85
|
-
this.state(),
|
|
86
|
-
this.lang === "de" ? "Begrüße die Person und beginne." : "Greet the person and begin.",
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** The run moved while nobody spoke: the model hears the new state and says what changed. */
|
|
91
|
-
async changed(): Promise<void> {
|
|
92
|
-
if (this.ended) {
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
await this.look(0);
|
|
96
|
-
if (this.report?.status === "done" && !this.linked) {
|
|
97
|
-
await this.link().catch((err) => this.deps.log.warn("the link was not texted:", err));
|
|
98
|
-
}
|
|
99
|
-
this.say(
|
|
100
|
-
this.state(),
|
|
101
|
-
this.lang === "de"
|
|
102
|
-
? "Sag in einem Satz, was sich getan hat, und mach weiter."
|
|
103
|
-
: "Say in one sentence what happened, and go on.",
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** The model called a tool: what it gets back. */
|
|
108
|
-
async call(name: string, args: Record<string, unknown>): Promise<string> {
|
|
109
|
-
switch (name) {
|
|
110
|
-
case "get_state":
|
|
111
|
-
await this.look(0);
|
|
112
|
-
return this.state();
|
|
113
|
-
case "choose_wizard": {
|
|
114
|
-
const wanted = String(args.name ?? "").toLowerCase();
|
|
115
|
-
const wizard =
|
|
116
|
-
this.lobby.find((w) => w.title.toLowerCase() === wanted) ??
|
|
117
|
-
this.lobby.find(
|
|
118
|
-
(w) => w.title.toLowerCase().includes(wanted) || wanted.includes(w.title.toLowerCase()),
|
|
119
|
-
);
|
|
120
|
-
if (!wizard) {
|
|
121
|
-
return `No wizard called "${args.name}". Offered: ${this.lobby.map((w) => w.title).join(", ")}.`;
|
|
122
|
-
}
|
|
123
|
-
await this.start(wizard);
|
|
124
|
-
return this.state();
|
|
125
|
-
}
|
|
126
|
-
case "send_link":
|
|
127
|
-
return this.link();
|
|
128
|
-
case "hang_up":
|
|
129
|
-
this.ended = true;
|
|
130
|
-
await this.deps.hangUp();
|
|
131
|
-
return "The call is over.";
|
|
132
|
-
}
|
|
133
|
-
if (!this.runId) {
|
|
134
|
-
return "No wizard runs yet: choose_wizard first.";
|
|
135
|
-
}
|
|
136
|
-
switch (name) {
|
|
137
|
-
case "set_field":
|
|
138
|
-
return this.setField(String(args.field ?? ""), args.value);
|
|
139
|
-
case "submit_page":
|
|
140
|
-
return this.submit();
|
|
141
|
-
case "accept_review":
|
|
142
|
-
return this.command(async (w) => {
|
|
143
|
-
if (!w || !("review" in w)) {
|
|
144
|
-
return "Nothing to review right now.";
|
|
145
|
-
}
|
|
146
|
-
await this.deps.runs.review(this.runId!, w.review, { type: "accept" });
|
|
147
|
-
return null;
|
|
148
|
-
});
|
|
149
|
-
case "regenerate":
|
|
150
|
-
return this.command(async (w) => {
|
|
151
|
-
if (!w || !("review" in w)) {
|
|
152
|
-
return "Nothing to review right now.";
|
|
153
|
-
}
|
|
154
|
-
await this.deps.runs.review(this.runId!, w.review, {
|
|
155
|
-
type: "regenerate",
|
|
156
|
-
target: String(args.target ?? w.show[0] ?? ""),
|
|
157
|
-
note: String(args.note ?? ""),
|
|
158
|
-
});
|
|
159
|
-
return null;
|
|
160
|
-
});
|
|
161
|
-
case "answer_ask":
|
|
162
|
-
return this.command(async (w) => {
|
|
163
|
-
if (!w || !("ask" in w)) {
|
|
164
|
-
return "No question is open right now.";
|
|
165
|
-
}
|
|
166
|
-
await this.deps.runs.answerAsk(this.runId!, w.ask, args.allow ? "allow" : "skip");
|
|
167
|
-
return null;
|
|
168
|
-
});
|
|
169
|
-
case "go_back":
|
|
170
|
-
return this.command(async () => {
|
|
171
|
-
await this.deps.runs.control(this.runId!, "back");
|
|
172
|
-
this.draft = null;
|
|
173
|
-
return null;
|
|
174
|
-
});
|
|
175
|
-
default:
|
|
176
|
-
return `Unknown tool ${name}.`;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
stop(): void {
|
|
181
|
-
this.ended = true;
|
|
182
|
-
if (this.runId) {
|
|
183
|
-
this.deps.unwatch(this.runId);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// ── Tools ──────────────────────────────────────────────────────────────────
|
|
188
|
-
|
|
189
|
-
private async setField(id: string, raw: unknown): Promise<string> {
|
|
190
|
-
await this.look(0);
|
|
191
|
-
const w = this.report?.waitingFor;
|
|
192
|
-
if (!w || !("page" in w)) {
|
|
193
|
-
return "No page is open right now. get_state says what the run waits for.";
|
|
194
|
-
}
|
|
195
|
-
const field = w.fields.find((f) => f.id === id && f.shown !== false);
|
|
196
|
-
if (!field) {
|
|
197
|
-
return `No open field "${id}" on this page.`;
|
|
198
|
-
}
|
|
199
|
-
if (!asks.has(field.kind)) {
|
|
200
|
-
return `"${field.label}" needs the screen: send_link, then wait.`;
|
|
201
|
-
}
|
|
202
|
-
if (!this.draft || this.draft.step !== w.page) {
|
|
203
|
-
this.draft = { step: w.page, values: {} };
|
|
204
|
-
}
|
|
205
|
-
const value = this.coerce(field.kind, field.options ?? [], raw);
|
|
206
|
-
if (value instanceof Error) {
|
|
207
|
-
return value.message;
|
|
208
|
-
}
|
|
209
|
-
this.draft.values[id] = value;
|
|
210
|
-
// A later field's condition may read this answer: the page is read again with the draft.
|
|
211
|
-
await this.look(0);
|
|
212
|
-
return this.state();
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** The answer as the page takes it, or what is wrong with it. */
|
|
216
|
-
private coerce(kind: string, options: string[], raw: unknown): unknown | Error {
|
|
217
|
-
const text = raw === undefined || raw === null ? "" : String(raw).trim();
|
|
218
|
-
switch (kind) {
|
|
219
|
-
case "select": {
|
|
220
|
-
const found = options.find((o) => o.toLowerCase() === text.toLowerCase());
|
|
221
|
-
return found ?? new Error(`Pick one of: ${options.join(" | ")}.`);
|
|
222
|
-
}
|
|
223
|
-
case "multiselect": {
|
|
224
|
-
const given = Array.isArray(raw) ? raw.map(String) : text.split(/[,;]+/);
|
|
225
|
-
const picked = given
|
|
226
|
-
.map((g) => options.find((o) => o.toLowerCase() === g.trim().toLowerCase()))
|
|
227
|
-
.filter((o): o is string => Boolean(o));
|
|
228
|
-
return picked.length ? picked : new Error(`Pick from: ${options.join(" | ")}.`);
|
|
229
|
-
}
|
|
230
|
-
case "toggle": {
|
|
231
|
-
if (typeof raw === "boolean") {
|
|
232
|
-
return raw;
|
|
233
|
-
}
|
|
234
|
-
const lower = text.toLowerCase();
|
|
235
|
-
return YES.has(lower) ? true : NO.has(lower) ? false : new Error("true or false.");
|
|
236
|
-
}
|
|
237
|
-
case "number": {
|
|
238
|
-
const n = Number(text.replace(",", "."));
|
|
239
|
-
return Number.isFinite(n) ? n : new Error("A number.");
|
|
240
|
-
}
|
|
241
|
-
case "date": {
|
|
242
|
-
const iso = toIso(text);
|
|
243
|
-
return iso ?? new Error("A date as YYYY-MM-DD or DD.MM.YYYY.");
|
|
244
|
-
}
|
|
245
|
-
case "location":
|
|
246
|
-
return text ? { label: text } : new Error("An address.");
|
|
247
|
-
default:
|
|
248
|
-
return text || new Error("An answer.");
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
private async submit(): Promise<string> {
|
|
253
|
-
await this.look(0);
|
|
254
|
-
const w = this.report?.waitingFor;
|
|
255
|
-
if (!w || !("page" in w)) {
|
|
256
|
-
return "No page is open right now.";
|
|
257
|
-
}
|
|
258
|
-
const values = this.draft?.step === w.page ? this.draft.values : {};
|
|
259
|
-
const missing = w.fields.filter(
|
|
260
|
-
(f) =>
|
|
261
|
-
f.shown !== false &&
|
|
262
|
-
f.required &&
|
|
263
|
-
asks.has(f.kind) &&
|
|
264
|
-
(values[f.id] === undefined || values[f.id] === null || values[f.id] === ""),
|
|
265
|
-
);
|
|
266
|
-
if (missing.length) {
|
|
267
|
-
return `Still open: ${missing.map((f) => `${f.id} "${f.label}"`).join(", ")}. Ask for them first.`;
|
|
268
|
-
}
|
|
269
|
-
if (w.fields.some((f) => f.shown !== false && !asks.has(f.kind))) {
|
|
270
|
-
return "This page has a field that needs the screen: send_link and wait until the state changes.";
|
|
271
|
-
}
|
|
272
|
-
try {
|
|
273
|
-
await this.deps.runs.answerPage(this.runId!, w.page, values);
|
|
274
|
-
} catch (err) {
|
|
275
|
-
const fields = (err as { data?: { fields?: { field: string; message: string }[] } }).data
|
|
276
|
-
?.fields;
|
|
277
|
-
return fields?.length
|
|
278
|
-
? `Not accepted: ${fields.map((f) => `${f.field}: ${f.message}`).join("; ")}. Ask again.`
|
|
279
|
-
: `Not accepted: ${(err as Error).message}`;
|
|
280
|
-
}
|
|
281
|
-
this.draft = null;
|
|
282
|
-
await this.look(15);
|
|
283
|
-
return this.state();
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** A command on the run, then the new state. `act` answers with text where the command does not fit. */
|
|
287
|
-
private async command(
|
|
288
|
-
act: (waiting: PluginRunReport["waitingFor"]) => Promise<string | null>,
|
|
289
|
-
): Promise<string> {
|
|
290
|
-
await this.look(0);
|
|
291
|
-
try {
|
|
292
|
-
const answer = await act(this.report?.waitingFor);
|
|
293
|
-
if (answer) {
|
|
294
|
-
return answer;
|
|
295
|
-
}
|
|
296
|
-
} catch (err) {
|
|
297
|
-
return `Not accepted: ${(err as Error).message}`;
|
|
298
|
-
}
|
|
299
|
-
await this.look(15);
|
|
300
|
-
return this.state();
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Texts the link to the run page, once per state; the run is watched from then on. */
|
|
304
|
-
private async link(): Promise<string> {
|
|
305
|
-
if (!this.runId) {
|
|
306
|
-
return "No wizard runs yet.";
|
|
307
|
-
}
|
|
308
|
-
const url = await this.deps.runs.handoffUrl(this.runId);
|
|
309
|
-
const done = this.report?.status === "done";
|
|
310
|
-
await this.deps.text(
|
|
311
|
-
this.lang === "de"
|
|
312
|
-
? done
|
|
313
|
-
? `Fertig! Alles liegt hier: ${url}`
|
|
314
|
-
: `Hier geht es weiter: ${url}`
|
|
315
|
-
: done
|
|
316
|
-
? `Done! Everything is here: ${url}`
|
|
317
|
-
: `It goes on here: ${url}`,
|
|
318
|
-
);
|
|
319
|
-
this.linked = done;
|
|
320
|
-
this.deps.watch(this.runId);
|
|
321
|
-
return "The link was texted. Say so; then wait for the state to change.";
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// ── The run ────────────────────────────────────────────────────────────────
|
|
325
|
-
|
|
326
|
-
/** Reads the run as it stands, waiting a little while it works. */
|
|
327
|
-
private async look(waitSeconds: number): Promise<PluginRunReport | null> {
|
|
328
|
-
if (!this.runId) {
|
|
329
|
-
this.report = null;
|
|
330
|
-
return null;
|
|
331
|
-
}
|
|
332
|
-
this.report = await this.deps.runs.report(this.runId, {
|
|
333
|
-
waitSeconds,
|
|
334
|
-
draft: this.draft?.values,
|
|
335
|
-
});
|
|
336
|
-
if (!this.report.waitingFor && this.report.status === "running") {
|
|
337
|
-
this.deps.watch(this.runId);
|
|
338
|
-
}
|
|
339
|
-
if (this.report.status === "done" || this.report.status === "failed") {
|
|
340
|
-
this.deps.unwatch(this.runId);
|
|
341
|
-
}
|
|
342
|
-
return this.report;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/** A message with the state, and a turn of the model with what to do about it. */
|
|
346
|
-
private say(state: string, instructions: string): void {
|
|
347
|
-
this.deps.send({
|
|
348
|
-
type: "conversation.item.create",
|
|
349
|
-
item: { type: "message", role: "user", content: [{ type: "input_text", text: state }] },
|
|
350
|
-
});
|
|
351
|
-
this.deps.send({ type: "response.create", response: { instructions } });
|
|
352
|
-
}
|
|
353
|
-
}
|