@flow-engineer/flow 0.0.1 → 0.2.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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +595 -0
  3. package/coding-agents/skill.md +321 -0
  4. package/coding-agents/test-skill.md +223 -0
  5. package/coding-agents/tools-skill.md +89 -0
  6. package/dist/flow-messaging/src/client.d.ts +34 -0
  7. package/dist/flow-messaging/src/client.js +130 -0
  8. package/dist/flow-messaging/src/index.d.ts +37 -0
  9. package/dist/flow-messaging/src/index.js +409 -0
  10. package/dist/simulators/src/index.d.ts +2 -0
  11. package/dist/simulators/src/index.js +2 -0
  12. package/dist/simulators/src/page.html +1245 -0
  13. package/dist/simulators/src/person.html +100 -0
  14. package/dist/simulators/src/platforms.d.ts +29 -0
  15. package/dist/simulators/src/platforms.js +72 -0
  16. package/dist/simulators/src/server.d.ts +201 -0
  17. package/dist/simulators/src/server.js +759 -0
  18. package/dist/src/agent.d.ts +504 -0
  19. package/dist/src/agent.js +818 -0
  20. package/dist/src/channels.d.ts +178 -0
  21. package/dist/src/channels.js +243 -0
  22. package/dist/src/conversation.d.ts +361 -0
  23. package/dist/src/conversation.js +1487 -0
  24. package/dist/src/db.d.ts +18 -0
  25. package/dist/src/db.js +57 -0
  26. package/dist/src/events.d.ts +17 -0
  27. package/dist/src/events.js +20 -0
  28. package/dist/src/http.d.ts +45 -0
  29. package/dist/src/http.js +269 -0
  30. package/dist/src/index.d.ts +14 -0
  31. package/dist/src/index.js +11 -0
  32. package/dist/src/model.d.ts +21 -0
  33. package/dist/src/model.js +96 -0
  34. package/dist/src/names.d.ts +17 -0
  35. package/dist/src/names.js +38 -0
  36. package/dist/src/nudges.d.ts +23 -0
  37. package/dist/src/nudges.js +117 -0
  38. package/dist/src/postgres.d.ts +20 -0
  39. package/dist/src/postgres.js +62 -0
  40. package/dist/src/schedules.d.ts +60 -0
  41. package/dist/src/schedules.js +249 -0
  42. package/dist/src/skills.d.ts +12 -0
  43. package/dist/src/skills.js +69 -0
  44. package/dist/src/store.d.ts +214 -0
  45. package/dist/src/store.js +448 -0
  46. package/dist/src/talk.d.ts +57 -0
  47. package/dist/src/talk.js +166 -0
  48. package/dist/src/time.d.ts +71 -0
  49. package/dist/src/time.js +170 -0
  50. package/dist/src/tools.d.ts +43 -0
  51. package/dist/src/tools.js +26 -0
  52. package/dist/src/traces.d.ts +31 -0
  53. package/dist/src/traces.js +303 -0
  54. package/dist/src/types.d.ts +260 -0
  55. package/dist/src/types.js +2 -0
  56. package/dist/testing/src/check-endpoint.d.ts +31 -0
  57. package/dist/testing/src/check-endpoint.js +158 -0
  58. package/dist/testing/src/chinwag.d.ts +2 -0
  59. package/dist/testing/src/chinwag.js +6 -0
  60. package/dist/testing/src/cli.d.ts +2 -0
  61. package/dist/testing/src/cli.js +563 -0
  62. package/dist/testing/src/client.d.ts +115 -0
  63. package/dist/testing/src/client.js +955 -0
  64. package/dist/testing/src/http-agent.d.ts +47 -0
  65. package/dist/testing/src/http-agent.js +155 -0
  66. package/dist/testing/src/index.d.ts +3 -0
  67. package/dist/testing/src/index.js +2 -0
  68. package/dist/testing/src/new.d.ts +9 -0
  69. package/dist/testing/src/new.js +122 -0
  70. package/dist/testing/src/print-run.d.ts +3 -0
  71. package/dist/testing/src/print-run.js +22 -0
  72. package/dist/testing/src/protocol.d.ts +159 -0
  73. package/dist/testing/src/protocol.js +28 -0
  74. package/dist/testing/src/report.d.ts +74 -0
  75. package/dist/testing/src/report.js +90 -0
  76. package/dist/testing/src/show.d.ts +36 -0
  77. package/dist/testing/src/show.js +280 -0
  78. package/dist/testing/src/suite.d.ts +87 -0
  79. package/dist/testing/src/suite.js +198 -0
  80. package/examples/cafe/agent/AGENTS.md +10 -0
  81. package/examples/cafe/agent/skills/allergens/SKILL.md +7 -0
  82. package/examples/cafe/src/agent.ts +39 -0
  83. package/examples/cafe/src/main.ts +16 -0
  84. package/examples/cafe/src/tools.ts +62 -0
  85. package/examples/cafe/tests/scenarios/latte-correction.yaml +30 -0
  86. package/examples/cafe/tests/scenarios/order-fails.yaml +26 -0
  87. package/examples/cafe/tests/tools/get_menu.yaml +14 -0
  88. package/examples/cafe/tests/tools/place_order.yaml +29 -0
  89. package/examples/http-agent/server.mjs +32 -0
  90. package/examples/http-agent/tests/scenarios/opening-hours.yaml +16 -0
  91. package/flow-messaging/README.md +152 -0
  92. package/package.json +62 -8
  93. package/spec/agent-endpoint.openapi.yaml +148 -0
  94. package/spec/nudges.openapi.yaml +167 -0
  95. package/spec/request.schema.json +121 -0
  96. package/spec/response.schema.json +284 -0
@@ -0,0 +1,759 @@
1
+ // The simulators: a simulator per platform (SMS, WhatsApp, iMessage, Telegram, web), each with that
2
+ // platform's rules. Nothing is really sent. They run in two ways.
3
+ //
4
+ // The playground: people test an agent as real users before its numbers are live. Only the
5
+ // agent's `testPeople` can be played, on the agent's own channels, through `agent.simulate()`: the
6
+ // same path as a provider's webhook, so a test person is a real person to the agent (identity,
7
+ // lookups, history, schedules); the simulator is just a different phone. One person is one
8
+ // conversation on every channel, and what is shown is that conversation's history in the agent's
9
+ // store, so it survives restarts. On a deployed agent it needs a token (`handler`). Without one it
10
+ // answers only a loopback Host, and its API only its own page (never another site's).
11
+ //
12
+ // GET /?person=<phone>&channel=<name> the playground; /person shows every channel side by side
13
+ // POST /api/messages {channel, person, text, replyTo?, choice?} → {messageId, at}
14
+ // POST /api/reactions {channel, person, messageId, emoji} the person reacts to an agent message
15
+ // GET /api/events ?channel&person&after? server-sent events: what the person sees, plus the trace
16
+ // GET /api/poll ?channel&person&after the same events for a page the stream can't reach (a
17
+ // tunnel that holds it back): those after `after` at once,
18
+ // else the next within 20 s → {boot, seq, events}
19
+ // GET /api/transcript ?channel&person the conversation on that channel, from the store, and the
20
+ // `seq` its events continue from
21
+ // POST /api/clear ?person forget the person: history, schedules, everything
22
+ // GET /api/status busy / idle, background tasks
23
+ // GET /api/platforms each platform's rules, the agent's channels and test people, link cards,
24
+ // the action labels (the Person tab's "Their account")
25
+ //
26
+ // The test cloud (`testCloud: true`, for Flow Framework's test cloud only): generated people per run, any
27
+ // person id, through AgentConnection.receive; the same API with `platform` for `channel`, `person`
28
+ // free, and POST /api/reset to start a conversation over. Its transcript is kept in memory.
29
+ //
30
+ // The person's messages have the agent's ids (m1, m2, ...); the agent's messages get a1, a2, ...
31
+ import { createHash } from "node:crypto";
32
+ import { readFileSync } from "node:fs";
33
+ import { createServer } from "node:http";
34
+ import { declaredTemplate, fillTemplate, fitMessage, } from "../../src/index.js";
35
+ import { requireToken, sameSecret, wrongTries } from "../../src/http.js";
36
+ import { PLATFORMS, channelOf, platform as platformOf, smsSegments } from "./platforms.js";
37
+ export function playgroundClock() {
38
+ let offset = 0;
39
+ return {
40
+ now: () => Date.now() + offset,
41
+ set: (at) => void (offset = Math.max(offset, at - Date.now())),
42
+ };
43
+ }
44
+ // What a US SMS provider sends for the standard keywords (Twilio's defaults).
45
+ const CARRIER = {
46
+ stop: ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"],
47
+ start: ["START", "YES", "UNSTOP"],
48
+ help: ["HELP", "INFO"],
49
+ stopReply: "You have successfully been unsubscribed. You will not receive any more messages from this number. Reply START to resubscribe.",
50
+ startReply: "You have successfully been re-subscribed to messages from this number. Reply HELP for help. Reply STOP to unsubscribe. Msg&Data Rates May Apply.",
51
+ helpReply: "Reply STOP to unsubscribe. Msg&Data Rates May Apply.",
52
+ };
53
+ const MAX_BODY = 16_384;
54
+ /** A message may carry files (base64): up to 4, about 11 MB in all, above the largest the agent reads (10 MB). */
55
+ const MAX_MESSAGE_BODY = 15_000_000;
56
+ const MAX_FILES = 4;
57
+ const COOKIE = "chinwag_playground";
58
+ /** The events kept per conversation for pollers, and how long one stays kept after nobody asked for it. */
59
+ const RECENT_EVENTS = 500;
60
+ const RECENT_IDLE_MS = 5 * 60_000;
61
+ /** How long a poll waits for the next event before it answers with none. */
62
+ const POLL_WAIT_MS = 20_000;
63
+ export function simulators(options = {}) {
64
+ const cloud = options.testCloud === true;
65
+ const playPage = options.pages?.page ?? readFileSync(new URL("./page.html", import.meta.url), "utf8");
66
+ const personPage = options.pages?.person ?? readFileSync(new URL("./person.html", import.meta.url), "utf8");
67
+ /**
68
+ * Changes every time the server starts, so an open page from an older run reloads itself. Made
69
+ * at the first request, not at import: on Cloudflare the clock and random numbers are fixed
70
+ * until a request runs.
71
+ */
72
+ let started = "";
73
+ const boot = () => (started ||= `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`);
74
+ const clients = new Map();
75
+ const threads = new Map();
76
+ const thread = (k) => {
77
+ let t = threads.get(k);
78
+ if (!t)
79
+ threads.set(k, (t = { lines: [], agentCount: 0, carrierCount: 0, optedOut: false }));
80
+ return t;
81
+ };
82
+ /** The number of the last event pushed, on any conversation. */
83
+ let seq = 0;
84
+ /** The last events of each conversation a page opened, for pages that poll. */
85
+ const recent = new Map();
86
+ /** The kept events of one conversation, kept from now on if they were not; drops ones nobody asked for lately. */
87
+ function recentOf(k) {
88
+ const now = Date.now();
89
+ for (const [key, r] of recent)
90
+ if (!r.waiters.size && !clients.get(key)?.size && now - r.touched > RECENT_IDLE_MS)
91
+ recent.delete(key);
92
+ let r = recent.get(k);
93
+ if (!r)
94
+ recent.set(k, (r = { events: [], floor: seq, waiters: new Set(), touched: now }));
95
+ r.touched = now;
96
+ return r;
97
+ }
98
+ /** The events after `after`, or a reset when some of them are no longer kept (the page loads the transcript again). */
99
+ function since(r, after) {
100
+ return Number.isInteger(after) && after >= r.floor ? r.events.filter((e) => e.seq > after) : [{ type: "reset" }];
101
+ }
102
+ function push(k, payload) {
103
+ const event = { ...payload, seq: ++seq };
104
+ const data = `data: ${JSON.stringify(event)}\n\n`;
105
+ for (const client of clients.get(k) ?? [])
106
+ client.write(data);
107
+ const r = recent.get(k);
108
+ if (!r)
109
+ return;
110
+ r.events.push(event);
111
+ if (r.events.length > RECENT_EVENTS)
112
+ r.floor = r.events.splice(0, r.events.length - RECENT_EVENTS).at(-1).seq;
113
+ for (const wake of [...r.waiters])
114
+ wake();
115
+ }
116
+ /** Record a line and show it to the open simulators. */
117
+ function emitLine(k, line) {
118
+ const t = thread(k);
119
+ t.lines.push(line);
120
+ if (t.lines.length > 500)
121
+ t.lines.splice(0, t.lines.length - 500);
122
+ push(k, { type: "line", line });
123
+ }
124
+ function findLine(k, id) {
125
+ return thread(k).lines.find((l) => (l.kind === "in" || l.kind === "out") && l.id === id);
126
+ }
127
+ /** Tool calls in progress, by call id, so later events update the same line. */
128
+ const toolLines = new Map();
129
+ function show(k, event) {
130
+ if (event.type === "tool.start" && typeof event.callId === "string") {
131
+ const line = { kind: "tool", callId: event.callId, tool: String(event.tool), input: event.input, at: event.at, status: "running" };
132
+ toolLines.set(`${k}:${event.callId}`, line);
133
+ emitLine(k, line);
134
+ }
135
+ else if ((event.type === "tool.end" || event.type === "tool.detached" || event.type === "task.done") && typeof event.callId === "string") {
136
+ const line = toolLines.get(`${k}:${event.callId}`);
137
+ if (line) {
138
+ line.ms = Number(event.ms);
139
+ if (event.type === "tool.detached")
140
+ line.status = "background";
141
+ else {
142
+ line.status = event.ok === false ? "failed" : "done";
143
+ line.output = typeof event.output === "string" ? event.output : undefined;
144
+ toolLines.delete(`${k}:${event.callId}`);
145
+ }
146
+ push(k, { type: "lineUpdate", line });
147
+ }
148
+ }
149
+ if (event.type === "message.undelivered") {
150
+ emitLine(k, { kind: "undelivered", text: String(event.text), reason: String(event.reason), at: event.at });
151
+ }
152
+ push(k, { type: "trace", event });
153
+ }
154
+ /** What the platform does with one thing the agent sends. Throws when it would not arrive. */
155
+ function deliverer(p, k, templates = []) {
156
+ return (outbound) => {
157
+ const t = thread(k);
158
+ if (outbound.type === "template") {
159
+ const { type: _type, ...sent } = outbound;
160
+ const declared = declaredTemplate(templates, sent);
161
+ let filled = null;
162
+ try {
163
+ if (p.id !== "whatsapp")
164
+ throw new Error(`${p.name} has no templates`);
165
+ if (declared)
166
+ filled = fillTemplate(declared, sent);
167
+ }
168
+ catch (error) {
169
+ emitLine(k, { kind: "undelivered", text: `template ${sent.name}`, reason: error instanceof Error ? error.message : String(error), at: Date.now() });
170
+ throw error;
171
+ }
172
+ const { body, ...shown } = filled ?? { body: valuesOf(sent) };
173
+ emitLine(k, { kind: "out", id: `a${++t.agentCount}`, text: body, at: Date.now(), template: { name: sent.name, ...shown } });
174
+ return;
175
+ }
176
+ if (outbound.type === "message") {
177
+ if (t.optedOut)
178
+ throw new Error("the person texted STOP, so the carrier blocks messages to them");
179
+ const sms = p.segments ? smsSegments(outbound.text) : undefined;
180
+ emitLine(k, {
181
+ kind: "out",
182
+ id: `a${++t.agentCount}`,
183
+ text: outbound.text,
184
+ at: Date.now(),
185
+ ...(outbound.replyTo ? { replyTo: outbound.replyTo } : {}),
186
+ ...(outbound.buttons ? { buttons: outbound.buttons } : {}),
187
+ ...(outbound.list ? { list: outbound.list } : {}),
188
+ ...(outbound.link ? { link: outbound.link } : {}),
189
+ ...(outbound.image ? { image: outbound.image } : {}),
190
+ ...(outbound.fields ? { fields: outbound.fields } : {}),
191
+ ...(sms ? { sms: { segments: sms.segments, encoding: sms.encoding } } : {}),
192
+ });
193
+ return;
194
+ }
195
+ if (outbound.type === "reaction") {
196
+ emitLine(k, { kind: "reaction", messageId: outbound.messageId, emoji: outbound.emoji, at: Date.now(), by: "agent" });
197
+ return;
198
+ }
199
+ if (outbound.type === "read") {
200
+ emitLine(k, { kind: "read", messageIds: outbound.messageIds, at: Date.now() });
201
+ return;
202
+ }
203
+ push(k, { ...outbound, at: Date.now() });
204
+ };
205
+ }
206
+ /** The SMS carrier's own handling of STOP, START and HELP. The text still reaches the agent. */
207
+ function carrier(p, k, text) {
208
+ if (!p.carrierKeywords)
209
+ return;
210
+ const word = text.trim().toUpperCase();
211
+ const t = thread(k);
212
+ let reply = null;
213
+ if (CARRIER.stop.includes(word)) {
214
+ t.optedOut = true;
215
+ reply = CARRIER.stopReply;
216
+ }
217
+ else if (CARRIER.start.includes(word)) {
218
+ t.optedOut = false;
219
+ reply = CARRIER.startReply;
220
+ }
221
+ else if (CARRIER.help.includes(word)) {
222
+ reply = options.sms?.helpReply ?? CARRIER.helpReply;
223
+ }
224
+ if (reply) {
225
+ const sms = smsSegments(reply);
226
+ // In the playground the agent's ids come from the store, so the carrier's lines have their own.
227
+ const id = cloud ? `a${++t.agentCount}` : `c${++t.carrierCount}`;
228
+ emitLine(k, { kind: "out", id, text: reply, at: Date.now(), carrier: true, sms: { segments: sms.segments, encoding: sms.encoding } });
229
+ }
230
+ }
231
+ function page(template, base) {
232
+ return template.replaceAll("{{title}}", escapeHtml(options.title ?? "Agent")).replaceAll("{{boot}}", boot()).replaceAll("{{base}}", base).replaceAll("{{mode}}", cloud ? "cloud" : "playground");
233
+ }
234
+ function serve(handle, port) {
235
+ const server = createServer(handle);
236
+ return new Promise((resolve) => {
237
+ server.listen(port, "127.0.0.1", () => resolve({
238
+ // The bound port, so port 0 (any free port) works too.
239
+ url: `http://127.0.0.1:${server.address().port}`,
240
+ close: () => new Promise((done) => server.close(() => done())),
241
+ }));
242
+ });
243
+ }
244
+ const guarded = (work) => (req, res) => work(req, res).catch((error) => {
245
+ console.warn("flow simulators:", error);
246
+ if (!res.headersSent)
247
+ res.writeHead(500).end();
248
+ });
249
+ function events(req, res, k, url) {
250
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
251
+ res.write(`data: ${JSON.stringify({ type: "hello", boot: boot() })}\n\n`);
252
+ // What happened since the page read the transcript, so nothing between the two is lost.
253
+ const after = url.searchParams.get("after");
254
+ if (after !== null)
255
+ for (const e of since(recentOf(k), Number(after)))
256
+ res.write(`data: ${JSON.stringify(e)}\n\n`);
257
+ const set = clients.get(k) ?? new Set();
258
+ set.add(res);
259
+ clients.set(k, set);
260
+ const keepAlive = setInterval(() => res.write(": ping\n\n"), 15_000);
261
+ req.on("close", () => {
262
+ clearInterval(keepAlive);
263
+ set.delete(res);
264
+ // Kept a while longer, so a stream that reconnects goes on from where it was.
265
+ const r = recent.get(k);
266
+ if (r)
267
+ r.touched = Date.now();
268
+ });
269
+ }
270
+ /** The same events for a page the stream does not reach: at once if there are any after `after`, else the next. */
271
+ async function poll(res, k, url) {
272
+ const r = recentOf(k);
273
+ const after = Number(url.searchParams.get("after"));
274
+ if (!since(r, after).length) {
275
+ await new Promise((resolve) => {
276
+ const done = () => {
277
+ clearTimeout(timer);
278
+ r.waiters.delete(done);
279
+ res.off("close", done);
280
+ resolve();
281
+ };
282
+ const timer = setTimeout(done, POLL_WAIT_MS);
283
+ r.waiters.add(done);
284
+ res.on("close", done);
285
+ });
286
+ if (res.destroyed)
287
+ return;
288
+ }
289
+ r.touched = Date.now();
290
+ json(res, 200, { boot: boot(), seq, events: since(r, after) });
291
+ }
292
+ /** The person's message as the agent gets it: the text, a quoted message, a tapped button, files. */
293
+ function incomingOf(k, body, text, replies) {
294
+ const replyId = replies && typeof body?.replyTo === "string" ? body.replyTo : undefined;
295
+ const quoted = replyId ? findLine(k, replyId) : undefined;
296
+ const choice = body?.choice && typeof body.choice === "object" ? body.choice : undefined;
297
+ const attachments = attachmentsOf(body);
298
+ const incoming = {
299
+ text,
300
+ ...(quoted && (quoted.kind === "in" || quoted.kind === "out") ? { replyTo: { text: quoted.text, fromAgent: quoted.kind === "out" } } : {}),
301
+ ...(choice?.title ? { choice: { id: choice.id, title: String(choice.title) } } : {}),
302
+ ...(attachments.length ? { attachments } : {}),
303
+ };
304
+ const files = attachments.map((a) => ({ kind: a.kind, ...(a.name ? { name: a.name } : {}) }));
305
+ const line = { kind: "in", id: null, text, at: Date.now(), ...(quoted ? { replyTo: replyId } : {}), ...(choice?.title ? { choice: true } : {}), ...(files.length ? { attachments: files } : {}) };
306
+ return { incoming, line };
307
+ }
308
+ // ---------------------------------------------------------------------------------------------
309
+ // The test cloud.
310
+ if (cloud) {
311
+ const generations = new Map();
312
+ const key = (platformId, person) => `${platformId}:${person}`;
313
+ const conversationId = (k) => {
314
+ const generation = generations.get(k) ?? 0;
315
+ return generation ? `${k}#${generation}` : k;
316
+ };
317
+ const cloudEvents = (event) => show(event.conversationId.split("#")[0], event);
318
+ async function handle(agent, req, res, base) {
319
+ const url = new URL(req.url ?? "/", "http://localhost");
320
+ const path = inside(url, base);
321
+ const qp = platformOf(url.searchParams.get("platform"));
322
+ const qperson = (url.searchParams.get("person") || "demo").slice(0, 128);
323
+ const qk = key(qp.id, qperson);
324
+ if (req.method === "GET" && path === "/")
325
+ return html(res, page(playPage, base));
326
+ if (req.method === "GET" && path === "/api/platforms") {
327
+ const own = PLATFORMS.filter((p) => ownChannel(agent, p, conversationId(key(p.id, qperson)))).map((p) => ({ platform: p.id, channel: ownChannel(agent, p, conversationId(key(p.id, qperson))).name }));
328
+ return json(res, 200, { platforms: PLATFORMS, own, cards: options.cards ?? [] });
329
+ }
330
+ if (req.method === "GET" && path === "/api/status")
331
+ return json(res, 200, agent.status());
332
+ if (req.method === "GET" && path === "/api/transcript") {
333
+ const t = thread(qk);
334
+ recentOf(qk);
335
+ return json(res, 200, { platform: qp.id, person: qperson, optedOut: t.optedOut, lines: t.lines, seq });
336
+ }
337
+ if (req.method === "POST" && path === "/api/reset") {
338
+ generations.set(qk, (generations.get(qk) ?? 0) + 1);
339
+ threads.delete(qk);
340
+ push(qk, { type: "reset" });
341
+ return json(res, 200, { ok: true });
342
+ }
343
+ if (req.method === "GET" && path === "/api/events")
344
+ return events(req, res, qk, url);
345
+ if (req.method === "GET" && path === "/api/poll")
346
+ return poll(res, qk, url);
347
+ const body = req.method === "POST" ? await readBody(req, path === "/api/messages" ? MAX_MESSAGE_BODY : MAX_BODY) : null;
348
+ const p = typeof body?.platform === "string" ? platformOf(body.platform) : qp;
349
+ const person = typeof body?.person === "string" && body.person ? body.person.slice(0, 128) : qperson;
350
+ const k = key(p.id, person);
351
+ if (req.method === "POST" && path === "/api/reactions") {
352
+ const messageId = typeof body?.messageId === "string" ? body.messageId : "";
353
+ const emoji = typeof body?.emoji === "string" ? body.emoji.slice(0, 16) : "";
354
+ const target = findLine(k, messageId);
355
+ if (!p.personReactions || !target || target.kind !== "out" || !emoji) {
356
+ return json(res, 400, { error: "reactions need a platform that has them, an agent message id and an emoji" });
357
+ }
358
+ emitLine(k, { kind: "reaction", messageId, emoji, at: Date.now(), by: "person" });
359
+ agent.reacted(conversationId(k), emoji, target.text);
360
+ return json(res, 202, { ok: true });
361
+ }
362
+ if (req.method === "POST" && path === "/api/messages") {
363
+ const text = typeof body?.text === "string" ? body.text.trim().slice(0, 4_000) : "";
364
+ if (!text && !attachmentsOf(body).length)
365
+ return json(res, 400, { error: "text or a file is required" });
366
+ const { incoming, line } = incomingOf(k, body, text, Boolean(p.replies));
367
+ emitLine(k, line);
368
+ carrier(p, k, text);
369
+ const id = conversationId(k);
370
+ const own = ownChannel(agent, p, id);
371
+ const messageId = agent.receive(id, incoming, deliverer(p, k), own ? { channel: own.info, channelName: own.name } : { channel: channelOf(p) });
372
+ line.id = messageId;
373
+ push(k, { type: "id", at: line.at, messageId });
374
+ return json(res, 202, { messageId, at: line.at });
375
+ }
376
+ res.writeHead(404).end();
377
+ }
378
+ const testCloud = {
379
+ events: cloudEvents,
380
+ listen: (agent, port) => serve(guarded((req, res) => handle(agent, req, res, "")), port),
381
+ handler: (agent, base = "") => guarded((req, res) => handle(agent, req, res, base)),
382
+ };
383
+ return testCloud;
384
+ }
385
+ // ---------------------------------------------------------------------------------------------
386
+ // The playground.
387
+ /** The test person (by phone) each conversation belongs to, so its trace shows in their simulators. */
388
+ const people = new Map();
389
+ let playing = null;
390
+ const threadKey = (channel, person) => `${channel}:${person.phone}`;
391
+ const playgroundEvents = (event) => {
392
+ const person = people.get(event.conversationId);
393
+ // A failed turn (a wrong or expired model key, say) on the terminal too, also for no known person.
394
+ if (event.type === "model.error")
395
+ console.error(`flow: model error${person ? ` (${person.name})` : ""}: ${String(event.error)}`);
396
+ if (!person || !playing)
397
+ return;
398
+ for (const c of playing.channels())
399
+ show(threadKey(c.name, person), event);
400
+ };
401
+ function attach(agent) {
402
+ playing = agent;
403
+ agent.attachPlayground(({ conversationId, person, channel, outbound }) => {
404
+ people.set(conversationId, person);
405
+ const own = agent.channels().find((c) => c.name === channel);
406
+ if (own)
407
+ return deliverer(platformFor(own), threadKey(channel, person), agent.templates?.())(outbound);
408
+ });
409
+ }
410
+ /** The conversation on one channel, rebuilt from the agent's store, with what only the platform showed (the carrier's replies). */
411
+ async function load(agent, person, channel) {
412
+ const k = threadKey(channel.name, person);
413
+ const t = thread(k);
414
+ const id = await agent.testConversation(person.phone);
415
+ if (id)
416
+ people.set(id, person);
417
+ const p = platformFor(channel);
418
+ let count = 0;
419
+ const lines = [];
420
+ for (const e of id ? await agent.transcript(id) : []) {
421
+ if (e.kind === "tool") {
422
+ lines.push({ kind: "tool", callId: e.callId, tool: e.tool, input: e.input, at: e.at, status: e.ok ? "done" : "failed", output: e.output });
423
+ continue;
424
+ }
425
+ if (e.channel !== channel.name)
426
+ continue;
427
+ if (e.kind === "in")
428
+ lines.push({ kind: "in", id: e.id, text: e.text, at: e.at, ...(e.choice ? { choice: true } : {}) });
429
+ else if (e.kind === "out" && e.template) {
430
+ // The store keeps a template's name and body text, not its other values: those show as declared.
431
+ const declared = agent.templates?.().find((d) => d.name === e.template);
432
+ lines.push({ kind: "out", id: `a${++count}`, text: e.text, at: e.at, template: { name: e.template, ...(declared ? asDeclared(declared) : {}) } });
433
+ }
434
+ else if (e.kind === "reaction")
435
+ lines.push({ kind: "reaction", messageId: e.messageId, emoji: e.emoji, at: e.at, by: "agent" });
436
+ else {
437
+ // The agent's message as this platform showed it (a link's URL in the text where it has no link button).
438
+ const { kind: _kind, channel: _channel, template: _template, ...rest } = fitMessage(channel.info, e);
439
+ const sms = p.segments ? smsSegments(rest.text) : undefined;
440
+ lines.push({ kind: "out", id: `a${++count}`, ...rest, ...(sms ? { sms: { segments: sms.segments, encoding: sms.encoding } } : {}) });
441
+ }
442
+ }
443
+ const stored = new Set(lines.flatMap((l) => (l.kind === "in" && l.id ? [l.id] : [])));
444
+ const onlyHere = t.lines.filter((l) => (l.kind === "out" && l.carrier) || (l.kind === "in" && !(l.id && stored.has(l.id))));
445
+ t.lines = [...lines, ...onlyHere].sort((a, b) => a.at - b.at);
446
+ t.agentCount = count;
447
+ return t;
448
+ }
449
+ async function handle(agent, req, res, base, gate) {
450
+ const url = new URL(req.url ?? "/", "http://localhost");
451
+ const path = inside(url, base);
452
+ // Without a token, a page on another site (or a DNS name rebound to 127.0.0.1) must not run
453
+ // turns on the developer's key: only a loopback Host, and the API only from the playground's own page.
454
+ if (!gate && !loopback(req.headers.host))
455
+ return forbid(res, "This playground answers only on localhost or 127.0.0.1.");
456
+ if (path.startsWith("/api/") && crossSite(req))
457
+ return forbid(res, "This playground's API answers only its own page.");
458
+ if (gate && !gate.allows(req, res, url, base))
459
+ return;
460
+ if (req.method === "GET" && path === "/")
461
+ return html(res, page(playPage, base));
462
+ if (req.method === "GET" && path === "/person")
463
+ return html(res, page(personPage, base));
464
+ if (req.method === "GET" && path === "/api/status")
465
+ return json(res, 200, agent.status());
466
+ if (req.method === "GET" && path === "/api/platforms") {
467
+ const channels = agent.channels().map((c) => ({ name: c.name, kind: c.kind, platform: platformFor(c) }));
468
+ return json(res, 200, {
469
+ platforms: PLATFORMS, channels, people: agent.testPeople(), cards: options.cards ?? [],
470
+ templates: (agent.templates?.() ?? []).map((t) => ({ name: t.name, body: t.body })),
471
+ panel: { person: !!(agent.contact && agent.outbox), nudge: !!agent.recordNudge, clock: !!(options.clock && agent.runDue) },
472
+ });
473
+ }
474
+ const body = req.method === "POST" ? await readBody(req, path === "/api/messages" ? MAX_MESSAGE_BODY : MAX_BODY) : null;
475
+ /** What the Person panel did (moved the clock, sent a nudge), in every open page's trace. */
476
+ const announce = (label, outcome) => {
477
+ const event = { type: "action", label, at: Date.now(), ...outcome };
478
+ for (const key of new Set([...clients.keys(), ...recent.keys()]))
479
+ push(key, event);
480
+ };
481
+ if (req.method === "POST" && path === "/api/clock") {
482
+ if (!options.clock || !agent.runDue)
483
+ return json(res, 400, { error: "give simulators({ clock }) the agent's clock (playgroundClock())" });
484
+ const to = typeof body?.to === "number" ? body.to : typeof body?.advanceMs === "number" ? options.clock.now() + body.advanceMs : NaN;
485
+ if (!Number.isFinite(to))
486
+ return json(res, 400, { error: "give to (ms since the epoch) or advanceMs" });
487
+ options.clock.set(to);
488
+ const ran = await agent.runDue();
489
+ const result = `clock at ${new Date(options.clock.now()).toISOString().replace(".000Z", "Z")}; ${ran} scheduled message${ran === 1 ? "" : "s"} due`;
490
+ announce("Clock", { ok: true, result });
491
+ return json(res, 200, { now: options.clock.now(), ran });
492
+ }
493
+ const asked = (name) => (typeof body?.[name] === "string" ? body[name] : (url.searchParams.get(name) ?? ""));
494
+ const person = agent.testPeople().find((p) => digits(p.phone) === digits(asked("person")) && digits(p.phone) !== "");
495
+ if (!person)
496
+ return json(res, 403, { error: "only this agent's test people can be played: declare them in createAgent({ testPeople })" });
497
+ if (req.method === "POST" && path === "/api/clear") {
498
+ const id = await agent.testConversation(person.phone);
499
+ if (id) {
500
+ await agent.forgetPerson(id);
501
+ people.delete(id);
502
+ }
503
+ for (const c of agent.channels()) {
504
+ const k = threadKey(c.name, person);
505
+ threads.delete(k);
506
+ push(k, { type: "reset" });
507
+ }
508
+ return json(res, 200, { ok: true });
509
+ }
510
+ const channel = agent.channels().find((c) => c.name === asked("channel"));
511
+ if (!channel)
512
+ return json(res, 400, { error: "channel must be one of the agent's own channels" });
513
+ const k = threadKey(channel.name, person);
514
+ const p = platformFor(channel);
515
+ // The Person panel: what is set and waiting for this person, and the business's side (nudges).
516
+ const to = { channel: channel.name, address: channel.kind === "custom" ? `test:${person.phone}` : person.phone };
517
+ if (req.method === "GET" && path === "/api/person") {
518
+ if (!agent.contact || !agent.outbox)
519
+ return json(res, 404, { error: "this agent has no contact record" });
520
+ const id = await agent.testConversation(person.phone);
521
+ // One person on every channel: their record from whichever channel they have written on.
522
+ let contact = await agent.contact(to);
523
+ for (const c of agent.channels())
524
+ contact ??= await agent.contact({ channel: c.name, address: c.kind === "custom" ? `test:${person.phone}` : person.phone });
525
+ // When the agent wrote on each channel (its latest 20), so the page can flag a channel it is not showing.
526
+ const lastOut = {};
527
+ for (const e of id ? await agent.transcript(id) : [])
528
+ if (e.kind === "out")
529
+ lastOut[e.channel] = [...(lastOut[e.channel] ?? []), e.at].slice(-20);
530
+ return json(res, 200, { now: options.clock?.now() ?? Date.now(), contact, outbox: id ? await agent.outbox(id) : { schedules: [], held: [] }, lastOut });
531
+ }
532
+ if (req.method === "POST" && path === "/api/nudge") {
533
+ if (!agent.recordNudge)
534
+ return json(res, 404, { error: "this agent takes no nudges" });
535
+ const kind = body?.kind;
536
+ const input = {
537
+ to,
538
+ ...(kind === "text" ? { text: String(body?.text ?? "") } : kind === "template" ? { template: { name: String(body?.template ?? ""), ...(Array.isArray(body?.body) && body.body.length ? { body: body.body.map(String) } : {}) } } : { prompt: String(body?.prompt ?? "") }),
539
+ ...(typeof body?.at === "number" ? { at: body.at } : {}),
540
+ ...(body?.respect_quiet_hours === false ? { respectQuietHours: false } : {}),
541
+ ...(typeof body?.expires_at === "number" ? { expiresAt: body.expires_at } : {}),
542
+ ...(typeof body?.idempotency_key === "string" && body.idempotency_key ? { idempotencyKey: body.idempotency_key } : {}),
543
+ };
544
+ try {
545
+ const state = await agent.recordNudge(input);
546
+ announce(`Nudge (${kind ?? "prompt"}) to ${person.name}`, { ok: true, result: `${state.status}${state.reason ? `: ${state.reason}` : ""}${state.id ? ` (${state.id})` : ""}` });
547
+ return json(res, 202, state);
548
+ }
549
+ catch (error) {
550
+ const message = error instanceof Error ? error.message : String(error);
551
+ announce(`Nudge to ${person.name}`, { ok: false, error: message });
552
+ return json(res, 400, { error: message });
553
+ }
554
+ }
555
+ if (req.method === "POST" && (path === "/api/time-zone" || path === "/api/quiet-hours")) {
556
+ if (!agent.setTimeZone || !agent.setQuietHours)
557
+ return json(res, 404, { error: "this agent has no contact record" });
558
+ try {
559
+ const result = path === "/api/time-zone"
560
+ ? await agent.setTimeZone({ to, timeZone: String(body?.time_zone ?? "") })
561
+ : await agent.setQuietHours({ to, quietHours: (body?.quiet_hours ?? null) });
562
+ announce(path === "/api/time-zone" ? `Time zone for ${person.name}` : `Quiet hours for ${person.name}`, { ok: true, result: result.status === "set" ? "set" : "they have not written on this channel yet" });
563
+ return json(res, result.status === "set" ? 200 : 404, result);
564
+ }
565
+ catch (error) {
566
+ return json(res, 400, { error: error instanceof Error ? error.message : String(error) });
567
+ }
568
+ }
569
+ if (req.method === "GET" && path === "/api/transcript") {
570
+ const t = await load(agent, person, channel);
571
+ // Taken with the lines, in the same step: every event after this number came after these lines.
572
+ recentOf(k);
573
+ return json(res, 200, { channel: channel.name, person, optedOut: t.optedOut, lines: t.lines, seq });
574
+ }
575
+ if (req.method === "GET" && path === "/api/events")
576
+ return events(req, res, k, url);
577
+ if (req.method === "GET" && path === "/api/poll")
578
+ return poll(res, k, url);
579
+ if (req.method === "POST" && path === "/api/reactions") {
580
+ const messageId = typeof body?.messageId === "string" ? body.messageId : "";
581
+ const emoji = typeof body?.emoji === "string" ? body.emoji.slice(0, 16) : "";
582
+ const target = findLine(k, messageId);
583
+ const id = await agent.testConversation(person.phone);
584
+ if (!p.personReactions || !target || target.kind !== "out" || !emoji || !id) {
585
+ return json(res, 400, { error: "reactions need a platform that has them, an agent message id and an emoji" });
586
+ }
587
+ emitLine(k, { kind: "reaction", messageId, emoji, at: Date.now(), by: "person" });
588
+ agent.reacted(id, emoji, target.text);
589
+ return json(res, 202, { ok: true });
590
+ }
591
+ if (req.method === "POST" && path === "/api/messages") {
592
+ const text = typeof body?.text === "string" ? body.text.trim().slice(0, 4_000) : "";
593
+ if (!text && !attachmentsOf(body).length)
594
+ return json(res, 400, { error: "text or a file is required" });
595
+ const { incoming, line } = incomingOf(k, body, text, Boolean(p.replies));
596
+ console.info(JSON.stringify({ chinwag: "playground.message", person: person.name, phone: person.phone, channel: channel.name, at: new Date(line.at).toISOString(), ip: req.socket.remoteAddress ?? null }));
597
+ emitLine(k, line);
598
+ carrier(p, k, text);
599
+ const taken = await agent.simulate({ person: person.phone, channel: channel.name, message: incoming });
600
+ if (taken)
601
+ people.set(taken.conversationId, person);
602
+ line.id = taken?.messageId ?? null;
603
+ push(k, { type: "id", at: line.at, messageId: line.id });
604
+ return json(res, 202, { messageId: line.id, at: line.at });
605
+ }
606
+ res.writeHead(404).end();
607
+ }
608
+ const playground = {
609
+ events: playgroundEvents,
610
+ listen: (agent, port, listenOptions = {}) => {
611
+ const gate = listenOptions.token === undefined ? null : tokenGate(listenOptions.token);
612
+ attach(agent);
613
+ return serve(guarded((req, res) => handle(agent, req, res, "", gate)), port);
614
+ },
615
+ handler: (agent, handlerOptions) => {
616
+ const gate = tokenGate(handlerOptions?.token);
617
+ attach(agent);
618
+ const base = handlerOptions.base ?? "";
619
+ return guarded((req, res) => handle(agent, req, res, base, gate));
620
+ },
621
+ };
622
+ return playground;
623
+ }
624
+ /**
625
+ * The playground's lock on a deployed agent. The token comes once as `?token=` (swapped for an
626
+ * HttpOnly, SameSite=Strict cookie, and dropped from the URL) or on every API call as
627
+ * `Authorization: Bearer`. Compared in constant time; wrong tokens are limited per address.
628
+ */
629
+ function tokenGate(token) {
630
+ requireToken(token, "flow simulators: a deployed playground");
631
+ // The cookie holds a value made from the token, not the token itself.
632
+ const session = createHash("sha256").update(`chinwag-playground:${token}`).digest("hex");
633
+ const tries = wrongTries();
634
+ return {
635
+ allows(req, res, url, base) {
636
+ const ip = req.socket.remoteAddress ?? "unknown";
637
+ const blocked = tries.blocked(ip);
638
+ const fail = () => {
639
+ tries.failed(ip);
640
+ refuse(res, 401);
641
+ return false;
642
+ };
643
+ const query = url.searchParams.get("token");
644
+ const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1];
645
+ if ((query !== null || bearer !== undefined) && blocked) {
646
+ refuse(res, 429);
647
+ return false;
648
+ }
649
+ if (query !== null) {
650
+ if (req.method !== "GET" || !sameSecret(query, token))
651
+ return fail();
652
+ url.searchParams.delete("token");
653
+ const secure = req.socket.encrypted || req.headers["x-forwarded-proto"] === "https";
654
+ const cookie = `${COOKIE}=${session}; Path=${base || "/"}; HttpOnly; SameSite=Strict${secure ? "; Secure" : ""}`;
655
+ const rest = url.searchParams.toString();
656
+ res.writeHead(303, { "set-cookie": cookie, location: url.pathname + (rest ? `?${rest}` : ""), "cache-control": "no-store" }).end();
657
+ return false;
658
+ }
659
+ if (bearer !== undefined)
660
+ return sameSecret(bearer, token) ? true : fail();
661
+ const cookie = (req.headers.cookie ?? "").split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
662
+ if (cookie !== undefined && sameSecret(cookie, session))
663
+ return true;
664
+ refuse(res, 401);
665
+ return false;
666
+ },
667
+ };
668
+ }
669
+ function refuse(res, status) {
670
+ const text = status === 429 ? "Too many wrong tokens from this address. Try again later." : "This playground needs its token: open it with ?token=<token>, or send Authorization: Bearer <token>.";
671
+ res.writeHead(status, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }).end(text);
672
+ }
673
+ /** A Host that names this machine: localhost (or a name under it), 127.x.x.x or [::1], any port. */
674
+ function loopback(host) {
675
+ const name = (host ?? "").toLowerCase().replace(/:\d+$/, "");
676
+ return name === "localhost" || name.endsWith(".localhost") || name === "[::1]" || /^127(\.\d{1,3}){3}$/.test(name);
677
+ }
678
+ /** A browser call from another origin: Sec-Fetch-Site says so, or (older browsers) an Origin unlike the Host. */
679
+ function crossSite(req) {
680
+ const site = req.headers["sec-fetch-site"];
681
+ if (site !== undefined)
682
+ return site !== "same-origin" && site !== "none";
683
+ const origin = req.headers.origin;
684
+ if (origin === undefined)
685
+ return false;
686
+ const forwarded = req.headers["x-forwarded-host"];
687
+ const host = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0].trim() || req.headers.host;
688
+ try {
689
+ return new URL(origin).host !== host;
690
+ }
691
+ catch {
692
+ return true;
693
+ }
694
+ }
695
+ function forbid(res, text) {
696
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }).end(text);
697
+ }
698
+ /** The path inside the simulators, whatever they are mounted under. */
699
+ function inside(url, base) {
700
+ return base && url.pathname.startsWith(base) ? url.pathname.slice(base.length) || "/" : url.pathname;
701
+ }
702
+ /** The simulator that plays one of the agent's channels: SMS, WhatsApp, or the web for a custom UI. */
703
+ function platformFor(channel) {
704
+ return { ...platformOf(channel.kind === "custom" ? "web" : channel.kind), ...channel.info };
705
+ }
706
+ const digits = (phone) => phone.replace(/[^\d]/g, "");
707
+ /** The agent's own channel a simulator plays, if it has one of that kind. */
708
+ function ownChannel(agent, p, conversationId) {
709
+ const kind = p.id === "web" ? "custom" : p.id;
710
+ return agent.channels?.(conversationId).find((c) => c.kind === kind);
711
+ }
712
+ /** A template the agent did not declare: the values it was sent with, one per line. */
713
+ function valuesOf(sent) {
714
+ const header = sent.header && (sent.header.kind === "text" ? sent.header.text : `${sent.header.kind}: ${sent.header.url ?? sent.header.fileId}`);
715
+ const body = sent.body ?? Object.values(sent.variables ?? {});
716
+ return [...(header ? [header] : []), ...body, ...(sent.buttons ?? []).map((b) => `button ${b.index}: ${b.value}`)].join("\n");
717
+ }
718
+ /** A declared template's header, footer and buttons without one send's values (a header's {{1}} as …). */
719
+ function asDeclared(d) {
720
+ const header = d.header?.kind === "text" ? { kind: "text", text: d.header.text.replace("{{1}}", "…") } : d.header;
721
+ const buttons = (d.buttons ?? []).map((b) => (b.type === "quick_reply" ? { ...b, payload: b.text } : b.type === "url" ? { ...b, url: b.url.replace(/\{\{1\}\}$/, "") } : b));
722
+ return { ...(header ? { header } : {}), ...(d.footer ? { footer: d.footer } : {}), buttons };
723
+ }
724
+ /** The files of a message, from the page: { kind, mime, name?, data (base64) }. */
725
+ function attachmentsOf(body) {
726
+ if (!Array.isArray(body?.attachments))
727
+ return [];
728
+ return body.attachments.slice(0, MAX_FILES).flatMap((a) => {
729
+ if (!a || typeof a !== "object" || typeof a.mime !== "string" || typeof a.data !== "string" || !["image", "audio", "document"].includes(a.kind))
730
+ return [];
731
+ const bytes = new Uint8Array(Buffer.from(a.data, "base64"));
732
+ return [{ kind: a.kind, mime: a.mime.slice(0, 100), ...(typeof a.name === "string" && a.name ? { name: a.name.slice(0, 200) } : {}), data: async () => bytes }];
733
+ });
734
+ }
735
+ async function readBody(req, limit = MAX_BODY) {
736
+ let size = 0;
737
+ const chunks = [];
738
+ for await (const chunk of req) {
739
+ size += chunk.length;
740
+ if (size > limit)
741
+ return null;
742
+ chunks.push(chunk);
743
+ }
744
+ try {
745
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
746
+ }
747
+ catch {
748
+ return null;
749
+ }
750
+ }
751
+ function json(res, status, value) {
752
+ res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value));
753
+ }
754
+ function html(res, body) {
755
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(body);
756
+ }
757
+ function escapeHtml(text) {
758
+ return text.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] ?? c);
759
+ }